@lotics/app-sdk 0.103.0 → 0.105.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.
package/AGENTS.md CHANGED
@@ -4,9 +4,8 @@
4
4
  hooks over the host bridge, the cell readers and `mount()`; `@lotics/app-sdk/router` is
5
5
  `AppRouter`. Data and RPC only — no component. What the app draws is its own.
6
6
 
7
- **`@lotics/ui` and its engines are not on npm.** The component kit is installed only inside a
8
- sandbox session's working tree; an app scaffolded on your machine draws with its own components.
9
- Where a doc pairs an SDK value with a kit component, that pairing applies in a sandbox session.
7
+ **Draw with any UI library.** The SDK hands over data and nothing to draw it with, so the app
8
+ picks its own components; the docs' examples use plain elements.
10
9
 
11
10
  This file is the index. The **exact type** of anything is its shipped declaration,
12
11
  `dist/<name>.d.ts` for a hook or reader — **never guess a shape; open the file.**
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Folds the AI-SDK UI-message SSE stream into the `UIMessagePart[]` shape
3
- * `@lotics/ui` `AgentRun` renders. `ai` is imported for types only, so no `ai`
2
+ * Folds the AI-SDK UI-message SSE stream into the ai-sdk `UIMessagePart[]`
3
+ * shape. `ai` is imported for types only, so no `ai`
4
4
  * runtime enters the app bundle; unknown chunk types are ignored. A structured
5
5
  * agent's `output` is the result the server accepted (`data-run-output`, sent
6
6
  * before `finish`); until it arrives, `submit_result`'s input — never a part.
@@ -74,7 +74,7 @@ export interface PendingChoice {
74
74
  }
75
75
  /** The pending `ask_user_choice`, non-null exactly while parked. */
76
76
  export declare function pendingInteractiveCall(state: AgentRunState): PendingChoice | null;
77
- /** `answers` align to `questions` by index (`ClarifyWizard`'s contract); a missing or empty one is skipped. */
77
+ /** `answers` align to `questions` by index; a missing or empty one is skipped. */
78
78
  export declare function buildChoiceOutput(questions: ChoiceQuestion[], answers: {
79
79
  value: string;
80
80
  custom: boolean;
package/dist/hooks.d.ts CHANGED
@@ -91,14 +91,8 @@ export declare function useFileUpload(): FileUploadState;
91
91
  * ```tsx
92
92
  * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
93
93
  * const design = useWorkflow("design");
94
- * // attach: <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
95
- * // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake_case → camelCase) — the
96
- * // app owns this data→UI adapter; the SDK never imports @lotics/ui:
97
- * // files.map((f) => (
98
- * // <FileThumbnail
99
- * // file={{ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.preview_url }}
100
- * // uploading={f.status === "uploading"} onRemove={() => remove(f.id)} />
101
- * // ))
94
+ * // attach: <button onClick={() => pickFiles({ accept: "image/*" }).then(add)}>Attach</button>
95
+ * // preview: files.map((f) => <img key={f.id} src={f.preview_url} alt={f.filename} />)
102
96
  * // send: design({ photo: fileIds[0] }); clear();
103
97
  * ```
104
98
  */
@@ -161,9 +155,7 @@ export interface MembersOptions {
161
155
  *
162
156
  * ```tsx
163
157
  * const { members } = useMembers({ group: GRP.sale });
164
- * // <Select variant="native" options={members.map((m) => ({
165
- * // value: m.id, label: m.name || m.email || m.id, image: m.image,
166
- * // }))} />
158
+ * // members.map((m) => <option key={m.id} value={m.id}>{m.name || m.email || m.id}</option>)
167
159
  * ```
168
160
  */
169
161
  export declare function useMembers(opts?: MembersOptions): MembersState;
@@ -186,9 +178,9 @@ export interface UseAgentRun<TInput, TOutput> {
186
178
  cancel: () => void;
187
179
  /** `awaiting_input`: parked on the agent's question (`pendingChoice`). */
188
180
  status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
189
- /** The ordered transcript, for `@lotics/ui` `AgentRun` as is. */
181
+ /** The ordered transcript, as ai-sdk `UIMessage.parts`. */
190
182
  parts: AgentUIPart[];
191
- /** Non-null exactly while `awaiting_input`; `questions` map onto `ClarifyWizard`. */
183
+ /** Non-null exactly while `awaiting_input`. */
192
184
  pendingChoice: PendingChoice | null;
193
185
  /** Answer the pending ask (one `{value, custom}` per question, by index) and
194
186
  * continue the run; resolves like `run`. Rejects when nothing is pending or
@@ -211,8 +203,8 @@ export interface UseAgentRun<TInput, TOutput> {
211
203
  * ```tsx
212
204
  * const recognize = useAgentRun("recognize");
213
205
  * await recognize.run({ image_file_id }, { sessionId });
214
- * // <AgentRun parts={recognize.parts} state={recognize.status === "streaming" ? "streaming" : "done"} />
215
- * // then read recognize.output (structured) or recognize.text (free-text)
206
+ * // recognize.parts streams the transcript; then read recognize.output
207
+ * // (structured) or recognize.text (free-text)
216
208
  * ```
217
209
  */
218
210
  export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** `@lotics/app-sdk`: hooks, cell readers and `mount()`. Data and RPC only — no kit component. */
1
+ /** `@lotics/app-sdk`: hooks, cell readers and `mount()`. Data and RPC only — no component. */
2
2
  export { mount } from "./mount.js";
3
3
  export type { MountOptions } from "./mount.js";
4
4
  export { reportAppError } from "./error_report.js";
package/dist/index.js CHANGED
@@ -24214,7 +24214,7 @@ var appWorkflowContractSchema = zod_default.object({
24214
24214
  inputs: zod_default.record(zod_default.string(), appWorkflowInputSchema).optional(),
24215
24215
  outputs: zod_default.record(zod_default.string(), appWorkflowOutputSchema).optional()
24216
24216
  });
24217
- var APP_EXPORT_REPORT_KEYS = ["title", "lines", "readings"];
24217
+ var APP_EXPORT_REPORT_KEYS = ["title", "lines", "readings", "at", "dates"];
24218
24218
  var appExportTemplateSchema = zod_default.object({
24219
24219
  template_id: zod_default.string().min(1).describe("The document template (`dtl_\u2026`) filled: an Excel workbook, or an HTML page made a PDF."),
24220
24220
  rows: zod_default.string().regex(APP_ALIAS_REGEX).refine((key) => !APP_EXPORT_REPORT_KEYS.some((taken) => taken === key), {
package/dist/members.d.ts CHANGED
@@ -22,9 +22,8 @@ export interface ResolvedMember {
22
22
  /** ISO timestamp of joining the organization. */
23
23
  joined?: string;
24
24
  /**
25
- * `true` for a member who has left; never `false`. Feed it to `inactive` on
26
- * `MemberChip` / `MemberProfileCard`. The roster never returns one, since a
27
- * departed member cannot be assigned.
25
+ * `true` for a member who has left; never `false`. The roster never returns
26
+ * one, since a departed member cannot be assigned.
28
27
  */
29
28
  archived?: true;
30
29
  }
package/dist/queries.d.ts CHANGED
@@ -56,8 +56,7 @@ export interface QueryFilterGroup<C extends string = string> {
56
56
  }
57
57
  /**
58
58
  * Runtime filter applied after the named query, bounded to its projected
59
- * columns — by `C` at compile time and again on the server. Build a group with
60
- * `columnFilterToConditions` (`@lotics/ui/column_filter`).
59
+ * columns — by `C` at compile time and again on the server.
61
60
  */
62
61
  export type QueryFilter<C extends string = string> = QueryFilterCondition<C> | QueryFilterGroup<C>;
63
62
  /** A `filter`/`sort` key on alias `K`: its projected columns, else `string`. */
@@ -185,6 +184,13 @@ export interface ExportReport {
185
184
  title: string;
186
185
  lines: string[];
187
186
  readings: ReportReading[];
187
+ /** Each date filter narrowing the rows, by field alias: its bounds, a day or a minute of one, with no zone. */
188
+ dates?: Record<string, {
189
+ from?: string;
190
+ to?: string;
191
+ }>;
192
+ /** The file's name, in the template grammar over the report; absent, the template's name, or `title`. */
193
+ filename?: string;
188
194
  }
189
195
  /** The file an export makes: the default report workbook — its words in `language`, the rows under `columns` on the
190
196
  * tab `sheet` — or a template the query declares under `templates`, by its name there. */
@@ -232,8 +238,8 @@ export interface FieldOptionsState<C extends string = string> {
232
238
  fields: Partial<Record<C, FieldOptions>>;
233
239
  /**
234
240
  * A number column read in each row's own unit or currency, keyed the same way: a
235
- * condition comparing it names one of these as its `unit_option` — pass it whole as
236
- * a `FilterChip` column's `units`. A figure read in one unit for every row has none.
241
+ * condition comparing it names one of these as its `unit_option`. A figure read in one
242
+ * unit for every row has none.
237
243
  */
238
244
  units: Partial<Record<C, FigureUnits>>;
239
245
  loading: boolean;
@@ -252,13 +258,12 @@ export interface FieldOptionsOptions {
252
258
  *
253
259
  * ```tsx
254
260
  * const { fields, units } = useFieldOptions("records");
255
- * // populate + color a picker:
256
- * <Select variant="native" options={fields.status?.options ?? []}
257
- * renderOptionContent={(o) => <Status option={o} />} />
261
+ * // populate a picker:
262
+ * (fields.status?.options ?? []).map((o) => <option key={o.key} value={o.key}>{o.label}</option>)
258
263
  * // color a stored value:
259
- * <Status option={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
260
- * // a range on a figure each row reads in its own unit:
261
- * <FilterChip column={{ key: "weight", label: "Weight", type: "number", units: units.weight }} … />
264
+ * fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")?.color
265
+ * // a range on a figure each row reads in its own unit names one:
266
+ * units.weight?.options.map((o) => o.key) // → a condition's `unit_option`
262
267
  * ```
263
268
  */
264
269
  export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState<ColumnKeyOf<K>>;
@@ -33,12 +33,12 @@ export interface UseRecording<I> {
33
33
  * const visit = useRecording("log_visit");
34
34
  * if (!visit.available) return null;
35
35
  * if (visit.state.phase === "live" && visit.state.inputs.site === siteId) {
36
- * return <Button onPress={() => visit.stop()}>Stop</Button>;
36
+ * return <button onClick={() => visit.stop()}>Stop</button>;
37
37
  * }
38
38
  * return (
39
- * <Button disabled={visit.busy} onPress={() => visit.start({ site: siteId })}>
39
+ * <button disabled={visit.busy} onClick={() => visit.start({ site: siteId })}>
40
40
  * Record the visit
41
- * </Button>
41
+ * </button>
42
42
  * );
43
43
  * ```
44
44
  */
package/dist/row.d.ts CHANGED
@@ -54,8 +54,7 @@ export interface AppFile {
54
54
  document_template_id?: string;
55
55
  }
56
56
  /**
57
- * The presigned files, skipping any without a `url`; `toDisplayFile`
58
- * (`@lotics/ui/file_thumbnail`) converts one for the kit. A lookup arrives one
57
+ * The presigned files, skipping any without a `url`. A lookup arrives one
59
58
  * level deeper, flattened as in {@link readLinks}.
60
59
  */
61
60
  export declare function readFiles(v: unknown): AppFile[];
package/dist/select.d.ts CHANGED
@@ -5,10 +5,10 @@ export interface ResolvedOption {
5
5
  label: string;
6
6
  /**
7
7
  * A palette token (`"blue"`), from `useFieldOptions` only — a cell carries
8
- * key and label. `@lotics/ui` `Status` takes the option whole.
8
+ * key and label.
9
9
  */
10
10
  color?: string;
11
- /** A brand or icon mark, from `useFieldOptions` only; `Status` draws it in the dot's place. */
11
+ /** A brand or icon mark, from `useFieldOptions` only. */
12
12
  mark?: {
13
13
  kind: "brand";
14
14
  name: string;
package/dist/viewer.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare function useAppContext(): {
27
27
  export declare function useWorkspaceTimezone(): string | undefined;
28
28
  /**
29
29
  * The ISO 4217 code for money whose field states none — pass it as `currency`,
30
- * or the kit prints its home market. `undefined` until resolved.
30
+ * or a money format prints a default market. `undefined` until resolved.
31
31
  */
32
32
  export declare function useWorkspaceCurrency(): string | undefined;
33
33
  /**
package/docs/ai.md CHANGED
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
13
13
 
14
14
  ## Declared agents — what `useAgentRun` runs
15
15
 
16
- An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`, and a sandbox deploy warns about any alias the code calls that is not bound.
16
+ An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`.
17
17
 
18
18
  A declaration carries:
19
19
 
@@ -39,7 +39,7 @@ A declaration carries:
39
39
 
40
40
  So an agent that reads or writes records needs a named query or workflow for each thing it touches — the same declaration the app's own UI uses. Declare only what that agent needs; a second agent in the same app can declare a narrower set.
41
41
 
42
- **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app and by `lotics app create --custom` and `lotics app deploy`, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry falls back to `Record<string, unknown>` input / `unknown` output.
42
+ **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings by `lotics app create --custom` and `lotics app deploy`, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry falls back to `Record<string, unknown>` input / `unknown` output.
43
43
 
44
44
  ---
45
45
 
@@ -62,35 +62,26 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
62
62
  | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this; the platform stamps `app_agent_runs.cancel_requested_at` |
63
63
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
64
64
  | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
65
- | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
66
- | `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
67
- | `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
65
+ | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. Each of its `questions` is `{question, options: {label, description}[], allow_custom}` |
66
+ | `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index. Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
67
+ | `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed |
68
68
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
69
69
  | `output` | `TOutput \| undefined` | The structured result the server accepted — read it once `status` is `"completed"`. `undefined` for a free-text agent |
70
70
  | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
71
71
 
72
- ### The `parts` transcript → `@lotics/ui` `AgentRun`
72
+ ### The `parts` transcript
73
73
 
74
74
  `parts` is the **ai-sdk `UIMessagePart[]`** shape — the same wire model chat and app agents both emit, so there is no bespoke transcript type to keep in sync. The reducer folds the run's SSE chunks into it, in stream order:
75
75
 
76
76
  | Part `type` | Carries | Rendering intent |
77
77
  |---|---|---|
78
78
  | `"text"` | `text`, `state` | Answer prose, grows as it streams |
79
- | `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so `AgentRun` shows it collapsed / revealed on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
80
- | `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for `AgentRun`'s on-demand reveal, not for the feed row |
79
+ | `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so a feed can show it collapsed and reveal it on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
80
+ | `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for an on-demand reveal, not for the feed row |
81
81
 
82
- The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
82
+ The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. Because `parts` is the ai-sdk shape, any renderer of ai-sdk message parts draws it with no adapter.
83
83
 
84
- ```tsx
85
- <AgentRun
86
- parts={run.parts}
87
- state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
88
- error={run.error} // the breaking error — a terminal danger row
89
- labelForCall={(call) => toolLabels[call.toolName]} // optional: localize tool labels
90
- />
91
- ```
92
-
93
- `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
84
+ **Errors surface two ways:** a per-tool failure is an `output-error` part — the feed flows on, exactly like a run that retried and recovered; a **breaking** error that killed the run lives in `run.error` (not in `parts`), and is the run's last word. A feed in a bounded container (a dialog, a panel) should follow the stream, so new content never grows below the fold. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
94
85
 
95
86
  `run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
96
87
 
@@ -114,20 +105,16 @@ same eventual `output`:
114
105
  const run = useAgentRun("importer");
115
106
  // …
116
107
  {run.pendingChoice ? (
117
- <ClarifyWizard
118
- questions={run.pendingChoice.questions.map((q) => ({
119
- question: q.question,
120
- answers: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })),
121
- allowCustom: q.allow_custom,
122
- }))}
123
- onSubmit={(answers) => { void run.answerChoice(answers); }}
108
+ <ChoiceForm
109
+ questions={run.pendingChoice.questions} // your own form: one pick (or free text) per question
110
+ onSubmit={(answers) => { void run.answerChoice(answers); }} // [{ value, custom }], one per question
124
111
  onCancel={() => run.cancel()}
125
112
  />
126
113
  ) : null}
127
114
  ```
128
115
 
129
116
  The ask renders in the feed as a settled tool row once answered. `run()` resolves
130
- `{kind:"parked"}` when the run parks — the wizard renders off `pendingChoice` — and the
117
+ `{kind:"parked"}` when the run parks — the form renders off `pendingChoice` — and the
131
118
  continuation's landing (from `answerChoice`) carries the final output, or `parked` again for a
132
119
  follow-up ask. A dropped stream can't lose the question: the recovery poll rebuilds it from the
133
120
  persisted run, so `awaiting_input` always yields an answerable `pendingChoice` (or a retryable
@@ -11,20 +11,20 @@ alias** (bound with `set_app_query`) and fills the template's declared `{{params
11
11
  value holes; the server holds the canonical AST and runs it under the **app owner's** authority
12
12
  ([./security.md](./security.md)). The generated `.lotics/app_queries.d.ts` augments `AppQueries`,
13
13
  so params are typed per the binding; an alias it does not carry is accepted as a plain string,
14
- untyped. It is written from the app's live bindings whenever a sandbox session opens on the app,
15
- and by `lotics app create --custom` and `lotics app deploy`. Every hook reads through the SDK's own cache, over the host RPC bridge.
14
+ untyped. It is written from the app's live bindings by `lotics app create --custom` and
15
+ `lotics app deploy`. Every hook reads through the SDK's own cache, over the host RPC bridge.
16
16
 
17
17
  ## Choosing a read
18
18
 
19
19
  | Call | Answers | Reach for it when |
20
20
  |---|---|---|
21
21
  | `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
22
- | `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages behind `@lotics/ui` `Pagination` |
22
+ | `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages |
23
23
  | `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
24
24
  | `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
25
25
  | `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
26
26
  | `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
27
- | `exportQuery(alias, { params, filter, sort, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `report` is `{ title, lines, readings }`; `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
27
+ | `exportQuery(alias, { params, filter, sort, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `report` is `{ title, lines, readings, dates?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
28
28
 
29
29
  Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
30
30
  `isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
@@ -86,7 +86,7 @@ all its calls, and each call states its own `filter`, `sort`, `total` and `rows`
86
86
  | `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Disabling also **hides** previously loaded rows (the cached answer survives; it re-renders instantly when re-enabled). |
87
87
  | `revalidateOnFocus` | `boolean` | `true` | `false` = no re-read on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
88
88
  | `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc", blank_position? }]`. An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
89
- | `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. Build from per-column UI state with `columnFilterToConditions` (`@lotics/ui/column_filter`). |
89
+ | `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. |
90
90
  | `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
91
91
  | `page` | `number` | — | Numbered pages of `page` rows: `page` (from 0), `setPage`, `pageCount`, `hasMore`, and `total`, counted by default. |
92
92
  | `pageAt` | `{ at, onChange }` | — | With `page`, the page the screen keeps (its address, so a way back or a reload opens on it): `at` is read, `setPage` calls `onChange`, and nothing resets it — send a changed read back to 0 yourself. |
@@ -119,10 +119,6 @@ check reaches the helper that derives the key, not only the hook call that sends
119
119
  projected columns — ask the alias that CARRIES the column
120
120
  ([./members_and_options.md](./members_and_options.md)).
121
121
 
122
- `@lotics/ui`'s `columnFilterToConditions` carries the same parameter (`FilterableColumn<C>` →
123
- `FilterConditionNode<C>`), so a per-column filter UI built over a typed alias composes without a
124
- cast.
125
-
126
122
  Refinement order is fixed: the runtime **filter narrows** the named query's result, then **sort
127
123
  orders** it, then **`limit` / `page` / `more` cut** it. The template's own filters/sort/limit run first,
128
124
  inside the named query.
@@ -304,7 +300,7 @@ const { rows, total, pageCount, page, setPage, hasMore } =
304
300
  useQuery("orders", { q }, { page: 25, sort, filter });
305
301
  ```
306
302
 
307
- The page model behind a numbered table (pairs with `@lotics/ui` `Pagination`). It owns the page
303
+ The page model behind a numbered table. It owns the page
308
304
  index and reads two things: the current page, and a count over the filtered set.
309
305
 
310
306
  - **Result-set identity is `(params, filter, sort)`.** Changing any of them resets to page 0.
@@ -416,14 +412,14 @@ and the UI prints `00:00`. When you need the time, the query must output the col
416
412
  `"2026-05"` (month) or `"2026"` (year) — when a document carries only that precision. Both decoders
417
413
  resolve it to its **period start** (missing month/day → 1): `row.date("2026-05")` →
418
414
  May 1 2026 at local midnight, `row.date("2026")` → Jan 1 2026 — never `null`. For DISPLAY that
419
- respects the precision (show "05/2026", not "01/05/2026"), format with `@lotics/ui`'s `formatDate`
420
- on the raw cell string rather than the decoded `Date`. Sorting/filtering server-side already treats
415
+ respects the precision (show "05/2026", not "01/05/2026"), format the raw cell string rather than
416
+ the decoded `Date`. Sorting/filtering server-side already treats
421
417
  a partial as its period start.
422
418
 
423
419
  **`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
424
420
  `useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
425
- `label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `Status`
426
- — see [./members_and_options.md](./members_and_options.md).
421
+ `label === key` (the stale state is explicit, never hidden) — see
422
+ [./members_and_options.md](./members_and_options.md).
427
423
 
428
424
  **`readMembers`.** The member shape, its gates and its `null` name:
429
425
  [./members_and_options.md](./members_and_options.md#member-cells-readmembers).
@@ -446,8 +442,8 @@ show the locked state and route edits through the locked-change request flow
446
442
 
447
443
  **`readCreatedAt` / `readUpdatedAt`.** Take the whole row object, not a cell. Unlike a `date`/`datetime` CELL (a timezone-less
448
444
  workspace wall-clock at minute precision), these are true **instants**: ISO-8601 with an offset on
449
- the wire, parsed as instants, dated in the workspace's zone — `useWorkspaceTimezone()`, which
450
- the app's root hands the kit's locale ([members & options](./members_and_options.md)). `null` on a grouped row, which has no
445
+ the wire, parsed as instants, dated in the workspace's zone — `useWorkspaceTimezone()`
446
+ ([members & options](./members_and_options.md)). `null` on a grouped row, which has no
451
447
  originating record.
452
448
 
453
449
  ## Complete select option sets
@@ -471,18 +467,12 @@ never options derived from loaded rows, which are incomplete until every page lo
471
467
  - **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED
472
468
  fields to its update workflow. The full rule and the locked-record path:
473
469
  [./mutations.md](./mutations.md).
474
- - **Reuse the kit's utilities.** `@lotics/ui` ships the formatters (`formatMoney`, `formatDate` /
475
- `parseDate` / `toISODate`) — never hand-roll `dd/MM` or currency strings. Grep `@lotics/ui`
476
- exports before writing one.
477
470
 
478
471
  ## Search-as-you-type
479
472
 
480
- A search-first picker (type → ranked results → pick) is one `@lotics/ui` component plus three SDK
473
+ A search-first picker (type → ranked results → pick) is the app's own combobox over three SDK
481
474
  pieces. Compose these — don't hand-roll search:
482
475
 
483
- - **`Combobox`** (`@lotics/ui/combobox`) owns the interaction — debounced `onSearchChange`, a
484
- popover listbox with rich rows (`renderOptionContent`), keyboard navigation, `recentOptions`,
485
- `allowCustom`. (For a known small list with no search box, `Select`.)
486
476
  - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
487
477
  maintained search document: **diacritics- and case-insensitive**, trigram-indexed, and AND-ed
488
478
  with the template's `filter` (search within a scope). The full `search` contract is in
@@ -497,19 +487,17 @@ pieces. Compose these — don't hand-roll search:
497
487
  - **Debounce the term — `enabled` is not a substitute.** `enabled` decides *whether* to ask, not
498
488
  *how often*: wire an input's own state into `params` and every keystroke past the first is a
499
489
  fresh cache key and a fresh request (and a re-count, where `total` is asked). Keep the input's
500
- value in one state and debounce the COMMIT into a second (`useDebouncedCallback` from
501
- `@lotics/ui/use_debounced_callback`, ~250 ms); anything reporting on the results (an empty state,
502
- a count) reads the committed one. `Combobox` already
503
- debounces its own `onSearchChange`; this is for a search box you built yourself.
504
- - **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
505
- `recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
490
+ value in one state and debounce the COMMIT into a second (~250 ms); anything reporting on the
491
+ results (an empty state, a count) reads the committed one.
492
+ - **`useRecents(key, { max })`** — persist the picked option locally and offer the list before
493
+ anything is typed ([./navigation_and_state.md](./navigation_and_state.md)).
506
494
 
507
495
  ```tsx
508
496
  const [typed, setTyped] = useState(""); // the input's own value, every keystroke
509
497
  const [term, setTerm] = useState(""); // what the server is asked, once typing settles
510
- const commit = useDebouncedCallback(setTerm, 250);
498
+ const commit = useDebouncedCallback(setTerm, 250); // any debounce helper
511
499
 
512
- <TextInput type="search" value={typed} onChangeText={(v) => { setTyped(v); commit(v.trim()); }} />;
500
+ <input type="search" value={typed} onChange={(e) => { setTyped(e.target.value); commit(e.target.value.trim()); }} />;
513
501
 
514
502
  const { rows, loading } = useQuery(
515
503
  "searchCustomers",
@@ -529,13 +517,13 @@ relationship exists.
529
517
  When the user doesn't know the term — "show me everything, let me narrow it" — build a modal table
530
518
  they can browse (numbered pages), search, sort, and filter:
531
519
 
532
- - **The screen is app-owned**, composed from `@lotics/ui`: a `Dialog` over a search `TextInput` + filter
533
- pills (`FilterChip column=`) + `Table` + `Pagination`. It needs both `@lotics/ui` and the SDK (which is
534
- UI-free), so it lives in the app (e.g. a `record_picker.tsx`) — reuse it for any table by passing
535
- a different `alias` + column config.
520
+ - **The screen is app-owned**: a dialog over a search input, filter pills, a table and
521
+ pagination, drawn with whatever UI the app uses over the SDK (which is UI-free). It lives in the
522
+ app (e.g. a `record_picker.tsx`) — reuse it for any table by passing a different `alias` + column
523
+ config.
536
524
  - **`useQuery(alias, params, { page: n, sort, filter })`** drives it: the page of rows, the
537
525
  `total` and `pageCount` for "Page 1 of N" (counted by default with `page`), and `setPage`.
538
- - **Filter pills → runtime `filter`** via `columnFilterToConditions` (`@lotics/ui/column_filter`);
526
+ - **Filter pills → runtime `filter`**, one condition per pill;
539
527
  **column-header sort → runtime `sort`** by mapping the table's `{ key, order }` to
540
528
  `[{ field_key: key, order }]`. Both are server-bounded to the query's output columns — the picker
541
529
  can't widen exposure ([./queries.md](./queries.md)).
package/docs/files.md CHANGED
@@ -13,7 +13,7 @@ discipline in [data fetching](./data_fetching.md).
13
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 |
14
14
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
15
15
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
16
- | Show it | `@lotics/ui` `FileThumbnail` / `FileThumbnailGrid` / `FileGalleryDialog` | map to `DisplayFile` (see below) |
16
+ | Show it | the app's own thumbnail and viewer | `url` / `thumbnail_url` (see below) |
17
17
  | Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
18
18
  | Rename one a record holds | `renameFile(file_id, filename)` | an `AppFile` — a new, unattached file over the same bytes |
19
19
 
@@ -145,16 +145,14 @@ const { files, add, remove, clear, uploading, fileIds } = useAttachments();
145
145
  const design = useWorkflow("design");
146
146
 
147
147
  // attach: picking is the app's choice — button, paste, or drop
148
- <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
148
+ <button type="button" onClick={() => pickFiles({ accept: "image/*" }).then(add)}>Attach</button>
149
149
 
150
- // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake -> camel)
150
+ // preview: the local preview is there before the upload finishes
151
151
  {files.map((f) => (
152
- <FileThumbnail
153
- key={f.id}
154
- file={{ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.preview_url }}
155
- uploading={f.status === "uploading"}
156
- onRemove={() => remove(f.id)}
157
- />
152
+ <figure key={f.id} aria-busy={f.status === "uploading"}>
153
+ <img src={f.preview_url} alt={f.filename} />
154
+ <button type="button" onClick={() => remove(f.id)}>Remove</button>
155
+ </figure>
158
156
  ))}
159
157
 
160
158
  // send: gate on `uploading`, payload is `fileIds`
@@ -174,15 +172,10 @@ const onAdd = async (picked: File[]) => {
174
172
  await save({ item_id: r.__source_record_id, photo_added: stored });
175
173
  };
176
174
 
177
- <RecordFiles
178
- files={held.map(toDisplayFile)}
179
- uploads={queue.files.map((f) => ({
180
- id: f.id,
181
- filename: f.filename,
182
- mimeType: f.mime_type,
183
- previewUrl: f.preview_url,
184
- status: f.status === "error" ? "error" : "uploading",
185
- }))}
175
+ // One grid: the stored pile, then the queue still uploading (or failed) beside it.
176
+ <FileGrid
177
+ stored={held} // AppFile[]: url, thumbnail_url, filename, mime_type
178
+ pending={queue.files} // AttachedFile[]: preview_url, status
186
179
  onAdd={(picked) => { void onAdd(picked).catch(report); }}
187
180
  />
188
181
  ```
@@ -216,13 +209,10 @@ Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id?
216
209
  - `file_id` — the stored id, set once `status` is `"ready"`. `fileIds` includes only ready
217
210
  entries, so sending while `uploading` is true would silently drop in-flight files — gate on it.
218
211
 
219
- Wiring to `@lotics/ui`: in a `Composer`, trigger picking from `actionsButton` (via `pickFiles`),
220
- render the attachment pills with `FileThumbnail` as above, and gate `sendDisabled` on `uploading`.
221
- A record's section passes the queue to `RecordFiles` `uploads` (a `PendingUpload` each) so the
222
- picked file and the stored pile stand in one grid. For a full add-files *screen*, map each
223
- `AttachedFile` to a `FileThumbnailGrid` `FileUpload` entry — ready -> `{ status: "complete", id:
224
- file_id, file: <DisplayFile> }`, else `{ status, id, filename, mimeType: mime_type, previewUrl:
225
- preview_url }` — and the grid renders the uploading/error/retry tiles itself.
212
+ In a composer, trigger picking with `pickFiles`, show each attachment's `preview_url` as above,
213
+ and disable Send while `uploading`. A record's section draws the queue beside the stored pile, so
214
+ the picked file and the files the record holds stand in one grid; an entry whose `status` is
215
+ `"error"` offers remove and re-add.
226
216
 
227
217
  ## File cells in query results — `readFiles` and `AppFile`
228
218
 
@@ -263,8 +253,8 @@ file. It is pure and never throws.
263
253
  nothing.
264
254
 
265
255
  - **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
266
- the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
267
- falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
256
+ the variant exists; a not-yet-generated variant 404s on fetch. An `<img src={thumbnail_url}>`
257
+ falls back to `url` on image error.
268
258
  - **A presigned `url` is a bearer credential for the bytes.** It carries its own authorization —
269
259
  anyone holding it fetches the file for 24 h with no session — so it must never leave the render
270
260
  path: not into a log line, not into telemetry, not into an error report or a bug ticket. Don't
@@ -360,35 +350,17 @@ over 200 characters, punctuation alone, or the empty string a name expression yi
360
350
  nothing. `null` is the one absence that is not an error (an empty record field reads as `null`): it
361
351
  means "no name", and that entry keeps the stored filename.
362
352
 
363
- ## Previewing — wiring to `@lotics/ui`
353
+ ## Previewing
364
354
 
365
- Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
366
- hand-roll per-type rendering, and don't use `openExternal` as a preview (that's "open elsewhere",
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:
355
+ Render a file inline with whatever viewer the app uses — `openExternal` is not a preview (that's
356
+ "open elsewhere", not viewing). What the SDK hands over: `url` (the bytes), `thumbnail_url` (an
357
+ image's smaller variant), `filename` and `mime_type` to choose a viewer by. For an `AttachedFile`
358
+ still uploading, `preview_url` stands in for `url` (there is no server URL yet).
369
359
 
370
- | `AppFile` (SDK) | `DisplayFile` (`@lotics/ui`) |
371
- |---|---|
372
- | `id` | `id` |
373
- | `filename` | `filename` |
374
- | `mime_type` | `mimeType` |
375
- | `url` | `url` |
376
- | `thumbnail_url` | `thumbnailUrl` |
377
-
378
- For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
379
-
380
- - **`FileThumbnail`** — one square tile; `uploading` takes the queue status and overlays the
381
- scrim, the spinner or the retry, images fall back from `previewUrl` to `thumbnailUrl` to `url`.
382
- - **`FileThumbnailGrid`** — a grid of stored files plus a live `uploads` queue, in one grid.
383
- - **`FileGalleryDialog`** — the full-screen viewer (filename · counter · actions · close, with
384
- prev/next + ESC), delegating per-file to **`FilePreview`**, which dispatches by MIME. Wire
385
- `onFilePress` → a `number | null` `activeIndex`.
386
- - PDF renders **inline to a canvas** (a nested PDF browsing context is blocked in the sandboxed
387
- iframe; a canvas isn't). The PDF, Word, and Excel/CSV engines ship as `@lotics/ui`
388
- dependencies — no separate install — and are lazy-loaded, so apps that never preview a type pay
389
- no bundle cost.
390
- - The gallery's "open in new tab" action can't pop a window in the sandbox — pass
391
- `onOpenExternal` wired to the SDK's `openExternal` (omit it and the action hides).
360
+ - **A PDF draws to a canvas.** A nested PDF browsing context is blocked in the sandboxed iframe; a
361
+ canvas isn't, so a PDF viewer has to render pages itself.
362
+ - **"Open in new tab" goes through `openExternal`.** The sandbox drops `window.open`, so an action
363
+ that opens a file elsewhere calls the SDK's `openExternal(url)`.
392
364
 
393
365
  ## Filtering on files fields
394
366
 
@@ -54,13 +54,12 @@ up table edits with no app change.
54
54
 
55
55
  ```tsx
56
56
  const { fields } = useFieldOptions("orders"); // same alias you query
57
- // populate + color a picker (Select is @lotics/ui's rich select; its options
58
- // are { value, label }, so map the option key to `value`):
59
- <Select
60
- options={(fields.status?.options ?? []).map((o) => ({ value: o.key, label: o.label }))}
61
- renderOptionContent={(o) => <Status option={fields.status?.byKey(o.value)} />}
62
- value={status} onValueChange={setStatus}
63
- />
57
+ // populate a picker: the option KEY is the value a workflow input takes
58
+ <select value={status} onChange={(e) => setStatus(e.target.value)}>
59
+ {(fields.status?.options ?? []).map((o) => (
60
+ <option key={o.key} value={o.key}>{o.label}</option>
61
+ ))}
62
+ </select>
64
63
  ```
65
64
 
66
65
  ### What comes back
@@ -75,8 +74,8 @@ a compile error, and every key is optional. Each `FieldOptions`:
75
74
  | `options` | Every option of the field — `{ key, label, color, mark? }` (the option's own brand or glyph, where the field marks every option) — in field-config order, **including options not present in any current row** |
76
75
  | `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
77
76
 
78
- - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
79
- `@lotics/ui`'s `Status`; a missing/unrecognized token degrades to a neutral badge.
77
+ - `color` is a named palette token (e.g. `"blue"`, `"emerald"`), mapped to a color by the app;
78
+ draw a missing or unrecognized token neutral.
80
79
  - **A column the server can't map to a single source select field is simply absent** from
81
80
  `fields` — a UNION output whose arms disagree on the source field, or a computed column:
82
81
  `fields.status?.options ?? []`.
@@ -91,13 +90,15 @@ A number whose field names a `unit_field` or `currency_field` reads each row in
91
90
  on that row holds, so a comparison on it states which one as `unit_option` — the server refuses one
92
91
  that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
93
92
  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:
93
+ per such column, whether or not the query projects the select. A range filter on such a column
94
+ offers its `options` and puts the picked key on each condition:
95
95
 
96
96
  ```tsx
97
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`
98
+ const filter = {
99
+ field_key: "weight", operator: "greater_than", value: 10,
100
+ unit_option: unit, // one of units.weight?.options[].key
101
+ };
101
102
  ```
102
103
 
103
104
  ### Caching & freshness
@@ -112,7 +113,8 @@ until an edit drawer opens). State: `{ fields, units, loading, isValidating, err
112
113
 
113
114
  ```tsx
114
115
  const opt = readSelect(row.status)[0];
115
- <Status option={opt ? (fields.status?.byKey(opt.key) ?? opt) : null} />
116
+ const shown = opt ? (fields.status?.byKey(opt.key) ?? opt) : null; // carries `color` when known
117
+ {shown ? <Badge color={shown.color} label={shown.label} /> : null}
116
118
  ```
117
119
 
118
120
  `byKey` hit → the configured color. `byKey` miss (option removed post-write) → fall back to the
@@ -141,12 +143,11 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, ro
141
143
  "Admin" beside a name as rank. If the question your screen asks is "who is this person in the
142
144
  company", the answer is `groups`.
143
145
  - **`joined` is an ISO timestamp of when the membership began.** Render it at whatever precision
144
- your question needs — `@lotics/ui`'s `MemberPeek` shows month and year, because "is this
145
- the new person?" does not want a day. Absent on a public response, and on an id that did not
146
+ your question needs — "is this the new person?" wants month and year, not a day. Absent on a public response, and on an id that did not
146
147
  resolve: there is no membership to have begun.
147
148
  - **`archived: true` marks someone who has LEFT** — omitted otherwise, never `false`. It is the one
148
149
  field that is NOT gated (a departed colleague reading as a current assignee is wrong on a public
149
- app too). Feed it to `inactive` on `MemberChip` / `MemberPeek`.
150
+ app too). Draw such a person as inactive wherever they appear.
150
151
  - **An id that no longer resolves** (removed member, id outside the org) comes back with
151
152
  `name: null` — an explicit missing state, never an empty string. Render a placeholder.
152
153
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
@@ -219,11 +220,11 @@ the `select_member` field in the query, and the cell already carries the name.
219
220
  ```tsx
220
221
  // No useMembers. The projected cell is the member source.
221
222
  {readMembers(row.assignee).map((m) => (
222
- <MemberChip key={m.id} name={m.name ?? "—"} />
223
+ <span key={m.id}>{m.name ?? "—"}</span>
223
224
  ))}
224
225
  ```
225
226
 
226
- Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials). If a chip
227
+ Names render; only the avatar photo is unavailable, so draw initials in its place. If a name
227
228
  renders blank, fix the query projection — never widen the bindings.
228
229
 
229
230
  ## `useViewer()` — display-only identity
@@ -251,11 +252,9 @@ surface that dates or counts by them draws the failure rather than guessing the
251
252
 
252
253
  ## The workspace's zone at the root
253
254
 
254
- An app that draws with `@lotics/ui` mounts the kit's `LoticsLocaleProvider` at the root of
255
- `src/main.tsx` with `zone` set to `useAppContext().timezone`, and draws nothing until the context
256
- has `settled` — where it failed, it draws `failure` with its `retry` in place of the app. Then every
257
- instant the kit prints (`useFormatDate`) and every day it files by or counts to (`useCalendarDay`)
258
- is the workspace's, never the browser's. The sandbox starter's `src/main.tsx` does exactly this.
255
+ An app's root draws nothing until `useAppContext()` has `settled` — where it failed, it draws
256
+ `failure` with its `retry` in place of the app — and then dates every instant it prints, and every
257
+ day it files by or counts to, in `useAppContext().timezone`: the workspace's, never the browser's.
259
258
 
260
259
  ## `useWorkspaceTimezone()` / `useWorkspaceCurrency()` — what the workspace states
261
260
 
@@ -264,11 +263,11 @@ until it resolves, where it failed, and from a host that predates them.
264
263
 
265
264
  - **`useWorkspaceTimezone()`** → the workspace's IANA zone (`Asia/Ho_Chi_Minh`). It dates an
266
265
  INSTANT — a record's own `__created_at`, a comment's time. A stored `date`/`datetime` cell is a
267
- wall clock and needs none. The kit reads it through the root locale provider (above) — never
268
- passed to a date call by hand.
266
+ wall clock and needs none. Hand it to the root's date formatting once (above), rather than to
267
+ each date call.
269
268
  - **`useWorkspaceCurrency()`** → the workspace's ISO 4217 code (`VND`), what money is counted in
270
269
  where the field states no code of its own. Pass it as the money's `currency`: a money format
271
- given no code prints the kit's home market, which says nothing about this workspace.
270
+ given no code prints a default market, which says nothing about this workspace.
272
271
 
273
272
  ## Comments: `useComments` / `useCommentCounts`
274
273
 
@@ -319,8 +318,8 @@ columns, and a hook cannot be called conditionally, so the guard belongs at the
319
318
  renders the panel. State: `{ comments, loading, error, available, createComment,
320
319
  updateComment, deleteComment, refetch }`.
321
320
 
322
- - `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
323
- `Timeline`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
321
+ - `comments` — newest first on the wire (server order); a thread usually reads oldest-first, so
322
+ reverse it for display. Each `AppComment`: `{ id, record_id,
324
323
  table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
325
324
  (`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
326
325
  the server re-reads every attachment from storage by that id, so nothing else you hold about a
@@ -358,18 +357,6 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
358
357
  rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
359
358
  row is on screen — no request is sent and `counts` is `{}`.
360
359
 
361
- ## Pairing with `@lotics/ui`
362
-
363
- The SDK ships zero components; these `@lotics/ui` components (a sandbox session's kit, see
364
- [AGENTS.md](../AGENTS.md)) are shaped to accept SDK values directly:
365
-
366
- | Value | Component | Feed it |
367
- | --- | --- | --- |
368
- | A select value (stored or picker option) | `Status` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
369
- | A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
370
- | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
371
- | A comment thread | `Timeline` + `Composer` (`@lotics/ui/composer`) | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
372
-
373
360
  ## Where each hook works
374
361
 
375
362
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
package/docs/mutations.md CHANGED
@@ -40,8 +40,8 @@ Exact signature: `dist/hooks.d.ts`. `useWorkflow(alias)` returns a stable async
40
40
  render: one callable per alias, each run exactly as `useWorkflow`'s.
41
41
 
42
42
  Typing comes from `.lotics/app_workflows.d.ts`, which augments the SDK's `AppWorkflows` and
43
- `AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` whenever a sandbox
44
- session opens on the app, and by `lotics app create --custom` and `lotics app deploy`. The result:
43
+ `AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` by
44
+ `lotics app create --custom` and `lotics app deploy`. The result:
45
45
 
46
46
  | Declaration | Call-site type |
47
47
  |---|---|
@@ -379,9 +379,7 @@ The same trap one level up: **an action takes an ID, never a captured object.**
379
379
  `onPress={() => issueInvoice(invoice)}` closes over the invoice as it was when the press
380
380
  happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
381
381
  pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
382
- then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
383
- (`docs/data_entry.md` § A field that saves itself), which fixes the ordering; the captured value is the app's
384
- to get right.
382
+ then re-read or `find` where it is consumed).
385
383
 
386
384
  ## Diff before update — send only what changed
387
385
 
@@ -685,8 +683,8 @@ any alias: stand the other acts down, since the host would refuse them. A record
685
683
  app or the chat is invisible to this app, so `start` can still be refused while `busy` is false.
686
684
 
687
685
  **Filed means written.** When `state` becomes `filed`, every mounted query re-reads, exactly as
688
- after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation)). Draw the filed row's
689
- files and transcript with `@lotics/ui/recording`.
686
+ after a successful `useWorkflow` call ([refetch](#refetch-after-a-mutation)), and the filed row's
687
+ files and transcript read like any other.
690
688
 
691
689
  A mock fixture's `recordings` map (alias → `state`) draws each phase without a host
692
690
  ([runtime](./runtime.md#the-mock-harness-optionsfixture--__mock1)).
@@ -69,8 +69,7 @@ routes, and splats all work. Inside the tree, use react-router normally:
69
69
  ```
70
70
 
71
71
  `message` takes the path so the words may place it where the language needs
72
- it. Where the app uses `@lotics/ui`, read both from the kit's locale — the
73
- SDK ships none of its own.
72
+ it. The SDK ships no words of its own.
74
73
  - **Limitation: element routing only.** `AppRouter` mounts a plain browser
75
74
  router, not a react-router *data* router — route `loader`/`action` fields
76
75
  are ignored, and `useLoaderData` **throws** ("must be used within a data
package/docs/runtime.md CHANGED
@@ -279,11 +279,12 @@ predates the two workspace facts answers without them; the recording fields are
279
279
  context type is not exported from the package root, so type the result yourself
280
280
  via the `rpc<T>` generic.
281
281
 
282
- An app's root draws nothing until this op has answered or failed, then hands
283
- the zone to the kit's locale, so every instant the kit formats is dated in the
284
- workspace's zone ([members & options](./members_and_options.md)); money whose
285
- field states no code is counted in the workspace's currency. Where neither the field nor the host names a
286
- currency, the screen refuses by name rather than printing one nobody stated.
282
+ An app's root draws nothing until this op has answered or failed, then dates
283
+ every instant it formats in the workspace's zone
284
+ ([members & options](./members_and_options.md)); money whose field states no
285
+ code is counted in the workspace's currency. Where neither the field nor the
286
+ host names a currency, the screen refuses by name rather than printing one
287
+ nobody stated.
287
288
 
288
289
  ## `openExternal()` — open a link in a new tab
289
290
 
@@ -306,9 +307,8 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
306
307
  its own side — a URL handed across the bridge is never trusted.
307
308
  - Typical use: opening a workflow-generated file's `url` from
308
309
  `WorkflowResult.files[]` (see [mutations](./mutations.md)).
309
- - **Not a preview mechanism.** To *view* a file inline, use `@lotics/ui`'s
310
- `FilePreview`/`FileGalleryDialog` (see [files](./files.md)); `openExternal` is
311
- "leave the app".
310
+ - **Not a preview mechanism.** To *view* a file inline, render it in the app
311
+ (see [files](./files.md)); `openExternal` is "leave the app".
312
312
 
313
313
  ## `openApp()` — the cross-app hop
314
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.103.0",
3
+ "version": "0.105.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {