@lotics/cli 0.54.2 → 0.55.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.
@@ -24,9 +24,9 @@
24
24
  * wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
25
25
  * streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
26
26
  * the iframe aborts with { id, type: "abort" }.
27
- * url-state (useUrlState): the iframe reads/writes the wrapper's address bar
28
- * via the urlState.get/set ops; the wrapper pushes { type: "url-state",
29
- * params } (no id) on back/forward.
27
+ * urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
28
+ * address bar via urlState.get/set/go; set push/replace and go drive history,
29
+ * and back/forward broadcast { type: "url-state", params } back to the iframe.
30
30
  */
31
31
  export interface WrapperPageArgs {
32
32
  app_name: string;
@@ -24,9 +24,9 @@
24
24
  * wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
25
25
  * streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
26
26
  * the iframe aborts with { id, type: "abort" }.
27
- * url-state (useUrlState): the iframe reads/writes the wrapper's address bar
28
- * via the urlState.get/set ops; the wrapper pushes { type: "url-state",
29
- * params } (no id) on back/forward.
27
+ * urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
28
+ * address bar via urlState.get/set/go; set push/replace and go drive history,
29
+ * and back/forward broadcast { type: "url-state", params } back to the iframe.
30
30
  */
31
31
  export function buildWrapperPage(args) {
32
32
  const { app_name, app_id, workspace_id, vite_url, api_url } = args;
@@ -185,6 +185,7 @@ export function buildWrapperPage(args) {
185
185
  });
186
186
  return out;
187
187
  }
188
+ var lastPushAt = 0;
188
189
  function handleUrlStateSet(payload) {
189
190
  const params = (payload && payload.params) || {};
190
191
  const sp = new URLSearchParams(window.location.search);
@@ -197,10 +198,21 @@ export function buildWrapperPage(args) {
197
198
  });
198
199
  const qs = sp.toString();
199
200
  const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
200
- // pushState/replaceState don't fire popstate, so no echo the app
201
- // updated itself optimistically on write.
202
- if (payload && payload.push) window.history.pushState(null, "", url);
203
- else window.history.replaceState(null, "", url);
201
+ // push adds a back-able entry (the router adapter); replace for filter
202
+ // churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
203
+ // replaceState don't fire popstate, so no echo. Mirrors the production host.
204
+ const now = Date.now();
205
+ if (payload && payload.push && now - lastPushAt > 100) {
206
+ lastPushAt = now;
207
+ window.history.pushState(null, "", url);
208
+ } else {
209
+ window.history.replaceState(null, "", url);
210
+ }
211
+ return undefined;
212
+ }
213
+ function handleUrlStateGo(payload) {
214
+ const delta = payload && payload.delta;
215
+ if (typeof delta === "number") window.history.go(delta);
204
216
  return undefined;
205
217
  }
206
218
 
@@ -269,6 +281,8 @@ export function buildWrapperPage(args) {
269
281
  ? readUrlParams()
270
282
  : msg.op === "urlState.set"
271
283
  ? handleUrlStateSet(msg.payload)
284
+ : msg.op === "urlState.go"
285
+ ? handleUrlStateGo(msg.payload)
272
286
  : await rpc(msg.op, msg.payload);
273
287
  const ms = Math.round(performance.now() - startedAt);
274
288
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
@@ -286,9 +300,9 @@ export function buildWrapperPage(args) {
286
300
  }
287
301
  });
288
302
 
