@lotics/cli 0.54.0 → 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.
@@ -86,7 +86,10 @@ export async function startDevServer(args) {
86
86
  // ── HTTP server ────────────────────────────────────────────────────────
87
87
  const server = http.createServer(async (req, res) => {
88
88
  const url = req.url ?? "/";
89
- if (req.method === "GET" && (url === "/" || url === "/index.html")) {
89
+ // Route by pathname `useUrlState` writes a query string onto the wrapper
90
+ // URL, so a refresh / shared link requests `/?…`; the page must still serve.
91
+ const pathname = url.split("?")[0];
92
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
90
93
  res.writeHead(200, {
91
94
  "Content-Type": "text/html; charset=utf-8",
92
95
  "Cache-Control": "no-cache, no-store, must-revalidate",
@@ -24,6 +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
+ * 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.
27
30
  */
28
31
  export interface WrapperPageArgs {
29
32
  app_name: string;
@@ -24,6 +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
+ * 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.
27
30
  */
28
31
  export function buildWrapperPage(args) {
29
32
  const { app_name, app_id, workspace_id, vite_url, api_url } = args;
@@ -165,6 +168,54 @@ export function buildWrapperPage(args) {
165
168
  return undefined;
166
169
  }
167
170
 
171
+ // urlState.get/set — useUrlState keeps app view-state (filters, search) in
172
+ // THIS wrapper page's address bar so it survives refresh and is shareable,
173
+ // mirroring the production host. Handled locally (the URL is the store; no
174
+ // API hop). The wrapper page is its own top-level page, so its query string
175
+ // is entirely the app's. Repeated keys become arrays. Parse/merge MUST
176
+ // match @lotics/app-sdk parseSearch/serializeMerge and the production host
177
+ // (inline JS here — no module system to share the trivial copy).
178
+ function readUrlParams() {
179
+ const sp = new URLSearchParams(window.location.search);
180
+ const out = {};
181
+ sp.forEach(function (_value, key) {
182
+ if (key in out) return;
183
+ const all = sp.getAll(key);
184
+ out[key] = all.length > 1 ? all : all[0];
185
+ });
186
+ return out;
187
+ }
188
+ var lastPushAt = 0;
189
+ function handleUrlStateSet(payload) {
190
+ const params = (payload && payload.params) || {};
191
+ const sp = new URLSearchParams(window.location.search);
192
+ Object.keys(params).forEach(function (key) {
193
+ sp.delete(key);
194
+ const value = params[key];
195
+ if (value === undefined || value === null) return;
196
+ if (Array.isArray(value)) value.forEach(function (item) { sp.append(key, item); });
197
+ else sp.append(key, value);
198
+ });
199
+ const qs = sp.toString();
200
+ const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
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);
216
+ return undefined;
217
+ }
218
+
168
219
  // Streaming agent runs (op "agentRun"): the response is a stream, so it
169
220
  // can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
170
221
  // body, and forward chunks to the iframe as the SDK's bridged streaming
@@ -226,6 +277,12 @@ export function buildWrapperPage(args) {
226
277
  ? await handleUpload(msg.payload)
227
278
  : msg.op === "openExternal"
228
279
  ? handleOpenExternal(msg.payload)
280
+ : msg.op === "urlState.get"
281
+ ? readUrlParams()
282
+ : msg.op === "urlState.set"
283
+ ? handleUrlStateSet(msg.payload)
284
+ : msg.op === "urlState.go"
285
+ ? handleUrlStateGo(msg.payload)
229
286
  : await rpc(msg.op, msg.payload);
230
287
  const ms = Math.round(performance.now() - startedAt);
231
288
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
@@ -242,6 +299,16 @@ export function buildWrapperPage(args) {
242
299
  );
243
300
  }
244
301
  });
302
+
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.
306
+ window.addEventListener("popstate", function () {
307
+ iframe.contentWindow.postMessage(
308
+ { type: "url-state", params: readUrlParams() },
309
+ VITE_ORIGIN
310
+ );
311
+ });
245
312
  })();
246
313
  </script>
247
314
  </body>
package/dist/src/cli.js CHANGED
@@ -30241,8 +30241,8 @@ import { spawn as spawn2 } from "node:child_process";
30241
30241
  import { tmpdir } from "node:os";
30242
30242
 
30243
30243
  // src/starter_template.ts
30244
- var STARTER_FALLBACK_UI_VERSION = "2.3.0";
30245
- var STARTER_FALLBACK_SDK_VERSION = "0.11.0";
30244
+ var STARTER_FALLBACK_UI_VERSION = "6.1.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",
@@ -30409,8 +30412,18 @@ export default defineConfig({
30409
30412
  // otherwise serves them without a synthesized default export ("does not
30410
30413
  // provide an export named 'default'"), blanking the iframe. (Production/rollup
30411
30414
  // resolves the interop already, so this is dev-only.)
30415
+ //
30416
+ // \`react-markdown\` (@lotics/ui's Markdown renderer, reached by AgentRun and
30417
+ // any markdown surface) pulls a transitive CJS dep, \`style-to-js\`, whose
30418
+ // default export the dev optimizer won't synthesize unbundled ("does not
30419
+ // provide an export named 'default'") \u2014 blanking the iframe the moment a
30420
+ // markdown component mounts. Pre-bundling react-markdown folds the whole
30421
+ // subtree (incl. style-to-js) into one interop'd chunk; remark-gfm (imported
30422
+ // alongside it) is pre-bundled for the same consistency. Both are regular
30423
+ // deps of @lotics/ui (hoisted), so they need no package.json entry here.
30412
30424
  include: [
30413
30425
  "react-native-svg",
30426
+ "react-markdown", "remark-gfm",
30414
30427
  "react-native-web", "@react-native/normalize-colors",
30415
30428
  "inline-style-prefixer/lib/createPrefixer",
30416
30429
  "inline-style-prefixer/lib/plugins/crossFade",
@@ -30487,6 +30500,10 @@ export default defineConfig({
30487
30500
  // the hooks dispatcher the instant a hook (useAgentRun / useQuery)
30488
30501
  // runs in a rendered App tree.
30489
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",
30490
30507
  ],
30491
30508
  esbuildOptions: {
30492
30509
  resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
@@ -30533,73 +30550,132 @@ export default defineConfig({
30533
30550
  content: `import "@lotics/ui/index.css";
30534
30551
  import "@lotics/ui/fonts.css";
30535
30552
  import { PortalHost } from "@lotics/ui/portal";
30553
+ import { LoticsLocaleProvider, vi } from "@lotics/ui/locale";
30536
30554
  import { mount } from "@lotics/app-sdk";
30537
30555
  import App from "./App";
30538
30556
 
30539
30557
  // PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
30540
30558
  // Dialog). Without it, Portal renders nothing \u2014 keep it wrapping the app.
30559
+ //
30560
+ // LoticsLocaleProvider supplies the kit's own strings (Pagination, sort
30561
+ // headers, select-all, the Drawer's record nav\u2026) in one language \u2014 Vietnamese
30562
+ // by default, since Lotics apps are Vietnamese. Swap to \`en\` (or your own
30563
+ // LoticsLocale pack) for another language; remove it to fall back to English.
30541
30564
  mount(
30542
- <PortalHost>
30543
- <App />
30544
- </PortalHost>,
30565
+ <LoticsLocaleProvider locale={vi}>
30566
+ <PortalHost>
30567
+ <App />
30568
+ </PortalHost>
30569
+ </LoticsLocaleProvider>,
30545
30570
  );
30546
30571
  `
30547
30572
  },
30548
30573
  {
30549
30574
  path: "src/App.tsx",
30550
- // Welcome screen demonstrates the full-container layout pattern that
30551
- // real apps (dashboards, tables, full-page surfaces) rely on:
30552
- // - Outer <View flex:1> claims the full iframe height (works because
30553
- // index.html sets html/body/#root to 100% + #root is a flex column).
30554
- // - Inner View is content-sized + centered for a card layout.
30555
- //
30556
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
30557
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
30558
- // propagation to children that need to claim flexible height (e.g., a
30559
- // scrollable table or list that fills the remaining space below a
30560
- // header + search).
30561
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
30562
- // that hosts a flex-filling child; Stack is fine for content-sized
30563
- // groupings inside such a chain.
30564
- 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";
30565
30591
  import { Card } from "@lotics/ui/card";
30566
30592
  import { Text } from "@lotics/ui/text";
30567
30593
  import { Button } from "@lotics/ui/button";
30568
30594
 
30569
- // Outer <View flex:1> claims the full iframe height \u2014 works because
30570
- // index.html sets html/body/#root to 100% and #root is a flex column.
30571
- // For real layouts with a fill-remaining-space child (a scrollable
30572
- // table/list etc.), keep this flex chain plain \u2014 @lotics/ui/stack wraps
30573
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
30574
- 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 }) {
30612
+ return (
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();
30575
30621
  return (
30576
- <View
30577
- style={{
30578
- flex: 1,
30579
- padding: 24,
30580
- justifyContent: "center",
30581
- alignItems: "center",
30582
- }}
30583
- >
30584
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
30585
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
30586
- <Card>
30587
- <View style={{ padding: 16, gap: 8 }}>
30588
- <Text>This is your new Lotics app.</Text>
30589
- <Text color="muted">
30590
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
30591
- <Text weight="medium">lotics app deploy</Text> to publish.
30592
- </Text>
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)} />
30593
30642
  </View>
30594
30643
  </Card>
30595
- <Text color="muted" size="sm">
30596
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
30597
- </Text>
30598
- <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)} />
30599
30659
  </View>
30600
- </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>
30601
30668
  );
30602
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
+ }
30603
30679
  `
30604
30680
  },
30605
30681
  {
@@ -30678,9 +30754,9 @@ import { render } from "@testing-library/react";
30678
30754
  import App from "./App";
30679
30755
 
30680
30756
  describe("App", () => {
30681
- test("renders without crashing", () => {
30757
+ test("renders the default route", () => {
30682
30758
  const { container } = render(<App />);
30683
- expect(container).not.toBeNull();
30759
+ expect(container.textContent).toContain("First item");
30684
30760
  });
30685
30761
  });
30686
30762
  `
@@ -30764,6 +30840,16 @@ import { Text } from "@lotics/ui/text";
30764
30840
  in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
30765
30841
  the full export list at https://www.npmjs.com/package/@lotics/ui.
30766
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
+
30767
30853
  See https://lotics.ai/docs/app-sdk for the SDK reference.
30768
30854
  `
30769
30855
  }
@@ -31060,6 +31146,54 @@ function buildWrapperPage(args) {
31060
31146
  return undefined;
31061
31147
  }
31062
31148
 
31149
+ // urlState.get/set \u2014 useUrlState keeps app view-state (filters, search) in
31150
+ // THIS wrapper page's address bar so it survives refresh and is shareable,
31151
+ // mirroring the production host. Handled locally (the URL is the store; no
31152
+ // API hop). The wrapper page is its own top-level page, so its query string
31153
+ // is entirely the app's. Repeated keys become arrays. Parse/merge MUST
31154
+ // match @lotics/app-sdk parseSearch/serializeMerge and the production host
31155
+ // (inline JS here \u2014 no module system to share the trivial copy).
31156
+ function readUrlParams() {
31157
+ const sp = new URLSearchParams(window.location.search);
31158
+ const out = {};
31159
+ sp.forEach(function (_value, key) {
31160
+ if (key in out) return;
31161
+ const all = sp.getAll(key);
31162
+ out[key] = all.length > 1 ? all : all[0];
31163
+ });
31164
+ return out;
31165
+ }
31166
+ var lastPushAt = 0;
31167
+ function handleUrlStateSet(payload) {
31168
+ const params = (payload && payload.params) || {};
31169
+ const sp = new URLSearchParams(window.location.search);
31170
+ Object.keys(params).forEach(function (key) {
31171
+ sp.delete(key);
31172
+ const value = params[key];
31173
+ if (value === undefined || value === null) return;
31174
+ if (Array.isArray(value)) value.forEach(function (item) { sp.append(key, item); });
31175
+ else sp.append(key, value);
31176
+ });
31177
+ const qs = sp.toString();
31178
+ const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
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);
31194
+ return undefined;
31195
+ }
31196
+
31063
31197
  // Streaming agent runs (op "agentRun"): the response is a stream, so it
31064
31198
  // can't use rpc()'s single JSON round-trip. POST /_agent_run, read the SSE
31065
31199
  // body, and forward chunks to the iframe as the SDK's bridged streaming
@@ -31121,6 +31255,12 @@ function buildWrapperPage(args) {
31121
31255
  ? await handleUpload(msg.payload)
31122
31256
  : msg.op === "openExternal"
31123
31257
  ? handleOpenExternal(msg.payload)
31258
+ : msg.op === "urlState.get"
31259
+ ? readUrlParams()
31260
+ : msg.op === "urlState.set"
31261
+ ? handleUrlStateSet(msg.payload)
31262
+ : msg.op === "urlState.go"
31263
+ ? handleUrlStateGo(msg.payload)
31124
31264
  : await rpc(msg.op, msg.payload);
31125
31265
  const ms = Math.round(performance.now() - startedAt);
31126
31266
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
@@ -31137,6 +31277,16 @@ function buildWrapperPage(args) {
31137
31277
  );
31138
31278
  }
31139
31279
  });
31280
+
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.
31284
+ window.addEventListener("popstate", function () {
31285
+ iframe.contentWindow.postMessage(
31286
+ { type: "url-state", params: readUrlParams() },
31287
+ VITE_ORIGIN
31288
+ );
31289
+ });
31140
31290
  })();
31141
31291
  </script>
31142
31292
  </body>
@@ -31195,7 +31345,8 @@ async function startDevServer(args) {
31195
31345
  process.once("exit", killViteOnExit);
31196
31346
  const server = http.createServer(async (req, res) => {
31197
31347
  const url = req.url ?? "/";
31198
- if (req.method === "GET" && (url === "/" || url === "/index.html")) {
31348
+ const pathname = url.split("?")[0];
31349
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
31199
31350
  res.writeHead(200, {
31200
31351
  "Content-Type": "text/html; charset=utf-8",
31201
31352
  "Cache-Control": "no-cache, no-store, must-revalidate"
@@ -11,8 +11,10 @@
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
13
  * - Entry: src/App.tsx with `export default`. main.tsx wires
14
- * `mount(<PortalHost><App/></PortalHost>)` — PortalHost is the render
15
- * target @lotics/ui overlays (Popover/Tooltip/Dialog) need and imports
14
+ * `mount(<LoticsLocaleProvider locale={vi}><PortalHost><App/></PortalHost></LoticsLocaleProvider>)` —
15
+ * LoticsLocaleProvider sets the kit's strings to Vietnamese (Lotics apps are
16
+ * Vietnamese; swap to `en` for English); PortalHost is the render target
17
+ * @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
18
  * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
17
19
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
18
20
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
@@ -39,8 +41,8 @@ export interface StarterFile {
39
41
  * scaffolds resolve the live version via `fetchLatestNpmVersion` and only
40
42
  * fall back here when the lookup fails.
41
43
  */
42
- export declare const STARTER_FALLBACK_UI_VERSION = "2.3.0";
43
- export declare const STARTER_FALLBACK_SDK_VERSION = "0.11.0";
44
+ export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
45
+ export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
44
46
  /**
45
47
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
46
48
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -11,8 +11,10 @@
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
13
  * - Entry: src/App.tsx with `export default`. main.tsx wires
14
- * `mount(<PortalHost><App/></PortalHost>)` — PortalHost is the render
15
- * target @lotics/ui overlays (Popover/Tooltip/Dialog) need and imports
14
+ * `mount(<LoticsLocaleProvider locale={vi}><PortalHost><App/></PortalHost></LoticsLocaleProvider>)` —
15
+ * LoticsLocaleProvider sets the kit's strings to Vietnamese (Lotics apps are
16
+ * Vietnamese; swap to `en` for English); PortalHost is the render target
17
+ * @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
18
  * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
17
19
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
18
20
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
@@ -35,8 +37,11 @@
35
37
  * scaffolds resolve the live version via `fetchLatestNpmVersion` and only
36
38
  * fall back here when the lookup fails.
37
39
  */
38
- export const STARTER_FALLBACK_UI_VERSION = "2.3.0";
39
- export const STARTER_FALLBACK_SDK_VERSION = "0.11.0";
40
+ export const STARTER_FALLBACK_UI_VERSION = "6.1.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";
40
45
  /**
41
46
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
42
47
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -92,6 +97,9 @@ export function buildStarterTemplate(args) {
92
97
  "react-native": STARTER_REACT_NATIVE_VERSION,
93
98
  "react-native-svg": "^15.0.0",
94
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",
95
103
  },
96
104
  devDependencies: {
97
105
  "@testing-library/react": "^16.1.0",
@@ -209,8 +217,18 @@ export default defineConfig({
209
217
  // otherwise serves them without a synthesized default export ("does not
210
218
  // provide an export named 'default'"), blanking the iframe. (Production/rollup
211
219
  // resolves the interop already, so this is dev-only.)
220
+ //
221
+ // \`react-markdown\` (@lotics/ui's Markdown renderer, reached by AgentRun and
222
+ // any markdown surface) pulls a transitive CJS dep, \`style-to-js\`, whose
223
+ // default export the dev optimizer won't synthesize unbundled ("does not
224
+ // provide an export named 'default'") — blanking the iframe the moment a
225
+ // markdown component mounts. Pre-bundling react-markdown folds the whole
226
+ // subtree (incl. style-to-js) into one interop'd chunk; remark-gfm (imported
227
+ // alongside it) is pre-bundled for the same consistency. Both are regular
228
+ // deps of @lotics/ui (hoisted), so they need no package.json entry here.
212
229
  include: [
213
230
  "react-native-svg",
231
+ "react-markdown", "remark-gfm",
214
232
  "react-native-web", "@react-native/normalize-colors",
215
233
  "inline-style-prefixer/lib/createPrefixer",
216
234
  "inline-style-prefixer/lib/plugins/crossFade",
@@ -287,6 +305,10 @@ export default defineConfig({
287
305
  // the hooks dispatcher the instant a hook (useAgentRun / useQuery)
288
306
  // runs in a rendered App tree.
289
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",
290
312
  ],
291
313
  esbuildOptions: {
292
314
  resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
@@ -333,73 +355,132 @@ export default defineConfig({
333
355
  content: `import "@lotics/ui/index.css";
334
356
  import "@lotics/ui/fonts.css";
335
357
  import { PortalHost } from "@lotics/ui/portal";
358
+ import { LoticsLocaleProvider, vi } from "@lotics/ui/locale";
336
359
  import { mount } from "@lotics/app-sdk";
337
360
  import App from "./App";
338
361
 
339
362
  // PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
340
363
  // Dialog). Without it, Portal renders nothing — keep it wrapping the app.
364
+ //
365
+ // LoticsLocaleProvider supplies the kit's own strings (Pagination, sort
366
+ // headers, select-all, the Drawer's record nav…) in one language — Vietnamese
367
+ // by default, since Lotics apps are Vietnamese. Swap to \`en\` (or your own
368
+ // LoticsLocale pack) for another language; remove it to fall back to English.
341
369
  mount(
342
- <PortalHost>
343
- <App />
344
- </PortalHost>,
370
+ <LoticsLocaleProvider locale={vi}>
371
+ <PortalHost>
372
+ <App />
373
+ </PortalHost>
374
+ </LoticsLocaleProvider>,
345
375
  );
346
376
  `,
347
377
  },
348
378
  {
349
379
  path: "src/App.tsx",
350
- // Welcome screen demonstrates the full-container layout pattern that
351
- // real apps (dashboards, tables, full-page surfaces) rely on:
352
- // - Outer <View flex:1> claims the full iframe height (works because
353
- // index.html sets html/body/#root to 100% + #root is a flex column).
354
- // - Inner View is content-sized + centered for a card layout.
355
- //
356
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
357
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
358
- // propagation to children that need to claim flexible height (e.g., a
359
- // scrollable table or list that fills the remaining space below a
360
- // header + search).
361
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
362
- // that hosts a flex-filling child; Stack is fine for content-sized
363
- // groupings inside such a chain.
364
- 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";
365
396
  import { Card } from "@lotics/ui/card";
366
397
  import { Text } from "@lotics/ui/text";
367
398
  import { Button } from "@lotics/ui/button";
368
399
 
369
- // Outer <View flex:1> claims the full iframe heightworks because
370
- // index.html sets html/body/#root to 100% and #root is a flex column.
371
- // For real layouts with a fill-remaining-space child (a scrollable
372
- // table/list etc.), keep this flex chain plain @lotics/ui/stack wraps
373
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
374
- 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 }) {
375
417
  return (
376
- <View
377
- style={{
378
- flex: 1,
379
- padding: 24,
380
- justifyContent: "center",
381
- alignItems: "center",
382
- }}
383
- >
384
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
385
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
386
- <Card>
387
- <View style={{ padding: 16, gap: 8 }}>
388
- <Text>This is your new Lotics app.</Text>
389
- <Text color="muted">
390
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
391
- <Text weight="medium">lotics app deploy</Text> to publish.
392
- </Text>
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();
426
+ return (
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)} />
393
447
  </View>
394
448
  </Card>
395
- <Text color="muted" size="sm">
396
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
397
- </Text>
398
- <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)} />
399
464
  </View>
400
- </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>
401
473
  );
402
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
+ }
403
484
  `,
404
485
  },
405
486
  {
@@ -478,9 +559,9 @@ import { render } from "@testing-library/react";
478
559
  import App from "./App";
479
560
 
480
561
  describe("App", () => {
481
- test("renders without crashing", () => {
562
+ test("renders the default route", () => {
482
563
  const { container } = render(<App />);
483
- expect(container).not.toBeNull();
564
+ expect(container.textContent).toContain("First item");
484
565
  });
485
566
  });
486
567
  `,
@@ -564,6 +645,16 @@ import { Text } from "@lotics/ui/text";
564
645
  in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
565
646
  the full export list at https://www.npmjs.com/package/@lotics/ui.
566
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
+
567
658
  See https://lotics.ai/docs/app-sdk for the SDK reference.
568
659
  `,
569
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.0",
3
+ "version": "0.55.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {