@lotics/app-sdk 0.100.1 → 0.101.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 (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/files.md CHANGED
@@ -1,11 +1,7 @@
1
1
  # Files
2
2
 
3
- Files end to end in a custom-code app: uploading bytes (`useFileUpload`), an add-queue with
4
- instant previews (`useAttachments`), decoding `files` cells from query results (`readFiles` →
5
- `AppFile`), documents a workflow generates (`WorkflowResult.files`), previewing with `@lotics/ui`,
6
- filtering on files fields, and the server-side delivery bounds that decide whether a file-bearing
7
- query succeeds at all. Read this before building any screen that shows, collects, or generates
8
- files. Query mechanics (projection, unnest, aggregates) live in [queries](./queries.md); fetching
3
+ Files end to end: uploading, the add-queue, `files` cells, generated documents, previewing,
4
+ filtering, and the delivery bounds that decide whether a file-bearing query succeeds. Query mechanics (projection, unnest, aggregates) live in [queries](./queries.md); fetching
9
5
  discipline in [data fetching](./data_fetching.md).
10
6
 
11
7
  ## The lifecycle in one table
@@ -14,18 +10,29 @@ discipline in [data fetching](./data_fetching.md).
14
10
  |---|---|---|
15
11
  | Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
16
12
  | Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
17
-
18
- `useAttachments().add(files, { fidelity })` and `.attach(files, { fidelity })` take the same
19
- `fidelity` as `upload`.
20
13
  | Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
21
14
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
22
15
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
23
16
  | Show it | `@lotics/ui` `FileThumbnail` / `FileThumbnailGrid` / `FileGalleryDialog` | map to `DisplayFile` (see below) |
24
17
  | Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
18
+ | Rename one a record holds | `renameFile(file_id, filename)` | an `AppFile` — a new, unattached file over the same bytes |
25
19
 
26
20
  An uploaded file is **inert until a workflow attaches it** — it has an id and serving URLs, but
27
- belongs to no record. There is no direct-write path from app code; attaching goes through a
28
- declared workflow (see [mutations](./mutations.md)).
21
+ belongs to no record ([mutations](./mutations.md)).
22
+
23
+ ## Renaming — `renameFile`
24
+
25
+ ```tsx
26
+ const renamed = await renameFile(file.id, "Invoice 9.pdf");
27
+ await save({ record_id, papers_added: [renamed.id], papers_removed: [file.id] });
28
+ ```
29
+
30
+ A file is never renamed in place: `renameFile` answers a NEW file over the same bytes (same
31
+ storage, size and upload day), which is inert like an upload until a save puts it in the old one's
32
+ place. A save carrying `_added` and `_removed` of one field puts the added file where the removed
33
+ one stood, so a rename or a replace keeps its position. The name keeps the file's extension
34
+ exactly — `"scan.pdf"` → `"Invoice 9.pdf"`; `.html` is refused — and holds no `/`, `\` or control
35
+ character. Offer it only where the app writes that field: the save is what may be refused.
29
36
 
30
37
  ## Uploading — `useFileUpload`
31
38
 
@@ -38,23 +45,21 @@ const surveyPhoto = await upload(file, { fidelity: "standard" }); // bulk captur
38
45
  await submit({ ...fields, invoice_file_id: invoiceShot.id });
39
46
  ```
40
47
 
41
- Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
48
+ Signature: `dist/hooks.d.ts`. Returns `{ upload, uploading, error }`:
42
49
 
43
50
  - `upload(file: File, options?: { fidelity }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
44
51
  (the file is never partially stored). `UploadedFile` is
45
52
  `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
46
53
  (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
47
54
  round-trip.
48
- - `fidelity` — **how faithful the stored image must be.** The platform owns the pixels behind
49
- each step and re-tunes them centrally as model tiers change.
55
+ - `fidelity` — **how faithful the stored image must be**; the platform owns the pixels behind
56
+ each step. `useAttachments().add` and `.attach` take it too.
50
57
 
51
58
  Defaults to `"high"` — enough to resolve the text of a photographed document. Drop to
52
59
  `"standard"` when collecting in volume.
53
60
 
54
61
  **This only affects photographs.** A PDF, Word, Excel or CSV file is stored untouched at every
55
- step, so `"original"` is not what keeps a document intact — it already is. Note too that HEIC
56
- (what an iPhone camera produces) is transcoded to JPEG server-side whatever you choose, because
57
- no browser can render it.
62
+ step, so `"original"` is not what keeps a document intact — it already is.
58
63
 
59
64
  | `fidelity` | Use it when | Stored at |
60
65
  |---|---|---|
@@ -62,10 +67,9 @@ Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
62
67
  | `"high"` *(default)* | The image is READ — invoices, release orders, screenshots of dense tables — by a model or a person. Near-lossless, because a model's token cost depends on dimension alone, so quality is free accuracy. | 1568 px, q 0.95 |
63
68
  | `"original"` | The bytes themselves matter: evidence of record, or anything that may be re-read at a higher fidelity later. | unchanged |
64
69
 
65
- Getting it wrong is never fatal — too low stores fewer pixels than the reader wanted, it never
66
- produces a wrong answer. Rule of thumb: **if text on the image has to be legible, or a person
67
- may zoom, use `"high"`.** Reach for `"original"` only when the file IS the record rather than
68
- a picture of it — it is the one step whose meaning survives a change to these numbers.
70
+ **If text on the image has to be legible, or a person may zoom, use `"high"`.** Reach for
71
+ `"original"` only when the file IS the record rather than a picture of it — it is the one step
72
+ whose meaning survives a change to these numbers.
69
73
  - `uploading` — true while **any** upload from this hook is in flight.
70
74
  - `error` — message of the most recent failed upload; cleared when a new one starts.
71
75
 
@@ -77,18 +81,18 @@ use-access to the app itself.
77
81
  The pipeline is: optimize (images only) → mint a presigned upload URL → `PUT` the bytes straight
78
82
  to object storage → finalize. The API server never proxies the bytes.
79
83
 
80
- - **Image optimization.** JPEG/PNG/WebP images larger than 1280 px on the long edge are
81
- resized to ≤1280 px and re-encoded as JPEG at quality 0.75 before upload — phone photos
82
- typically shrink 10–20×. Non-image files pass through unchanged, and any optimization failure
83
- falls back to uploading the original bytes (never an upload error).
84
- **Warning:** PNG/WebP larger than 1280 px on the long edge are *converted to JPEG* —
85
- transparency is lost and the stored filename's extension becomes `.jpg`. If you need lossless
86
- originals, keep PNG/WebP ≤1280 px or upload them as non-image MIME types.
87
- - **HEIC/HEIF always becomes JPEG, regardless of size.** Most browsers cannot decode HEIC
88
- (the Samsung/iPhone camera default), so where the browser can't convert it client-side the
89
- server converts it at finalize under the same ≤1280 px / q0.75 policy (conversion failure
90
- stores the original bytes — never an upload error). Either way the stored file — and the
91
- file identity your app gets back — is `name.jpg` with `mime_type: "image/jpeg"`.
84
+ - **Image optimization.** A JPEG/PNG/WebP image longer than its `fidelity`'s ceiling is resized
85
+ and re-encoded as JPEG at that step's quality before upload; one at or below it, and every
86
+ `"original"`, is stored untouched. Non-image files pass through unchanged, and so does an image
87
+ the browser cannot resize (no canvas or decoder, or a canvas that exports nothing). Any other
88
+ optimization error fails a standalone app's upload; a member-facing app's host uploads the
89
+ original bytes instead.
90
+ **Warning:** a resized PNG/WebP is *converted to JPEG* — transparency is lost and the stored
91
+ filename's extension becomes `.jpg`. For lossless originals, upload at `"original"`.
92
+ - **HEIC/HEIF always becomes JPEG, at every `fidelity`.** Where the browser can't convert it
93
+ client-side the server converts it at finalize (conversion failure stores the original bytes —
94
+ never an upload error). Either way the stored file — and the file identity your app gets back —
95
+ is `name.jpg` with `mime_type: "image/jpeg"`.
92
96
  - **Transport resilience.** The storage `PUT` has a 5-minute per-request timeout and retries
93
97
  network errors / timeouts / 5xx up to 3 attempts with 1 s → 2 s backoff between attempts. 4xx
94
98
  responses are terminal (no retry). The presigned upload URL itself is valid for 10 minutes.
@@ -183,11 +187,11 @@ const onAdd = async (picked: File[]) => {
183
187
  />
184
188
  ```
185
189
 
186
- Contract (`dist/src/attachments.d.ts`):
190
+ Contract (`dist/attachments.d.ts`):
187
191
 
188
192
  | Member | Behavior |
189
193
  |---|---|
190
- | `files: AttachedFile[]` | Current attachments, in the order added |
194
+ | `files: readonly AttachedFile[]` | Current attachments, in the order added |
191
195
  | `add(files: File[])` | Each file shows its local preview at once and uploads in the background. Never rejects — a failure is the entry's `status` |
192
196
  | `attach(files: File[])` | The same add, resolved with the stored ids once **every** one is up. Rejects with the first refusal rather than answering short |
193
197
  | `remove(id)` | Removes one attachment and revokes its preview object-URL |
@@ -196,6 +200,11 @@ Contract (`dist/src/attachments.d.ts`):
196
200
  | `fileIds: string[]` | Stored file ids of the **completed** uploads — the workflow/agent payload |
197
201
  | `useAttachments({ landed })` | Stored ids the destination now holds; each matching entry leaves the queue and its preview is revoked |
198
202
 
203
+ Several piles from one list — a record's file sections, a form's file inputs — take one
204
+ `useAttachmentPiles({ landed })` rather than a `useAttachments` per pile: a hook called in a loop
205
+ shifts every later hook when the list changes. `of(pile)` is that pile's queue, the contract above
206
+ with verbs stable per pile; `landed` is keyed by pile; `clearAll()` empties every pile.
207
+
199
208
  Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id? }`:
200
209
 
201
210
  - `id` — a stable *local* id (the React key and the `remove(id)` handle), **not** the stored file
@@ -223,10 +232,15 @@ file-shaped value anywhere in the result rows** at read time:
223
232
  - `url` — a presigned serving URL, valid **24 hours**, anonymous-fetchable. Loads directly from
224
233
  the sandboxed app iframe and for public-app visitors — no session, no proxy, no URL derivation.
225
234
  - `thumbnail_url` — a presigned small-variant URL, emitted for every image.
226
- - `size` (bytes) and `created_at` (ISO upload timestamp) — resolved from the file object at
227
- serving time, batch-loaded per response.
235
+ - `size` (bytes), `created_at` (ISO upload timestamp) and `document_template_id` (the template a
236
+ generated file was made from) — resolved from the file object at serving time, batch-loaded per
237
+ response.
238
+
239
+ A caller outside the app's organization receives only `id`, `filename`, `mime_type`, `url`,
240
+ `thumbnail_url`, `preview_url`, `size`, `created_at` and `document_template_id` — never the storage
241
+ address.
228
242
 
229
- Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
243
+ Decode with `readFiles(cell)` → `AppFile[]` (`dist/row.d.ts`):
230
244
 
231
245
  | Field | Type | Notes |
232
246
  |---|---|---|
@@ -237,6 +251,7 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
237
251
  | `thumbnail_url` | `string?` | Presigned image thumbnail (see the 404 note below) |
238
252
  | `size` | `number?` | Byte size — **absent on older files not yet backfilled**; render only when present |
239
253
  | `created_at` | `string?` | ISO upload timestamp |
254
+ | `document_template_id` | `string?` | The document template (`dtl_…`) the file was generated from — absent on a file a person uploaded, whatever its name |
240
255
 
241
256
  `readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
242
257
  file. It is pure and never throws.
@@ -244,9 +259,8 @@ file. It is pure and never throws.
244
259
  - **A LOOKUP of a files field is one list per linked row.** The cell holds one entry per linked
245
260
  record and each entry is that record's whole files cell, so the value arrives one level deeper
246
261
  than a stored column's — exactly as a looked-up link does. `readFiles` opens that level itself,
247
- in link order, naming a file two linked rows both carry once, so a picture read through a link
248
- needs no unwrapping at the call site. Read entry-by-entry it decodes to nothing, which is a mark
249
- falling back to its glyph while the bytes are plainly there.
262
+ in link order, naming a file two linked rows both carry once; read entry-by-entry it decodes to
263
+ nothing.
250
264
 
251
265
  - **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
252
266
  the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
@@ -282,8 +296,8 @@ Design around it:
282
296
  hits the ceiling (and over-exposes storage metadata). Keep list queries file-free; fetch files
283
297
  in the detail query for the selected row.
284
298
  - **Paginate.** The row `limit` defaults to the server's 10,000-row cap; a files projection at
285
- that size is thousands of entries. `usePaginatedQuery`/`useInfiniteQuery` with a modest page
286
- size keeps each response far under the ceiling.
299
+ that size is thousands of entries. `useQuery` with a modest `page` or `more` keeps each
300
+ response far under the ceiling.
287
301
  - **Push counts and per-file analysis into the query.** You never need the file *objects* to
288
302
  count them: `has_file_count`/`is_empty` filters, the `filled`/`empty`/`percent_*` aggregates,
289
303
  and the `unnest` node (one output row per file, emitting the file **id** as text; `keep_empty`
@@ -319,11 +333,8 @@ if (result.status === "success" && result.files?.length) {
319
333
  the extracted entry is presigned while a hand-returned id is not.
320
334
  - The presigned URLs work for **anonymous public-app viewers** — a public visitor can download a
321
335
  document the workflow generated for them.
322
- - `openExternal(url)` is the way to open one: the embedded iframe is sandboxed without popups, so
323
- a direct `window.open` is silently dropped; `openExternal` routes the open to the host
324
- (scheme-validated, `http`/`https` only).
325
- - For bytes the app builds *in the browser* (a client-side .xlsx/CSV export), the counterpart is
326
- `downloadFile(filename, data, mimeType?)` — see [runtime](./runtime.md).
336
+ - Open one with `openExternal(url)`, never `window.open`; bytes the app builds *in the browser*
337
+ leave through `downloadFile` — both [runtime](./runtime.md).
327
338
 
328
339
  ### Naming what goes into a zip
329
340
 
@@ -353,8 +364,8 @@ means "no name", and that entry keeps the stored filename.
353
364
 
354
365
  Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
355
366
  hand-roll per-type rendering, and don't use `openExternal` as a preview (that's "open elsewhere",
356
- not viewing). Component contracts live in the `@lotics/ui` reference
357
- ([`../../ui/AGENTS.md`](../../ui/AGENTS.md)); the SDK-side contract is only the data mapping:
367
+ not viewing). The kit is installed only in a sandbox session (see [AGENTS.md](../AGENTS.md)); the
368
+ SDK-side contract is only the data mapping:
358
369
 
359
370
  | `AppFile` (SDK) | `DisplayFile` (`@lotics/ui`) |
360
371
  |---|---|
@@ -365,7 +376,6 @@ not viewing). Component contracts live in the `@lotics/ui` reference
365
376
  | `thumbnail_url` | `thumbnailUrl` |
366
377
 
367
378
  For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
368
- The app owns this data→UI adapter — the SDK deliberately never imports `@lotics/ui`.
369
379
 
370
380
  - **`FileThumbnail`** — one square tile; `uploading` takes the queue status and overlays the
371
381
  scrim, the spinner or the retry, images fall back from `previewUrl` to `thumbnailUrl` to `url`.
@@ -1,12 +1,8 @@
1
1
  # Members & select options
2
2
 
3
- How an app renders and picks **people** and **select-field options**, plus the **record comments**
4
- surface. Covers the two cell readers (`readSelect`, `readMembers`), the two catalog hooks
5
- (`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), comments
6
- (`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
7
- building an assign picker, a colored `Status` mark, a per-viewer ("my records") screen, or a
8
- comment thread. Query mechanics live in [queries](./queries.md); the authority model in
9
- [security](./security.md).
3
+ How an app renders and picks **people** and **select-field options**, the host context they are
4
+ read from, and **record comments**. Query mechanics live in [queries](./queries.md); the authority
5
+ model in [security](./security.md).
10
6
 
11
7
  ## Cells vs. catalogs — the model
12
8
 
@@ -16,6 +12,7 @@ Every select and member value reaches the app in one of two shapes, and most scr
16
12
  | --- | --- | --- |
17
13
  | Render a stored select value | `readSelect(cell)` | The options the record actually holds — `{ key, label }`, **no color** |
18
14
  | Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
15
+ | Filter a number each row reads in its own unit | `useFieldOptions(alias).units` | The units or currencies a range on it compares in — `{ vocabulary, options }` |
19
16
  | Render a stored member value | `readMembers(cell)` | The members the record actually holds |
20
17
  | Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
21
18
 
@@ -36,7 +33,7 @@ projected `select` cell to `Array<{ key, label }>` before the row reaches the ap
36
33
  *genuinely computed* select column (no source field at all) keeps bare keys.
37
34
  - **Order** is the cell's own stored order.
38
35
 
39
- Decode with **`readSelect(cell)`** (`dist/src/select.d.ts`) → `ResolvedOption[]`:
36
+ Decode with **`readSelect(cell)`** (`dist/select.d.ts`) → `ResolvedOption[]`:
40
37
 
41
38
  - Returns `[]` for `null`/`undefined`/empty cells and for any unexpected shape — iterate without
42
39
  null-checks.
@@ -50,7 +47,7 @@ below).
50
47
 
51
48
  ## The full option set: `useFieldOptions(alias)`
52
49
 
53
- The picker companion to `useQuery` (`dist/src/hooks.d.ts`). Where a cell carries only the options a
50
+ The picker companion to `useQuery` (`dist/hooks.d.ts`). Where a cell carries only the options a
54
51
  record holds (key + label), this resolves each select **output column's complete option list with
55
52
  colors**, straight from field config — so it populates a dropdown, colors stored values, and picks
56
53
  up table edits with no app change.
@@ -70,9 +67,7 @@ const { fields } = useFieldOptions("orders"); // same alias you query
70
67
 
71
68
  `fields` is keyed by the query's **output column name**, not the field key — and by *that alias's*
72
69
  columns: `Partial<Record<ColumnKeyOf<"alias">, FieldOptions>>`. A key the alias does not project is
73
- a compile error, so a picker fed from a sibling query's option map fails at your desk instead of
74
- rendering with no options; and every key is optional, so the read is through `?.` and never a bare
75
- `.options`. Each `FieldOptions`:
70
+ a compile error, and every key is optional. Each `FieldOptions`:
76
71
 
77
72
  | Property | Meaning |
78
73
  | --- | --- |
@@ -83,20 +78,35 @@ rendering with no options; and every key is optional, so the read is through `?.
83
78
  - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
84
79
  `@lotics/ui`'s `Status`; a missing/unrecognized token degrades to a neutral badge.
85
80
  - **A column the server can't map to a single source select field is simply absent** from
86
- `fields` — a UNION output whose arms disagree on the source field, or a computed column. That is
87
- why the type makes every key optional: `fields.status?.options ?? []`.
81
+ `fields` — a UNION output whose arms disagree on the source field, or a computed column:
82
+ `fields.status?.options ?? []`.
88
83
  - Addressed by the same alias you query, and scoped exactly like running that query — it exposes
89
84
  nothing the query itself doesn't. Params are irrelevant (they only fill filter values, never
90
85
  change the projection), so no params argument exists.
91
86
  - Works in embedded **and** standalone/public apps.
92
87
 
88
+ ### A figure read in each row's own unit: `units`
89
+
90
+ A number whose field names a `unit_field` or `currency_field` reads each row in the option a select
91
+ on that row holds, so a comparison on it states which one as `unit_option` — the server refuses one
92
+ that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
93
+ converted). `units` is keyed like `fields`: `{ vocabulary: "unit" | "currency", options: { key, label }[] }`
94
+ per such column, whether or not the query projects the select. Pass it whole to a range filter:
95
+
96
+ ```tsx
97
+ const { units } = useFieldOptions("loads");
98
+ <FilterChip column={{ key: "weight", label: "Weight", type: "number", units: units.weight }}
99
+ value={weight} onChange={setWeight} />
100
+ // columnFilterToConditions puts the picked unit on each condition as `unit_option`
101
+ ```
102
+
93
103
  ### Caching & freshness
94
104
 
95
105
  Field config is slow-changing, so the hook does **not** revalidate on window focus or reconnect — it
96
106
  re-reads on arrival (a remount) like the query hooks, and on an explicit `refetch()`. The option set
97
107
  is resolved live from field config at fetch time, so an option added, renamed, or recolored in the
98
108
  table flows through on the next fetch, with no app redeploy. `opts.enabled` defers the fetch (e.g.
99
- until an edit drawer opens). State: `{ fields, loading, isValidating, error, refetch }`.
109
+ until an edit drawer opens). State: `{ fields, units, loading, isValidating, error, refetch }`.
100
110
 
101
111
  ### Coloring a stored value (the `byKey` idiom)
102
112
 
@@ -142,12 +152,12 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, ro
142
152
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
143
153
  wider directory, and only their avatars are signed.
144
154
 
145
- Decode with **`readMembers(cell)`** (`dist/src/members.d.ts`) → `ResolvedMember[]`. Same defensive
155
+ Decode with **`readMembers(cell)`** (`dist/members.d.ts`) → `ResolvedMember[]`. Same defensive
146
156
  contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entries dropped.
147
157
 
148
158
  ## The org roster: `useMembers(opts?)`
149
159
 
150
- The candidate set for an "assign to a member" picker (`dist/src/hooks.d.ts`):
160
+ The candidate set for an "assign to a member" picker (`dist/hooks.d.ts`):
151
161
 
152
162
  ```tsx
153
163
  const { members, loading, error } = useMembers({ group: GRP.fulfillment });
@@ -155,15 +165,13 @@ const { members, loading, error } = useMembers({ group: GRP.fulfillment });
155
165
  ```
156
166
 
157
167
  Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups, role, joined }`.
158
- The one field it never carries is `archived`, and that is the answer rather than a gap: this roster
159
- answers "who may I ASSIGN?", and a departed member is not a candidate, so it never returns one. A
160
- resolved CELL is the display door and keeps naming them.
168
+ It never carries `archived`: this roster answers "who may I ASSIGN?", so a departed member is not
169
+ in it, while a resolved CELL keeps naming them.
161
170
  `image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
162
171
  may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
163
172
  resolves `{ members: [], loading: false, error }`.
164
173
 
165
- **Limitation:** `useMembers` is not SWR-cached — every mounted hook instance issues its own
166
- request. Call it once near the top of the screen and pass the roster down; don't call it per row.
174
+ Every hook reading one `group` shares one roster: it is read again when a screen reading it mounts, never on focus.
167
175
 
168
176
  ### Access gates (when it errors)
169
177
 
@@ -172,9 +180,9 @@ three gates are observable as an `error` on the hook:
172
180
 
173
181
  1. **Members-only, same-org.** The viewer must be an authenticated member of the app's own
174
182
  organization. Anonymous visitors (every standalone/public app view) and members of other orgs
175
- get an error. In `lotics app dev` the call runs under the owner's key and succeeds.
183
+ get an error.
176
184
  2. **Declared member access.** The app must declare that it works with members: at least one
177
- workflow input **or** query param of `{ "type": "member" }` in its manifest. An app with no
185
+ workflow input **or** query param of `{ "type": "member" }` in its bindings. An app with no
178
186
  member declaration gets: *"This app has not declared member access — listing members requires a
179
187
  declared workflow `member` input or query `member` param."* Member access is a declared,
180
188
  auditable surface, like queries and workflows.
@@ -183,21 +191,16 @@ three gates are observable as an `error` on the hook:
183
191
  app can only enumerate groups it actually assigns into. A declared group with no members
184
192
  resolves to `{ members: [] }`.
185
193
 
186
- Name the group `GRP.<group>`, from the generated `.lotics/app_fields.ts` — `GRP` is that workspace's
187
- group directory keyed by slugified display name, so the same source resolves in every copy of the
188
- app. A pasted `grp_…` id is an id one workspace minted: it resolves to nothing anywhere else, and
189
- `lotics app check` refuses it. (The manifest DECLARATION below is the exception — it carries the
190
- concrete id, and publish inverts it.)
194
+ Name the group by the `grp_…` id the workflow input's `group` declares — the binding below, set
195
+ with `set_app_workflow`:
191
196
 
192
197
  ```jsonc
193
- // package.json → "lotics" — the declaration that unlocks useMembers
194
- "workflows": {
195
- "assignOrder": {
196
- "workflow_id": "wfl_x",
197
- "inputs": {
198
- "record_id": { "type": "record_link", "table_id": "tbl_orders" },
199
- "assignee": { "type": "member", "group": "grp_fulfillment" }
200
- }
198
+ // set_app_workflow — the declaration that unlocks useMembers
199
+ {
200
+ "alias": "assignOrder",
201
+ "inputs": {
202
+ "record_id": { "type": "record_link", "table_id": "tbl_orders" },
203
+ "assignee": { "type": "member", "group": "grp_fulfillment" }
201
204
  }
202
205
  }
203
206
  ```
@@ -219,36 +222,66 @@ the `select_member` field in the query, and the cell already carries the name.
219
222
  ))}
220
223
  ```
221
224
 
222
- Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials). This
223
- respects the privacy boundary the gate enforces. If a chip renders blank, fix the query projection
224
- (project the member field) — never widen the manifest to "fix" it.
225
+ Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials). If a chip
226
+ renders blank, fix the query projection — never widen the bindings.
225
227
 
226
228
  ## `useViewer()` — display-only identity
227
229
 
228
- `useViewer()` (`dist/src/viewer.d.ts`) → `{ memberId, loading }` — the signed-in member currently
229
- viewing the app. Under an admin's "View as" (product UI or `lotics app dev --view-as`) it returns
230
+ `useViewer()` (`dist/viewer.d.ts`) → `{ memberId, loading }` — the signed-in member currently
231
+ viewing the app. Under an admin's "View as" in the product it returns
230
232
  the **view-as target**. It is `null` for a standalone/public visitor, and until the context
231
233
  resolves — gate viewer-dependent UI on `loading`.
232
234
 
233
235
  Use it to **personalize**: greet the member, default an assignment picker to them.
234
236
 
235
- **Never use it as an authorization fact or for data scoping.** Per-row scoping belongs in the query
236
- template via the `is_current_member` filter operator — the server binds the same viewer (honoring
237
- view-as) with nothing client-supplied to spoof. Write attribution belongs server-side in the
238
- workflow body (`runtime.triggered_by_member_id`). Full model: [security](./security.md).
237
+ **Never an authorization fact or a scoping mechanism** —
238
+ [security](./security.md#useviewer-is-display-only).
239
+
240
+ `loading` settles to `false` when the context read fails too: `memberId` is then `null`, and the
241
+ failure is logged to the console.
242
+
243
+ ## `useAppContext()` — the host's context, and whether it could be read
244
+
245
+ `useAppContext()` (`dist/viewer.d.ts`) is the one read every hook above is a view of: the
246
+ signed-in member, whether comments are enabled, the workspace's zone and currency, and three
247
+ states — `resolved` once it answered, `settled` once it answered OR failed, and `failure` —
248
+ `{ message, retry }`, `null` unless the read failed. Where it failed every fact is unknown, so a
249
+ surface that dates or counts by them draws the failure rather than guessing the reader's zone.
250
+
251
+ ## The workspace's zone at the root
252
+
253
+ An app that draws with `@lotics/ui` mounts the kit's `LoticsLocaleProvider` at the root of
254
+ `src/main.tsx` with `zone` set to `useAppContext().timezone`, and draws nothing until the context
255
+ has `settled` — where it failed, it draws `failure` with its `retry` in place of the app. Then every
256
+ instant the kit prints (`useFormatDate`) and every day it files by or counts to (`useCalendarDay`)
257
+ is the workspace's, never the browser's. The sandbox starter's `src/main.tsx` does exactly this.
258
+
259
+ ## `useWorkspaceTimezone()` / `useWorkspaceCurrency()` — what the workspace states
260
+
261
+ Both read the same context as `useViewer()` (`dist/viewer.d.ts`), and both are `undefined`
262
+ until it resolves, where it failed, and from a host that predates them.
263
+
264
+ - **`useWorkspaceTimezone()`** → the workspace's IANA zone (`Asia/Ho_Chi_Minh`). It dates an
265
+ INSTANT — a record's own `__created_at`, a comment's time. A stored `date`/`datetime` cell is a
266
+ wall clock and needs none. The kit reads it through the root locale provider (above) — never
267
+ passed to a date call by hand.
268
+ - **`useWorkspaceCurrency()`** → the workspace's ISO 4217 code (`VND`), what money is counted in
269
+ where the field states no code of its own. Pass it as the money's `currency`: a money format
270
+ given no code prints the kit's home market, which says nothing about this workspace.
239
271
 
240
272
  ## Comments: `useComments` / `useCommentCounts`
241
273
 
242
274
  Record comments — member-to-member discussion attached to any record the app reaches
243
- (`dist/src/comments.d.ts`).
275
+ (`dist/comments.d.ts`).
244
276
 
245
277
  ### Enabling comments
246
278
 
247
- Opt in via the manifest; the flag syncs on deploy:
279
+ Opt in on the app — a JSON app states it in its model; a custom-code app sets it live, as a new
280
+ version:
248
281
 
249
282
  ```jsonc
