@wix/himalaya-cli 0.1465.0 → 0.1466.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.
@@ -64,9 +64,9 @@ not say why there are no rows.
64
64
  | Initial, no query | Search prompt plus useful recent queries, categories, or suggestions. Avoid presenting this as a failed search. |
65
65
  | Loading | First load: a skeleton or progress state. Refresh: keep current results visible and show a compact progress indicator when possible. |
66
66
  | Results | Matching rows, result count, and the available sort/filter controls. |
67
- | No matches | Explain that the current query or filters found nothing; offer Clear or a way to adjust them. |
67
+ | No matches | An `EmptyState` explaining that the current query or filters found nothing; offer a way to adjust them, and Clear only where the field is controlled (the native slot) — the inline `SearchField` is uncontrolled, so Clear there empties the query but not the field. |
68
68
  | Empty catalog | Explain that the catalog itself has no items yet, independent of the query; provide a useful browse/back action. |
69
- | Error | Show an error with Retry, retain the query and controls, and keep it distinct from no matches. |
69
+ | Error | Classified as in `himi docs recipe-four-state`: an icon-less `EmptyState` per class with user-facing copy, Try again only for offline and server, Sign in for sign-in, no action for permission. Retain the query and controls, and keep it distinct from no matches. |
70
70
 
71
71
  ### Native slot and old-client fallback
72
72
 
@@ -170,7 +170,12 @@ interface PostsState {
170
170
  all: PostRow[]; // the full fetched set — NOT bound by the descriptor
171
171
  items: PostRow[]; // ← itemsPath binds here; always derived from all + query
172
172
  query: string;
173
- loadingText: string; emptyText: string; error: string; notErrored: boolean;
173
+ hideNoPosts: boolean; // the EmptyState for "loaded, and there is nothing at all"
174
+ hideNoMatches: boolean; // the EmptyState for "there are posts, none match the query"
175
+ errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e)
176
+ hideLoading: boolean; hideOfflineError: boolean; hideServerError: boolean;
177
+ hideSignInError: boolean; hidePermissionError: boolean;
178
+ hideRefreshError: boolean; // hiddenPath flags, as in recipe-four-state
174
179
  }
175
180
 
176
181
  function visible(all: PostRow[], query: string): PostRow[] {
@@ -186,31 +191,103 @@ function queryFrom(arg: unknown): string {
186
191
  return String(a?.value ?? a?.text ?? a?.query ?? "");
187
192
  }
188
193
 
194
+ // Actions are not serialized: the user can type while load() is in flight, and the
195
+ // action that finishes last writes the whole state. So the latest query lives outside
196
+ // the state, and every fetch applies it when it returns.
197
+ let currentQuery = "";
198
+
199
+ // The SearchField is uncontrolled: it opens empty, so the query must start over too.
200
+ // `load` (opening the screen) resets it; a query typed while that fetch is in flight
201
+ // still wins. `retry` keeps it.
202
+ load: async (s) => { currentQuery = ""; return loadList(s); },
203
+
204
+ // After defineService: a cold boot restores the persisted `query` AND the `items` and
205
+ // empty flags derived from it. Re-derive them for an empty query before anything paints.
206
+ const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
207
+ if (booted && typeof booted === "object") {
208
+ Object.assign(booted, shape({ ...(booted as Partial<PostsState>), query: "", items: undefined }));
209
+ }
210
+
189
211
  // actions:
190
212
  search: (s, arg) => {
191
213
  const query = queryFrom(arg);
214
+ currentQuery = query;
192
215
  const items = visible(s.all, query);
193
- return shape({ ...s, query, items }); // shape() recomputes emptyText from items+query
216
+ return shape({ ...s, query, items }); // shape() recomputes the empty flags from all+items+query
194
217
  },
195
- clear: (s) => shape({ ...s, query: "", items: s.all }),
218
+ clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all }); },
196
219
  ```
197
220
 
198
- - `load()` fetches once, fills `all`, and sets `items = visible(all, query)`.
221
+ - `load()` fetches once, fills `all`, and sets `items = visible(all, currentQuery)`.
222
+ On a `signIn` or `permission` failure it clears **`all` as well as `items`** —
223
+ `search`, `clear` and `retry` rebuild rows from `all`, so rows left there come
224
+ back on the next keystroke.
199
225
  - The shipped Blog v3 `WixPost` type includes `categoryIds` and both Wix-image and
200
226
  embed-thumbnail media projections. Normalize them without a local cast:
201
227
  `coverUrl: post.media?.wixMedia?.image?.url ?? post.media?.embedMedia?.thumbnail?.url ?? ""`
202
228
  and `categoryIds: post.categoryIds ?? []`.
203
- - **`emptyText` should distinguish** "no data at all" from "no matches":
204
- `query ? `No matches for “${query}”.` : "No posts yet."` — recompute it in
205
- `shape()` wherever items change.
229
+ - **Two empty states, both `EmptyState`**: "no data at all" and "no matches" are
230
+ different screens. Search's `shape()` takes four-state's flags with one change:
231
+ `blank` is `all.length === 0`, not `items.length === 0` — `items` is the filtered
232
+ view, so a query with no matches over rows kept after a failed refresh must not
233
+ read as "nothing loaded" and show the full-screen error. In `shape()`, wherever
234
+ items change, recompute
235
+ `hideNoPosts = !(status === "ready" && all.length === 0)` and
236
+ `hideNoMatches = !(all.length > 0 && items.length === 0)` — no `status` check,
237
+ because the user can still search the rows kept after a failed refresh. Their
238
+ copy is static — an `EmptyState` cannot bind its text on native — and the
239
+ no-matches one has no action: the `SearchField` is uncontrolled, so a "Clear
240
+ search" button would empty the worker's query while the field still shows it:
241
+
242
+ ```jsonc
243
+ { "id": "empty.noPosts",
244
+ "body": { "package": "himalaya", "type": "EmptyState",
245
+ "props": { "icon": "inbox", "title": "No posts yet",
246
+ "subtitle": "New posts will show up here." } },
247
+ "modifiers": { "hiddenPath": "$service:posts.hideNoPosts" } },
248
+ { "id": "empty.noMatches",
249
+ "body": { "package": "himalaya", "type": "EmptyState",
250
+ "props": { "icon": "search", "title": "No posts match your search",
251
+ "subtitle": "Try a different word." } },
252
+ "modifiers": { "hiddenPath": "$service:posts.hideNoMatches" } }
253
+ ```
254
+
255
+ ## 3. Loading, error and retry
256
+
257
+ A searchable list loads data, so it carries the four states of
258
+ `himi docs recipe-four-state`:
259
+
260
+ - **Loading** — a `Skeleton` shaped like the result rows, gated by `hideLoading`,
261
+ while the first `load()` runs. The search field is usable at once.
262
+ - **Error, nothing loaded** — one `EmptyState` per class `classifyError` can
263
+ return, **no icon**, user-facing copy; offline and server get **Try again** →
264
+ `tier5.posts.retry`, sign-in gets **Sign in**, permission gets no action. The
265
+ `catch` sets `errorKind` from `classifyError(e)`; it never shows `e.message`.
266
+ - **Retry keeps the query.** `retry` re-runs `load()` and then re-applies
267
+ `currentQuery` — not `s.query`, which is the query from when the retry
268
+ STARTED — so what the user typed during the fetch is not lost.
269
+ - **No matches is not an error.** A successful search with zero results shows the
270
+ no-matches `EmptyState` ("No posts match your search"), with no action and no retry.
271
+
272
+ ```ts
273
+ retry: async (s) => {
274
+ const next = await load(s); // classified on failure
275
+ // Lost access clears the rows; any other failure keeps them for the Banner.
276
+ const lostAccess = next.errorKind === "signIn" || next.errorKind === "permission";
277
+ const all = lostAccess ? [] : next.all;
278
+ return shape({ ...next, all, query: currentQuery, items: visible(all, currentQuery) });
279
+ },
280
+ ```
206
281
 
207
- ## 3. Server-side search (large datasets)
282
+ ## 4. Server-side search (large datasets)
208
283
 
209
284
  In-worker filtering is right up to a few hundred rows. Past that, make `search`
210
285
  re-query the API with the term (most Wix domain query endpoints take a filter,
211
286
  e.g. blog `queryPosts` — check `himi sdk wix-<domain>`), keep `status: "loading"`
212
287
  during the round-trip, and debounce higher (300–400ms). The descriptor does not
213
- change — only the action's body.
288
+ change — only the action's body. A failed server-side search **keeps the query and
289
+ the results already on screen** and shows the refresh `Banner` with **Try again**,
290
+ which runs the same query again.
214
291
 
215
292
  ## Gotchas
216
293
 
@@ -231,6 +308,6 @@ change — only the action's body.
231
308
  ## See also
232
309
 
233
310
  - `himi docs recipe-dynamic-list` — the list this recipe filters.
234
- - `himi docs recipe-four-state` — the emptyText/error/loading strings.
311
+ - `himi docs recipe-four-state` — the loading, classified error and retry idiom.
235
312
  - `himi components SearchField` / `FilterChips` / `Tabs`.
236
313
  - `.agents/skills/himalaya/examples/search-list.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.
@@ -36,6 +36,21 @@ export async function finishVoiceJournal(sessionId: string) {
36
36
  }
37
37
  ```
38
38
 
39
+ **States.** Recording reads no remote data, so the journal screen needs no network
40
+ loading or error states — but it does have states the user must see:
41
+
42
+ - **Unavailable** (`status: "unavailable"` — no microphone, permission denied, or a
43
+ host without capture): an icon-less `EmptyState` that says recording is not
44
+ available and gives the next step the platform offers (for example, how to allow
45
+ the microphone). No **Try again**: the same call fails the same way until the
46
+ user changes something.
47
+ - **Processing** (transcription, diarization): a progress or a placeholder line on
48
+ the entry, never a blocking spinner; a processing failure keeps the audio and
49
+ shows the entry without a transcript.
50
+ - **Saved entries list**: reading `local-db` is local and quick, so it needs no
51
+ skeleton; an empty journal is an `EmptyState` with a "Record your first entry"
52
+ action.
53
+
39
54
  The SDUI layer can use the existing list, text, button, progress, audio/media, and state-binding
40
55
  components for recording, transcript detail, search, deferred processing, summaries, categories,
41
56
  delete, and export. Generate summaries and structured categories with `@himalaya/on-device-ai`
@@ -33,54 +33,115 @@
33
33
  "id": "loading",
34
34
  "body": {
35
35
  "package": "himalaya",
36
- "type": "Text",
36
+ "type": "Skeleton",
37
37
  "props": {
38
- "text": "$service:posts.loadingText",
39
- "style": "body",
40
- "color": "textSecondary",
41
- "collapseWhenEmpty": true
38
+ "height": 56,
39
+ "cornerRadius": 12,
40
+ "count": 5
42
41
  }
42
+ },
43
+ "modifiers": {
44
+ "hiddenPath": "$service:posts.hideLoading"
45
+ }
46
+ },
47
+ {
48
+ "id": "empty.no_posts",
49
+ "body": {
50
+ "package": "himalaya",
51
+ "type": "EmptyState",
52
+ "props": {
53
+ "icon": "doc",
54
+ "title": "No posts yet",
55
+ "subtitle": "Posts you publish show up here."
56
+ }
57
+ },
58
+ "modifiers": {
59
+ "hiddenPath": "$service:posts.hideNoPosts"
60
+ }
61
+ },
62
+ {
63
+ "id": "empty.no_matches",
64
+ "body": {
65
+ "package": "himalaya",
66
+ "type": "EmptyState",
67
+ "props": {
68
+ "icon": "search",
69
+ "title": "No posts match your search",
70
+ "subtitle": "Try a different word."
71
+ }
72
+ },
73
+ "modifiers": {
74
+ "hiddenPath": "$service:posts.hideNoMatches"
75
+ }
76
+ },
77
+ {
78
+ "id": "error.offline",
79
+ "body": {
80
+ "package": "himalaya",
81
+ "type": "EmptyState",
82
+ "props": {
83
+ "title": "Reconnect to the internet",
84
+ "subtitle": "Connect to the internet, then try again.",
85
+ "action": {
86
+ "actionId": "tier5.posts.retry"
87
+ },
88
+ "actionLabel": "Try again"
89
+ }
90
+ },
91
+ "modifiers": {
92
+ "hiddenPath": "$service:posts.hideOfflineError"
43
93
  }
44
94
  },
45
95
  {
46
- "id": "empty",
96
+ "id": "error.server",
47
97
  "body": {
48
98
  "package": "himalaya",
49
- "type": "Text",
99
+ "type": "EmptyState",
50
100
  "props": {
51
- "text": "$service:posts.emptyText",
52
- "style": "body",
53
- "color": "textSecondary",
54
- "collapseWhenEmpty": true
101
+ "title": "Unable to load posts",
102
+ "subtitle": "There was a technical issue on our end. Please wait and try again later.",
103
+ "action": {
104
+ "actionId": "tier5.posts.retry"
105
+ },
106
+ "actionLabel": "Try again"
55
107
  }
108
+ },
109
+ "modifiers": {
110
+ "hiddenPath": "$service:posts.hideServerError"
56
111
  }
57
112
  },
58
113
  {
59
- "id": "error",
114
+ "id": "error.permission",
60
115
  "body": {
61
116
  "package": "himalaya",
62
- "type": "Text",
117
+ "type": "EmptyState",
63
118
  "props": {
64
- "text": "$service:posts.error",
65
- "style": "body",
66
- "color": "danger",
67
- "collapseWhenEmpty": true
119
+ "title": "You need permission to view this screen",
120
+ "subtitle": "Ask the owner of this blog for access."
68
121
  }
122
+ },
123
+ "modifiers": {
124
+ "hiddenPath": "$service:posts.hidePermissionError"
69
125
  }
70
126
  },
71
127
  {
72
- "id": "retry",
128
+ "id": "error.refresh",
73
129
  "body": {
74
130
  "package": "himalaya",
75
- "type": "Button",
131
+ "type": "Banner",
76
132
  "props": {
77
- "title": "Retry",
78
- "style": "secondary",
79
- "size": "sm",
80
- "action": { "actionId": "tier5.posts.retry" }
133
+ "severity": "warning",
134
+ "title": "Couldn't refresh",
135
+ "message": "Showing what was loaded before.",
136
+ "action": {
137
+ "actionId": "tier5.posts.retry"
138
+ },
139
+ "actionLabel": "Try again"
81
140
  }
82
141
  },
83
- "modifiers": { "hiddenPath": "$service:posts.notErrored" }
142
+ "modifiers": {
143
+ "hiddenPath": "$service:posts.hideRefreshError"
144
+ }
84
145
  },
85
146
  {
86
147
  "id": "posts_list",
@@ -6,8 +6,9 @@
6
6
  // the current query, and let the SearchField's debounced onChange action swap
7
7
  // what the single bound path holds. The descriptor never rebinds anything.
8
8
  //
9
- // Builds on the dynamic-list recipe (worker owns the array, four-state display
10
- // strings) — see examples/dynamic-list.* for that base. Declares "wix_api".
9
+ // Builds on the dynamic-list recipe (worker owns the array, one hiddenPath flag per
10
+ // state, classified errors) — see examples/dynamic-list.* for that base. A retry
11
+ // keeps the user's query. Declares "wix_api".
11
12
 
12
13
  import { defineService } from "@wix/himalaya/state";
13
14
  import { queryPosts, type WixPost } from "@wix/himalaya/wix-blog";
@@ -20,15 +21,24 @@ interface PostRow {
20
21
  categoryIds: string[];
21
22
  }
22
23
 
24
+ // A public blog has no sign-in flow, so a 401 reads as "permission" here.
25
+ type ErrorKind = "" | "offline" | "server" | "permission";
26
+
23
27
  interface PostsState {
24
28
  status: "loading" | "ready" | "error";
25
29
  all: PostRow[]; // the full fetched set — NOT bound by the descriptor
26
30
  items: PostRow[]; // ← itemsPath binds here; ALWAYS derived from all + query
27
31
  query: string;
28
- loadingText: string;
29
- emptyText: string; // distinguishes "no data" from "no matches"
30
- error: string;
31
- notErrored: boolean;
32
+ errorKind: ErrorKind;
33
+ // hiddenPath flags (hide when TRUE) — see `himi docs recipe-four-state`
34
+ hideLoading: boolean;
35
+ hideNoPosts: boolean; // EmptyState: the blog has no posts at all
36
+ hideNoMatches: boolean; // EmptyState: posts exist, none match the query (not an error)
37
+ notEmpty: boolean; // false while either EmptyState shows
38
+ hideOfflineError: boolean;
39
+ hideServerError: boolean;
40
+ hidePermissionError: boolean;
41
+ hideRefreshError: boolean;
32
42
  }
33
43
 
34
44
  function normalize(raw: WixPost): PostRow {
@@ -54,38 +64,78 @@ function shape(s: Partial<PostsState>): PostsState {
54
64
  const all = s.all ?? [];
55
65
  const query = s.query ?? "";
56
66
  const items = s.items ?? visible(all, query);
67
+ const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
68
+ const blank = all.length === 0; // nothing loaded to keep
57
69
  return {
58
70
  status,
59
71
  all,
60
72
  items,
61
73
  query,
62
- loadingText: status === "loading" ? "Loading posts…" : "",
63
- emptyText:
64
- status === "ready" && items.length === 0
65
- ? query
66
- ? `No matches for “${query}”.`
67
- : "No posts yet."
68
- : "",
69
- error: s.error ?? "",
70
- notErrored: status !== "error",
74
+ errorKind,
75
+ // Two EmptyStates, not one bound Text: iOS/Android do not bind EmptyState text.
76
+ hideNoPosts: !(status === "ready" && blank),
77
+ // Over rows kept after a failed refresh too: no matches is still empty, not an error.
78
+ hideNoMatches: !(!blank && items.length === 0),
79
+ notEmpty: !((status === "ready" && blank) || (!blank && items.length === 0)),
80
+ hideLoading: !(status === "loading" && blank),
81
+ hideOfflineError: !(blank && errorKind === "offline"),
82
+ hideServerError: !(blank && errorKind === "server"),
83
+ hidePermissionError: !(blank && errorKind === "permission"),
84
+ hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
71
85
  };
72
86
  }
73
87
 
88
+ // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
89
+ // under `himi test` / `himi render`. Only a transport failure is "offline".
90
+ const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
91
+ // No answer at all — what a dropped connection says on every platform.
92
+ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
93
+ // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
94
+ // No session on the device: the native bridge refuses before any request leaves.
95
+ // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
96
+ // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
97
+ const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
98
+
99
+ // The same classifier as `himi docs recipe-four-state`, except that a public screen with no sign-in flow shows the permission state for a sign-in failure.
100
+ function classifyError(e: unknown): ErrorKind {
101
+ const msg = String((e as Error)?.message ?? e ?? "");
102
+ const code = String((e as { code?: unknown })?.code ?? "");
103
+ const status = Number(STATUS.exec(msg)?.[1] ?? 0);
104
+ if (status === 401) return "permission";
105
+ if (status === 403) return "permission";
106
+ if (SIGNED_OUT.test(msg)) return "permission";
107
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
108
+ // A web call that never reached Wix arrives as
109
+ // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
110
+ if (status && !msg.includes("webwix_direct_failed")) return "server";
111
+ if (TRANSPORT.test(msg)) return "offline";
112
+ return "server"; // a bug or a malformed row is not the user's connection
113
+ }
114
+
74
115
  // The SearchField onChange arg shape varies by surface — read it defensively.
75
116
  function queryFrom(arg: unknown): string {
76
117
  const a = arg as { value?: string; text?: string; query?: string } | undefined;
77
118
  return String(a?.value ?? a?.text ?? a?.query ?? "");
78
119
  }
79
120
 
80
- async function loadList(): Promise<PostsState> {
121
+ // Actions are not serialized: the user can type while load() is in flight, and the
122
+ // action that finishes last writes the whole state. So the latest query lives outside
123
+ // the state, and the fetch applies it when it returns — a retry or a slow first load
124
+ // never throws away what the user typed.
125
+ let currentQuery = "";
126
+
127
+ async function loadList(s: Partial<PostsState>): Promise<PostsState> {
81
128
  try {
82
129
  const res = await queryPosts({ query: { paging: { limit: 100 } } });
83
130
  const all = ((res as { posts?: WixPost[] }).posts ?? [])
84
131
  .map(normalize)
85
132
  .filter((r) => r.id);
86
- return shape({ status: "ready", all, query: "" });
133
+ return shape({ status: "ready", all, query: currentQuery });
87
134
  } catch (e) {
88
- return shape({ status: "error", all: [], error: String((e as Error)?.message ?? e) });
135
+ const errorKind = classifyError(e);
136
+ // Keep what was loaded on a refresh failure; clear it when access is gone.
137
+ const all = errorKind === "permission" ? [] : s.all ?? [];
138
+ return shape({ status: "error", all, query: currentQuery, errorKind });
89
139
  }
90
140
  }
91
141
 
@@ -97,17 +147,29 @@ const svc = defineService<PostsState, {
97
147
  }>("posts", {
98
148
  state: shape({ status: "loading" }),
99
149
  actions: {
100
- load: async () => loadList(),
101
- retry: async () => loadList(),
150
+ // `load` opens the screen, and the uncontrolled SearchField opens empty: start the
151
+ // query over too. A query typed while this fetch is in flight still wins.
152
+ load: async (s) => { currentQuery = ""; return loadList(s); },
153
+ retry: async (s) => loadList(s),
102
154
  // In-worker filter: swap what the bound `items` path holds. For datasets past
103
155
  // a few hundred rows, make this re-query the API instead (same descriptor).
104
156
  search: (s, arg) => {
105
157
  const query = queryFrom(arg);
158
+ currentQuery = query;
106
159
  return shape({ ...s, query, items: visible(s.all, query) });
107
160
  },
108
- clear: (s) => shape({ ...s, query: "", items: s.all }),
161
+ clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all }); },
109
162
  },
110
163
  });
111
164
 
165
+ // The SearchField is uncontrolled and shows empty on a cold boot, but the runtime restores the
166
+ // persisted snapshot: last run's `query`, and the `items` and empty flags derived from it. Re-derive
167
+ // them in place for an empty query before anything paints, or the list stays filtered by text the
168
+ // field does not show until the first fetch settles.
169
+ const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
170
+ if (booted && typeof booted === "object") {
171
+ Object.assign(booted, shape({ ...(booted as Partial<PostsState>), query: "", items: undefined }));
172
+ }
173
+
112
174
  svc.load(); // fetch once at boot; searching never re-fetches
113
175
  export { svc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1465.0",
3
+ "version": "0.1466.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "19e0653508dd0a35a2413578ca22c6452471d39f3797b2cd827fb652"
74
+ "falconPackageHash": "45c573391083ede76066e2c3b621e23db07135b7ad8781d653f59629"
75
75
  }