@lotics/app-sdk 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +93 -63
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -34
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
@@ -1,117 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react";
2
- const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3
- const ID_LENGTH = 12;
4
- /**
5
- * Mint a record id locally, in the shape the platform mints.
6
- *
7
- * The generator is restated here rather than imported because the canonical one
8
- * lives in a package that is not published, and this one is. The duplication is
9
- * safe by construction rather than by discipline: the server validates the shape on
10
- * every write, so a client that drifted would be rejected loudly at the first call
11
- * instead of quietly persisting a malformed primary key.
12
- *
13
- * Uses `crypto.getRandomValues` — available in every browser this SDK runs in — and
14
- * rejection-samples so each character is uniformly drawn from the alphabet. A plain
15
- * `% 62` over bytes would bias the first 8 characters, which is a poor property for
16
- * something used as a primary key.
17
- */
18
- export function newRecordId() {
19
- const max = 256 - (256 % ID_ALPHABET.length);
20
- let out = "";
21
- const buf = new Uint8Array(ID_LENGTH * 2);
22
- while (out.length < ID_LENGTH) {
23
- crypto.getRandomValues(buf);
24
- for (let i = 0; i < buf.length && out.length < ID_LENGTH; i++) {
25
- if (buf[i] < max)
26
- out += ID_ALPHABET[buf[i] % ID_ALPHABET.length];
27
- }
28
- }
29
- return `rec_${out}`;
30
- }
31
- /**
32
- * A record that does not exist yet, named before it does.
33
- *
34
- * The problem this removes: when the server mints the id, a surface editing a new
35
- * record has nothing to identify it by until the first write returns. Everything
36
- * keyed on that id — the route, the drawer, a list selection — therefore changes
37
- * identity mid-edit, which React resolves by remounting the surface the user is
38
- * typing into. Minting the id locally makes it stable from the first render, so the
39
- * create stops being an event the UI has to survive.
40
- *
41
- * Creation still happens on the first write, not on mount: a surface the user opens
42
- * and abandons should leave nothing behind.
43
- *
44
- * The transport is the caller's. Like `useOptimistic`, this hook has no idea how the
45
- * app persists anything — it takes `create` and `update` thunks and owns only the id
46
- * and the ordering, which is the part that is easy to get wrong:
47
- *
48
- * - Two blur-saves fired before the first resolves must not both create. That is a
49
- * duplicate record, and it is the failure this exists to prevent.
50
- * - A save arriving mid-create must wait for it, or it updates a row that is not
51
- * there yet.
52
- * - A create that FAILS must not latch. Otherwise every later save updates a record
53
- * that was never written, and the user's work goes nowhere while looking saved.
54
- *
55
- * ```tsx
56
- * const { id, save } = useNewRecord({
57
- * create: (id, patch) => createCustomer({ record_id: id, ...patch }),
58
- * update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
59
- * onCreated: (id) => select(id), // it exists now — the list re-reads itself
60
- * });
61
- * <InlineText onBlur={(name) => save({ name })} />
62
- * ```
63
- */
64
- export function useNewRecord(opts) {
65
- const idRef = useRef("");
66
- if (idRef.current === "")
67
- idRef.current = newRecordId();
68
- // Callbacks are read through a ref so `save` keeps a stable identity across
69
- // renders — it is typically handed to an `onBlur`, and a new function every render
70
- // would re-bind every field on every keystroke.
71
- //
72
- // Updated in an effect rather than during render: React only sanctions writing a
73
- // ref while rendering for one-time initialisation (as `idRef` above does), because
74
- // a render that is discarded would otherwise leave the ref holding props that never
75
- // committed. `save` runs from event handlers, which are always after commit, so the
76
- // effect is early enough.
77
- const optsRef = useRef(opts);
78
- useEffect(() => {
79
- optsRef.current = opts;
80
- });
81
- /** The in-flight or settled create. `null` means the record does not exist yet. */
82
- const createdRef = useRef(null);
83
- /** Tail of the write chain, so saves apply in the order the user made them. */
84
- const queueRef = useRef(Promise.resolve());
85
- const save = useCallback((patch) => {
86
- const run = async () => {
87
- const id = idRef.current;
88
- const { create, update, onCreated } = optsRef.current;
89
- if (createdRef.current === null) {
90
- const attempt = (async () => {
91
- await create(id, patch);
92
- })();
93
- createdRef.current = attempt;
94
- try {
95
- await attempt;
96
- }
97
- catch (error) {
98
- // Unlatch. The record was not written, so the next save has to be free to
99
- // create it — leaving the promise in place would send every later write to
100
- // a row that does not exist.
101
- createdRef.current = null;
102
- throw error;
103
- }
104
- onCreated?.(id);
105
- return;
106
- }
107
- await createdRef.current;
108
- await update(id, patch);
109
- };
110
- // Chained on both settle paths: one failed save must not strand the ones behind
111
- // it, but they still have to run in order.
112
- const result = queueRef.current.then(run, run);
113
- queueRef.current = result.catch(() => undefined);
114
- return result;
115
- }, []);
116
- return { id: idRef.current, save };
117
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * Open a record's screen in the sibling app that owns it — the cross-app hop.
3
- * The host owns app routing and lands the viewer on `route` inside `appId`,
4
- * same tab, chrome kept. `route` is the target app's own in-app path, exactly
5
- * as its router declares it (`/` for its register).
6
- *
7
- * ```tsx
8
- * import { openApp } from "@lotics/app-sdk";
9
- * await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
10
- * ```
11
- *
12
- * Standalone (`<slug>.lotics.app`) has no sibling apps, so the call rejects
13
- * there — gate the control on `isEmbedded()`.
14
- */
15
- export declare function openApp(appId: string, route?: string): Promise<void>;
@@ -1,18 +0,0 @@
1
- import { rpc } from "./rpc.js";
2
- /**
3
- * Open a record's screen in the sibling app that owns it — the cross-app hop.
4
- * The host owns app routing and lands the viewer on `route` inside `appId`,
5
- * same tab, chrome kept. `route` is the target app's own in-app path, exactly
6
- * as its router declares it (`/` for its register).
7
- *
8
- * ```tsx
9
- * import { openApp } from "@lotics/app-sdk";
10
- * await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
11
- * ```
12
- *
13
- * Standalone (`<slug>.lotics.app`) has no sibling apps, so the call rejects
14
- * there — gate the control on `isEmbedded()`.
15
- */
16
- export function openApp(appId, route = "/") {
17
- return rpc("openApp", { app_id: appId, route });
18
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Open an external URL in a new tab.
3
- *
4
- * The embedded app iframe is sandboxed without `allow-popups`, so a direct
5
- * `window.open` from app code is silently dropped. This routes the open to
6
- * whoever can actually perform it: the un-sandboxed host frame (embedded) or
7
- * the app's own top-level page (public). The URL is scheme-validated
8
- * (`http`/`https` only) at the point it opens — a non-string or disallowed
9
- * scheme rejects.
10
- *
11
- * ```tsx
12
- * import { openExternal } from "@lotics/app-sdk";
13
- * await openExternal(invoiceUrl);
14
- * ```
15
- */
16
- export declare function openExternal(url: string): Promise<void>;
@@ -1,19 +0,0 @@
1
- import { rpc } from "./rpc.js";
2
- /**
3
- * Open an external URL in a new tab.
4
- *
5
- * The embedded app iframe is sandboxed without `allow-popups`, so a direct
6
- * `window.open` from app code is silently dropped. This routes the open to
7
- * whoever can actually perform it: the un-sandboxed host frame (embedded) or
8
- * the app's own top-level page (public). The URL is scheme-validated
9
- * (`http`/`https` only) at the point it opens — a non-string or disallowed
10
- * scheme rejects.
11
- *
12
- * ```tsx
13
- * import { openExternal } from "@lotics/app-sdk";
14
- * await openExternal(invoiceUrl);
15
- * ```
16
- */
17
- export function openExternal(url) {
18
- return rpc("openExternal", { url });
19
- }
@@ -1,59 +0,0 @@
1
- import { type RecordingState } from "./recording_state.js";
2
- import type { AppWorkflows } from "./types.js";
3
- /** The platform fills a receiving workflow's `recording` input; the app passes
4
- * the rest of what the workflow declares. */
5
- type ActInputsOf<K extends keyof AppWorkflows & string> = AppWorkflows[K] extends Record<string, unknown> ? Omit<AppWorkflows[K], "recording"> : Record<string, unknown>;
6
- export interface UseRecording<I> {
7
- /**
8
- * Whether this surface can record. False standalone, in a host that
9
- * predates recording, on an instance without transcription, and until the
10
- * host's context has answered. A control that starts a recording is drawn
11
- * only when this is true.
12
- */
13
- available: boolean;
14
- /** The most recent recording this app started through this alias. */
15
- state: RecordingState;
16
- /** Whether a recording this app started is live, through any alias. The
17
- * host records one at a time, so a start while this is true is refused. */
18
- busy: boolean;
19
- /**
20
- * Ask the host to record. The host opens its own capture dialog, and nothing
21
- * records until the member presses Start there. Resolves `{ started: true }`
22
- * once capture runs, `{ started: false }` when the member closed the dialog;
23
- * rejects, with the reason, when the host refuses (another recording is
24
- * running, the inputs fail the workflow's contract, recording is unavailable).
25
- * When the recording is transcribed, the host runs the alias's workflow with
26
- * these inputs plus `recording`.
27
- */
28
- start: {} extends I ? (inputs?: I) => Promise<{
29
- started: boolean;
30
- }> : (inputs: I) => Promise<{
31
- started: boolean;
32
- }>;
33
- /** Stop this alias's live recording. Resolves whether or not one was live. */
34
- stop: () => Promise<void>;
35
- }
36
- /**
37
- * Record a call, a visit, a meeting — and file it through a workflow.
38
- *
39
- * `alias` names a workflow that declares the platform's `recording` input; the
40
- * host records as the signed-in member and, once the transcript is ready, runs
41
- * that workflow once with the app's inputs and the recording. Its write reaches
42
- * the screen like any workflow's.
43
- *
44
- * ```tsx
45
- * const visit = useRecording("log_visit");
46
- * if (!visit.available) return null;
47
- * if (visit.state.phase === "live" && visit.state.inputs.site === siteId) {
48
- * return <Button onPress={() => visit.stop()}>Stop</Button>;
49
- * }
50
- * return (
51
- * <Button disabled={visit.busy} onPress={() => visit.start({ site: siteId })}>
52
- * Record the visit
53
- * </Button>
54
- * );
55
- * ```
56
- */
57
- export declare function useRecording<K extends keyof AppWorkflows & string>(alias: K): UseRecording<ActInputsOf<K>>;
58
- export declare function useRecording(alias: string): UseRecording<Record<string, unknown>>;
59
- export {};
@@ -1,30 +0,0 @@
1
- import { useCallback, useSyncExternalStore } from "react";
2
- import { rpc } from "./rpc.js";
3
- import { getMockRecordings } from "./mock.js";
4
- import { useAppContext } from "./viewer.js";
5
- import { anyRecordingLive, recordingStateOf, subscribeRecordings, } from "./recording_state.js";
6
- const NO_MOCKS = {};
7
- export function useRecording(alias) {
8
- const { recordingEnabled } = useAppContext();
9
- const mocks = getMockRecordings() ?? NO_MOCKS;
10
- const mocked = mocks[alias];
11
- const hostState = useSyncExternalStore(subscribeRecordings, () => recordingStateOf(alias));
12
- const hostBusy = useSyncExternalStore(subscribeRecordings, anyRecordingLive);
13
- const start = useCallback(async (inputs) => {
14
- if (getMockRecordings()?.[alias])
15
- return { started: true };
16
- return rpc("recording.start", { alias, inputs: inputs ?? {} });
17
- }, [alias]);
18
- const stop = useCallback(async () => {
19
- if (getMockRecordings()?.[alias])
20
- return;
21
- await rpc("recording.stop", { alias });
22
- }, [alias]);
23
- return {
24
- available: mocked !== undefined || recordingEnabled,
25
- state: mocked ?? hostState,
26
- busy: hostBusy || Object.values(mocks).some((state) => state.phase === "live"),
27
- start,
28
- stop,
29
- };
30
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * What the host says about the recordings THIS app started, one per workflow
3
- * alias: the most recently started recording through that alias.
4
- *
5
- * A leaf module, because two sides write it and one reads it: the bridge
6
- * listener in `rpc.ts` applies the host's `recordingChanged` push, the context
7
- * fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
8
- * and `useRecording` reads it. Both writes arrive on the one postMessage channel
9
- * in the order the host sent them, so the later arrival is always the newer
10
- * state.
11
- */
12
- /** The act's own inputs the recording was started with — what `start` was
13
- * passed, echoed back so a screen drawing one act per row can tell which row
14
- * the recording is about. */
15
- export type RecordingInputs = Record<string, unknown>;
16
- /**
17
- * One recording, from the host's side. `idle`: nothing of this alias's is
18
- * running or waiting. `live`: capturing. `interrupted`: the tab closed mid-call
19
- * and the member has not yet resumed or finished it from the recording bar.
20
- * `processing`: stopped, the transcript is on its way. `filed`: the workflow
21
- * ran and succeeded. `failed`: transcription failed, the platform refused to
22
- * file (the member lost access, the alias or its declaration changed), or the
23
- * run failed; the recording is kept, and the member retries from the recording
24
- * bar.
25
- */
26
- export type RecordingState = {
27
- phase: "idle";
28
- } | {
29
- phase: "live";
30
- elapsed_seconds: number;
31
- inputs: RecordingInputs;
32
- } | {
33
- phase: "interrupted";
34
- elapsed_seconds: number;
35
- inputs: RecordingInputs;
36
- } | {
37
- phase: "processing";
38
- inputs: RecordingInputs;
39
- } | {
40
- phase: "filed";
41
- execution_id: string;
42
- inputs: RecordingInputs;
43
- } | {
44
- phase: "failed";
45
- message: string;
46
- inputs: RecordingInputs;
47
- };
48
- export declare const IDLE_RECORDING: RecordingState;
49
- export declare function subscribeRecordings(listener: () => void): () => void;
50
- export declare function recordingStateOf(alias: string): RecordingState;
51
- export declare function anyRecordingLive(): boolean;
52
- /** The host's push for one alias. */
53
- export declare function applyRecordingChange(alias: string, state: RecordingState): void;
54
- /** The snapshot `context` carries: every alias it omits is idle. */
55
- export declare function replaceRecordings(snapshot: Record<string, RecordingState>): void;
56
- /** A host message's `state`, or null when it is not one. */
57
- export declare function readRecordingState(raw: unknown): RecordingState | null;
58
- /** A context's `recordings`, keeping each well-formed entry. */
59
- export declare function readRecordingSnapshot(raw: unknown): Record<string, RecordingState>;
@@ -1,94 +0,0 @@
1
- /**
2
- * What the host says about the recordings THIS app started, one per workflow
3
- * alias: the most recently started recording through that alias.
4
- *
5
- * A leaf module, because two sides write it and one reads it: the bridge
6
- * listener in `rpc.ts` applies the host's `recordingChanged` push, the context
7
- * fetch in `viewer.ts` applies the snapshot a newly loaded frame starts from,
8
- * and `useRecording` reads it. Both writes arrive on the one postMessage channel
9
- * in the order the host sent them, so the later arrival is always the newer
10
- * state.
11
- */
12
- export const IDLE_RECORDING = { phase: "idle" };
13
- const states = new Map();
14
- const listeners = new Set();
15
- function emit() {
16
- for (const listener of listeners)
17
- listener();
18
- }
19
- export function subscribeRecordings(listener) {
20
- listeners.add(listener);
21
- return () => {
22
- listeners.delete(listener);
23
- };
24
- }
25
- export function recordingStateOf(alias) {
26
- return states.get(alias) ?? IDLE_RECORDING;
27
- }
28
- export function anyRecordingLive() {
29
- for (const state of states.values())
30
- if (state.phase === "live")
31
- return true;
32
- return false;
33
- }
34
- /** The host's push for one alias. */
35
- export function applyRecordingChange(alias, state) {
36
- if (state.phase === "idle")
37
- states.delete(alias);
38
- else
39
- states.set(alias, state);
40
- emit();
41
- }
42
- /** The snapshot `context` carries: every alias it omits is idle. */
43
- export function replaceRecordings(snapshot) {
44
- states.clear();
45
- for (const [alias, state] of Object.entries(snapshot)) {
46
- if (state.phase !== "idle")
47
- states.set(alias, state);
48
- }
49
- emit();
50
- }
51
- function isRecord(value) {
52
- return value !== null && typeof value === "object" && !Array.isArray(value);
53
- }
54
- /** A host message's `state`, or null when it is not one. */
55
- export function readRecordingState(raw) {
56
- if (!isRecord(raw))
57
- return null;
58
- if (raw.phase === "idle")
59
- return IDLE_RECORDING;
60
- const inputs = raw.inputs;
61
- if (!isRecord(inputs))
62
- return null;
63
- switch (raw.phase) {
64
- case "live":
65
- case "interrupted":
66
- return typeof raw.elapsed_seconds === "number"
67
- ? { phase: raw.phase, elapsed_seconds: raw.elapsed_seconds, inputs }
68
- : null;
69
- case "processing":
70
- return { phase: "processing", inputs };
71
- case "filed":
72
- return typeof raw.execution_id === "string"
73
- ? { phase: "filed", execution_id: raw.execution_id, inputs }
74
- : null;
75
- case "failed":
76
- return typeof raw.message === "string" ? { phase: "failed", message: raw.message, inputs } : null;
77
- default:
78
- return null;
79
- }
80
- }
81
- /** A context's `recordings`, keeping each well-formed entry. */
82
- export function readRecordingSnapshot(raw) {
83
- if (!isRecord(raw))
84
- return {};
85
- const entries = Object.entries(raw).flatMap(([alias, value]) => {
86
- const state = readRecordingState(value);
87
- if (state === null) {
88
- console.error(`The host's context carried a malformed recording for "${alias}"; it reads as idle.`, value);
89
- return [];
90
- }
91
- return [[alias, state]];
92
- });
93
- return Object.fromEntries(entries);
94
- }
@@ -1,17 +0,0 @@
1
- import { type RouteObject } from "react-router";
2
- /**
3
- * The not-found screen's words. The SDK ships no locale, so an app whose reader
4
- * does not read English passes its own — from `@lotics/ui`'s locale where the app
5
- * uses the kit, from its own strings otherwise.
6
- */
7
- export interface NotFoundWords {
8
- /** Names the address that has no screen. Takes the path so the words may put
9
- * it anywhere the language needs it. */
10
- message: (path: string) => string;
11
- /** The label of the link back to the first screen. */
12
- firstScreen: string;
13
- }
14
- export declare function AppRouter({ routes, notFound }: {
15
- routes: RouteObject[];
16
- notFound?: NotFoundWords;
17
- }): import("react").JSX.Element;
@@ -1,144 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * In-app routing for custom-code apps — `AppRouter` lets an app use react-router
4
- * normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
5
- * addressable URLs. The app owns its OWN url (a plain browser history) in both
6
- * modes; the host url only ever *mirrors* the screen, it never drives the router:
7
- *
8
- * - **Standalone** (the app's own origin): the page's own browser history — real
9
- * path URLs, native browser back/forward, deep-link/refresh via the app host's
10
- * SPA fallback.
11
- * - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
12
- * (the iframe is same-origin to itself) via `pushState`. The user sees the
13
- * host's address bar, never the iframe's, and the host never sees the iframe's
14
- * url — so it never navigates and never remounts the iframe. (Reflecting the
15
- * screen into the host url with a *navigation* is what used to remount the app
16
- * iframe and reload the whole app on every in-app navigation.) The iframe's
17
- * history still participates in the session history, so browser Back/Forward
18
- * walk app screens (then leave the app).
19
- *
20
- * To stay shareable + refresh-survivable, `AppRouter` *mirrors* the current
21
- * screen into the host url under `_loc` via `setUrlParams` — a non-remounting
22
- * `history.replaceState` on the host, never a navigation. The READ half is the
23
- * host's job: on (re)load it bakes `_loc` into the iframe src, so the app boots
24
- * *directly* at the saved screen. There's deliberately no async seed here —
25
- * that would race the app's own first navigation; the iframe's initial url is
26
- * the source of truth, set synchronously by the host.
27
- *
28
- * Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
29
- * never pull react-router into their bundle:
30
- *
31
- * import { AppRouter } from "@lotics/app-sdk/router";
32
- * export default function App() {
33
- * return <AppRouter routes={[
34
- * { path: "/", element: <List /> },
35
- * { path: "/item/:id", element: <Detail /> },
36
- * ]} />;
37
- * }
38
- *
39
- * An address none of the routes claim renders the SDK's not-found screen rather
40
- * than nothing — see {@link NotFoundScreen} and {@link withNotFound}. Its words
41
- * are the `notFound` prop ({@link NotFoundWords}), because an app's reader reads
42
- * the app's language and the SDK ships no locale.
43
- */
44
- import { useEffect } from "react";
45
- import { BrowserRouter, Link, useLocation, useRoutes, } from "react-router";
46
- import { isEmbedded, setUrlParams } from "./rpc.js";
47
- /** Host query key carrying the app's current screen, so it's shareable and the
48
- * host can restore it on refresh. */
49
- const LOC_KEY = "_loc";
50
- /** The host handshake param the host puts on the iframe src — present on the
51
- * initial url only, never part of a route, so it's stripped from the mirror. */
52
- const HOST_KEY = "lotics_host";
53
- function screenHref(loc) {
54
- const search = new URLSearchParams(loc.search);
55
- search.delete(HOST_KEY);
56
- const qs = search.toString();
57
- return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
58
- }
59
- /**
60
- * Embedded only: mirror the current screen into the host url under `_loc` — a
61
- * non-remounting `setUrlParams` → `history.replaceState`, never a navigation, so
62
- * it never reloads the app. Write-only by design: the host reads `_loc` back and
63
- * bakes it into the iframe src on (re)load, so the app already boots at the saved
64
- * screen — no async read here, hence no seed-vs-navigation race. Standalone needs
65
- * none of this — the app's own url already IS the screen.
66
- */
67
- function HostScreenMirror() {
68
- const location = useLocation();
69
- const href = screenHref(location);
70
- useEffect(() => {
71
- void setUrlParams({ [LOC_KEY]: href });
72
- }, [href]);
73
- return null;
74
- }
75
- function RoutedRoutes({ routes }) {
76
- return useRoutes(routes);
77
- }
78
- /*
79
- * Shaped like the kit's `RegionState` — a centred message and one destination —
80
- * and written in its tokens with literal fallbacks, so it takes the app's theme
81
- * where `@lotics/ui/styles.css` is loaded and stays legible where it is not. It
82
- * cannot BE `RegionState`: the SDK ships no kit component.
83
- */
84
- const NOT_FOUND_ROOT = {
85
- display: "flex",
86
- flexDirection: "column",
87
- alignItems: "center",
88
- justifyContent: "center",
89
- gap: "var(--lotics-space-8, 8px)",
90
- paddingBlock: "var(--lotics-space-48, 48px)",
91
- paddingInline: "var(--lotics-space-16, 16px)",
92
- textAlign: "center",
93
- fontFamily: "var(--font-sans, system-ui, sans-serif)",
94
- };
95
- const NOT_FOUND_MESSAGE = {
96
- margin: 0,
97
- fontSize: "var(--lotics-text-sm, 14px)",
98
- color: "var(--lotics-ink-muted, #71717a)",
99
- };
100
- const NOT_FOUND_LINK = {
101
- fontSize: "var(--lotics-text-sm, 14px)",
102
- color: "var(--lotics-accent, #2563eb)",
103
- };
104
- const NOT_FOUND_ENGLISH = {
105
- message: (path) => `No screen at ${path}`,
106
- firstScreen: "Go to the first screen",
107
- };
108
- /**
109
- * What an app shows at an address none of its routes claim. Without it
110
- * `useRoutes` matches nothing and the page renders EMPTY — no message and no
111
- * console error — so a stale link or a typo reads as a crash.
112
- */
113
- function NotFoundScreen({ home, words }) {
114
- const { pathname } = useLocation();
115
- return (_jsxs("div", { style: NOT_FOUND_ROOT, children: [
116
- _jsx("p", { style: NOT_FOUND_MESSAGE, children: words.message(pathname) }), _jsx(Link, { to: home, style: NOT_FOUND_LINK, children: words.firstScreen })
117
- ] }));
118
- }
119
- /**
120
- * The catch-all, appended at EVERY level of the tree: a route with `children`
121
- * is a layout, and a catch-all among those children is what keeps that layout's
122
- * shell on screen instead of swapping the whole page for the message. An app
123
- * that declares its own `*` still wins — react-router ranks equal matches by
124
- * declaration order and ours is appended last.
125
- */
126
- function withNotFound(routes, element) {
127
- return [
128
- ...routes.map((route) => route.children === undefined
129
- ? route
130
- : { ...route, children: withNotFound(route.children, element) }),
131
- { path: "*", element },
132
- ];
133
- }
134
- /** The screen the not-found sends the reader back to — the first one declared. */
135
- function firstScreenPath(routes) {
136
- const first = routes.find((route) => route.path !== undefined && route.path !== "*");
137
- return first?.path ?? "/";
138
- }
139
- export function AppRouter({ routes, notFound = NOT_FOUND_ENGLISH, }) {
140
- // `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
141
- // it's known synchronously at first render.
142
- const embedded = isEmbedded();
143
- return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: withNotFound(routes, _jsx(NotFoundScreen, { home: firstScreenPath(routes), words: notFound })) })] }));
144
- }