@wix/himalaya-cli 0.1619.0 → 0.1621.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.
package/dist/cli.mjs CHANGED
@@ -208648,13 +208648,19 @@ import { copyFileSync as copyFileSync3, mkdtempSync as mkdtempSync12, readFileSy
208648
208648
  import { tmpdir as tmpdir13 } from "node:os";
208649
208649
  import { basename as basename29, dirname as dirname66, join as join131, resolve as resolve93 } from "node:path";
208650
208650
  import { fileURLToPath as fileURLToPath39 } from "node:url";
208651
- function parseVerdict(value2) {
208651
+ function parseVerdict(value2, captured) {
208652
208652
  const v2 = value2;
208653
208653
  if (!v2 || !VERDICTS.has(v2.design) || !VERDICTS.has(v2.parity) || !CONFIDENCE.has(v2.confidence) || !Array.isArray(v2.findings)) return null;
208654
208654
  for (const f2 of v2.findings) {
208655
- if (!f2 || !PANES.has(f2.surface) || typeof f2.region !== "string" || typeof f2.what !== "string") return null;
208655
+ if (!f2 || !PANES.has(f2.surface) || !KINDS2.has(f2.kind) || typeof f2.region !== "string" || typeof f2.what !== "string") return null;
208656
208656
  }
208657
- return { verdict: worse(v2.design, v2.parity), design: v2.design, parity: v2.parity, findings: v2.findings, confidence: v2.confidence };
208657
+ const findings = captured ? v2.findings.filter((f2) => captured.has(f2.surface)) : v2.findings;
208658
+ const blank = (panes) => findings.some((f2) => f2.kind === "blank" && panes.includes(f2.surface));
208659
+ const onlySkipped = (panes) => findings.length < v2.findings.length && v2.findings.some((f2) => panes.includes(f2.surface)) && !findings.some((f2) => panes.includes(f2.surface));
208660
+ const own6 = (said, panes) => (said === "partial" || said === "mismatch") && onlySkipped(panes) ? "match" : said;
208661
+ const design = blank(["web"]) ? worse(v2.design, "mismatch") : own6(v2.design, ["web"]);
208662
+ const parity = blank(["ios", "android"]) ? worse(v2.parity, "mismatch") : own6(v2.parity, ["ios", "android"]);
208663
+ return { verdict: worse(design, parity), design, parity, findings, confidence: v2.confidence };
208658
208664
  }
208659
208665
  function judgeArgs(schema2) {
208660
208666
  return [
@@ -208710,14 +208716,14 @@ async function judgeStrip(strip3, screen, variant, opts = {}) {
208710
208716
  } catch {
208711
208717
  return cannotTell("the judge's answer was not JSON");
208712
208718
  }
208713
- return parseVerdict(parsed) ?? cannotTell("the judge's answer did not match tools/judges/fidelity/verdict.schema.json");
208719
+ return parseVerdict(parsed, opts.captured) ?? cannotTell("the judge's answer did not match tools/judges/fidelity/verdict.schema.json");
208714
208720
  }
208715
- var SOURCE_JUDGE_DIR, JUDGE_MODEL, spawnProcess, cannotTell, SEVERITY, worse, VERDICTS, CONFIDENCE, PANES;
208721
+ var SOURCE_JUDGE_DIR, JUDGE_MODEL, spawnProcess, cannotTell, SEVERITY, worse, VERDICTS, CONFIDENCE, PANES, KINDS2;
208716
208722
  var init_judge = __esm({
208717
208723
  "src/fidelity/judge.ts"() {
208718
208724
  init_define_HHP_CATALOG();
208719
208725
  SOURCE_JUDGE_DIR = resolve93(dirname66(fileURLToPath39(import.meta.url)), "../../../judges/fidelity");
208720
- JUDGE_MODEL = "sonnet";
208726
+ JUDGE_MODEL = "claude-sonnet-5-5";
208721
208727
  spawnProcess = (cmd, args, { cwd, input, timeoutMs }) => new Promise((done) => {
208722
208728
  let stdout = "", stderr = "", timedOut = false;
208723
208729
  const child = spawn7(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
@@ -208745,6 +208751,7 @@ var init_judge = __esm({
208745
208751
  VERDICTS = /* @__PURE__ */ new Set(["match", "partial", "mismatch", "cannot_tell"]);
208746
208752
  CONFIDENCE = /* @__PURE__ */ new Set(["low", "medium", "high"]);
208747
208753
  PANES = /* @__PURE__ */ new Set(["web", "ios", "android"]);
208754
+ KINDS2 = /* @__PURE__ */ new Set(["blank", "missing", "colour", "clipping", "order", "type_scale"]);
208748
208755
  }
208749
208756
  });
208750
208757
 
@@ -209005,8 +209012,8 @@ async function runFidelity(root, opts = {}) {
209005
209012
  verdict = skippedJudge(!w2?.file ? "no web render, and web is the reference for both design and parity" : frames.some((f2) => f2.noReference) ? `the mockup does not style ${variant}, and there is no native pane to compare with web` : "fewer than two captured panes, so there is nothing to compare");
209006
209013
  } else {
209007
209014
  stripFile = await strip3(judged2, stripPath2);
209008
- verdict = await judge(stripFile, screen, variant, opts.judgeDir ? { judgeDir: opts.judgeDir } : {});
209009
209015
  const captured = new Set(frames.filter((f2) => f2.file).map((f2) => f2.surface));
209016
+ verdict = await judge(stripFile, screen, variant, { captured, ...opts.judgeDir ? { judgeDir: opts.judgeDir } : {} });
209010
209017
  const parity = captured.has("ios") || captured.has("android") ? verdict.parity : "cannot_tell";
209011
209018
  verdict = { ...verdict, parity, verdict: worse(verdict.design, parity), findings: verdict.findings.filter((f2) => captured.has(f2.surface)) };
209012
209019
  }
@@ -209067,18 +209074,25 @@ var init_run2 = __esm({
209067
209074
  // src/fidelity/native.ts
209068
209075
  var native_exports = {};
209069
209076
  __export(native_exports, {
209077
+ BLANK_CONTRAST: () => BLANK_CONTRAST,
209070
209078
  defaultRunner: () => defaultRunner3,
209071
209079
  deviceFor: () => deviceFor,
209072
209080
  nativeCapture: () => nativeCapture,
209073
209081
  parseAppLocales: () => parseAppLocales,
209074
209082
  parseNightMode: () => parseNightMode,
209075
209083
  parsePlistArray: () => parsePlistArray,
209084
+ resumedPackages: () => resumedPackages,
209076
209085
  sameLocalOrigin: () => sameLocalOrigin,
209077
209086
  screenLink: () => screenLink
209078
209087
  });
209079
209088
  import { spawn as spawn9 } from "node:child_process";
209080
209089
  import { readFileSync as readFileSync143, writeFileSync as writeFileSync60 } from "node:fs";
209081
209090
  import { join as join135 } from "node:path";
209091
+ function resumedPackages(dumpsys) {
209092
+ const lines = dumpsys.split("\n").filter((l2) => /\b(?:topResumedActivity|mResumedActivity|ResumedActivity)\b/.test(l2));
209093
+ if (!lines.length) return void 0;
209094
+ return lines.flatMap((l2) => [...l2.matchAll(/\s([A-Za-z0-9_.]+)\/[A-Za-z0-9_.$]+/g)].map((m2) => m2[1]));
209095
+ }
209082
209096
  function deviceFor(devices, platform, variant) {
209083
209097
  if (variant === "tablet") return platform === "ios" ? devices.iosTablet : devices.androidTablet;
209084
209098
  return platform === "ios" ? devices.ios : devices.android;
@@ -209100,12 +209114,18 @@ function nativeCapture(devices, opts) {
209100
209114
  const maxWaitMs = opts.maxWaitMs ?? 3e4;
209101
209115
  const iosBundle = opts.iosBundleId ?? "dev.himalaya.player.dev";
209102
209116
  const androidPackage = opts.androidPackage ?? "dev.himalaya.player.dev";
209117
+ const packages = /* @__PURE__ */ new Map();
209103
209118
  const rtl = opts.rtlLanguage ?? "he";
209104
209119
  const picker = opts.pickerAppId ?? "himi-player";
209105
209120
  const blackFraction = opts.blackFraction ?? (async (file2) => {
209106
209121
  const r3 = await run2("magick", [file2, "-colorspace", "Gray", "-threshold", "5%", "-format", "%[fx:1-mean]", "info:"]);
209107
209122
  return r3.code === 0 ? Number(r3.stdout.toString().trim()) || 0 : 0;
209108
209123
  });
209124
+ const contrast = opts.contrast ?? (async (file2) => {
209125
+ const r3 = await run2("magick", [file2, "-gravity", "South", "-crop", "100%x90%+0+0", "+repage", "-colorspace", "Gray", "-format", "%[fx:standard_deviation]", "info:"]);
209126
+ const v2 = r3.code === 0 ? Number.parseFloat(r3.stdout.toString()) : NaN;
209127
+ return Number.isFinite(v2) ? v2 : 1;
209128
+ });
209109
209129
  const saved = /* @__PURE__ */ new Map();
209110
209130
  const applied = /* @__PURE__ */ new Map();
209111
209131
  const originChecked = /* @__PURE__ */ new Map();
@@ -209114,6 +209134,18 @@ function nativeCapture(devices, opts) {
209114
209134
  if (r3.code !== 0) throw new Error(`${cmd} ${args.join(" ")} exited ${r3.code}`);
209115
209135
  return r3.stdout.toString().trim();
209116
209136
  };
209137
+ async function playerPackage(id) {
209138
+ const known = packages.get(id);
209139
+ if (known) return known;
209140
+ let pkg = androidPackage;
209141
+ if (!opts.androidPackage) {
209142
+ const r3 = await run2("adb", ["-s", id, "shell", "pm", "list", "packages", androidPackage]);
209143
+ const listed = r3.code === 0 ? r3.stdout.toString().split("\n").map((l2) => l2.trim().replace(/^package:/, "")).filter(Boolean) : [];
209144
+ pkg = listed.includes(androidPackage) ? androidPackage : listed.find((l2) => l2.startsWith(`${androidPackage}.`)) ?? androidPackage;
209145
+ }
209146
+ packages.set(id, pkg);
209147
+ return pkg;
209148
+ }
209117
209149
  async function remember(platform, id) {
209118
209150
  const key3 = `${platform}:${id}`;
209119
209151
  if (saved.has(key3)) return;
@@ -209123,7 +209155,7 @@ function nativeCapture(devices, opts) {
209123
209155
  saved.set(key3, { appearance, language: languages.code === 0 ? languages.stdout.toString().trim() : void 0 });
209124
209156
  } else {
209125
209157
  const night = await ok3("adb", ["-s", id, "shell", "cmd", "uimode", "night"]);
209126
- const locales = await run2("adb", ["-s", id, "shell", "cmd", "locale", "get-app-locales", androidPackage]);
209158
+ const locales = await run2("adb", ["-s", id, "shell", "cmd", "locale", "get-app-locales", await playerPackage(id)]);
209127
209159
  saved.set(key3, { appearance: parseNightMode(night), language: locales.code === 0 ? parseAppLocales(locales.stdout.toString()) : void 0 });
209128
209160
  }
209129
209161
  }
@@ -209140,7 +209172,7 @@ function nativeCapture(devices, opts) {
209140
209172
  } else {
209141
209173
  await ok3("adb", ["-s", id, "shell", "cmd", "uimode", "night", dark ? "yes" : "no"]);
209142
209174
  const wasRtl = applied.get(key3) === "rtl";
209143
- if (right || wasRtl) await ok3("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", androidPackage, "--locales", right ? rtl : ""]);
209175
+ if (right || wasRtl) await ok3("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", await playerPackage(id), "--locales", right ? rtl : ""]);
209144
209176
  }
209145
209177
  applied.set(key3, want);
209146
209178
  }
@@ -209169,19 +209201,31 @@ function nativeCapture(devices, opts) {
209169
209201
  writeFileSync60(file2, r3.stdout);
209170
209202
  return r3.stdout;
209171
209203
  }
209204
+ async function notInFront(platform, id) {
209205
+ if (platform !== "android") return "";
209206
+ const r3 = await run2("adb", ["-s", id, "shell", "dumpsys", "activity", "activities"]);
209207
+ const resumed = r3.code === 0 ? resumedPackages(r3.stdout.toString()) : void 0;
209208
+ const player = await playerPackage(id);
209209
+ return !resumed || resumed.includes(player) ? "" : `the Player was not in front (${resumed.join(", ") || "nothing resumed"})`;
209210
+ }
209172
209211
  async function settle2(platform, id, file2, notFrame) {
209173
209212
  let previous;
209213
+ let why = "the screen did not stop changing";
209174
209214
  for (let waited = 0; ; waited += settleMs) {
209175
209215
  const frame = await screenshot(platform, id, file2);
209176
- if (previous && frame.equals(previous) && !(notFrame && frame.equals(notFrame))) return frame;
209177
- if (waited >= maxWaitMs) return frame;
209216
+ if (previous && frame.equals(previous)) {
209217
+ const front = await notInFront(platform, id);
209218
+ why = notFrame && frame.equals(notFrame) ? "the Player still showed the picker" : front ? front : await contrast(file2) < BLANK_CONTRAST ? "the screen was still blank or loading" : "";
209219
+ if (!why) return { frame };
209220
+ } else why = "the screen did not stop changing";
209221
+ if (waited >= maxWaitMs) return { frame, unsettled: `${why} within ${Math.round(maxWaitMs / 1e3)}s` };
209178
209222
  previous = frame;
209179
209223
  await sleep(settleMs);
209180
209224
  }
209181
209225
  }
209182
209226
  async function open(platform, id, link) {
209183
209227
  if (platform === "ios") await ok3("xcrun", ["simctl", "openurl", id, link]);
209184
- else await ok3("adb", ["-s", id, "shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-d", `'${link.replace(/'/g, "%27")}'`]);
209228
+ else await ok3("adb", ["-s", id, "shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-p", await playerPackage(id), "-d", `'${link.replace(/'/g, "%27")}'`]);
209185
209229
  }
209186
209230
  const capture = (async (screen, entry, variant, outDir) => {
209187
209231
  const frames = [];
@@ -209205,11 +209249,19 @@ function nativeCapture(devices, opts) {
209205
209249
  await apply(platform, id, variant);
209206
209250
  await open(platform, id, `himi://app/${encodeURIComponent(picker)}`);
209207
209251
  const file2 = join135(outDir, `${screen}.${variant}.${platform}.png`);
209208
- const pickerFrame = await settle2(platform, id, file2);
209252
+ const pickerFrame = (await settle2(platform, id, file2)).frame;
209209
209253
  await open(platform, id, screenLink(opts.appId, screen, entry, opts.release));
209210
- await settle2(platform, id, file2, pickerFrame);
209254
+ const { unsettled } = await settle2(platform, id, file2, pickerFrame);
209255
+ if (unsettled) {
209256
+ frames.push({ surface: platform, screen, variant, skipped: { reason: `capture_unsettled: ${unsettled}` } });
209257
+ continue;
209258
+ }
209211
209259
  if (platform === "android" && await blackFraction(file2) > 0.95) {
209212
- await settle2(platform, id, file2);
209260
+ const retry = await settle2(platform, id, file2, pickerFrame);
209261
+ if (retry.unsettled) {
209262
+ frames.push({ surface: platform, screen, variant, skipped: { reason: `capture_unsettled: ${retry.unsettled}` } });
209263
+ continue;
209264
+ }
209213
209265
  if (await blackFraction(file2) > 0.95) {
209214
209266
  frames.push({ surface: platform, screen, variant, skipped: { reason: "black frame" } });
209215
209267
  continue;
@@ -209233,7 +209285,7 @@ function nativeCapture(devices, opts) {
209233
209285
  await run2("xcrun", ["simctl", "terminate", id, iosBundle]);
209234
209286
  } else {
209235
209287
  if (was.appearance) await run2("adb", ["-s", id, "shell", "cmd", "uimode", "night", was.appearance]);
209236
- await run2("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", androidPackage, "--locales", was.language ?? ""]);
209288
+ await run2("adb", ["-s", id, "shell", "cmd", "locale", "set-app-locales", packages.get(id) ?? androidPackage, "--locales", was.language ?? ""]);
209237
209289
  }
209238
209290
  } catch {
209239
209291
  }
@@ -209265,10 +209317,11 @@ function parseAppLocales(out2) {
209265
209317
  function parsePlistArray(out2) {
209266
209318
  return out2.replace(/^\s*\(|\)\s*$/g, "").split(",").map((s2) => s2.trim().replace(/^"|"$/g, "")).filter(Boolean);
209267
209319
  }
209268
- var defaultRunner3;
209320
+ var BLANK_CONTRAST, defaultRunner3;
209269
209321
  var init_native = __esm({
209270
209322
  "src/fidelity/native.ts"() {
209271
209323
  init_define_HHP_CATALOG();
209324
+ BLANK_CONTRAST = 0.03;
209272
209325
  defaultRunner3 = (cmd, args) => new Promise((resolve95) => {
209273
209326
  const child = spawn9(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
209274
209327
  const chunks = [];
@@ -215542,16 +215595,16 @@ WHAT A PATCH DOES NOT CARRY \u2014 all reported in the output, never silent
215542
215595
  async function patchCommand(ctx) {
215543
215596
  const { io: io2, pos, flags, client: client2, runReleasePatch, patchBlastRadius } = ctx;
215544
215597
  const PATCH_USAGE = "himi patch screen <id> --file <d.json> | bundle --file <b.js> (--for-screen <id>[,<id>]|--no-rebind) | tokens --file <t.json> | app-config --file <c.json> ; --app <a> (--draft|--live) [--from-release <id>] [--accept-regressions] [--notes <t>]";
215545
- const KINDS2 = ["screen", "bundle", "tokens", "app-config"];
215598
+ const KINDS3 = ["screen", "bundle", "tokens", "app-config"];
215546
215599
  if (flags.help === true) {
215547
215600
  io2.log(PATCH_HELP);
215548
215601
  return 0;
215549
215602
  }
215550
215603
  const kind = pos[1];
215551
- if (!kind || !KINDS2.includes(kind)) {
215604
+ if (!kind || !KINDS3.includes(kind)) {
215552
215605
  io2.error(JSON.stringify({
215553
215606
  error: kind ? `unknown patch kind "${kind}"` : "patch needs a <kind>",
215554
- kinds: KINDS2,
215607
+ kinds: KINDS3,
215555
215608
  usage: PATCH_USAGE
215556
215609
  }));
215557
215610
  return 2;
package/guides/hhp.md CHANGED
@@ -218,7 +218,7 @@ himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [
218
218
  For each screen and variant (`light`, `dark`, `rtl`, `tablet`), the command does four things:
219
219
 
220
220
  1. **Captures the mockup.** It draws the page in Chromium at the variant's width. Dark mode is drawn by emulating `prefers-color-scheme`. RTL is drawn by setting `dir=rtl` on the page.
221
- 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the app's right-to-left language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
221
+ 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the app's right-to-left language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match, the frame is not a blank or loading page, and, on Android, the Player is the app in front rather than the launcher. A screen that has not settled that way after 30 seconds is reported `skipped` as `capture_unsettled`, never judged. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
222
222
  3. **Scores mockup↔web** with ImageMagick SSIM, globally and per tile. The worst tile says where the screens differ most.
223
223
  4. **Asks a fresh model** two questions, using the repo's fidelity judge prompt. The model never ran in the session that wrote the app.
224
224
  - **Design:** does WEB look like the MOCKUP? This judges the port.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1619.0",
3
+ "version": "0.1621.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": [
@@ -72,5 +72,5 @@
72
72
  "artifactId": "himalaya-cli"
73
73
  }
74
74
  },
75
- "falconPackageHash": "d9d834feab74f884aec00e986ae13ed5dcb2f5fea15f6ea9ac2e5796"
75
+ "falconPackageHash": "c21c6aa4541e5ed5dda0d5f14934ad131349b6f6f125a9d5ff58315a"
76
76
  }
@@ -652,6 +652,20 @@ const register = withIapPresentation<S>({
652
652
  },
653
653
  });
654
654
 
655
+ /** A read that answers at once (a cache, a local fixture, a headless run whose transport never
656
+ * suspends) is over before the action that started it returns. Its `readChanged` is a dispatch
657
+ * of this same service from a detached job, which a headless run never services after the active
658
+ * action returns, and which on a device would commit under the skeleton the action is about to
659
+ * return. Wait a bounded number of turns and fold the settled status into the action's own
660
+ * result instead; a read that is still in flight keeps the skeleton and lands through
661
+ * `readChanged` as before. `undefined` means a newer action took over while this one waited. */
662
+ async function settledWithin(next: S, category: string, gen: number): Promise<S | undefined> {
663
+ const controller = reader(category);
664
+ for (let i = 0; i < 500 && controller.getStatus().refreshing; i++) await Promise.resolve();
665
+ if (gen !== generation) return undefined;
666
+ return controller.getStatus().refreshing ? next : currentReadState(next);
667
+ }
668
+
655
669
  type A = (s: S, arg?: unknown) => S | undefined | Promise<S | undefined>;
656
670
  const svc = register<S, Record<string, A>>("himiBrandedShop", {
657
671
  state: BOOT,
@@ -659,8 +673,12 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
659
673
  actions: {
660
674
  load: (s, arg) => load(s, arg),
661
675
  // Pull to refresh: the categories and the scope again, from Wix.
662
- refresh: (s, arg) => load(s, arg, true),
663
- retry: (s) => showScope(s, s.categorySelected, true),
676
+ refresh: async (s, arg) => {
677
+ const next = await load(s, arg, true);
678
+ // The result owns its scope; another action may have changed the globals during load.
679
+ return next ? settledWithin(next, next.categorySelected, next.scopeGen) : undefined;
680
+ },
681
+ retry: (s) => settledWithin(showScope(s, s.categorySelected, true), s.categorySelected, generation),
664
682
  recover: async () => { await fetchScope(selectedCategory, generation, true); return undefined; },
665
683
  readChanged: (s, arg) => {
666
684
  if (!arg || typeof arg !== 'object') return undefined;
@@ -683,7 +701,7 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
683
701
  // A background page landed for the current scope.
684
702
  scopeGrew: (s) => derive(s),
685
703
  // Single-select: one chip is always chosen.
686
- selectCategory: (s, arg) => {
704
+ selectCategory: async (s, arg) => {
687
705
  // The chip row sends { selectedIds }; a deselected chip falls back to All.
688
706
  const selection = arg as { selectedIds?: unknown; refresh?: boolean } | undefined;
689
707
  const id = selection?.selectedIds !== undefined ? selected(arg)[0] ?? ALL : idOf(arg);
@@ -691,12 +709,15 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
691
709
  const cancelsPendingRoute = !!pendingLink || selectedCategory !== id;
692
710
  categoryIntentVersion++;
693
711
  pendingLink = undefined;
694
- if (id !== ALL && !categories.some((c) => c.id === id)) return s;
712
+ // undefined, not s: s is the snapshot from before any in-flight load, loading flag included.
713
+ if (id !== ALL && !categories.some((c) => c.id === id)) return undefined;
695
714
  // showScope compares the prior selection before committing the requested category.
696
715
  // categorySelected is committed with the tap (showScope), so a tap back to the chip
697
716
  // on screen while another category loads is a no-op on the right scope.
698
- if (id === s.categorySelected) return cancelsPendingRoute || refresh ? showScope(s, id, refresh) : s;
699
- const next = showScope(s, id, refresh);
717
+ if (id === s.categorySelected) return cancelsPendingRoute || refresh ? settledWithin(showScope(s, id, refresh), id, generation) : undefined;
718
+ const shown = showScope(s, id, refresh);
719
+ const next = await settledWithin(shown, id, generation);
720
+ if (!next) return undefined;
700
721
  const pending = pendingAuxiliary;
701
722
  if (pending && pending.version === auxiliaryVersion && pending.cartCount >= 0) {
702
723
  pendingAuxiliary = undefined;
@@ -652,6 +652,20 @@ const register = withIapPresentation<S>({
652
652
  },
653
653
  });
654
654
 
655
+ /** A read that answers at once (a cache, a local fixture, a headless run whose transport never
656
+ * suspends) is over before the action that started it returns. Its `readChanged` is a dispatch
657
+ * of this same service from a detached job, which a headless run never services after the active
658
+ * action returns, and which on a device would commit under the skeleton the action is about to
659
+ * return. Wait a bounded number of turns and fold the settled status into the action's own
660
+ * result instead; a read that is still in flight keeps the skeleton and lands through
661
+ * `readChanged` as before. `undefined` means a newer action took over while this one waited. */
662
+ async function settledWithin(next: S, category: string, gen: number): Promise<S | undefined> {
663
+ const controller = reader(category);
664
+ for (let i = 0; i < 500 && controller.getStatus().refreshing; i++) await Promise.resolve();
665
+ if (gen !== generation) return undefined;
666
+ return controller.getStatus().refreshing ? next : currentReadState(next);
667
+ }
668
+
655
669
  type A = (s: S, arg?: unknown) => S | undefined | Promise<S | undefined>;
656
670
  const svc = register<S, Record<string, A>>("himiBrandedShop", {
657
671
  state: BOOT,
@@ -659,8 +673,12 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
659
673
  actions: {
660
674
  load: (s, arg) => load(s, arg),
661
675
  // Pull to refresh: the categories and the scope again, from Wix.
662
- refresh: (s, arg) => load(s, arg, true),
663
- retry: (s) => showScope(s, s.categorySelected, true),
676
+ refresh: async (s, arg) => {
677
+ const next = await load(s, arg, true);
678
+ // The result owns its scope; another action may have changed the globals during load.
679
+ return next ? settledWithin(next, next.categorySelected, next.scopeGen) : undefined;
680
+ },
681
+ retry: (s) => settledWithin(showScope(s, s.categorySelected, true), s.categorySelected, generation),
664
682
  recover: async () => { await fetchScope(selectedCategory, generation, true); return undefined; },
665
683
  readChanged: (s, arg) => {
666
684
  if (!arg || typeof arg !== 'object') return undefined;
@@ -683,7 +701,7 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
683
701
  // A background page landed for the current scope.
684
702
  scopeGrew: (s) => derive(s),
685
703
  // Single-select: one chip is always chosen.
686
- selectCategory: (s, arg) => {
704
+ selectCategory: async (s, arg) => {
687
705
  // The chip row sends { selectedIds }; a deselected chip falls back to All.
688
706
  const selection = arg as { selectedIds?: unknown; refresh?: boolean } | undefined;
689
707
  const id = selection?.selectedIds !== undefined ? selected(arg)[0] ?? ALL : idOf(arg);
@@ -691,12 +709,15 @@ const svc = register<S, Record<string, A>>("himiBrandedShop", {
691
709
  const cancelsPendingRoute = !!pendingLink || selectedCategory !== id;
692
710
  categoryIntentVersion++;
693
711
  pendingLink = undefined;
694
- if (id !== ALL && !categories.some((c) => c.id === id)) return s;
712
+ // undefined, not s: s is the snapshot from before any in-flight load, loading flag included.
713
+ if (id !== ALL && !categories.some((c) => c.id === id)) return undefined;
695
714
  // showScope compares the prior selection before committing the requested category.
696
715
  // categorySelected is committed with the tap (showScope), so a tap back to the chip
697
716
  // on screen while another category loads is a no-op on the right scope.
698
- if (id === s.categorySelected) return cancelsPendingRoute || refresh ? showScope(s, id, refresh) : s;
699
- const next = showScope(s, id, refresh);
717
+ if (id === s.categorySelected) return cancelsPendingRoute || refresh ? settledWithin(showScope(s, id, refresh), id, generation) : undefined;
718
+ const shown = showScope(s, id, refresh);
719
+ const next = await settledWithin(shown, id, generation);
720
+ if (!next) return undefined;
700
721
  const pending = pendingAuxiliary;
701
722
  if (pending && pending.version === auxiliaryVersion && pending.cartCount >= 0) {
702
723
  pendingAuxiliary = undefined;