289
- // Push the URL back into the app on back/forward so useUrlState
290
- // re-hydrates (the app's own writes use replace/pushState — no popstate —
291
- // so there's no echo). Matches the production host.
303
+ // Browser/adapter back/forward broadcast the new params so useUrlState and
304
+ // the router adapter re-hydrate (the app's own set writes use push/replace
305
+ // State — no popstate — so there's no echo). Mirrors the production host.
292
306
  window.addEventListener("popstate", function () {
293
307
  iframe.contentWindow.postMessage(
294
308
  { type: "url-state", params: readUrlParams() },
package/dist/src/cli.js CHANGED
@@ -30242,7 +30242,7 @@ import { tmpdir } from "node:os";
30242
30242
 
30243
30243
  // src/starter_template.ts
30244
30244
  var STARTER_FALLBACK_UI_VERSION = "6.1.0";
30245
- var STARTER_FALLBACK_SDK_VERSION = "0.11.0";
30245
+ var STARTER_FALLBACK_SDK_VERSION = "0.40.0";
30246
30246
  var STARTER_REACT_NATIVE_VERSION = "0.85.3";
30247
30247
  function buildStarterTemplate(args) {
30248
30248
  const uiVersion = args.ui_version ?? `^${STARTER_FALLBACK_UI_VERSION}`;
@@ -30284,7 +30284,10 @@ function buildStarterTemplate(args) {
30284
30284
  "react-dom": "^19.0.0",
30285
30285
  "react-native": STARTER_REACT_NATIVE_VERSION,
30286
30286
  "react-native-svg": "^15.0.0",
30287
- "react-native-web": "^0.21.0"
30287
+ "react-native-web": "^0.21.0",
30288
+ // In-app routing for the scaffolded list→detail example. The app owns
30289
+ // its routing; the SDK's `isEmbedded()` picks memory vs browser history.
30290
+ "react-router-dom": "^7.0.0"
30288
30291
  },
30289
30292
  devDependencies: {
30290
30293
  "@testing-library/react": "^16.1.0",
@@ -30497,6 +30500,10 @@ export default defineConfig({
30497
30500
  // the hooks dispatcher the instant a hook (useAgentRun / useQuery)
30498
30501
  // runs in a rendered App tree.
30499
30502
  "@lotics/app-sdk",
30503
+ // react-router-dom (App's router) also ships compiled dist JS \u2014 pin it
30504
+ // into the shared chunk so its hooks don't split the react instance
30505
+ // ("Invalid hook call") when a test renders the routed App tree.
30506
+ "react-router-dom",
30500
30507
  ],
30501
30508
  esbuildOptions: {
30502
30509
  resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
@@ -30565,59 +30572,110 @@ mount(
30565
30572
  },
30566
30573
  {
30567
30574
  path: "src/App.tsx",
30568
- // Welcome screen demonstrates the full-container layout pattern that
30569
- // real apps (dashboards, tables, full-page surfaces) rely on:
30570
- // - Outer <View flex:1> claims the full iframe height (works because
30571
- // index.html sets html/body/#root to 100% + #root is a flex column).
30572
- // - Inner View is content-sized + centered for a card layout.
30573
- //
30574
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
30575
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
30576
- // propagation to children that need to claim flexible height (e.g., a
30577
- // scrollable table or list that fills the remaining space below a
30578
- // header + search).
30579
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
30580
- // that hosts a flex-filling child; Stack is fine for content-sized
30581
- // groupings inside such a chain.
30582
- content: `import { View } from "react-native";
30575
+ // Default scaffold = a minimal in-app router example (a list screen and a
30576
+ // detail screen), so a new app starts with the recommended routing shape:
30577
+ // - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
30578
+ // which makes screens real addressable URLs embedded, they live in the
30579
+ // host address bar (?_loc=…) and the browser Back/Forward walk screens;
30580
+ // standalone, real path URLs.
30581
+ // - The full-container layout pattern is preserved in `Screen`: an outer
30582
+ // <View flex:1> claims the iframe height (index.html sets
30583
+ // html/body/#root to 100% + #root is a flex column). Keep that chain
30584
+ // plain @lotics/ui/stack wraps children in an unstyled <View> that
30585
+ // breaks `flex: 1` propagation to a fill-remaining-space child.
30586
+ // A single-screen app can delete the router and render one Screen directly.
30587
+ content: `import type { ReactNode } from "react";
30588
+ import { View } from "react-native";
30589
+ import { useNavigate, useParams } from "react-router-dom";
30590
+ import { AppRouter } from "@lotics/app-sdk/router";
30583
30591
  import { Card } from "@lotics/ui/card";
30584
30592
  import { Text } from "@lotics/ui/text";
30585
30593
  import { Button } from "@lotics/ui/button";
30586
30594
 
30587
- // Outer <View flex:1> claims the full iframe height \u2014 works because
30588
- // index.html sets html/body/#root to 100% and #root is a flex column.
30589
- // For real layouts with a fill-remaining-space child (a scrollable
30590
- // table/list etc.), keep this flex chain plain \u2014 @lotics/ui/stack wraps
30591
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
30592
- export default function App() {
30595
+ // AppRouter makes the app's screens real, addressable URLs \u2014 write plain
30596
+ // react-router (useNavigate / useParams / <Link>) and it handles both modes:
30597
+ // - Embedded in the Lotics host: the current screen lives in the host address
30598
+ // bar (?_loc=\u2026) \u2014 shareable + refresh-survivable \u2014 and the browser Back /
30599
+ // Forward buttons walk app screens (then leave the app).
30600
+ // - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
30601
+
30602
+ const ITEMS = [
30603
+ { id: "1", name: "First item" },
30604
+ { id: "2", name: "Second item" },
30605
+ { id: "3", name: "Third item" },
30606
+ ];
30607
+
30608
+ // Outer <View flex:1> claims the full iframe height \u2014 works because index.html
30609
+ // sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
30610
+ // plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
30611
+ function Screen({ children }: { children: ReactNode }) {
30593
30612
  return (
30594
- <View
30595
- style={{
30596
- flex: 1,
30597
- padding: 24,
30598
- justifyContent: "center",
30599
- alignItems: "center",
30600
- }}
30601
- >
30602
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
30603
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
30604
- <Card>
30605
- <View style={{ padding: 16, gap: 8 }}>
30606
- <Text>This is your new Lotics app.</Text>
30607
- <Text color="muted">
30608
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
30609
- <Text weight="medium">lotics app deploy</Text> to publish.
30610
- </Text>
30613
+ <View style={{ flex: 1, padding: 24, alignItems: "center" }}>
30614
+ <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
30615
+ </View>
30616
+ );
30617
+ }
30618
+
30619
+ function ListScreen() {
30620
+ const navigate = useNavigate();
30621
+ return (
30622
+ <Screen>
30623
+ <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
30624
+ <Text color="muted">
30625
+ Tap an item to open its detail screen \u2014 the app routes itself. Edit{" "}
30626
+ <Text weight="medium">src/App.tsx</Text> and run{" "}
30627
+ <Text weight="medium">lotics app deploy</Text> to publish.
30628
+ </Text>
30629
+ {ITEMS.map((item) => (
30630
+ <Card key={item.id}>
30631
+ <View
30632
+ style={{
30633
+ padding: 16,
30634
+ flexDirection: "row",
30635
+ alignItems: "center",
30636
+ justifyContent: "space-between",
30637
+ gap: 12,
30638
+ }}
30639
+ >
30640
+ <Text weight="medium">{item.name}</Text>
30641
+ <Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
30611
30642
  </View>
30612
30643
  </Card>
30613
- <Text color="muted" size="sm">
30614
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
30615
- </Text>
30616
- <Button title="Get started" onPress={() => {}} color="primary" />
30644
+ ))}
30645
+ </Screen>
30646
+ );
30647
+ }
30648
+
30649
+ function ItemDetailScreen() {
30650
+ const { id } = useParams();
30651
+ const navigate = useNavigate();
30652
+ const item = ITEMS.find((i) => i.id === id);
30653
+ return (
30654
+ <Screen>
30655
+ {/* An in-app Back control; navigate(-1) walks the history (the browser Back
30656
+ button walks app screens too). */}
30657
+ <View style={{ alignItems: "flex-start" }}>
30658
+ <Button title="Back" onPress={() => navigate(-1)} />
30617
30659
  </View>
30618
- </View>
30660
+ <Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
30661
+ <Card>
30662
+ <View style={{ padding: 16, gap: 8 }}>
30663
+ <Text>Detail for item {id}.</Text>
30664
+ <Text color="muted">Reached via in-app navigation, not a host route.</Text>
30665
+ </View>
30666
+ </Card>
30667
+ </Screen>
30619
30668
  );
30620
30669
  }
30670
+
30671
+ const routes = [
30672
+ { path: "/", element: <ListScreen /> },
30673
+ { path: "/item/:id", element: <ItemDetailScreen /> },
30674
+ ];
30675
+
30676
+ export default function App() {
30677
+ return <AppRouter routes={routes} />;
30678
+ }
30621
30679
  `
30622
30680
  },
30623
30681
  {
@@ -30696,9 +30754,9 @@ import { render } from "@testing-library/react";
30696
30754
  import App from "./App";
30697
30755
 
30698
30756
  describe("App", () => {
30699
- test("renders without crashing", () => {
30757
+ test("renders the default route", () => {
30700
30758
  const { container } = render(<App />);
30701
- expect(container).not.toBeNull();
30759
+ expect(container.textContent).toContain("First item");
30702
30760
  });
30703
30761
  });
30704
30762
  `
@@ -30782,6 +30840,16 @@ import { Text } from "@lotics/ui/text";
30782
30840
  in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
30783
30841
  the full export list at https://www.npmjs.com/package/@lotics/ui.
30784
30842
 
30843
+ ## Routing
30844
+
30845
+ \`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
30846
+ your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` \u2014 it makes screens real,
30847
+ addressable URLs in both modes: embedded in the Lotics host the current screen
30848
+ lives in the host address bar (\`?_loc=\u2026\`, shareable + refresh-survivable) and the
30849
+ browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
30850
+ normal browser router with real path URLs. A single-screen app can drop the
30851
+ router and render one screen directly.
30852
+
30785
30853
  See https://lotics.ai/docs/app-sdk for the SDK reference.
30786
30854
  `
30787
30855
  }
@@ -31095,6 +31163,7 @@ function buildWrapperPage(args) {
31095
31163
  });
31096
31164
  return out;
31097
31165
  }
31166
+ var lastPushAt = 0;
31098
31167
  function handleUrlStateSet(payload) {
31099
31168
  const params = (payload && payload.params) || {};
31100
31169
  const sp = new URLSearchParams(window.location.search);
@@ -31107,10 +31176,21 @@ function buildWrapperPage(args) {
31107
31176
  });
31108
31177
  const qs = sp.toString();
31109
31178
  const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
31110
- // pushState/replaceState don't fire popstate, so no echo \u2014 the app
31111
- // updated itself optimistically on write.
31112
- if (payload && payload.push) window.history.pushState(null, "", url);
31113
- else window.history.replaceState(null, "", url);
31179
+ // push adds a back-able entry (the router adapter); replace for filter
31180
+ // churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
31181
+ // replaceState don't fire popstate, so no echo. Mirrors the production host.
31182
+ const now = Date.now();
31183
+ if (payload && payload.push && now - lastPushAt > 100) {
31184
+ lastPushAt = now;
31185
+ window.history.pushState(null, "", url);
31186
+ } else {
31187
+ window.history.replaceState(null, "", url);
31188
+ }
31189
+ return undefined;
31190
+ }
31191
+ function handleUrlStateGo(payload) {
31192
+ const delta = payload && payload.delta;
31193
+ if (typeof delta === "number") window.history.go(delta);
31114
31194
  return undefined;
31115
31195
  }
31116
31196
 
@@ -31179,6 +31259,8 @@ function buildWrapperPage(args) {
31179
31259
  ? readUrlParams()
31180
31260
  : msg.op === "urlState.set"
31181
31261
  ? handleUrlStateSet(msg.payload)
31262
+ : msg.op === "urlState.go"
31263
+ ? handleUrlStateGo(msg.payload)
31182
31264
  : await rpc(msg.op, msg.payload);
31183
31265
  const ms = Math.round(performance.now() - startedAt);
31184
31266
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
@@ -31196,9 +31278,9 @@ function buildWrapperPage(args) {
31196
31278
  }
31197
31279
  });
31198
31280
 
31199
- // Push the URL back into the app on back/forward so useUrlState
31200
- // re-hydrates (the app's own writes use replace/pushState \u2014 no popstate \u2014
31201
- // so there's no echo). Matches the production host.
31281
+ // Browser/adapter back/forward \u2192 broadcast the new params so useUrlState and
31282
+ // the router adapter re-hydrate (the app's own set writes use push/replace
31283
+ // State \u2014 no popstate \u2014 so there's no echo). Mirrors the production host.
31202
31284
  window.addEventListener("popstate", function () {
31203
31285
  iframe.contentWindow.postMessage(
31204
31286
  { type: "url-state", params: readUrlParams() },
@@ -42,7 +42,7 @@ export interface StarterFile {
42
42
  * fall back here when the lookup fails.
43
43
  */
44
44
  export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
45
- export declare const STARTER_FALLBACK_SDK_VERSION = "0.11.0";
45
+ export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
46
46
  /**
47
47
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
48
48
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -38,7 +38,10 @@
38
38
  * fall back here when the lookup fails.
39
39
  */
40
40
  export const STARTER_FALLBACK_UI_VERSION = "6.1.0";
41
- export const STARTER_FALLBACK_SDK_VERSION = "0.11.0";
41
+ // Must be the release that added `@lotics/app-sdk/router` (`AppRouter`, used by
42
+ // the scaffolded App.tsx) — an older offline pin won't resolve the subpath and
43
+ // the scaffold won't typecheck.
44
+ export const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
42
45
  /**
43
46
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
44
47
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -94,6 +97,9 @@ export function buildStarterTemplate(args) {
94
97
  "react-native": STARTER_REACT_NATIVE_VERSION,
95
98
  "react-native-svg": "^15.0.0",
96
99
  "react-native-web": "^0.21.0",
100
+ // In-app routing for the scaffolded list→detail example. The app owns
101
+ // its routing; the SDK's `isEmbedded()` picks memory vs browser history.
102
+ "react-router-dom": "^7.0.0",
97
103
  },
98
104
  devDependencies: {
99
105
  "@testing-library/react": "^16.1.0",
@@ -299,6 +305,10 @@ export default defineConfig({
299
305
  // the hooks dispatcher the instant a hook (useAgentRun / useQuery)
300
306
  // runs in a rendered App tree.
301
307
  "@lotics/app-sdk",
308
+ // react-router-dom (App's router) also ships compiled dist JS — pin it
309
+ // into the shared chunk so its hooks don't split the react instance
310
+ // ("Invalid hook call") when a test renders the routed App tree.
311
+ "react-router-dom",
302
312
  ],
303
313
  esbuildOptions: {
304
314
  resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
@@ -367,59 +377,110 @@ mount(
367
377
  },
368
378
  {
369
379
  path: "src/App.tsx",
370
- // Welcome screen demonstrates the full-container layout pattern that
371
- // real apps (dashboards, tables, full-page surfaces) rely on:
372
- // - Outer <View flex:1> claims the full iframe height (works because
373
- // index.html sets html/body/#root to 100% + #root is a flex column).
374
- // - Inner View is content-sized + centered for a card layout.
375
- //
376
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
377
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
378
- // propagation to children that need to claim flexible height (e.g., a
379
- // scrollable table or list that fills the remaining space below a
380
- // header + search).
381
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
382
- // that hosts a flex-filling child; Stack is fine for content-sized
383
- // groupings inside such a chain.
384
- content: `import { View } from "react-native";
380
+ // Default scaffold = a minimal in-app router example (a list screen and a
381
+ // detail screen), so a new app starts with the recommended routing shape:
382
+ // - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
383
+ // which makes screens real addressable URLs embedded, they live in the
384
+ // host address bar (?_loc=…) and the browser Back/Forward walk screens;
385
+ // standalone, real path URLs.
386
+ // - The full-container layout pattern is preserved in `Screen`: an outer
387
+ // <View flex:1> claims the iframe height (index.html sets
388
+ // html/body/#root to 100% + #root is a flex column). Keep that chain
389
+ // plain @lotics/ui/stack wraps children in an unstyled <View> that
390
+ // breaks `flex: 1` propagation to a fill-remaining-space child.
391
+ // A single-screen app can delete the router and render one Screen directly.
392
+ content: `import type { ReactNode } from "react";
393
+ import { View } from "react-native";
394
+ import { useNavigate, useParams } from "react-router-dom";
395
+ import { AppRouter } from "@lotics/app-sdk/router";
385
396
  import { Card } from "@lotics/ui/card";
386
397
  import { Text } from "@lotics/ui/text";
387
398
  import { Button } from "@lotics/ui/button";
388
399
 
389
- // Outer <View flex:1> claims the full iframe heightworks because
390
- // index.html sets html/body/#root to 100% and #root is a flex column.
391
- // For real layouts with a fill-remaining-space child (a scrollable
392
- // table/list etc.), keep this flex chain plain @lotics/ui/stack wraps
393
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
394
- export default function App() {
400
+ // AppRouter makes the app's screens real, addressable URLswrite plain
401
+ // react-router (useNavigate / useParams / <Link>) and it handles both modes:
402
+ // - Embedded in the Lotics host: the current screen lives in the host address
403
+ // bar (?_loc=…) shareable + refresh-survivableand the browser Back /
404
+ // Forward buttons walk app screens (then leave the app).
405
+ // - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
406
+
407
+ const ITEMS = [
408
+ { id: "1", name: "First item" },
409
+ { id: "2", name: "Second item" },
410
+ { id: "3", name: "Third item" },
411
+ ];
412
+
413
+ // Outer <View flex:1> claims the full iframe height — works because index.html
414
+ // sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
415
+ // plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
416
+ function Screen({ children }: { children: ReactNode }) {
417
+ return (
418
+ <View style={{ flex: 1, padding: 24, alignItems: "center" }}>
419
+ <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
420
+ </View>
421
+ );
422
+ }
423
+
424
+ function ListScreen() {
425
+ const navigate = useNavigate();
395
426
  return (
396
- <View
397
- style={{
398
- flex: 1,
399
- padding: 24,
400
- justifyContent: "center",
401
- alignItems: "center",
402
- }}
403
- >
404
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
405
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
406
- <Card>
407
- <View style={{ padding: 16, gap: 8 }}>
408
- <Text>This is your new Lotics app.</Text>
409
- <Text color="muted">
410
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
411
- <Text weight="medium">lotics app deploy</Text> to publish.
412
- </Text>
427
+ <Screen>
428
+ <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
429
+ <Text color="muted">
430
+ Tap an item to open its detail screen — the app routes itself. Edit{" "}
431
+ <Text weight="medium">src/App.tsx</Text> and run{" "}
432
+ <Text weight="medium">lotics app deploy</Text> to publish.
433
+ </Text>
434
+ {ITEMS.map((item) => (
435
+ <Card key={item.id}>
436
+ <View
437
+ style={{
438
+ padding: 16,
439
+ flexDirection: "row",
440
+ alignItems: "center",
441
+ justifyContent: "space-between",
442
+ gap: 12,
443
+ }}
444
+ >
445
+ <Text weight="medium">{item.name}</Text>
446
+ <Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
413
447
  </View>
414
448
  </Card>
415
- <Text color="muted" size="sm">
416
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
417
- </Text>
418
- <Button title="Get started" onPress={() => {}} color="primary" />
449
+ ))}
450
+ </Screen>
451
+ );
452
+ }
453
+
454
+ function ItemDetailScreen() {
455
+ const { id } = useParams();
456
+ const navigate = useNavigate();
457
+ const item = ITEMS.find((i) => i.id === id);
458
+ return (
459
+ <Screen>
460
+ {/* An in-app Back control; navigate(-1) walks the history (the browser Back
461
+ button walks app screens too). */}
462
+ <View style={{ alignItems: "flex-start" }}>
463
+ <Button title="Back" onPress={() => navigate(-1)} />
419
464
  </View>
420
- </View>
465
+ <Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
466
+ <Card>
467
+ <View style={{ padding: 16, gap: 8 }}>
468
+ <Text>Detail for item {id}.</Text>
469
+ <Text color="muted">Reached via in-app navigation, not a host route.</Text>
470
+ </View>
471
+ </Card>
472
+ </Screen>
421
473
  );
422
474
  }
475
+
476
+ const routes = [
477
+ { path: "/", element: <ListScreen /> },
478
+ { path: "/item/:id", element: <ItemDetailScreen /> },
479
+ ];
480
+
481
+ export default function App() {
482
+ return <AppRouter routes={routes} />;
483
+ }
423
484
  `,
424
485
  },
425
486
  {
@@ -498,9 +559,9 @@ import { render } from "@testing-library/react";
498
559
  import App from "./App";
499
560
 
500
561
  describe("App", () => {
501
- test("renders without crashing", () => {
562
+ test("renders the default route", () => {
502
563
  const { container } = render(<App />);
503
- expect(container).not.toBeNull();
564
+ expect(container.textContent).toContain("First item");
504
565
  });
505
566
  });
506
567
  `,
@@ -584,6 +645,16 @@ import { Text } from "@lotics/ui/text";
584
645
  in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
585
646
  the full export list at https://www.npmjs.com/package/@lotics/ui.
586
647
 
648
+ ## Routing
649
+
650
+ \`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
651
+ your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` — it makes screens real,
652
+ addressable URLs in both modes: embedded in the Lotics host the current screen
653
+ lives in the host address bar (\`?_loc=…\`, shareable + refresh-survivable) and the
654
+ browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
655
+ normal browser router with real path URLs. A single-screen app can drop the
656
+ router and render one screen directly.
657
+
587
658
  See https://lotics.ai/docs/app-sdk for the SDK reference.
588
659
  `,
589
660
  },
@@ -77,4 +77,24 @@ describe("buildStarterTemplate", () => {
77
77
  expect(pkg.dependencies["@lotics/ui"]).toBe(`^${STARTER_FALLBACK_UI_VERSION}`);
78
78
  expect(pkg.dependencies["@lotics/app-sdk"]).toBe(`^${STARTER_FALLBACK_SDK_VERSION}`);
79
79
  });
80
+ test("package.json includes react-router-dom for the in-app router example", () => {
81
+ const pkg = JSON.parse(fileNamed(buildStarterTemplate(baseArgs), "package.json"));
82
+ expect(pkg.dependencies["react-router-dom"]).toBeDefined();
83
+ });
84
+ test("App.tsx routes via the SDK's AppRouter (real addressable URLs, both modes)", () => {
85
+ // The scaffold demonstrates the recommended shape: the app uses react-router
86
+ // through `AppRouter`, which makes screens addressable URLs in both modes.
87
+ // Guards against the wiring being dropped back to a single screen.
88
+ const app = fileNamed(buildStarterTemplate(baseArgs), "src/App.tsx");
89
+ expect(app).toContain('from "react-router-dom"');
90
+ expect(app).toContain('from "@lotics/app-sdk/router"');
91
+ expect(app).toContain("<AppRouter routes={routes}");
92
+ });
93
+ test("vite.config.ts pins react-router-dom into the vitest web optimizer (else the routed App test splits the react instance)", () => {
94
+ // react-router-dom ships compiled dist JS; un-pinned, its hooks load a second
95
+ // react instance under vitest and the scaffold's App.test fails with "Invalid
96
+ // hook call" the moment it renders RouterProvider.
97
+ const config = fileNamed(buildStarterTemplate(baseArgs), "vite.config.ts");
98
+ expect(config).toMatch(/include:\s*\[[\s\S]*"react-router-dom"/);
99
+ });
80
100
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.54.2",
3
+ "version": "0.55.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {