@lotics/app-sdk 0.90.0 → 0.90.2

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
@@ -24,7 +24,7 @@ signature; open the file.**
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
27
- | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`openApp`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
27
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, **the API address a standalone bundle reads out of its own page** (`<meta name="lotics-api-base">`, declared by whatever serves the app — no address is compiled into the SDK, so one bundle runs on any instance and a page without it refuses rather than guessing), `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`openApp`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
28
28
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
@@ -47,6 +47,10 @@ signature; open the file.**
47
47
  - **Narrow `__source_record_id` / `__source_table_id` before use** — a grouped query emits neither,
48
48
  so an unchecked read hands a workflow (or an AI record ref) `undefined`.
49
49
  → [data_fetching](./docs/data_fetching.md)
50
+ - **A standalone app takes its API address from the page it was served in**, never from a
51
+ constant — the serving host declares `<meta name="lotics-api-base">`, and a bundle that finds
52
+ none refuses instead of addressing someone else's instance. Nothing for an app to configure;
53
+ build against 0.90.2 or later. → [runtime](./docs/runtime.md)
50
54
  - **Errors fail loud** — no swallowed catches, no silent fallbacks.
51
55
 
52
56
  ## Keeping this reference current
@@ -383,10 +383,10 @@ export interface FieldOptionsOptions {
383
383
  * ```tsx
384
384
  * const { fields } = useFieldOptions("records");
385
385
  * // populate + color a picker:
386
- * <Picker options={fields.status?.options ?? []}
387
- * renderOptionContent={(o) => <OptionBadge value={o} />} />
386
+ * <Select variant="native" options={fields.status?.options ?? []}
387
+ * renderOptionContent={(o) => <Status option={o} />} />
388
388
  * // color a stored value:
389
- * <OptionBadge value={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
389
+ * <Status option={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
390
390
  * ```
391
391
  */
392
392
  export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState;
@@ -448,7 +448,7 @@ export declare function usePaginatedQuery<K extends string>(alias: K, ...args: Q
448
448
  *
449
449
  * ```tsx
450
450
  * const { total } = useCount("orders", { q }, { filter: unpaidFilter });
451
- * return <Badge label={total == null ? "…" : `${total}`} />;
451
+ * return <Status label={total == null ? "…" : `${total}`} />;
452
452
  * ```
453
453
  */
454
454
  export declare function useCount<K extends string>(alias: K, ...args: QueryArgs<K, CountOptions<ColumnKeyOf<K>>>): CountState;
@@ -586,7 +586,7 @@ export declare function useAttachments(): AttachmentsState;
586
586
  * });
587
587
  * ```
588
588
  *
589
- * No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
589
+ * No-ops with no embedding host (standalone on the app's own origin — there is no
590
590
  * chat surface to inform) and in mock mode. Since 0.52.
591
591
  */
592
592
  export declare function useAiContext(slot: string, context: AiContextValue | null): void;
@@ -619,7 +619,7 @@ export interface MembersOptions {
619
619
  *
620
620
  * ```tsx
621
621
  * const { members } = useMembers({ group: "grp_..." });
622
- * // <Picker options={members.map((m) => ({
622
+ * // <Select variant="native" options={members.map((m) => ({
623
623
  * // value: m.id, label: m.name || m.email || m.id, image: m.image,
624
624
  * // }))} />
625
625
  * ```
package/dist/src/hooks.js CHANGED
@@ -498,7 +498,7 @@ function serializeAiContext(context) {
498
498
  * });
499
499
  * ```
500
500
  *
501
- * No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
501
+ * No-ops with no embedding host (standalone on the app's own origin — there is no
502
502
  * chat surface to inform) and in mock mode. Since 0.52.
503
503
  */
504
504
  export function useAiContext(slot, context) {
@@ -536,7 +536,7 @@ export function useAiContext(slot, context) {
536
536
  *
537
537
  * ```tsx
538
538
  * const { members } = useMembers({ group: "grp_..." });
539
- * // <Picker options={members.map((m) => ({
539
+ * // <Select variant="native" options={members.map((m) => ({
540
540
  * // value: m.id, label: m.name || m.email || m.id, image: m.image,
541
541
  * // }))} />
542
542
  * ```
@@ -4,15 +4,14 @@
4
4
  * "@lotics/app-sdk"` and ship the resulting bundle via `lotics app deploy`.
5
5
  *
6
6
  * This SDK is data + RPC only — it deliberately does NOT re-export any
7
- * `@lotics/ui` components, so it ships without pulling in packages/ui's
8
- * React Native Web dependency tree. That is a packaging choice, NOT a
9
- * limitation on apps: apps import `@lotics/ui` directly as a normal dep.
10
- * The starter scaffold (`packages/sdk/src/starter_template.ts`) wires the
11
- * full setup — `@lotics/ui` + react-native + react-native-web, the
12
- * react-native→react-native-web Vite alias, `@lotics/ui/index.css` +
13
- * `fonts.css`, and a `PortalHost` for overlays. Build dashboards by
14
- * composing `@lotics/ui` components (Card, KpiCard, charts, Table, …),
15
- * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
7
+ * `@lotics/ui` component, so an app's dependency on the kit is its own and one
8
+ * version answers for it. That is a packaging choice, NOT a limitation: apps
9
+ * import `@lotics/ui` directly as an ordinary dependency. The starter scaffold
10
+ * (`packages/sdk/src/starter_template.ts`) wires it — the kit as a dependency,
11
+ * `@lotics/ui/styles.css` + `fonts.css` in the entry, and `loticsResolve()` as
12
+ * the whole `resolve` block. Build screens by composing kit components (Card,
13
+ * Metric, charts, Table, …), not raw HTML/CSS. See `docs/apps.md` → "Styling
14
+ * & components".
16
15
  */
17
16
  export { mount } from "./mount.js";
18
17
  export type { MountOptions } from "./mount.js";
package/dist/src/index.js CHANGED
@@ -4,15 +4,14 @@
4
4
  * "@lotics/app-sdk"` and ship the resulting bundle via `lotics app deploy`.
5
5
  *
6
6
  * This SDK is data + RPC only — it deliberately does NOT re-export any
7
- * `@lotics/ui` components, so it ships without pulling in packages/ui's
8
- * React Native Web dependency tree. That is a packaging choice, NOT a
9
- * limitation on apps: apps import `@lotics/ui` directly as a normal dep.
10
- * The starter scaffold (`packages/sdk/src/starter_template.ts`) wires the
11
- * full setup — `@lotics/ui` + react-native + react-native-web, the
12
- * react-native→react-native-web Vite alias, `@lotics/ui/index.css` +
13
- * `fonts.css`, and a `PortalHost` for overlays. Build dashboards by
14
- * composing `@lotics/ui` components (Card, KpiCard, charts, Table, …),
15
- * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
7
+ * `@lotics/ui` component, so an app's dependency on the kit is its own and one
8
+ * version answers for it. That is a packaging choice, NOT a limitation: apps
9
+ * import `@lotics/ui` directly as an ordinary dependency. The starter scaffold
10
+ * (`packages/sdk/src/starter_template.ts`) wires it — the kit as a dependency,
11
+ * `@lotics/ui/styles.css` + `fonts.css` in the entry, and `loticsResolve()` as
12
+ * the whole `resolve` block. Build screens by composing kit components (Card,
13
+ * Metric, charts, Table, …), not raw HTML/CSS. See `docs/apps.md` → "Styling
14
+ * & components".
16
15
  */
17
16
  export { mount } from "./mount.js";
18
17
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * addressable URLs. The app owns its OWN url (a plain browser history) in both
6
6
  * modes; the host url only ever *mirrors* the screen, it never drives the router:
7
7
  *
8
- * - **Standalone** (`<slug>.lotics.app`): the page's own browser history — real
8
+ * - **Standalone** (the app's own origin): the page's own browser history — real
9
9
  * path URLs, native browser back/forward, deep-link/refresh via the app host's
10
10
  * SPA fallback.
11
11
  * - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
package/dist/src/rpc.d.ts CHANGED
@@ -11,10 +11,11 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
11
11
  * ops over postMessage and the host makes the API call. Used by internal
12
12
  * apps — the member's credentials must never reach the app.
13
13
  *
14
- * - **Standalone** — the app is served on its own at `<slug>.lotics.app`
15
- * with no host. It calls the public `/v1/apps/{id}/*` endpoints directly;
16
- * those are anonymous-accessible for a publicly-shared app. The app holds
17
- * no credentials, so there is nothing to protect.
14
+ * - **Standalone** — the app is served on its own origin, with no host. It
15
+ * calls the public `/v1/apps/{id}/*` endpoints directly, at the API address
16
+ * the serving host injected into the page (`API_BASE_META`); those are
17
+ * anonymous-accessible for a publicly-shared app. The app holds no
18
+ * credentials, so there is nothing to protect.
18
19
  *
19
20
  * Wire protocol (bridged — must match `app_iframe_host.tsx`):
20
21
  * app → host: { id, op, payload }
@@ -84,7 +85,7 @@ export interface AppContext {
84
85
  }
85
86
  /**
86
87
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
87
- * own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
88
+ * own origin). `rpc()`, `useUrlState`, and `AppRouter` use this to
88
89
  * pick the transport / behaviour; an app rarely needs it directly.
89
90
  */
90
91
  export declare function isEmbedded(): boolean;
@@ -106,7 +107,7 @@ export declare function subscribeUrlParams(cb: (params: UrlParams) => void): ()
106
107
  /**
107
108
  * Push a fire-and-forget notification to the embedding host — no id, no reply.
108
109
  * Only the embedded host can receive it (it owns the chat surface), so this
109
- * no-ops standalone (`<slug>.lotics.app` has no host to inform) and never
110
+ * no-ops standalone (the app's own origin has no host to inform) and never
110
111
  * throws. Distinct from `rpc()`: this is one-way, app → host.
111
112
  */
112
113
  export declare function postHostNotification(message: HostNotification): void;
package/dist/src/rpc.js CHANGED
@@ -19,7 +19,7 @@ function getHostOrigin() {
19
19
  }
20
20
  /**
21
21
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
22
- * own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
22
+ * own origin). `rpc()`, `useUrlState`, and `AppRouter` use this to
23
23
  * pick the transport / behaviour; an app rarely needs it directly.
24
24
  */
25
25
  export function isEmbedded() {
@@ -81,7 +81,7 @@ export function subscribeUrlParams(cb) {
81
81
  /**
82
82
  * Push a fire-and-forget notification to the embedding host — no id, no reply.
83
83
  * Only the embedded host can receive it (it owns the chat surface), so this
84
- * no-ops standalone (`<slug>.lotics.app` has no host to inform) and never
84
+ * no-ops standalone (the app's own origin has no host to inform) and never
85
85
  * throws. Distinct from `rpc()`: this is one-way, app → host.
86
86
  */
87
87
  export function postHostNotification(message) {
@@ -307,7 +307,7 @@ export function rpcAgentRunContinue(payload, onText) {
307
307
  const continueToken = runTokens.get(payload.run_id);
308
308
  if (continueToken)
309
309
  headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
310
- const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
310
+ const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
311
311
  method: "POST",
312
312
  headers,
313
313
  body: JSON.stringify({ tool_call_id: payload.tool_call_id, output: payload.output }),
@@ -344,7 +344,7 @@ function agentRunStandalone(payload, onText, onRunId) {
344
344
  const headers = { "content-type": "application/json" };
345
345
  if (sessionToken)
346
346
  headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
347
- const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
347
+ const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
348
348
  method: "POST",
349
349
  headers,
350
350
  body: JSON.stringify({ session_id: payload.session_id, input: payload.input }),
@@ -382,9 +382,33 @@ function agentRunStandalone(payload, onText, onRunId) {
382
382
  return { done, abort: () => controller.abort() };
383
383
  }
384
384
  // ── Standalone transport ────────────────────────────────────────────────────
385
- // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
386
- // always bridged by the `lotics app dev` wrapper.
387
- const API_BASE = "https://api.lotics.ai";
385
+ /**
386
+ * Meta the host serving a standalone bundle injects into `index.html`, naming
387
+ * the API the app calls.
388
+ *
389
+ * It is the serving host's to state, not the SDK's to assume: the same bundle
390
+ * is served by whoever runs the instance, and an app that carried a compiled-in
391
+ * API address would call somebody else's server. A bridged app never reads it —
392
+ * there the host's own origin arrives on `?lotics_host=`.
393
+ */
394
+ const API_BASE_META = "lotics-api-base";
395
+ let apiBaseCache = null;
396
+ function apiBase() {
397
+ if (apiBaseCache !== null)
398
+ return apiBaseCache;
399
+ const content = document
400
+ .querySelector(`meta[name="${API_BASE_META}"]`)
401
+ ?.getAttribute("content")
402
+ ?.trim();
403
+ if (!content) {
404
+ // No default: guessing would send this app's data to whatever address the
405
+ // guess named. Say what is missing and who puts it there.
406
+ throw new Error(`This app has no API address. A standalone app reads it from <meta name="${API_BASE_META}"> ` +
407
+ "in the page the app host serves, and this page carries none.");
408
+ }
409
+ apiBaseCache = content.replace(/\/+$/, "");
410
+ return apiBaseCache;
411
+ }
388
412
  const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
389
413
  /**
390
414
  * Boot-time resolution result. Promise is shared so concurrent first calls
@@ -592,7 +616,7 @@ async function apiCall(method, path, body, opts) {
592
616
  }, API_TIMEOUT_MS);
593
617
  let res;
594
618
  try {
595
- res = await fetch(`${API_BASE}${path}`, {
619
+ res = await fetch(`${apiBase()}${path}`, {
596
620
  method,
597
621
  headers,
598
622
  body: body ? JSON.stringify(body) : undefined,
@@ -708,7 +732,7 @@ async function standaloneMembers(p) {
708
732
  }
709
733
  /**
710
734
  * Open an external URL in a new tab, scheme-validated. In standalone mode the
711
- * app is a normal top-level page (`<slug>.lotics.app`), so `window.open` is not
735
+ * app is a normal top-level page on its own origin, so `window.open` is not
712
736
  * sandbox-blocked — open directly. (Bridged apps route this op to the host,
713
737
  * which opens it in the un-sandboxed parent frame; see `app_iframe_host`.)
714
738
  * The scheme is re-validated wherever the open actually happens — never trust a
@@ -18,7 +18,7 @@ export interface ResolvedOption {
18
18
  * Named palette color token (e.g. `"blue"`, `"emerald"`). Populated by
19
19
  * `useFieldOptions` (which reads the field config); absent on options read
20
20
  * back from a query CELL via `readSelect` — a cell carries only key + label.
21
- * Pass the resolved option straight to `@lotics/ui`'s `OptionBadge`, which
21
+ * Pass the resolved option straight to `@lotics/ui`'s `Status`, which
22
22
  * degrades a missing/unknown token to a neutral badge.
23
23
  */
24
24
  color?: string;
package/docs/ai.md CHANGED
@@ -450,7 +450,7 @@ caps above).
450
450
 
451
451
  When the ambient chat agent's turn ends and it mutated records, the host pushes every mounted query hook to re-read, so the screen the member is looking at reflects the agent's change without a manual refresh. That companion behavior is automatic — you write no code for it — and is documented with the query caching contract in [data_fetching](./data_fetching.md#caching-loading-states-and-errors).
452
452
 
453
- **No-ops** with no embedding host (standalone `<slug>.lotics.app` — there's no chat surface to inform) and in mock mode.
453
+ **No-ops** with no embedding host (standalone on the app's own origin — there's no chat surface to inform) and in mock mode.
454
454
 
455
455
  ---
456
456
 
@@ -349,7 +349,7 @@ you supply the total yourself.
349
349
 
350
350
  ## Standalone (public) transport
351
351
 
352
- An app served standalone at `<slug>.lotics.app` (a public share with no Lotics host — see
352
+ An app served standalone on its own origin (a public share with no Lotics host — see
353
353
  [runtime](./runtime.md)) reaches the query endpoint through a thinner transport that forwards only
354
354
  `alias`, `params`, `limit`, and `offset`. It **silently drops** `sort`, `filter`, `count`, and the
355
355
  keyset `cursor`. Design a public app around this:
@@ -439,7 +439,7 @@ a partial as its period start.
439
439
 
440
440
  **`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
441
441
  `useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
442
- `label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `OptionBadge`
442
+ `label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `Status`
443
443
  — see [./members_and_options.md](./members_and_options.md).
444
444
 
445
445
  **`readMembers`.** `name` is `null` when the id doesn't resolve in the app's org (e.g. a removed
@@ -477,7 +477,7 @@ A query cell carries only the options a record actually holds (key + label, no c
477
477
  **complete** option list of a select column — every option including those in no current row, with
478
478
  colors — use **`useFieldOptions`**, and prefer it over deriving options from loaded rows
479
479
  (row-derived sets are incomplete until every page loads and carry no colors). Its full contract
480
- (return shape, `byKey`, `opts.enabled`, freshness), rendering the values (`OptionBadge`,
480
+ (return shape, `byKey`, `opts.enabled`, freshness), rendering the values (`Status`,
481
481
  `MemberChip`, `MemberSelect`), and the member roster (`useMembers`) live in
482
482
  [./members_and_options.md](./members_and_options.md).
483
483
 
@@ -510,7 +510,7 @@ pieces. Compose these — don't hand-roll search:
510
510
 
511
511
  - **`Combobox`** (`@lotics/ui/combobox`) owns the interaction — debounced `onSearchChange`, a
512
512
  popover listbox with rich rows (`renderOptionContent`), keyboard navigation, `recentOptions`,
513
- `allowCustom`. (For a known small list with no search box, `Picker`.)
513
+ `allowCustom`. (For a known small list with no search box, `Select`.)
514
514
  - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
515
515
  maintained search document: **diacritics- and case-insensitive**, trigram-indexed, and AND-ed
516
516
  with the template's `filter` (search within a scope). The full `search` contract is in
@@ -540,7 +540,7 @@ const [typed, setTyped] = useState(""); // the input's own value, every
540
540
  const [term, setTerm] = useState(""); // what the server is asked, once typing settles
541
541
  const commit = useDebouncedCallback(setTerm, 250);
542
542
 
543
- <SearchInput value={typed} onChangeText={(v) => { setTyped(v); commit(v.trim()); }} />;
543
+ <TextInput type="search" value={typed} onChangeText={(v) => { setTyped(v); commit(v.trim()); }} />;
544
544
 
545
545
  const { rows, loading } = useQuery(
546
546
  "searchCustomers",
@@ -560,8 +560,8 @@ relationship exists.
560
560
  When the user doesn't know the term — "show me everything, let me narrow it" — build a modal table
561
561
  they can browse (numbered pages), search, sort, and filter:
562
562
 
563
- - **The screen is app-owned**, composed from `@lotics/ui`: a `Dialog` over `SearchInput` + filter
564
- pills (`ColumnFilter`) + `Table` + `Pagination`. It needs both `@lotics/ui` and the SDK (which is
563
+ - **The screen is app-owned**, composed from `@lotics/ui`: a `Dialog` over a search `TextInput` + filter
564
+ pills (`FilterChip column=`) + `Table` + `Pagination`. It needs both `@lotics/ui` and the SDK (which is
565
565
  UI-free), so it lives in the app (e.g. a `record_picker.tsx`) — reuse it for any table by passing
566
566
  a different `alias` + column config.
567
567
  - **`usePaginatedQuery`** drives it: the page of rows, the `total` for "Page 1 of N" (the built-in
package/docs/files.md CHANGED
@@ -19,7 +19,7 @@ discipline in [data fetching](./data_fetching.md).
19
19
  | 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 |
20
20
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
21
21
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
22
- | Show it | `@lotics/ui` `FileThumbnail` / `FileGrid` / `FileGalleryModal` | map to `DisplayFile` (see below) |
22
+ | Show it | `@lotics/ui` `FileThumbnail` / `FileThumbnailGrid` / `FileGalleryDialog` | map to `DisplayFile` (see below) |
23
23
  | Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
24
24
 
25
25
  An uploaded file is **inert until a workflow attaches it** — it has an id and serving URLs, but
@@ -175,10 +175,10 @@ Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id?
175
175
 
176
176
  Wiring to `@lotics/ui`: in a `Composer`, trigger picking from `actionsButton` (via `pickFiles`),
177
177
  render the attachment pills with `FileThumbnail` as above, and gate `sendDisabled` on `uploading`.
178
- For a full add-files *screen* (not a composer pill), map each `AttachedFile` to a `FileGrid`
179
- `FileUpload` entry — ready → `{ status: "complete", id: file_id, file: <DisplayFile> }`, else
180
- `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }` — and `FileGrid` renders
181
- the uploading/error/retry tiles itself.
178
+ For a full add-files *screen* (not a composer pill), map each `AttachedFile` to a
179
+ `FileThumbnailGrid` `FileUpload` entry — ready → `{ status: "complete", id: file_id, file:
180
+ <DisplayFile> }`, else `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`
181
+ — and the grid renders the uploading/error/retry tiles itself.
182
182
 
183
183
  ## File cells in query results — `readFiles` and `AppFile`
184
184
 
@@ -325,11 +325,10 @@ not viewing). Component contracts live in the `@lotics/ui` reference
325
325
  For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
326
326
  The app owns this data→UI adapter — the SDK deliberately never imports `@lotics/ui`.
327
327
 
328
- - **`FileThumbnail`** — one square tile; `uploading` overlays a spinner, images fall back from
329
- `thumbnailUrl` to `url` on error.
330
- - **`FileGrid`** — a grid of completed files plus a live `uploads` queue (it renders
331
- uploading/error/retry tiles itself).
332
- - **`FileGalleryModal`** — the full-screen viewer (filename · counter · actions · close, with
328
+ - **`FileThumbnail`** — one square tile; `uploading` takes the queue status and overlays the
329
+ scrim, the spinner or the retry, images fall back from `previewUrl` to `thumbnailUrl` to `url`.
330
+ - **`FileThumbnailGrid`** — a grid of stored files plus a live `uploads` queue, in one grid.
331
+ - **`FileGalleryDialog`** — the full-screen viewer (filename · counter · actions · close, with
333
332
  prev/next + ESC), delegating per-file to **`FilePreview`**, which dispatches by MIME. Wire
334
333
  `onFilePress` → a `number | null` `activeIndex`.
335
334
  - PDF renders **inline to a canvas** (a nested PDF browsing context is blocked in the sandboxed
@@ -4,7 +4,7 @@ How an app renders and picks **people** and **select-field options**, plus the *
4
4
  surface. Covers the two cell readers (`readSelect`, `readMembers`), the two catalog hooks
5
5
  (`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), comments
6
6
  (`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
7
- building an assign picker, a colored status badge, a per-viewer ("my records") screen, or a
7
+ building an assign picker, a colored `Status` mark, a per-viewer ("my records") screen, or a
8
8
  comment thread. Query mechanics live in [queries](./queries.md); the authority model in
9
9
  [security](./security.md).
10
10
 
@@ -61,7 +61,7 @@ const { fields } = useFieldOptions("orders"); // same alias you query
61
61
  // are { value, label }, so map the option key to `value`):
62
62
  <Select
63
63
  options={(fields.status?.options ?? []).map((o) => ({ value: o.key, label: o.label }))}
64
- renderOptionContent={(o) => <OptionBadge value={fields.status?.byKey(o.value)} />}
64
+ renderOptionContent={(o) => <Status option={fields.status?.byKey(o.value)} />}
65
65
  value={status} onValueChange={setStatus}
66
66
  />
67
67
  ```
@@ -78,7 +78,7 @@ name**, not the field key. Each `FieldOptions`:
78
78
  | `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
79
79
 
80
80
  - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
81
- `@lotics/ui`'s `OptionBadge`; a missing/unrecognized token degrades to a neutral badge.
81
+ `@lotics/ui`'s `Status`; a missing/unrecognized token degrades to a neutral badge.
82
82
  - **A column the server can't map to a single source select field is simply absent** from
83
83
  `fields` — a UNION output whose arms disagree on the source field, or a computed column. Read
84
84
  defensively: `fields.status?.options ?? []`.
@@ -99,7 +99,7 @@ until an edit drawer opens). State: `{ fields, loading, isValidating, error, ref
99
99
 
100
100
  ```tsx
101
101
  const opt = readSelect(row.status)[0];
102
- <OptionBadge value={opt ? (fields.status?.byKey(opt.key) ?? opt) : null} />
102
+ <Status option={opt ? (fields.status?.byKey(opt.key) ?? opt) : null} />
103
103
  ```
104
104
 
105
105
  `byKey` hit → the configured color. `byKey` miss (option removed post-write) → fall back to the
@@ -128,12 +128,12 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, ro
128
128
  "Admin" beside a name as rank. If the question your screen asks is "who is this person in the
129
129
  company", the answer is `groups`.
130
130
  - **`joined` is an ISO timestamp of when the membership began.** Render it at whatever precision
131
- your question needs — `@lotics/ui`'s `MemberProfileCard` shows month and year, because "is this
131
+ your question needs — `@lotics/ui`'s `MemberPeek` shows month and year, because "is this
132
132
  the new person?" does not want a day. Absent on a public response, and on an id that did not
133
133
  resolve: there is no membership to have begun.
134
134
  - **`archived: true` marks someone who has LEFT** — omitted otherwise, never `false`. It is the one
135
135
  field that is NOT gated (a departed colleague reading as a current assignee is wrong on a public
136
- app too). Feed it to `inactive` on `MemberChip` / `MemberProfileCard`.
136
+ app too). Feed it to `inactive` on `MemberChip` / `MemberPeek`.
137
137
  - **An id that no longer resolves** (removed member, id outside the org) comes back with
138
138
  `name: null` — an explicit missing state, never an empty string. Render a placeholder.
139
139
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
@@ -256,7 +256,7 @@ client-side up front, and re-checked server-side.
256
256
  - **The author is always the real signed-in member** — correct attribution, enforced server-side.
257
257
  **Warning:** under "View as", comments still author as the real member (the admin), not the
258
258
  view-as target — unlike `useViewer` and `is_current_member` scoping, which follow the target.
259
- - **Edit and delete are author-only**, checked server-side against the viewer. `CommentList`'s
259
+ - **Edit and delete are author-only**, checked server-side against the viewer. `CommentThread`'s
260
260
  `currentMemberId` prop drives the matching affordance client-side.
261
261
 
262
262
  ### Reading & writing
@@ -277,13 +277,13 @@ renders the panel. State: `{ comments, loading, error, available, createComment,
277
277
  updateComment, deleteComment, refetch }`.
278
278
 
279
279
  - `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
280
- `CommentList`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
280
+ `CommentThread`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
281
281
  table_id, member_id, content, files, workspace_id, created_at, updated_at }`. Attachments
282
282
  (`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
283
283
  the server re-reads every attachment from storage by that id, so nothing else you hold about a
284
284
  file can affect what is stored. The `url` / `thumbnail_url` / `preview_url` fields exist on the type
285
285
  but the server does not populate them today — render attachments by name and type (what
286
- `CommentList`'s default file row does), never by counting on a fetchable URL.
286
+ `CommentThread`'s default file row does), never by counting on a fetchable URL.
287
287
  - `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
288
288
  `useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
289
289
  least one file (empty input is a client no-op; the server enforces the same rule). Content max
@@ -296,7 +296,7 @@ updateComment, deleteComment, refetch }`.
296
296
  on it.
297
297
  - **Limitation:** a comment carries `member_id` only — resolving the author's display name needs a
298
298
  member source: the `useMembers` roster (requires the member-access declaration above) or member
299
- cells in your own data. An unresolvable id should render a fallback (`CommentList` has an
299
+ cells in your own data. An unresolvable id should render a fallback (`CommentThread` has an
300
300
  `unknownMember` label for exactly this).
301
301
  - **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
302
302
  the next focus or explicit `refetch()` — not on its own. Comments are the one read that does not
@@ -320,10 +320,10 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
320
320
 
321
321
  | Value | Component | Feed it |
322
322
  | --- | --- | --- |
323
- | A select value (stored or picker option) | `OptionBadge` | 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. |
323
+ | 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. |
324
324
  | A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
325
325
  | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
326
- | A comment thread | `CommentList` + `CommentComposer` | `useComments` state; `resolveMember` bridges `member_id` → your member source |
326
+ | A comment thread | `CommentThread` + `CommentComposer` | `useComments` state; `resolveMember` bridges `member_id` → your member source |
327
327
 
328
328
  The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
329
329
 
package/docs/mutations.md CHANGED
@@ -70,7 +70,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
70
70
  ### The failure model: check `status`, never just try/catch
71
71
 
72
72
  **Every failure resolves — the promise (almost) never rejects.** All three transports
73
- (embedded product host, standalone `<slug>.lotics.app`, and the `lotics app dev` harness)
73
+ (embedded product host, standalone on the app's own origin, and the `lotics app dev` harness)
74
74
  convert every failure into a resolved `{ status: "error", message }`:
75
75
 
76
76
  | Failure | What resolves |
@@ -26,7 +26,7 @@ into a query's *server* params (see [named-query params](./queries.md) and the
26
26
 
27
27
  The embedded-vs-standalone distinction below is the runtime's: embedded means
28
28
  the app runs in an iframe inside the Lotics host, standalone means it runs on
29
- its own `<slug>.lotics.app` page (see [runtime](./runtime.md)). Both mechanisms
29
+ its own app-host page (see [runtime](./runtime.md)). Both mechanisms
30
30
  expose the same API in both modes with no per-mode code (the few behavioral
31
31
  differences — embedded first-paint hydration, cross-screen persistence — are
32
32
  flagged below); mode is detected automatically (from the `lotics_host` param
@@ -74,7 +74,7 @@ routes, and splats all work. Inside the tree, use react-router normally:
74
74
  The app owns its **own** URL in both modes — the host URL only ever *mirrors*
75
75
  the screen, it never drives the router:
76
76
 
77
- - **Standalone** (`<slug>.lotics.app`): a normal browser router. Screens are
77
+ - **Standalone** (the app's own origin): a normal browser router. Screens are
78
78
  real path URLs, browser Back/Forward are native, and deep-links/refresh work
79
79
  because the app host serves the entry HTML for any path the build didn't
80
80
  emit (SPA fallback).
package/docs/queries.md CHANGED
@@ -836,7 +836,7 @@ Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/
836
836
  `useFieldOptions` for a select filter's option set.
837
837
 
838
838
  **Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
839
- `sort`/`filter`/`count` through. The **standalone public transport** (`<slug>.lotics.app`)
839
+ `sort`/`filter`/`count` through. The **standalone public transport** (the app's own origin)
840
840
  forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are silently ignored
841
841
  there and a `count` never resolves ([data_fetching](./data_fetching.md) → Standalone transport).
842
842
  A standalone app must bake ordering/scoping into the template (or params) rather than rely on
package/docs/runtime.md CHANGED
@@ -108,9 +108,10 @@ helper works identically in both. `isEmbedded(): boolean` is exported
108
108
 
109
109
  | | **Embedded (bridged)** | **Standalone (direct)** |
110
110
  |---|---|---|
111
- | Where | Iframe inside the Lotics product, or the `lotics app dev` wrapper page | The app's own top-level page at `<slug>.lotics.app` |
111
+ | Where | Iframe inside the Lotics product, or the `lotics app dev` wrapper page | The app's own top-level page, on its own origin |
112
112
  | Who holds credentials | The **host** — the member's session never reaches the app | Nobody — the visitor is anonymous (an optional app password gates access, not identity) |
113
- | How ops travel | `postMessage` to the parent frame; the host makes the API call with its session | `fetch` to the public `https://api.lotics.ai/v1/apps/{id}/*` endpoints |
113
+ | How ops travel | `postMessage` to the parent frame; the host makes the API call with its session | `fetch` to the public `/v1/apps/{id}/*` endpoints, at the API address the serving host declared in the page |
114
+ | Which API | The host's, implicitly — it makes the call | `<meta name="lotics-api-base" content="…">` in the served document, read on the first call. **No address is compiled in**: one bundle runs on any Lotics, and a page that declares none makes the SDK refuse rather than address an instance nobody named. Every serving host injects it (the app host and `lotics app dev`), so there is nothing for an app to set |
114
115
  | Viewer identity | The signed-in member (`useViewer`, comments, agent runs available) | `member_id` is `null`; members-only surfaces reject |
115
116
 
116
117
  ### Embedded: the postMessage bridge
@@ -137,7 +138,7 @@ haven't verified exists in the host (see
137
138
  ops to the API; the bare Vite origin has no host param, so the SDK falls into
138
139
  standalone mode, tries to resolve an app from the hostname, and every data call
139
140
  fails. Dev apps are always bridged; standalone mode exists only on the deployed
140
- `<slug>.lotics.app` host.
141
+ app host.
141
142
 
142
143
  ### Standalone: direct public API
143
144
 
@@ -145,7 +146,7 @@ On first data call the SDK resolves the app's identity from its own subdomain
145
146
  (one shared `GET /v1/apps/by-subdomain/{slug}` fetch — concurrent first calls
146
147
  coalesce; a transient failure isn't cached, the next call retries). If the app
147
148
  is password-protected, the visitor never reaches the app at all until they
148
- clear the gate: `<slug>.lotics.app` is served by the app-host Worker, which
149
+ clear the gate: the app's own origin is served by the app host, which
149
150
  withholds every byte of the bundle and serves a password page instead. On
150
151
  success it sets `lotics_app_session` on the app's origin; the SDK reads that
151
152
  cookie and forwards it as `X-Lotics-App-Session` on data calls. The SDK owns no
@@ -266,7 +267,7 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
266
267
  - Typical use: opening a workflow-generated file's `url` from
267
268
  `WorkflowResult.files[]` (see [mutations](./mutations.md)).
268
269
  - **Not a preview mechanism.** To *view* a file inline, use `@lotics/ui`'s
269
- `FilePreview`/`FileGalleryModal` (see [files](./files.md)); `openExternal` is
270
+ `FilePreview`/`FileGalleryDialog` (`@lotics/ui` ≥ 48 — see [files](./files.md)); `openExternal` is
270
271
  "leave the app".
271
272
 
272
273
  ## `openApp()` — the cross-app hop
@@ -445,14 +446,11 @@ is a transport that wasn't wired.
445
446
  no dynamic `import()`, no Node built-ins. Don't touch `window` at module top
446
447
  level — resolve lazily (test environments import modules before `jsdom` is
447
448
  ready).
448
- - **Two bundlers in the family.** Apps build with Vite; the Lotics product
449
- frontend builds with Metro (React Native Web). The SDK itself is never
450
- Metro-bundled — the host frontend is forbidden from importing
451
- `@lotics/app-sdk` (enforced by a dependency-direction test) — but sibling
452
- packages apps share with the product (`@lotics/ui`, `@lotics/xlsx`,
453
- `@lotics/docx`) build under both. When a shared package must diverge per
454
- target, use platform files (`x.web.ts` / `x.ts`) plus conditional package
455
- `exports` — never a runtime `require` or dynamic import.
449
+ - **One module per entry.** A package apps share with the Lotics product
450
+ (`@lotics/ui`, `@lotics/xlsx`, `@lotics/docx`) ships ONE module per entry: no
451
+ platform twins, no per-target `exports` condition. The SDK itself is never
452
+ bundled by the product at all — the host frontend is forbidden from importing
453
+ `@lotics/app-sdk`, enforced by a dependency-direction test.
456
454
  - **Self-contained on npm.** The SDK cannot import workspace-private or
457
455
  host-only packages (`@lotics/shared`, `@lotics/ui-internal`, the frontend's
458
456
  `@/` alias) or any React Native / Expo module — also test-enforced. A helper
package/docs/security.md CHANGED
@@ -89,7 +89,7 @@ returns `true` when the **triggering member** belongs to any of the listed group
89
89
 
90
90
  ## Public apps: anonymous reach and its bounds
91
91
 
92
- A publicly-shared app (`<slug>.lotics.app`, or its public link) is reachable by **anyone** — the public share grants `app:use` to anonymous visitors and to authenticated members of any other org alike. What anonymous visitors can and cannot do:
92
+ A publicly-shared app (its own origin, or its public link) is reachable by **anyone** — the public share grants `app:use` to anonymous visitors and to authenticated members of any other org alike. What anonymous visitors can and cannot do:
93
93
 
94
94
  | Surface | Anonymous access |
95
95
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.90.0",
3
+ "version": "0.90.2",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {