@terreno/mcp 57.11.0 → 57.13.0

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 (53) hide show
  1. package/dist/docs/upgrades/57.12.0.md +89 -0
  2. package/dist/docs/upgrades/57.13.0.md +75 -0
  3. package/dist/docs/versioned/next/explanation/README.md +1 -1
  4. package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +69 -9
  5. package/dist/docs/versioned/next/explanation/ai-workflows.md +4 -3
  6. package/dist/docs/versioned/next/explanation/example-coverage.md +3 -3
  7. package/dist/docs/versioned/next/explanation/roadmap-process.md +4 -2
  8. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +12 -0
  9. package/dist/docs/versioned/next/how-to/README.md +2 -0
  10. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +187 -3
  11. package/dist/docs/versioned/next/how-to/circleci.md +8 -2
  12. package/dist/docs/versioned/next/how-to/github-actions-ci.md +5 -6
  13. package/dist/docs/versioned/next/how-to/preview-demo.md +5 -5
  14. package/dist/docs/versioned/next/how-to/rate-limiting.md +17 -0
  15. package/dist/docs/versioned/next/how-to/test-ui-accessibility.md +35 -0
  16. package/dist/docs/versioned/next/implementationPlans/agent-ui-rich-replies.md +359 -0
  17. package/dist/docs/versioned/next/reference/README.md +1 -1
  18. package/dist/docs/versioned/next/reference/admin-backend.md +26 -0
  19. package/dist/docs/versioned/next/reference/admin-frontend.md +15 -0
  20. package/dist/docs/versioned/next/reference/ai-harness.md +33 -5
  21. package/dist/docs/versioned/next/reference/ai.md +88 -7
  22. package/dist/docs/versioned/next/reference/api.md +4 -0
  23. package/dist/docs/versioned/next/reference/blocks.md +243 -11
  24. package/dist/docs/versioned/next/reference/environment-variables.md +3 -0
  25. package/dist/docs/versioned/next/reference/ui.md +222 -12
  26. package/dist/docs/versioned/next/tasks/agent-ui-rich-replies.md +188 -0
  27. package/package.json +3 -3
  28. package/src/docs/upgrades/57.12.0.md +89 -0
  29. package/src/docs/upgrades/57.13.0.md +75 -0
  30. package/src/docs/versioned/next/explanation/README.md +1 -1
  31. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +69 -9
  32. package/src/docs/versioned/next/explanation/ai-workflows.md +4 -3
  33. package/src/docs/versioned/next/explanation/example-coverage.md +3 -3
  34. package/src/docs/versioned/next/explanation/roadmap-process.md +4 -2
  35. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +12 -0
  36. package/src/docs/versioned/next/how-to/README.md +2 -0
  37. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +187 -3
  38. package/src/docs/versioned/next/how-to/circleci.md +8 -2
  39. package/src/docs/versioned/next/how-to/github-actions-ci.md +5 -6
  40. package/src/docs/versioned/next/how-to/preview-demo.md +5 -5
  41. package/src/docs/versioned/next/how-to/rate-limiting.md +17 -0
  42. package/src/docs/versioned/next/how-to/test-ui-accessibility.md +35 -0
  43. package/src/docs/versioned/next/implementationPlans/agent-ui-rich-replies.md +359 -0
  44. package/src/docs/versioned/next/reference/README.md +1 -1
  45. package/src/docs/versioned/next/reference/admin-backend.md +26 -0
  46. package/src/docs/versioned/next/reference/admin-frontend.md +15 -0
  47. package/src/docs/versioned/next/reference/ai-harness.md +33 -5
  48. package/src/docs/versioned/next/reference/ai.md +88 -7
  49. package/src/docs/versioned/next/reference/api.md +4 -0
  50. package/src/docs/versioned/next/reference/blocks.md +243 -11
  51. package/src/docs/versioned/next/reference/environment-variables.md +3 -0
  52. package/src/docs/versioned/next/reference/ui.md +222 -12
  53. package/src/docs/versioned/next/tasks/agent-ui-rich-replies.md +188 -0
@@ -0,0 +1,75 @@
1
+ # Upgrading to 57.13.0
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai, @terreno/ui
5
+
6
+ ## Summary
7
+
8
+ Agent replies can use rich blocks (stepper, checklist, gallery, list, card eyebrow, copy), and hosts with `uiBlocks` on offer them in the prompt by default. `DataTable` number and date columns render differently, `neutral500` is a slightly darker gray, and the admin Roles screen groups permissions by resource. `@terreno/api` adds a per-route rate limiter, and `DocumentStorageApp` can be opened to signed-in non-admins. Action is required only for hosts with `uiBlocks` on that still have native builds from before this release.
9
+
10
+ ## Breaking changes
11
+
12
+ ### Rich blocks are offered in the chat prompt by default
13
+
14
+ **What changed**
15
+
16
+ With `uiBlocks` on, the chat system prompt now offers `checklist`, `gallery`, `list`, card `eyebrow`, and `copy`, plus `stepper` when a host action has `handles: "stepper"`. A native build shipped before 57.13.0 cannot render those blocks and shows an error banner for a reply that uses them.
17
+
18
+ **Why**
19
+
20
+ Rich blocks make agent replies more useful, so new hosts get them without extra setup.
21
+
22
+ **Migration**
23
+
24
+ Keep the old catalog until every shipped native build is on 57.13.0 or later:
25
+
26
+ ```typescript
27
+ // Before
28
+ addGptRoutes(router, {aiService, uiBlocks: {hostActions}});
29
+
30
+ // After (until old native builds age out)
31
+ addGptRoutes(router, {aiService, uiBlocks: {hostActions, richBlocks: false}});
32
+ ```
33
+
34
+ With `richBlocks: false` the prompt is the same as before. Validation accepts the new blocks either way.
35
+
36
+ ### `DataTable` number and date cells
37
+
38
+ **What changed**
39
+
40
+ `columnType: "number"` columns right-align cells and header. `columnType: "date"` cells format an ISO string or `Date` as Luxon `DATE_MED` (date only, local zone). Strings that are not ISO dates show as before.
41
+
42
+ **Why**
43
+
44
+ Numbers line up by digit, and raw ISO timestamps were hard to read.
45
+
46
+ **Migration**
47
+
48
+ To keep the old look for a column, render it with `customColumnComponentMap`:
49
+
50
+ ```typescript
51
+ // Before
52
+ {columnType: "date", title: "Created", width: 150}
53
+
54
+ // After (keep the raw value)
55
+ {columnType: "rawDate", title: "Created", width: 150}
56
+ // with customColumnComponentMap={{rawDate: ({cellData}) => <Text>{String(cellData.value)}</Text>}}
57
+ ```
58
+
59
+ ## Deprecations
60
+
61
+ None
62
+
63
+ ## New capabilities
64
+
65
+ - `createRouteRateLimitMiddleware({name, max, windowMs, store?, keyBy?})` from `@terreno/api` for a fixed-window limit on one route.
66
+ - `DocumentStorageApp` `access: "authenticated"` (non-admins see only `users/<userId>/`) and `uploadRateLimit`.
67
+ - `scaleStepperHostAction`, `toggleChecklistHostAction`, `findAgentBlock`, and `runBufferedChatTurn` from `@terreno/ai`.
68
+ - `SegmentedControl` `size="sm"`; `Box` `accessibilityState` and `accessibilityLiveRegion`.
69
+ - The `neutral500` primitive is `#949494`. Override it with `setPrimitives({neutral500: "#9A9A9A"})` to keep the old gray.
70
+
71
+ ## Verification
72
+
73
+ - `bun run compile` and `bun run lint` at the app root
74
+ - Send a chat prompt that asks for a checklist; on a current build it renders, and with `richBlocks: false` the agent replies with the old block types
75
+ - Open a screen with a `DataTable` date column and check the dates read as `Oct 10, 2026`
@@ -19,7 +19,7 @@ Understanding-oriented documentation: concepts, architecture, and context.
19
19
  - [Durable agent harness](durable-agent-harness.md) — Phases, checkpoints, and why the audit span shares the checkpoint transaction
20
20
  - [AI-powered workflows](ai-workflows.md) — Autonomous documentation, testing, and maintenance workflows
21
21
  - [Agent UI Asks](agent-ui-asks.md) — Why agent questions are client-side tool calls, the pause and resume round trip, why cards carry exact answers, the watch paths, asks vs blocks
22
- - [Agent UI blocks](agent-ui-blocks.md) — Why chat replies use a closed YAML catalog instead of free-form UI
22
+ - [Agent UI blocks](agent-ui-blocks.md) — Why chat replies use a closed YAML catalog instead of free-form UI, why steppers and checklists call the server, and the rich-blocks rollout
23
23
  - [Authentication architecture](authentication.md) — Better Auth, JWT, and optional MCP service tokens
24
24
  - [Organization tenancy](organizations.md) — Optional Membership-backed tenancy, context, and isolation boundaries
25
25
  - [Configuration system](configuration-system.md) — Runtime configuration with database persistence
@@ -9,19 +9,79 @@ in [Agent UI Asks](agent-ui-asks.md).
9
9
  The design record is
10
10
  [agent-ui-blocks.md](../implementationPlans/agent-ui-blocks.md). Pick executes that work as
11
11
  Phase 5 of [Agent UI Asks](../implementationPlans/agent-ui-asks.md).
12
+ The stepper, checklist, gallery, list, card eyebrow, and copy additions are recorded in
13
+ `docs/implementationPlans/agent-ui-rich-replies.md`.
12
14
 
13
15
  ## Why a closed catalog
14
16
 
15
17
  An agent that can emit arbitrary components can also emit styles, scripts, and layout the
16
18
  app theme does not own. The catalog is the list of `@terreno/ui` components the chat can
17
- paint: heading, text, metric, badge, divider, context, chart, table, actions, and the two
18
- layout blocks `columns` and `card`. Unknown
19
- keys and unknown block types fail validation, so a reply cannot smuggle a `style` object
20
- or a hex color.
19
+ paint. Unknown keys and unknown block types fail validation, so a reply cannot smuggle a
20
+ `style` object or a hex color.
21
+
22
+ | Group | Blocks | Notes |
23
+ | --- | --- | --- |
24
+ | Text | `heading`, `text`, `context`, `callout`, `details` | `text` is markdown, including GFM tables |
25
+ | Values | `metric`, `badge`, `divider` | |
26
+ | Data | `chart`, `table` | Read an inline or `ref` dataset |
27
+ | Media | `image`, `gallery`, `list` | Sources are `data:image`, `file:`, or an allowed `https` host |
28
+ | Interactive | `actions` (`button`, `segmented`), `stepper`, `checklist` | Button actions: `reply`, `open`, `select`, `callback`, `copy` |
29
+ | Layout | `columns`, `card` (optional `title` and `eyebrow`) | Leaf blocks only inside |
30
+ | Escape hatch | `html` | Only with `uiBlocks.html`; sanitized before storage |
31
+
32
+ Field rules for each block are in the [reference](../reference/blocks.md).
21
33
 
22
34
  The same pure functions run wherever a document is checked. `parseBlocks` and
23
- `validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer
24
- and the chat route will call them; they do not grow a second grammar.
35
+ `validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer,
36
+ the chat route, and the `terreno-blocks` CLI all call them; none grows a second grammar.
37
+
38
+ ## Why interactive blocks call the server
39
+
40
+ A stepper that rescales a shopping list, or a checklist that remembers ticks, needs logic.
41
+ Terreno does not give the document a shared `state` with `{{formula}}` bindings, and it does
42
+ not run scripted html. Both need an expression language the theme and the validator cannot
43
+ see into. Instead, an interactive block names a host callback, as a callback button does:
44
+
45
+ | Interaction | Who runs it | Result |
46
+ | --- | --- | --- |
47
+ | Stepper − / + | Host action with `handles: "stepper"`, through `POST /gpt/actions` | The replacement stepper, swapped in by block id |
48
+ | Checklist tick | Host action with `handles: "checklist"`, or the device when the checklist has no callback | The replacement checklist, or a local tick |
49
+ | `copy` button | The device | Text on the clipboard; nothing reaches the server |
50
+
51
+ The cost is one round trip per tap. The view disables the block while the call is pending, so
52
+ fast taps are dropped, not queued, and a checklist tick cannot send ticks that miss the one
53
+ before it. The gain is that the agent writes plain data, and the app's code decides what a
54
+ tap means. `scaleStepperHostAction` and `toggleChecklistHostAction` in `@terreno/ai` cover the
55
+ common case. They read the block the agent wrote from the stored history and never trust the
56
+ tap request's copy of it. The history owner can still edit stored prompts, so an app whose
57
+ numbers matter (prices, stock) registers its own action over its own data.
58
+
59
+ Overrides live in client memory. A reload shows the agent's original block again.
60
+
61
+ ## Why photos come from a stored library
62
+
63
+ A model cannot make a photo while it writes a reply, and an `https` image needs an
64
+ `imageHosts` allowlist the model cannot know. The example app generates a photo library once
65
+ per environment with the Vertex image model, stores it through `FileStorageService`, and gives
66
+ the agent a `findPhotos` tool that returns `file:` ids. The reply cites ids; the client
67
+ resolves each id to a signed URL. Generation cost and latency stay out of the chat turn, and
68
+ every photo the user sees was made and stored ahead of time. See
69
+ [Give the agent photos](../how-to/agent-ui-blocks.md#give-the-agent-photos).
70
+
71
+ ## Rollout: rich blocks are on by default
72
+
73
+ `uiBlocks.richBlocks` defaults to `true`. The prompt then offers `checklist`, `gallery`,
74
+ `list`, `card.eyebrow`, and `copy`, and offers `stepper` when a host action has
75
+ `handles: "stepper"`. A stepper without such an action would have dead buttons, so the
76
+ prompt does not mention it.
77
+
78
+ The server and the web client upgrade together. A native build already in the app stores
79
+ does not know the new blocks: a reply that uses them fails its validation and shows the
80
+ error banner with the raw YAML. A host with such builds sets `uiBlocks.richBlocks: false`
81
+ until they update. The prompt is then the same as before rich blocks. Validation still
82
+ accepts the new blocks, so a stored reply that uses them still renders on new clients.
83
+ A client-declared catalog version, so the server picks blocks per client, would remove that
84
+ manual step; it is not built yet.
25
85
 
26
86
  ## Why the whole reply is YAML
27
87
 
@@ -49,9 +109,9 @@ another, which makes a small document able to expand in ways the limits cannot s
49
109
 
50
110
  | Layer | Owns |
51
111
  | --- | --- |
52
- | `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, CLI, limits, error codes |
53
- | `@terreno/ui` | `BlocksView` for heading, text, metric, badge, divider, context, chart, table, actions, columns, and card. A select action changes the target chart or table dataset in that view. A segmented control starts on the option that matches that dataset. A callback stays disabled when `hostActions` is set and does not include its name. `GPTChat` `uiBlocks` renders assistant messages through `BlocksView`, including a spinner while the reply is still streaming. A `file:` image stays as alt text until `resolveImage` returns a URL. |
54
- | `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
112
+ | `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, `blockPlainText`, CLI, limits, error codes |
113
+ | `@terreno/ui` | `BlocksView` renders every block in the catalog. A select action changes the target chart or table dataset in that view. A segmented control starts on the option that matches that dataset. A callback button or stepper stays disabled when `hostActions` is set and does not include its name; a checklist with such a callback ticks locally instead. A `copy` button writes the clipboard on the device. `GPTChat` `uiBlocks` renders assistant messages through `BlocksView`, including a spinner while the reply is still streaming, and swaps a block when a callback returns `{replace: "block"}`. A `file:` image stays as alt text until `resolveImage` returns a URL. |
114
+ | `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. The opt-in `scaleStepperHostAction` and `toggleChecklistHostAction`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
55
115
 
56
116
  Asks use the same package under `src/asks/`. A block shows something. An ask collects an
57
117
  answer and returns it to the agent.
@@ -49,9 +49,10 @@ Maintains centralized agent guidance and generates the matching configuration fo
49
49
 
50
50
  Rulesync generates native hooks from `.rulesync/hooks.json`. Biome checks changed files
51
51
  after edits and staged files before commits. At agent stop,
52
- `.rulesync/hooks/quality-check.sh` runs lint, TypeScript compilation, Knip, and
53
- dependency-cruiser. It keeps stdout machine-readable for each host and prevents
54
- retry-triggered Stop hooks from rerunning the commands.
52
+ `.rulesync/hooks/quality-check.sh` runs `bun install --frozen-lockfile` when
53
+ `node_modules/typescript` or any workspace package link is missing, then runs lint,
54
+ TypeScript compilation, Knip, and dependency-cruiser. It keeps stdout machine-readable
55
+ for each host and prevents retry-triggered Stop hooks from rerunning the commands.
55
56
 
56
57
  Existing findings are ratcheted so the checks reject new debt without requiring unrelated
57
58
  cleanup. See [Static analysis](static-analysis.md).
@@ -16,14 +16,14 @@ that file (and its imports) to a working example. Gaps stay explicit.
16
16
  | Admin panel (embedded) | yes — Admin plugin registration in `example-backend/src/server.ts` | yes — `example-frontend/app/admin/` | Expo routes wrap `@terreno/admin-frontend` screens. |
17
17
  | Admin SPA | yes — `AdminSpaServeApp` in `example-backend/src/server.ts` | n/a | Standalone SPA is served from the backend; not a frontend route. |
18
18
  | AI streaming chat | yes — `example-backend/src/api/ai.ts` (`addGptRoutes`) | yes — `example-frontend/app/(tabs)/ai.tsx` (`GPTChat`, optional `mascot`) | Empty-chat mascot is consumer-owned; the example fox is not a framework default. |
19
- | UI blocks | yes — `uiBlocks` with `repair: true` and `exportDataset` in `example-backend/src/ai/hostActions.ts`, wired from `example-backend/src/api/ai.ts`; `todoStats` in `example-backend/src/ai/tools.ts` stores a `ref` dataset | yes — `example-frontend/app/(tabs)/ai.tsx` (`uiBlocks`, dataset and action hooks, `hostActions` includes `exportDataset`) | e2e mocks a document with one inline and one `ref` chart. Steps: [agent-ui-blocks.md](../how-to/agent-ui-blocks.md). |
19
+ | UI blocks | yes — `uiBlocks` with `repair: true`, `exportDataset`, and the opt-in `scaleStepper` and `toggleChecklist` callbacks in `example-backend/src/ai/hostActions.ts`, wired from `example-backend/src/api/ai.ts`; `todoStats` in `example-backend/src/ai/tools.ts` stores a `ref` dataset; `findPhotos` in the same file cites the generated photo library, and `example-backend/src/api/photoLibrary.ts` signs its URLs | yes — `example-frontend/app/(tabs)/ai.tsx` (`uiBlocks`, dataset and action hooks, `hostActions` includes `exportDataset`, `scaleStepper`, and `toggleChecklist`, `resolveImage` resolves `file:` photo ids through `useLazyPhotoLibraryUrlQuery`) | e2e mocks a document with one inline and one `ref` chart. A second e2e test stores the Sunday roast reply (`blocks/src/fixtures/golden/sunday-roast.yaml` with `file:` photo ids) in a real `GptHistory`, streams it from the mocked model, and runs the stepper and checklist through the real `POST /gpt/actions`; photo URLs come from the real `GET /photoLibrary/:id/url`, and only the image download is stubbed (`example-frontend/e2e/helpers/seedGptHistory.ts`). Steps: [agent-ui-blocks.md](../how-to/agent-ui-blocks.md). |
20
20
  | Agent asks | yes — `asks: {approvals}` in `example-backend/src/api/ai.ts`, with the per-request `deleteCompletedTodos` approval tool from `example-backend/src/api/todoTools.ts`; it also passes `chat` to `addGptHistoryRoutes` for the headless `pendingAsks` and `turn` endpoints; keyless scripted agent with short compact-surface replies in `example-backend/src/api/demoAgent.ts` | yes — `example-frontend/app/(tabs)/ai.tsx` (`onAskSubmit`, `askErrors`), helpers in `example-frontend/lib/gptAsks.ts` | Without `GEMINI_API_KEY`, "Help me pick a plan" asks a `choice` in chat, "Pick toppings" asks a `select: "many"` `choice` with an Other field, "Send the weekly report" and "Archive old chats" ask a `confirm` (the second `destructive`), "Draft an announcement" asks a `markdown` edit of a draft, "Fill in the invoice details" asks a `form` with eight typed fields, "Upload a receipt" asks for `files`, sent as uploads when `GCS_BUCKET` is set and as data URLs otherwise, and "Delete my completed todos" calls `deleteCompletedTodos`, which runs only after the user approves the server's destructive approval card. e2e: `example-frontend/e2e/ai-chat.spec.ts`. Small clients: the curl walkthrough in the how-to and the demo's watch-sized `SimpleAskCard` story. Steps: [agent-ui-asks.md](../how-to/agent-ui-asks.md). |
21
21
  | AI structured output | no | no | `@terreno/ai` supports structured generation; the example only streams chat. Recorded gap — not claimed by a current tutorial. |
22
22
  | Feature flags + live updates | yes — `FeatureFlagsApp` in `example-backend/src/server.ts`, seed `example-backend/src/scripts/seed-feature-flags.ts` | yes — `useTerrenoFeatureFlags` in `example-frontend/app/_layout.tsx` | Socket-backed live updates share the realtime connection. |
23
23
  | Websockets / realtime | yes — `RealtimeApp` in `example-backend/src/server.ts`, `example-backend/src/websockets.ts` | yes — `useSocketConnection` in `example-frontend/app/_layout.tsx` | Sync deltas also use the socket (`SyncApp` + `@terreno/syncdb`). |
24
- | Consent forms | yes — `example-backend/src/consentDefinitions.ts`, seed scripts | yes — `example-frontend/app/(tabs)/consents.tsx`, admin `example-frontend/app/admin/consent-forms/` | First-login flow is covered by e2e (`example-frontend/e2e/consents.spec.ts`). |
24
+ | Consent forms | yes — `example-backend/src/consentDefinitions.ts`, seed scripts | yes — `example-frontend/app/(tabs)/documents.tsx` (Consents section), admin `example-frontend/app/admin/consent-forms/` | First-login flow is covered by e2e (`example-frontend/e2e/consents.spec.ts`). |
25
25
  | Product announcements | yes — `AnnouncementsApp` in `example-backend/src/server.ts`, release upload token via `ANNOUNCEMENTS_UPLOAD_TOKEN`, seed `example-backend/src/scripts/seed-announcements.ts`, sample release pack `example-backend/announcements/releases/1.14.0/` with `bun run announcements:pack` | yes — `AnnouncementNavigator` in `example-frontend/app/_layout.tsx` and admin announcement routes | Release packs support multiple Markdown surfaces and idempotent draft or explicit live API import. |
26
- | File upload / GCS | yes — `example-backend/src/api/settings.ts`, AI file routes in `example-backend/src/api/ai.ts`, `file-uploads` flag in `example-backend/src/api/fileUploads.ts` | yes — `example-frontend/app/(tabs)/files.tsx`, `example-frontend/app/(tabs)/ai.tsx`, `example-frontend/app/gcs-settings.tsx` | The `file-uploads` feature flag hides upload controls and rejects uploads when off. A missing flag leaves uploads enabled. |
26
+ | File upload / GCS | yes — `example-backend/src/api/settings.ts`, AI file routes in `example-backend/src/api/ai.ts`, `file-uploads` flag in `example-backend/src/api/fileUploads.ts` | yes — `example-frontend/app/(tabs)/documents.tsx` (Files section, every signed-in user, 1 upload / IP / min), `example-frontend/app/(tabs)/ai.tsx`, `example-frontend/app/gcs-settings.tsx` | The `file-uploads` feature flag hides upload controls and rejects uploads when off. A missing flag leaves uploads enabled. Deployed bucket is terraform-managed (`google_storage_bucket.example_documents`). |
27
27
  | Better Auth | yes — `example-backend/src/utils/betterAuthConfig.ts`, `BetterAuthApp` in `example-backend/src/server.ts` | yes — `example-frontend/app/login.tsx`, `example-frontend/lib/betterAuth.ts` | Default `AUTH_PROVIDER` is `better-auth`. |
28
28
  | syncdb local-first | yes — `SyncApp` in `example-backend/src/server.ts` | yes — `example-frontend/store/syncdb.ts`, `example-frontend/app/(tabs)/index.tsx` | PR #869 closed without merge; `@terreno/syncdb` shipped on the launch line and is the example todos path. |
29
29
  | SyncDB diagnostics | n/a | yes — `debug` registration in `example-frontend/store/syncdb.ts`, debugger UI at `example-frontend/app/syncdb-debug.tsx` | `terreno-mcp-local` can inspect, snapshot/compare/merge, mutate, flush, reconcile/resync, and exercise offline/conflict paths against the running development client. |
@@ -402,7 +402,7 @@ do not claim completion until done.
402
402
  | Workflow | Trigger | Purpose |
403
403
  | -------- | ------- | ------- |
404
404
  | [`.github/workflows/triage.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/triage.yml) | Issue opened | `status:needs-triage` + `area:*` from package dropdown + `type:*` from Kind when present; `area:*` / `type:*` labels the issue was opened with win, so `roadmap:sync` issues are not asked for an area |
405
- | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when it changes |
405
+ | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when roadmap content changes. A new **Last updated** timestamp alone does not open a pull request |
406
406
  | [`.github/workflows/roadmap-sync.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-sync.yml) | Taxonomy files change on `master` + manual | Apply labels and reconcile the board's fields and items |
407
407
  | [`.github/workflows/roadmap-reconcile.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-reconcile.yml) | IP or task files change on `master` + manual | Advance status from IP headers, add an entry for every new IP, open its tracking issue, push to the board, regenerate `ROADMAP.md` |
408
408
  All three share the `roadmap` concurrency group. They write the same board, and interleaving
@@ -495,4 +495,6 @@ GITHUB_TOKEN=$(gh auth token) TERRENO_PROJECT_NUMBER=... bun run roadmap:generat
495
495
  ```
496
496
 
497
497
  The generator exits non-zero when the project cannot be read, so a bad project number or a
498
- token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`.
498
+ token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`. When the
499
+ rendered list matches the file already on disk, it keeps that file's **Last updated** stamp
500
+ and does not write. The daily workflow then sees a clean diff and does not open a pull request.
@@ -1319,6 +1319,18 @@ One easy-to-use harness inside `@terreno/ai` that runs long-lived agentic work d
1319
1319
 
1320
1320
  ---
1321
1321
 
1322
+ ## agent-ui-rich-replies
1323
+
1324
+ **Title:** `Agent UI Rich Replies: steppers, checklists, galleries, and copy`
1325
+
1326
+ **Labels:** `area:dx`, `type:feature`
1327
+ **Project fields:** Area=`dx`, Target=`Next`, Impact=`Feature`, IP=`agent-ui-rich-replies`, Status=`Planned`
1328
+
1329
+ - **Implementation plan:** [agent-ui-rich-replies.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/agent-ui-rich-replies.md)
1330
+ - **Tasks:** [agent-ui-rich-replies.md](https://github.com/TerrenoLabs/terreno/blob/master/docs/tasks/agent-ui-rich-replies.md)
1331
+
1332
+ ---
1333
+
1322
1334
  # Shipped, umbrella, and declined IPs
1323
1335
 
1324
1336
  Tracking issues created for IPs that previously lacked a `**Roadmap issue:**` header.
@@ -53,6 +53,8 @@ Problem-oriented, practical steps. Use these when you know what you want to do.
53
53
  - [Add a GPT chat mascot](add-gpt-chat-mascot.md) — Optional consumer-owned character on empty `GPTChat`
54
54
  - [Add agent asks to a chat](agent-ui-asks.md) — Turn on `asks`, show and answer them in `GPTChat`, try the keyless demo agent, and answer asks from an Apple Watch or another small client
55
55
  - [Validate a block document locally](agent-ui-blocks.md) — `terreno-blocks validate` for a whole-reply YAML document
56
+ - [Add a stepper callback](agent-ui-blocks.md#add-a-stepper-callback) — `scaleStepperHostAction` or your own `handles: "stepper"` action; checklist ticks, copy buttons, the `richBlocks: false` opt-out, and the `blocks:smoke` model check are on the same page
57
+ - [Give the agent photos](agent-ui-blocks.md#give-the-agent-photos) — Generate a stored photo library once and let the agent cite it with `findPhotos`
56
58
 
57
59
  ## Admin
58
60
 
@@ -14,9 +14,17 @@ The same check is `validateBlocks` in `@terreno/blocks`. Field tables are in the
14
14
  ## Preview a document in the playground
15
15
 
16
16
  1. Open the component demo and choose **BlocksPlayground**.
17
- 2. Start from Layout or Invalid, or paste your own document into the text area.
17
+ 2. Start from a preset: **All blocks** (one of each block type), **Block type** (one preset per
18
+ block type, with every chart kind, callout status, and action kind), Layout, Invalid, or
19
+ Sunday roast. Or paste your own document into the text area.
18
20
  3. The preview updates as you type. An invalid document shows the error banner and keeps
19
21
  the raw text collapsed.
22
+ 4. Press the controls. The playground has no server: a stepper's − and + and a checklist tick
23
+ run on the device with `scaleStepperBlock` and `applyChecklistState` from `@terreno/blocks`
24
+ (the helpers `scaleStepperHostAction` and `toggleChecklistHostAction` use), and a toast names
25
+ the callback and its result, such as "scaleStepper → 6 People". A reply, open, select, or
26
+ other callback shows a toast describing the event instead. Copy writes the clipboard.
27
+ Editing the text drops the local results.
20
28
 
21
29
  ## Require documents from the model
22
30
 
@@ -72,6 +80,133 @@ addGptRoutes(router, {
72
80
 
73
81
  The path is `/gpt/actions`, so a limiter on `/gpt` covers it. Each call is stored as an `AIRequest` with `requestType: "ui_action"`.
74
82
 
83
+ ## Add a stepper callback
84
+
85
+ A `stepper` is a − value + control. Each tap goes to a host action, and the action returns the
86
+ replacement stepper. Use it when a number changes other numbers, such as guests changing a
87
+ shopping list.
88
+
89
+ 1. Register a stepper action. `scaleStepperHostAction` from `@terreno/ai` scales every item
90
+ linearly from the stepper the agent wrote:
91
+
92
+ ```ts
93
+ import {addGptRoutes, scaleStepperHostAction} from "@terreno/ai";
94
+
95
+ addGptRoutes(router, {
96
+ aiService,
97
+ uiBlocks: {hostActions: {scaleStepper: scaleStepperHostAction}},
98
+ });
99
+ ```
100
+
101
+ 2. Leave `uiBlocks.richBlocks` unset (it defaults to `true`). The prompt offers `stepper` only
102
+ when some host action has `handles: "stepper"`, and names those actions as its callbacks.
103
+ 3. List the same name in `GPTChat` `hostActions` (`["scaleStepper"]`). `onBlockCallback` posts
104
+ to `POST /gpt/actions` and returns the result, as in [Wire the example chat](#wire-the-example-chat).
105
+ 4. The agent writes a stepper whose `callback.name` is that action:
106
+
107
+ ```yaml
108
+ - type: stepper
109
+ id: guests
110
+ label: Number of people
111
+ unit: People
112
+ value: 5
113
+ min: 1
114
+ max: 20
115
+ callback: {name: scaleStepper}
116
+ items:
117
+ - {label: Bone-in leg of lamb, amount: 2, unit: kg, decimals: 1}
118
+ - {label: Carrots, amount: 8, round: up}
119
+ ```
120
+
121
+ 5. Press +. The client sends `payload: {...callback.payload, value: 6}`, with `blockId: guests`
122
+ and `elementId: guests_increase`. The action returns `{replace: "block", blocks}` and
123
+ `GPTChat` swaps the stepper: lamb reads "2.4 kg", carrots "10". Both buttons are disabled
124
+ until the reply lands, so a fast second tap is dropped.
125
+
126
+ A stepper whose `callback.name` is not a `handles: "stepper"` action fails `UNKNOWN_HOST_ACTION`.
127
+ `value` outside `min`–`max`, or off the `step` grid, returns 400.
128
+
129
+ **Stored prompts are owner-writable.** The history owner can change any stored prompt with
130
+ `PATCH /gpt/histories/:id`, including the stepper `scaleStepperHostAction` reads. When the numbers
131
+ matter (prices, stock, dosage), write your own action and compute them from your own data:
132
+
133
+ ```ts
134
+ import {findAgentBlock, type HostAction} from "@terreno/ai";
135
+ import {z} from "@terreno/api";
136
+
137
+ const priceGuests: HostAction = {
138
+ handles: "stepper",
139
+ logResponse: false, // keep the returned block out of the AIRequest log
140
+ payload: z.object({value: z.number().int().min(1).max(20)}).passthrough(),
141
+ handler: async ({blockId, history, messageId, payload, user}) => {
142
+ const stepper = findAgentBlock({blockId, history, messageId, type: "stepper"});
143
+ const {value} = payload as {value: number};
144
+ const items = await quoteItems({guests: value, userId: user._id}); // your data, not the stored block
145
+ return {replace: "block", blocks: {v: 1, blocks: [{...stepper, items, value}]}};
146
+ },
147
+ };
148
+ ```
149
+
150
+ `findAgentBlock` reads `history.prompts[n]` for `messageId: msg-<n>`, otherwise the only assistant
151
+ prompt holding that block: several matches throw 409, none 404. Field rules are in
152
+ [Stepper](../reference/blocks.md#stepper) and [Host actions](../reference/ai.md#host-actions).
153
+
154
+ ## Add a checklist callback
155
+
156
+ A `checklist` without a `callback` ticks on the device only. To send ticks to the server:
157
+
158
+ 1. Register `toggleChecklistHostAction` (or your own `handles: "checklist"` action) under a name,
159
+ such as `toggleChecklist`, and list it in `GPTChat` `hostActions`.
160
+ 2. The prompt then tells the model to set `callback: {name: toggleChecklist}` on a checklist.
161
+ 3. A tick sends `{...callback.payload, itemId, checked, state}`, where `state` holds every item's
162
+ tick after this one. The whole checklist is locked until the reply lands, and the "n of m"
163
+ counter changes when the returned checklist arrives.
164
+
165
+ `toggleChecklistHostAction` returns the stored checklist with `checked` from `state` and saves
166
+ nothing. An unknown item id returns 400. An app that records progress writes its own action.
167
+
168
+ ## Copy a block's text
169
+
170
+ Add a button with `action: {kind: copy, target: <block id>}` (or `text` for a literal). The
171
+ target is a `stepper`, `checklist`, `list`, `table`, or `text` block. `BlocksView` copies the
172
+ block as it is shown now, so a stepper at 6 copies the scaled amounts. Nothing reaches the
173
+ server, so `hostActions` does not apply. See [Copy](../reference/blocks.md#copy).
174
+
175
+ ## Keep the old catalog for shipped native builds
176
+
177
+ Rich blocks (`stepper`, `checklist`, `gallery`, `list`, `card.eyebrow`, `copy`) are in the prompt
178
+ by default. A native build released before them shows an error banner for a reply that uses
179
+ them. Until those builds update, turn them off:
180
+
181
+ ```ts
182
+ addGptRoutes(router, {aiService, uiBlocks: {hostActions, richBlocks: false}});
183
+ ```
184
+
185
+ The prompt is then the same as before rich blocks, even with a stepper action registered.
186
+ Validation still accepts the new blocks.
187
+
188
+ ## Smoke-test a real model
189
+
190
+ `bun run blocks:smoke` (from the repo root) sends "Give me a plan for a sunday lamb roast, I'm
191
+ having friends over still figuring out numbers tbh" through one real chat turn with the example
192
+ backend's `uiBlocks` options and the `findPhotos` tool.
193
+
194
+ | Check | Passes when |
195
+ | --- | --- |
196
+ | Document | The reply parses and passes `validateBlocks` |
197
+ | Blocks | It holds a `gallery`, `list`, `stepper`, and `checklist`, and a `copy` action |
198
+ | Photos | Every image `src` is a `file:` id that `findPhotos` returned in that turn |
199
+
200
+ | Variable | Use |
201
+ | --- | --- |
202
+ | `GOOGLE_VERTEX_PROJECT` (with Application Default Credentials) or `GEMINI_API_KEY` | The model. With neither, the script prints that it skipped and exits 0 |
203
+ | `BLOCKS_SMOKE_MODEL` | Optional model id instead of the server default |
204
+ | `MONGO_URI`, `MONGO_DB_NAME` | The database holding the photo library ([Give the agent photos](#give-the-agent-photos)) |
205
+ | `BLOCKS_SMOKE_OUT_DIR` or `--out <dir>` | Where `reply.yaml` and `result.json` go (default: a new temp directory) |
206
+
207
+ It exits 1 when any check fails. The turn runs as `blocks-smoke@system.invalid`, and its history
208
+ is deleted afterwards.
209
+
75
210
  ## Wire the example chat
76
211
 
77
212
  1. Turn `uiBlocks` on in `addGptRoutes` so `GET /gpt/datasets/:id` and `POST /gpt/actions` are in the OpenAPI spec.
@@ -84,11 +219,57 @@ The path is `/gpt/actions`, so a limiter on `/gpt` covers it. Each call is store
84
219
 
85
220
  ## Turn blocks on in the example backend
86
221
 
87
- `example-backend` passes `uiBlocks` with `repair: true` and one host callback, `exportDataset`. Its payload is `{dataset: string}`. The handler returns `{replace: "block", blocks}` with a badge that names that dataset. Dataset TTL stays at the default, so stored rows are kept.
222
+ `example-backend` passes `uiBlocks` (`exampleUiBlocksOptions` in `src/ai/hostActions.ts`) with
223
+ `repair: true`, `html: true`, and three host callbacks:
224
+
225
+ | Name | Handles | Does |
226
+ | --- | --- | --- |
227
+ | `exportDataset` | buttons | Payload `{dataset: string}`. Returns a badge that names that dataset |
228
+ | `scaleStepper` | `stepper` | `scaleStepperHostAction` |
229
+ | `toggleChecklist` | `checklist` | `toggleChecklistHostAction` |
230
+
231
+ `richBlocks` is left at its default (`true`). Dataset TTL stays at the default, so stored rows are kept.
88
232
 
89
233
  The per-request tool `todoStats` counts the signed-in user's open and completed todos, calls `registerAiDataset`, and returns `datasetId`. It stores the rows on `req.body.historyId` when that id belongs to the caller. With no history id it uses the caller's newest history, and with no history it returns `{datasetId: null, rowCount: 0}`. A chart dataset uses `source: ref` and that id. Columns are `status` and `count`.
90
234
 
91
- `GPTChat` lists `exportDataset` in `hostActions`, so that callback stays enabled. The e2e mock also allows `export_csv`.
235
+ `GPTChat` lists `exportDataset`, `scaleStepper`, and `toggleChecklist` in `hostActions`, so those callbacks stay enabled. The e2e mock also allows `export_csv`.
236
+
237
+ ## Give the agent photos
238
+
239
+ The model cannot make photos on the fly, and an `https` image needs `imageHosts`. The example
240
+ app generates a photo library once, and the agent cites its photos as `file:` ids.
241
+
242
+ 1. Generate the library, once per environment. From `example-backend`, with `GOOGLE_VERTEX_PROJECT`,
243
+ Application Default Credentials for Vertex, and `GCS_BUCKET` set, run `bun run photos:generate`.
244
+ Each prompt in `src/scripts/photoPrompts.ts` becomes one `PhotoLibraryEntry`. A re-run skips
245
+ prompts that already have an entry; pass `--force` to regenerate them. `PHOTO_IMAGE_MODEL`
246
+ (default `imagen-4.0-fast-generate-001`) and `GOOGLE_VERTEX_LOCATION` (default `us-central1`)
247
+ are optional.
248
+ 2. Register the `findPhotos` tool. `createFindPhotosTool()` from `src/ai/tools.ts` is in the
249
+ example's per-request tools. `findPhotos({query, count})` matches the query words against each
250
+ entry's `tags` and `alt`. `count` is 1–6. It returns `{photos: [{src: "file:<entry id>", alt}], note}`.
251
+ Entries that match more words come first; ties keep entry id order. Soft-deleted entries are
252
+ left out. When nothing matches, `photos` is empty and `note` tells the model not to invent an
253
+ id. The tool description tells the model to put each `src` into a `gallery`, `list`, or
254
+ `image` block exactly as returned.
255
+ 3. Serve the URLs. `photoLibraryRouter` (`src/api/photoLibrary.ts`) mounts `/photoLibrary`.
256
+ List and read need a signed-in user; create, update, and delete return 405.
257
+ `GET /photoLibrary/:id/url` returns `{url}`, a signed read URL that lasts one hour, to any
258
+ signed-in user. The library is shared, so the per-user `GET /files/*` route does not fit.
259
+ An unknown or soft-deleted id is 404. Without a bucket (`GCS_BUCKET` unset and none saved in
260
+ Profile) it returns 503 `Photo storage is not configured`.
261
+ 4. Regenerate the frontend SDK: `cd example-frontend && bun run sdk`. This adds
262
+ `useLazyPhotoLibraryUrlQuery`.
263
+ 5. Pass `resolveImage` to `GPTChat`. The example builds it with `createPhotoImageResolver`
264
+ (`example-frontend/lib/photoLibraryImages.ts`) around `openapi.useLazyPhotoLibraryUrlQuery`.
265
+ It fetches each id once while the URL is fresh (50 minutes). A failed lookup returns
266
+ `undefined`, so the block keeps its labelled placeholder, and a later render tries again.
267
+
268
+ In the example app, every `file:` image id in a block is a photo library entry id. Nothing else
269
+ in the example writes `file:` image ids, so the ids carry no extra prefix. A host that also cites
270
+ its own files must tell the two apart in its `resolveImage`. An id that is not in the library,
271
+ such as `file:roast-lamb` in the reference examples, resolves to `undefined` and shows the
272
+ placeholder.
92
273
 
93
274
  ## Render documents in chat
94
275
 
@@ -100,5 +281,8 @@ is true, finished top-level blocks render and a spinner marks the block still ar
100
281
  - `callback` calls `onBlockCallback`. Return `{replace: "block", blocks}` to swap that
101
282
  block, or `{text}` to append an assistant message. The button shows loading until the
102
283
  promise settles.
284
+ - Stepper taps and checklist ticks with a callback also call `onBlockCallback`. The block is
285
+ disabled until the promise settles.
286
+ - `copy` writes the clipboard on the device and calls nothing.
103
287
  - Pass `hostActions` to disable callbacks the host does not run, `resolveDataset` to
104
288
  load `ref` datasets, and `resolveImage` to turn a `file:` image id into a URL.
@@ -179,6 +179,8 @@ an isolated `pr-N` backend: `deploy-frontend-preview` always points
179
179
  up to 20 minutes for its `/health` before publishing, because `gcp-cd-preview`
180
180
  runs in a separate workflow.
181
181
 
182
+ Those backend deploys set `GCS_BUCKET` to `flourish-terreno-example-documents` (override with `GCP_DOCUMENTS_BUCKET`). Preview revisions also set `GCS_FOLDER_PREFIX=pr-<number>/` so each PR keeps its own folder. Without `GCS_BUCKET`, `GET /documents/` returns 503 and the Files section shows “Storage is not configured.”
183
+
182
184
  Backend previews prune not-Ready tagged revisions from traffic
183
185
  (`rebuild-cloud-run-ready-traffic.sh`), deploy untagged with
184
186
  `--revision-suffix`, then point the `pr-N` tag at that revision. Terraform
@@ -254,8 +256,12 @@ the review script so a PR cannot rewrite the reviewer.
254
256
  Netlify and GCP jobs **validate their context as the first step** and fail,
255
257
  before checkout or `bun install`, listing the missing variables. Preview jobs
256
258
  halt earlier on fork PRs, where CircleCI withholds contexts. Docker Layer
257
- Caching is off (200 credits per job). The docs Netlify target disables
258
- Docusaurus minification so the build stays within the 8 GB `large` executor.
259
+ Caching is off (200 credits per job). The docs Netlify target builds only the
260
+ current docs tree, disables Docusaurus minification, and pins SSG to one worker
261
+ thread so the build peaks near 3 GB and fits the 4 GB `medium` executor.
262
+ Released versions are not rebuilt: `archive-docs-version` (in `publish-release`,
263
+ `X.Y.0` tags only) builds each one once and uploads it to its GitHub release, and
264
+ `deploy-docs` unpacks those archives under `/<version>/`. See `website/README.md`.
259
265
 
260
266
  `terreno-gcp` uses CircleCI OIDC (`CIRCLE_OIDC_TOKEN_V2`), never a JSON service
261
267
  account key. Set `circleci_org_id`, `circleci_project_id`, and
@@ -7,24 +7,23 @@ describes how they behaved if one is ever re-enabled.
7
7
 
8
8
  ## Docs site (`docs-deploy.yml`)
9
9
 
10
- `Build docs site` is often the longest PR job because Docusaurus compiles every
11
- versioned tree under `website/versioned_docs/` and TypeDoc regenerates API
12
- pages.
10
+ `Build docs site` is often the longest PR job because TypeDoc regenerates API
11
+ pages. The site builds only the current docs tree; `website/versioned_docs/` is
12
+ markdown for the MCP server.
13
13
 
14
14
  PR builds set `DOCS_PREVIEW=true` and pass `--no-minify`. Both PR and
15
15
  `master` use Docusaurus Faster (Rspack/SWC via `@docusaurus/faster`).
16
16
 
17
17
  | Behavior | PR preview | `master` production |
18
18
  | --- | --- | --- |
19
- | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted (`disableVersioning`) | Built |
19
+ | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted | Prebuilt archives unpacked under `/<version>/` |
20
20
  | JS minify | Off | On |
21
21
  | Local search index | Skipped | Built |
22
22
  | Generated API + component MDX | Restored from cache when `api`/`rtk`/`ui` hashes match | Same cache, then full generate on miss |
23
23
  | TypeDoc workspace `tsc` | Once per generate (api+rtk deps share a process) | Same |
24
24
  | Rspack cache (`node_modules/.cache/rspack`) | Restored per `pull_request` vs `push` | Separate production key |
25
25
 
26
- Production still builds every version. Do not rely on `/57.1.0/…` URLs in a
27
- PR deploy preview.
26
+ Do not rely on `/57.1.0/…` URLs in a PR deploy preview.
28
27
 
29
28
  ## Other high-cost jobs
30
29
 
@@ -1,10 +1,10 @@
1
1
  # Preview a component demo
2
2
 
3
- Open a component demo and change the shareable query. The preview controls sit in the
4
- navigation header on demo and dev component routes: dropdowns for theme, viewport, and
3
+ Open a demo or dev route and change the shareable query. The preview controls sit in the
4
+ navigation header on every demo and dev screen, including the catalogs: dropdowns for theme, viewport, and
5
5
  background, plus a settings button (sliders icon) that opens a modal for right-to-left,
6
6
  reduced motion, locale, and the share query. In windows narrower than 768px every
7
- preview control moves into that modal; the Dev/Demo mode link stays in the header. `?embed=1` applies the same query and hides
7
+ preview control moves into that modal; the Dev/Demo mode link stays in the header. The header chrome uses the same `theme` query. `?embed=1` applies the same query and hides
8
8
  the controls.
9
9
 
10
10
  ```text
@@ -13,11 +13,11 @@ the controls.
13
13
 
14
14
  | Query | Values |
15
15
  | --- | --- |
16
- | `theme` | `light` (default) or `dark` (canonical dark role remap) |
16
+ | `theme` | `light` (default), `dark`, or `system` (follow the device color scheme) |
17
17
  | `viewport` | `full` (default), `320`, `375`, `1024`, `1280` |
18
18
  | `background` | `default`, `inverse`, `transparent` |
19
19
  | `locale` | Passed to components that already read a locale, such as consent |
20
20
  | `rtl` | `1` sets document direction to `rtl` on web |
21
21
  | `reducedMotion` | `1` sets `data-reduced-motion="1"` on the document element |
22
22
 
23
- Reload keeps the query. The settings modal shows the current query when any value is not the default. Choosing a control's default removes that key from the URL. Leaving the component route restores the app theme and, on web, document direction and reduced motion. Custom palettes stay on `/palette`.
23
+ Reload keeps the query. The settings modal shows the current query when any value is not the default. Choosing a control's default removes that key from the URL. Opening a component from the catalog keeps the current preview query. Leaving a component route restores document direction and reduced motion on web. Custom palettes stay on `/palette`.
@@ -52,6 +52,23 @@ Pass `{store: "mongo"}`. Hits live in `rateLimitHits` on the same mongoose conne
52
52
 
53
53
  Unauthenticated keys use `req.ip`. Express `trust proxy` defaults to **off** so a client cannot rotate `X-Forwarded-For` to bypass the auth bucket. On Cloud Run (one hop via GFE), pass `trustProxy: 1`. Extra proxies: hop count or a subnet list. `trustProxy: false` is the same as the default.
54
54
 
55
+ ## Limit one route
56
+
57
+ Use `createRouteRateLimitMiddleware` for a stricter limit on one expensive route. It works with or without the global limiter.
58
+
59
+ ```typescript
60
+ import {createRouteRateLimitMiddleware} from "@terreno/api";
61
+
62
+ const uploadLimit = createRouteRateLimitMiddleware({
63
+ name: "uploads",
64
+ max: 1,
65
+ windowMs: 60_000,
66
+ store: "mongo",
67
+ });
68
+ ```
69
+
70
+ `DocumentStorageApp` takes the same options as `uploadRateLimit`. The example backend allows one upload per IP per minute and sets `trust proxy` to `1` on Cloud Run so the key is the real client IP.
71
+
55
72
  ## Auth vs API buckets
56
73
 
57
74
  | Path | Bucket |