250
- // package.json → "lotics"
251
- "capabilities": { "comments": true }
283
+ // update_app
284
+ { "app_id": "app_…", "capabilities": { "comments": true } }
252
285
  ```
253
286
 
254
287
  Both hooks expose **`available`** — `true` only when a member is signed in **and** the app declared
@@ -305,17 +338,15 @@ updateComment, deleteComment, refetch }`.
305
338
  on it.
306
339
  - **The author names itself.** `author` carries `{ name, image? }`, resolved server-side on every
307
340
  comment the server returns — list, create and update alike — including one written from another
308
- app by someone the record references nowhere. So a thread that crosses a role boundary is legible without the app declaring member
309
- access it does not otherwise need, and a per-viewer app never widens its reach for a display
310
- string. `name` is `null` when the id no longer resolves in the org (a removed member) — render a
341
+ app by someone the record references nowhere, so a thread needs no declared member access.
342
+ `name` is `null` when the id no longer resolves in the org (a removed member) — render a
311
343
  fallback (`Timeline` has an `unknownMember` label for exactly this). The one comment with no
312
344
  `author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
313
345
  not their name, and it will not invent one. The server's row replaces it when the refetch lands.
314
- - **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
315
- the next focus or explicit `refetch()` — not on its own. Comments are the one read that does not
316
- keep itself current, and not because apps lack realtime: the channel carries **record** changes,
317
- and a comment is its own entity that emits no record event. Your queries DO get pushed
318
- ([data_fetching.md](./data_fetching.md)) — don't read this line as "no push" and build a poll.
346
+ - **Freshness:** cached like a query — re-read on arrival and on focus/reconnect, so another
347
+ viewer's comment appears on the next arrival, focus or explicit `refetch()` — not on its own: the realtime channel carries **record**
348
+ changes, and a comment emits no record event. Your queries DO get pushed
349
+ ([data_fetching.md](./data_fetching.md)).
319
350
 
320
351
  ### Counts
321
352
 
@@ -328,8 +359,8 @@ row is on screen — no request is sent and `counts` is `{}`.
328
359
 
329
360
  ## Pairing with `@lotics/ui`
330
361
 
331
- The SDK ships zero components; these `@lotics/ui` components are shaped to accept SDK values
332
- directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
362
+ The SDK ships zero components; these `@lotics/ui` components (a sandbox session's kit, see
363
+ [AGENTS.md](../AGENTS.md)) are shaped to accept SDK values directly:
333
364
 
334
365
  | Value | Component | Feed it |
335
366
  | --- | --- | --- |
@@ -338,8 +369,6 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
338
369
  | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
339
370
  | A comment thread | `Timeline` + `Composer` (`@lotics/ui/composer`) | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
340
371
 
341
- The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
342
-
343
372
  ## Where each hook works
344
373
 
345
374
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
@@ -349,4 +378,5 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
349
378
  | `useFieldOptions` | ✓ | ✓ |
350
379
  | `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
351
380
  | `useViewer` | member id (view-as target) | `null` |
381
+ | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
352
382
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |