@nativescript/vite 8.0.0-alpha.2 → 8.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/configuration/angular.d.ts +34 -1
  2. package/configuration/angular.js +380 -34
  3. package/configuration/angular.js.map +1 -1
  4. package/configuration/base.js +171 -7
  5. package/configuration/base.js.map +1 -1
  6. package/configuration/solid.js +27 -1
  7. package/configuration/solid.js.map +1 -1
  8. package/configuration/typescript.js +1 -1
  9. package/configuration/typescript.js.map +1 -1
  10. package/helpers/angular/angular-linker.js +3 -12
  11. package/helpers/angular/angular-linker.js.map +1 -1
  12. package/helpers/angular/inject-component-hmr-registration.d.ts +112 -0
  13. package/helpers/angular/inject-component-hmr-registration.js +359 -0
  14. package/helpers/angular/inject-component-hmr-registration.js.map +1 -0
  15. package/helpers/angular/inject-hmr-vite-ignore.d.ts +75 -0
  16. package/helpers/angular/inject-hmr-vite-ignore.js +288 -0
  17. package/helpers/angular/inject-hmr-vite-ignore.js.map +1 -0
  18. package/helpers/angular/util.d.ts +1 -0
  19. package/helpers/angular/util.js +88 -0
  20. package/helpers/angular/util.js.map +1 -1
  21. package/helpers/commonjs-plugins.d.ts +5 -2
  22. package/helpers/commonjs-plugins.js +126 -0
  23. package/helpers/commonjs-plugins.js.map +1 -1
  24. package/helpers/config-as-json.js +10 -0
  25. package/helpers/config-as-json.js.map +1 -1
  26. package/helpers/dev-host.d.ts +274 -0
  27. package/helpers/dev-host.js +491 -0
  28. package/helpers/dev-host.js.map +1 -0
  29. package/helpers/global-defines.d.ts +51 -0
  30. package/helpers/global-defines.js +77 -0
  31. package/helpers/global-defines.js.map +1 -1
  32. package/helpers/logging.d.ts +1 -0
  33. package/helpers/logging.js +63 -3
  34. package/helpers/logging.js.map +1 -1
  35. package/helpers/main-entry.d.ts +3 -1
  36. package/helpers/main-entry.js +450 -125
  37. package/helpers/main-entry.js.map +1 -1
  38. package/helpers/nativeclass-transformer-plugin.d.ts +9 -2
  39. package/helpers/nativeclass-transformer-plugin.js +157 -14
  40. package/helpers/nativeclass-transformer-plugin.js.map +1 -1
  41. package/helpers/ns-core-url.d.ts +88 -0
  42. package/helpers/ns-core-url.js +191 -0
  43. package/helpers/ns-core-url.js.map +1 -0
  44. package/helpers/prelink-angular.js +1 -4
  45. package/helpers/prelink-angular.js.map +1 -1
  46. package/helpers/project.d.ts +35 -0
  47. package/helpers/project.js +120 -2
  48. package/helpers/project.js.map +1 -1
  49. package/helpers/resolver.js +9 -1
  50. package/helpers/resolver.js.map +1 -1
  51. package/helpers/solid-jsx-deps.d.ts +15 -0
  52. package/helpers/solid-jsx-deps.js +178 -0
  53. package/helpers/solid-jsx-deps.js.map +1 -0
  54. package/helpers/ts-config-paths.js +50 -2
  55. package/helpers/ts-config-paths.js.map +1 -1
  56. package/helpers/workers.d.ts +20 -19
  57. package/helpers/workers.js +620 -3
  58. package/helpers/workers.js.map +1 -1
  59. package/hmr/client/css-handler.d.ts +1 -0
  60. package/hmr/client/css-handler.js +34 -5
  61. package/hmr/client/css-handler.js.map +1 -1
  62. package/hmr/client/css-update-overlay.d.ts +18 -0
  63. package/hmr/client/css-update-overlay.js +27 -0
  64. package/hmr/client/css-update-overlay.js.map +1 -0
  65. package/hmr/client/hmr-pending-overlay.d.ts +27 -0
  66. package/hmr/client/hmr-pending-overlay.js +50 -0
  67. package/hmr/client/hmr-pending-overlay.js.map +1 -0
  68. package/hmr/client/index.js +491 -34
  69. package/hmr/client/index.js.map +1 -1
  70. package/hmr/client/utils.d.ts +5 -0
  71. package/hmr/client/utils.js +283 -12
  72. package/hmr/client/utils.js.map +1 -1
  73. package/hmr/client/vue-sfc-update-overlay.d.ts +82 -0
  74. package/hmr/client/vue-sfc-update-overlay.js +133 -0
  75. package/hmr/client/vue-sfc-update-overlay.js.map +1 -0
  76. package/hmr/entry-runtime.d.ts +2 -1
  77. package/hmr/entry-runtime.js +253 -66
  78. package/hmr/entry-runtime.js.map +1 -1
  79. package/hmr/frameworks/angular/client/index.d.ts +3 -1
  80. package/hmr/frameworks/angular/client/index.js +802 -10
  81. package/hmr/frameworks/angular/client/index.js.map +1 -1
  82. package/hmr/frameworks/angular/server/linker.js +1 -4
  83. package/hmr/frameworks/angular/server/linker.js.map +1 -1
  84. package/hmr/frameworks/angular/server/strategy.js +30 -6
  85. package/hmr/frameworks/angular/server/strategy.js.map +1 -1
  86. package/hmr/frameworks/typescript/server/strategy.js +8 -2
  87. package/hmr/frameworks/typescript/server/strategy.js.map +1 -1
  88. package/hmr/frameworks/vue/client/index.js +30 -45
  89. package/hmr/frameworks/vue/client/index.js.map +1 -1
  90. package/hmr/helpers/ast-normalizer.js +52 -5
  91. package/hmr/helpers/ast-normalizer.js.map +1 -1
  92. package/hmr/helpers/cjs-named-exports.d.ts +23 -0
  93. package/hmr/helpers/cjs-named-exports.js +152 -0
  94. package/hmr/helpers/cjs-named-exports.js.map +1 -0
  95. package/hmr/helpers/package-exports.d.ts +16 -0
  96. package/hmr/helpers/package-exports.js +396 -0
  97. package/hmr/helpers/package-exports.js.map +1 -0
  98. package/hmr/server/constants.js +13 -4
  99. package/hmr/server/constants.js.map +1 -1
  100. package/hmr/server/core-sanitize.d.ts +93 -8
  101. package/hmr/server/core-sanitize.js +222 -49
  102. package/hmr/server/core-sanitize.js.map +1 -1
  103. package/hmr/server/import-map.js +80 -22
  104. package/hmr/server/import-map.js.map +1 -1
  105. package/hmr/server/index.d.ts +2 -1
  106. package/hmr/server/index.js.map +1 -1
  107. package/hmr/server/ns-core-cjs-shape.d.ts +204 -0
  108. package/hmr/server/ns-core-cjs-shape.js +271 -0
  109. package/hmr/server/ns-core-cjs-shape.js.map +1 -0
  110. package/hmr/server/ns-rt-bridge.d.ts +51 -0
  111. package/hmr/server/ns-rt-bridge.js +131 -0
  112. package/hmr/server/ns-rt-bridge.js.map +1 -0
  113. package/hmr/server/perf-instrumentation.d.ts +114 -0
  114. package/hmr/server/perf-instrumentation.js +195 -0
  115. package/hmr/server/perf-instrumentation.js.map +1 -0
  116. package/hmr/server/runtime-graph-filter.d.ts +5 -0
  117. package/hmr/server/runtime-graph-filter.js +21 -0
  118. package/hmr/server/runtime-graph-filter.js.map +1 -0
  119. package/hmr/server/shared-transform-request.d.ts +12 -0
  120. package/hmr/server/shared-transform-request.js +144 -0
  121. package/hmr/server/shared-transform-request.js.map +1 -0
  122. package/hmr/server/vite-plugin.d.ts +21 -1
  123. package/hmr/server/vite-plugin.js +497 -58
  124. package/hmr/server/vite-plugin.js.map +1 -1
  125. package/hmr/server/websocket-angular-entry.d.ts +2 -0
  126. package/hmr/server/websocket-angular-entry.js +68 -0
  127. package/hmr/server/websocket-angular-entry.js.map +1 -0
  128. package/hmr/server/websocket-angular-hot-update.d.ts +78 -0
  129. package/hmr/server/websocket-angular-hot-update.js +413 -0
  130. package/hmr/server/websocket-angular-hot-update.js.map +1 -0
  131. package/hmr/server/websocket-core-bridge.d.ts +58 -0
  132. package/hmr/server/websocket-core-bridge.js +368 -0
  133. package/hmr/server/websocket-core-bridge.js.map +1 -0
  134. package/hmr/server/websocket-css-hot-update.d.ts +33 -0
  135. package/hmr/server/websocket-css-hot-update.js +65 -0
  136. package/hmr/server/websocket-css-hot-update.js.map +1 -0
  137. package/hmr/server/websocket-graph-upsert.d.ts +21 -0
  138. package/hmr/server/websocket-graph-upsert.js +33 -0
  139. package/hmr/server/websocket-graph-upsert.js.map +1 -0
  140. package/hmr/server/websocket-hmr-pending.d.ts +43 -0
  141. package/hmr/server/websocket-hmr-pending.js +55 -0
  142. package/hmr/server/websocket-hmr-pending.js.map +1 -0
  143. package/hmr/server/websocket-module-bindings.d.ts +6 -0
  144. package/hmr/server/websocket-module-bindings.js +471 -0
  145. package/hmr/server/websocket-module-bindings.js.map +1 -0
  146. package/hmr/server/websocket-module-specifiers.d.ts +101 -0
  147. package/hmr/server/websocket-module-specifiers.js +820 -0
  148. package/hmr/server/websocket-module-specifiers.js.map +1 -0
  149. package/hmr/server/websocket-ns-m-finalize.d.ts +22 -0
  150. package/hmr/server/websocket-ns-m-finalize.js +88 -0
  151. package/hmr/server/websocket-ns-m-finalize.js.map +1 -0
  152. package/hmr/server/websocket-ns-m-paths.d.ts +3 -0
  153. package/hmr/server/websocket-ns-m-paths.js +92 -0
  154. package/hmr/server/websocket-ns-m-paths.js.map +1 -0
  155. package/hmr/server/websocket-ns-m-request.d.ts +45 -0
  156. package/hmr/server/websocket-ns-m-request.js +196 -0
  157. package/hmr/server/websocket-ns-m-request.js.map +1 -0
  158. package/hmr/server/websocket-served-module-helpers.d.ts +36 -0
  159. package/hmr/server/websocket-served-module-helpers.js +644 -0
  160. package/hmr/server/websocket-served-module-helpers.js.map +1 -0
  161. package/hmr/server/websocket-txn.d.ts +6 -0
  162. package/hmr/server/websocket-txn.js +45 -0
  163. package/hmr/server/websocket-txn.js.map +1 -0
  164. package/hmr/server/websocket-vendor-unifier.d.ts +10 -0
  165. package/hmr/server/websocket-vendor-unifier.js +51 -0
  166. package/hmr/server/websocket-vendor-unifier.js.map +1 -0
  167. package/hmr/server/websocket-vue-sfc.d.ts +26 -0
  168. package/hmr/server/websocket-vue-sfc.js +1053 -0
  169. package/hmr/server/websocket-vue-sfc.js.map +1 -0
  170. package/hmr/server/websocket.d.ts +58 -75
  171. package/hmr/server/websocket.js +2232 -1802
  172. package/hmr/server/websocket.js.map +1 -1
  173. package/hmr/shared/package-classifier.d.ts +9 -0
  174. package/hmr/shared/package-classifier.js +58 -0
  175. package/hmr/shared/package-classifier.js.map +1 -0
  176. package/hmr/shared/runtime/boot-placeholder-ui.d.ts +69 -0
  177. package/hmr/shared/runtime/boot-placeholder-ui.js +101 -0
  178. package/hmr/shared/runtime/boot-placeholder-ui.js.map +1 -0
  179. package/hmr/shared/runtime/boot-progress.d.ts +40 -0
  180. package/hmr/shared/runtime/boot-progress.js +128 -0
  181. package/hmr/shared/runtime/boot-progress.js.map +1 -0
  182. package/hmr/shared/runtime/boot-timeline.d.ts +18 -0
  183. package/hmr/shared/runtime/boot-timeline.js +52 -0
  184. package/hmr/shared/runtime/boot-timeline.js.map +1 -0
  185. package/hmr/shared/runtime/browser-runtime-contract.d.ts +64 -0
  186. package/hmr/shared/runtime/browser-runtime-contract.js +54 -0
  187. package/hmr/shared/runtime/browser-runtime-contract.js.map +1 -0
  188. package/hmr/shared/runtime/dev-overlay.d.ts +78 -3
  189. package/hmr/shared/runtime/dev-overlay.js +1094 -26
  190. package/hmr/shared/runtime/dev-overlay.js.map +1 -1
  191. package/hmr/shared/runtime/module-provenance.js +1 -4
  192. package/hmr/shared/runtime/module-provenance.js.map +1 -1
  193. package/hmr/shared/runtime/root-placeholder.d.ts +1 -0
  194. package/hmr/shared/runtime/root-placeholder.js +1019 -151
  195. package/hmr/shared/runtime/root-placeholder.js.map +1 -1
  196. package/hmr/shared/runtime/session-bootstrap.d.ts +1 -0
  197. package/hmr/shared/runtime/session-bootstrap.js +309 -0
  198. package/hmr/shared/runtime/session-bootstrap.js.map +1 -0
  199. package/hmr/shared/runtime/vendor-bootstrap.js +1 -9
  200. package/hmr/shared/runtime/vendor-bootstrap.js.map +1 -1
  201. package/hmr/shared/vendor/manifest.d.ts +32 -0
  202. package/hmr/shared/vendor/manifest.js +411 -46
  203. package/hmr/shared/vendor/manifest.js.map +1 -1
  204. package/index.d.ts +1 -0
  205. package/index.js +5 -0
  206. package/index.js.map +1 -1
  207. package/package.json +9 -1
  208. package/runtime/core-aliases-early.js +94 -67
  209. package/runtime/core-aliases-early.js.map +1 -1
@@ -1,4 +1,6 @@
1
- const BOOT_TITLE = 'Starting NativeScript + Vite dev server…';
1
+ import { BOOT_PLACEHOLDER_MOTION, computeBootProgressFillScale, formatBootDetailLine, formatBootPrimaryLine } from './boot-placeholder-ui.js';
2
+ const DEFAULT_OVERLAY_POSITION = 'top';
3
+ const BOOT_TITLE = 'NativeScript Vite preparing dev session...';
2
4
  const DEFAULT_SNAPSHOT = {
3
5
  visible: false,
4
6
  mode: 'hidden',
@@ -14,6 +16,37 @@ const DEFAULT_SNAPSHOT = {
14
16
  function getOverlayGlobal() {
15
17
  return globalThis;
16
18
  }
19
+ /**
20
+ * Resolve the configured live-overlay position.
21
+ *
22
+ * Reads `globalThis.__NS_HMR_OVERLAY_POSITION__` so a project can
23
+ * override the default at boot time (e.g. inside `app.ts` before the
24
+ * Vite session bootstraps). Falls back to 'top' which gives the
25
+ * toast-style chip with a slide-in animation and safe-area padding.
26
+ */
27
+ export function getHmrDevOverlayPosition() {
28
+ const g = getOverlayGlobal();
29
+ const stored = g.__NS_HMR_OVERLAY_POSITION__;
30
+ if (stored === 'top' || stored === 'bottom' || stored === 'center') {
31
+ return stored;
32
+ }
33
+ return DEFAULT_OVERLAY_POSITION;
34
+ }
35
+ /**
36
+ * Imperative setter for the live-overlay position. Re-applies the
37
+ * current snapshot so the change is visible without waiting for the
38
+ * next HMR cycle. Useful during dev to A/B between top/bottom/center
39
+ * without restarting the app.
40
+ */
41
+ export function setHmrDevOverlayPosition(position) {
42
+ if (position !== 'top' && position !== 'bottom' && position !== 'center') {
43
+ return;
44
+ }
45
+ const g = getOverlayGlobal();
46
+ g.__NS_HMR_OVERLAY_POSITION__ = position;
47
+ const state = getRuntimeState();
48
+ applyRuntimeSnapshot(state.snapshot);
49
+ }
17
50
  function getRuntimeState() {
18
51
  const g = getOverlayGlobal();
19
52
  if (!g.__NS_HMR_DEV_OVERLAY_STATE__) {
@@ -21,10 +54,25 @@ function getRuntimeState() {
21
54
  snapshot: { ...DEFAULT_SNAPSHOT },
22
55
  bootRefs: null,
23
56
  liveRefs: null,
57
+ iosRefs: null,
58
+ iosBuildFailed: false,
24
59
  verbose: false,
60
+ updateAutoHideTimer: null,
61
+ updateCycleStartedAt: 0,
25
62
  };
26
63
  }
27
- return g.__NS_HMR_DEV_OVERLAY_STATE__;
64
+ const state = g.__NS_HMR_DEV_OVERLAY_STATE__;
65
+ // Backfill newer fields for legacy state objects (e.g. after hot reload)
66
+ // so we never observe an undefined iosRefs/iosBuildFailed at runtime.
67
+ if (typeof state.iosRefs === 'undefined')
68
+ state.iosRefs = null;
69
+ if (typeof state.iosBuildFailed === 'undefined')
70
+ state.iosBuildFailed = false;
71
+ if (typeof state.updateAutoHideTimer === 'undefined')
72
+ state.updateAutoHideTimer = null;
73
+ if (typeof state.updateCycleStartedAt !== 'number')
74
+ state.updateCycleStartedAt = 0;
75
+ return state;
28
76
  }
29
77
  function describeAttempt(info) {
30
78
  const attempt = Number(info?.attempt || 0);
@@ -131,7 +179,11 @@ export function createBootOverlaySnapshot(stage, info) {
131
179
  badge: 'BOOT',
132
180
  title: BOOT_TITLE,
133
181
  phase: 'Importing the app entry',
134
- progress: 82,
182
+ // 30 (not 82) so the bar visibly climbs the ~62 points the
183
+ // heartbeat + snippet drive during the long HTTP-module-load
184
+ // phase. The monotonic ratchet in `setBootStage` prevents
185
+ // earlier-but-higher stages from being clobbered.
186
+ progress: 30,
135
187
  busy: true,
136
188
  blocking: true,
137
189
  tone: 'info',
@@ -147,6 +199,17 @@ export function createBootOverlaySnapshot(stage, info) {
147
199
  blocking: true,
148
200
  tone: 'info',
149
201
  },
202
+ 'app-root-committed': {
203
+ visible: true,
204
+ mode: 'boot',
205
+ badge: 'READY',
206
+ title: BOOT_TITLE,
207
+ phase: 'Real app root committed',
208
+ progress: 100,
209
+ busy: false,
210
+ blocking: true,
211
+ tone: 'info',
212
+ },
150
213
  ready: {
151
214
  visible: true,
152
215
  mode: 'boot',
@@ -193,8 +256,8 @@ export function createConnectionOverlaySnapshot(stage, info) {
193
256
  visible: true,
194
257
  mode: 'connection',
195
258
  badge: 'SOCKET',
196
- title: 'Waiting for the Vite dev server',
197
- phase: 'Opening the websocket connection',
259
+ title: 'Waiting for Vite dev server',
260
+ phase: 'Opening websocket connection',
198
261
  detail: 'Live updates are paused until the connection is healthy.',
199
262
  progress: null,
200
263
  busy: true,
@@ -206,7 +269,7 @@ export function createConnectionOverlaySnapshot(stage, info) {
206
269
  mode: 'connection',
207
270
  badge: 'SOCKET',
208
271
  title: 'HMR connection lost',
209
- phase: 'Reconnecting to the Vite websocket',
272
+ phase: 'Trying to reconnect Vite websocket',
210
273
  detail: 'The app may be stale until the dev server reconnects.',
211
274
  progress: null,
212
275
  busy: true,
@@ -230,7 +293,7 @@ export function createConnectionOverlaySnapshot(stage, info) {
230
293
  mode: 'connection',
231
294
  badge: 'OFFLINE',
232
295
  title: 'Vite dev server offline',
233
- phase: 'Waiting for a healthy dev server',
296
+ phase: 'Please check your terminal.',
234
297
  detail: 'The websocket and HTTP HMR path are both unavailable right now.',
235
298
  progress: null,
236
299
  busy: true,
@@ -245,6 +308,89 @@ export function createConnectionOverlaySnapshot(stage, info) {
245
308
  progress: typeof info?.progress === 'number' || info?.progress === null ? info.progress : base.progress,
246
309
  };
247
310
  }
311
+ // Snapshot factory for the HMR-applying overlay. Each stage owns a
312
+ // fixed phase string, badge, and progress %. We pick the percentages
313
+ // so users see continuous forward motion: the cheap stages (mutex
314
+ // acquire, eviction call) advance fast; the long tail (entry
315
+ // re-import + Angular reboot) sits at 60→90 so the bar keeps moving
316
+ // even when the V8 ESM walk dominates wall time.
317
+ //
318
+ // The 'complete' stage holds for a brief moment (the API auto-hides
319
+ // it via setUpdateStage) so the user gets visual closure ("the update
320
+ // landed") without staring at a frozen overlay; tone stays 'success'
321
+ // throughout so the colour scheme never flickers between phases.
322
+ const HMR_UPDATE_TITLE = 'HMR update applying...';
323
+ const HMR_UPDATE_DONE_TITLE = 'HMR update applied';
324
+ export function createUpdateOverlaySnapshot(stage, info) {
325
+ const phaseInfo = {
326
+ received: {
327
+ visible: true,
328
+ mode: 'update',
329
+ badge: 'HMR',
330
+ title: HMR_UPDATE_TITLE,
331
+ phase: 'Preparing update',
332
+ detail: '',
333
+ progress: 5,
334
+ busy: true,
335
+ blocking: false,
336
+ tone: 'success',
337
+ },
338
+ evicting: {
339
+ visible: true,
340
+ mode: 'update',
341
+ badge: 'HMR',
342
+ title: HMR_UPDATE_TITLE,
343
+ phase: 'Invalidating module cache',
344
+ detail: '',
345
+ progress: 30,
346
+ busy: true,
347
+ blocking: false,
348
+ tone: 'success',
349
+ },
350
+ reimporting: {
351
+ visible: true,
352
+ mode: 'update',
353
+ badge: 'HMR',
354
+ title: HMR_UPDATE_TITLE,
355
+ phase: 'Re-importing entry',
356
+ detail: '',
357
+ progress: 60,
358
+ busy: true,
359
+ blocking: false,
360
+ tone: 'success',
361
+ },
362
+ rebooting: {
363
+ visible: true,
364
+ mode: 'update',
365
+ badge: 'HMR',
366
+ title: HMR_UPDATE_TITLE,
367
+ phase: 'Rebooting Angular',
368
+ detail: '',
369
+ progress: 90,
370
+ busy: true,
371
+ blocking: false,
372
+ tone: 'success',
373
+ },
374
+ complete: {
375
+ visible: true,
376
+ mode: 'update',
377
+ badge: 'HMR',
378
+ title: HMR_UPDATE_DONE_TITLE,
379
+ phase: 'Update applied',
380
+ detail: '',
381
+ progress: 100,
382
+ busy: false,
383
+ blocking: false,
384
+ tone: 'success',
385
+ },
386
+ };
387
+ const base = phaseInfo[stage];
388
+ return {
389
+ ...base,
390
+ detail: info?.detail || base.detail,
391
+ progress: typeof info?.progress === 'number' || info?.progress === null ? info.progress : base.progress,
392
+ };
393
+ }
248
394
  function resolveCoreExport(name) {
249
395
  const g = getOverlayGlobal();
250
396
  try {
@@ -359,12 +505,12 @@ function applySnapshotToBootRefs(refs, snapshot) {
359
505
  return;
360
506
  }
361
507
  refs.page.actionBarHidden = true;
362
- refs.page.backgroundColor = asColor('#FFF9FAFB');
508
+ refs.page.backgroundColor = asColor(snapshot.tone === 'error' ? '#b4181068' : '#a1771683');
363
509
  refs.root.visibility = snapshot.visible && snapshot.mode === 'boot' ? 'visible' : 'collapse';
364
510
  refs.titleLabel.text = BOOT_TITLE;
365
- refs.titleLabel.color = asColor('#FF111827');
511
+ refs.titleLabel.color = asColor(snapshot.tone === 'error' ? '#b41810e6' : '#563e3fb1');
366
512
  refs.statusLabel.text = formatStatusText(snapshot) || 'Preparing the HTTP HMR bootstrap (4%)';
367
- refs.statusLabel.color = asColor(snapshot.tone === 'error' ? '#FFB91C1C' : '#FF4B5563');
513
+ refs.statusLabel.color = asColor(snapshot.tone === 'error' ? '#b41810e6' : '#563e3fb1');
368
514
  if (refs.activityIndicator) {
369
515
  refs.activityIndicator.busy = !!snapshot.busy;
370
516
  refs.activityIndicator.visibility = snapshot.visible && snapshot.mode === 'boot' && snapshot.busy ? 'visible' : 'collapse';
@@ -424,10 +570,28 @@ function findBootStatusLabel() {
424
570
  catch { }
425
571
  return null;
426
572
  }
573
+ function findBootDetailLabel() {
574
+ const g = getOverlayGlobal();
575
+ return g.__NS_DEV_BOOT_DETAIL_LABEL__ || null;
576
+ }
577
+ function findBootProgressFill() {
578
+ const g = getOverlayGlobal();
579
+ return g.__NS_DEV_BOOT_PROGRESS_FILL__ || null;
580
+ }
427
581
  function updateBootStatusLabel(snapshot) {
428
- const newText = formatStatusText(snapshot) || 'Preparing the HTTP HMR bootstrap (4%)';
429
582
  const statusLabel = findBootStatusLabel();
583
+ const detailLabel = findBootDetailLabel();
584
+ const progressFill = findBootProgressFill();
430
585
  const activityIndicator = findBootActivityIndicator();
586
+ // New (card) layout: phase line + detail line live in separate
587
+ // labels so the typography can differ. Legacy (single-label)
588
+ // layout: keep the original combined "phase (X%)\ndetail" text so
589
+ // nothing visually regresses for runtimes still attached to the
590
+ // older placeholder shape.
591
+ const hasSplitLabels = !!detailLabel;
592
+ const phaseLine = formatBootPrimaryLine(snapshot);
593
+ const detailLine = formatBootDetailLine(snapshot);
594
+ const combinedText = formatStatusText(snapshot) || 'Preparing the HTTP HMR bootstrap (4%)';
431
595
  if (!statusLabel) {
432
596
  if (activityIndicator) {
433
597
  try {
@@ -436,11 +600,16 @@ function updateBootStatusLabel(snapshot) {
436
600
  }
437
601
  catch { }
438
602
  }
603
+ applyBootProgressFill(progressFill, snapshot);
439
604
  return;
440
605
  }
441
606
  try {
442
- statusLabel.text = newText;
443
- statusLabel.color = asColor(snapshot.tone === 'error' ? '#FFB91C1C' : '#FF4B5563');
607
+ statusLabel.text = hasSplitLabels ? phaseLine || 'Preparing the HTTP HMR bootstrap' : combinedText;
608
+ // Card layout uses the calibrated phase-text colour from the
609
+ // palette; legacy single-label layout keeps the original muted
610
+ // brown so we don't visually regress mid-session.
611
+ const phaseColorHex = snapshot.tone === 'error' ? '#B91C1C' : hasSplitLabels ? '#475569' : '#563e3fb1';
612
+ statusLabel.color = asColor(phaseColorHex);
444
613
  if (typeof statusLabel.requestLayout === 'function') {
445
614
  statusLabel.requestLayout();
446
615
  }
@@ -450,6 +619,15 @@ function updateBootStatusLabel(snapshot) {
450
619
  }
451
620
  }
452
621
  catch { }
622
+ if (detailLabel) {
623
+ try {
624
+ detailLabel.text = detailLine;
625
+ detailLabel.color = asColor(snapshot.tone === 'error' ? '#DC2626' : '#94A3B8');
626
+ detailLabel.visibility = detailLine ? 'visible' : 'collapse';
627
+ }
628
+ catch { }
629
+ }
630
+ applyBootProgressFill(progressFill, snapshot);
453
631
  if (activityIndicator) {
454
632
  try {
455
633
  activityIndicator.busy = !!snapshot.busy;
@@ -458,6 +636,50 @@ function updateBootStatusLabel(snapshot) {
458
636
  catch { }
459
637
  }
460
638
  }
639
+ // Drive the progress fill scaleX from the snapshot. Uses NS's view
640
+ // animate API for a smooth 220 ms easeOut between heartbeat ticks; a
641
+ // monotonic ratchet on `globalThis.__NS_DEV_BOOT_PROGRESS_LAST_SCALE__`
642
+ // guards against the fill snapping backwards if a less-progressed
643
+ // snapshot ever lands between ticks (mirrors the JS-side
644
+ // `applyMonotonicBootProgress` contract).
645
+ function applyBootProgressFill(progressFill, snapshot) {
646
+ if (!progressFill)
647
+ return;
648
+ const g = getOverlayGlobal();
649
+ const isError = snapshot.tone === 'error';
650
+ progressFill.backgroundColor = asColor(isError ? '#B41810' : '#3B6FE5');
651
+ const targetScale = computeBootProgressFillScale(snapshot.progress ?? null);
652
+ const previousRaw = Number(g.__NS_DEV_BOOT_PROGRESS_LAST_SCALE__);
653
+ const previous = Number.isFinite(previousRaw) ? previousRaw : 0;
654
+ const next = Math.max(previous, targetScale);
655
+ g.__NS_DEV_BOOT_PROGRESS_LAST_SCALE__ = next;
656
+ try {
657
+ // NS view.animate scales around `originX`/`originY`; the
658
+ // placeholder builder pins `originX = 0` so the fill grows
659
+ // rightward. animate() may be unavailable in some headless
660
+ // test environments — fall through to a direct property set.
661
+ if (typeof progressFill.animate === 'function') {
662
+ progressFill
663
+ .animate({
664
+ scale: { x: next, y: 1 },
665
+ duration: BOOT_PLACEHOLDER_MOTION.progressDurationMs,
666
+ curve: 'easeOut',
667
+ })
668
+ .catch(() => {
669
+ try {
670
+ progressFill.scaleX = next;
671
+ }
672
+ catch { }
673
+ });
674
+ }
675
+ else {
676
+ progressFill.scaleX = next;
677
+ }
678
+ }
679
+ catch {
680
+ progressFill.scaleX = next;
681
+ }
682
+ }
461
683
  function resolveActivePage() {
462
684
  try {
463
685
  const Frame = resolveCoreExport('Frame');
@@ -494,8 +716,18 @@ function buildLiveOverlayView(snapshot) {
494
716
  overlay.height = '100%';
495
717
  overlay.horizontalAlignment = 'stretch';
496
718
  overlay.verticalAlignment = 'stretch';
719
+ // Toast mode lets touches reach the underlying app. We flip
720
+ // isUserInteractionEnabled in applySnapshotToLiveRefs based on
721
+ // the resolved position, but keep it false here as a safe default
722
+ // (the panel itself is purely informational).
723
+ try {
724
+ overlay.isUserInteractionEnabled = false;
725
+ }
726
+ catch { }
497
727
  const panel = new StackLayout();
498
728
  panel.horizontalAlignment = 'center';
729
+ // Vertical alignment is overridden in applySnapshotToLiveRefs
730
+ // based on getHmrDevOverlayPosition(); 'middle' is the default
499
731
  panel.verticalAlignment = 'middle';
500
732
  panel.width = 320;
501
733
  panel.margin = 24;
@@ -517,6 +749,8 @@ function buildLiveOverlayView(snapshot) {
517
749
  overlay,
518
750
  titleLabel,
519
751
  statusLabel,
752
+ wasVisible: false,
753
+ currentPosition: getHmrDevOverlayPosition(),
520
754
  };
521
755
  applySnapshotToLiveRefs(refs, snapshot);
522
756
  return refs;
@@ -573,26 +807,697 @@ function applySnapshotToLiveRefs(refs, snapshot) {
573
807
  if (!refs) {
574
808
  return;
575
809
  }
576
- const visible = snapshot.visible && snapshot.mode === 'connection';
577
- refs.overlay.visibility = visible ? 'visible' : 'collapse';
578
- refs.overlay.backgroundColor = asColor(snapshot.tone === 'error' ? '#AA7F1D1D' : '#992D3748');
810
+ // 'update' mode shares the live (in-tree) overlay chrome with
811
+ // 'connection'. Both render a small panel inside the page; only
812
+ // the colours, text, and (now) panel position change with the
813
+ // snapshot's tone and the configured overlay position.
814
+ const visible = snapshot.visible && (snapshot.mode === 'connection' || snapshot.mode === 'update');
815
+ const wasVisible = !!refs.wasVisible;
816
+ const position = getHmrDevOverlayPosition();
817
+ const previousPosition = refs.currentPosition || position;
818
+ const isToast = position !== 'center';
579
819
  refs.titleLabel.text = snapshot.title;
580
- refs.titleLabel.color = asColor('#FF111827');
581
820
  refs.statusLabel.text = formatStatusText(snapshot);
582
- refs.statusLabel.color = asColor(snapshot.tone === 'error' ? '#FF7F1D1D' : '#FF374151');
821
+ const textColor = snapshot.tone === 'error' ? '#b41810e6' : snapshot.tone === 'success' ? '#0e6e2fff' : '#563e3fb1';
822
+ refs.titleLabel.color = asColor(textColor);
823
+ refs.statusLabel.color = asColor(textColor);
824
+ // Backdrop tints (centered modal only). Toast modes use a fully
825
+ // transparent backdrop so the rest of the app stays visible AND
826
+ // reachable; the panel itself carries enough colour to stand out.
827
+ if (isToast) {
828
+ refs.overlay.backgroundColor = asColor('transparent');
829
+ }
830
+ else {
831
+ // Original wash-by-tone for centered:
832
+ // error → red wash (matches existing UX)
833
+ // success → richer green wash so the apply event is visible
834
+ // on bright app backgrounds
835
+ // default → warm orange (existing connection-overlay look)
836
+ const overlayBg = snapshot.tone === 'error' ? '#b4181068' : snapshot.tone === 'success' ? '#1f883d80' : '#a1771683';
837
+ refs.overlay.backgroundColor = asColor(overlayBg);
838
+ }
839
+ // Panel chrome — toast and centered share the same chip look,
840
+ // just position differs. We keep the slightly richer green tint
841
+ // for the HMR success state so it pops without needing the
842
+ // backdrop wash.
843
+ let panel = null;
583
844
  try {
584
- const panel = refs.titleLabel.parent;
845
+ panel = refs.titleLabel.parent;
585
846
  if (panel) {
586
- panel.backgroundColor = asColor('#FFFFFFFF');
847
+ const panelBg = snapshot.tone === 'success' ? '#E6F8E9FF' : '#FFFFFFFF';
848
+ panel.backgroundColor = asColor(panelBg);
587
849
  panel.opacity = 1;
588
850
  panel.padding = 16;
589
851
  try {
590
852
  panel.borderRadius = 12;
591
853
  }
592
854
  catch { }
855
+ // Position-aware alignment. The wrapper GridLayout fills
856
+ // the page content area, which on iOS is already inside
857
+ // the safe area; we add a small extra margin so the chip
858
+ // doesn't kiss the notch / home indicator.
859
+ try {
860
+ if (position === 'top') {
861
+ panel.verticalAlignment = 'top';
862
+ panel.margin = '12 16 0 16';
863
+ }
864
+ else if (position === 'bottom') {
865
+ panel.verticalAlignment = 'bottom';
866
+ panel.margin = '0 16 12 16';
867
+ }
868
+ else {
869
+ panel.verticalAlignment = 'middle';
870
+ panel.margin = 24;
871
+ }
872
+ }
873
+ catch { }
593
874
  }
594
875
  }
595
876
  catch { }
877
+ // Touch passthrough for toast; centered mode keeps the
878
+ // blocking modal so the dim backdrop is meaningful.
879
+ try {
880
+ refs.overlay.isUserInteractionEnabled = !isToast;
881
+ }
882
+ catch { }
883
+ const positionChanged = previousPosition !== position;
884
+ const justAppeared = visible && (!wasVisible || positionChanged);
885
+ const justDismissed = !visible && wasVisible;
886
+ if (justAppeared) {
887
+ refs.overlay.visibility = 'visible';
888
+ if (isToast && panel && typeof panel.animate === 'function') {
889
+ animateLivePanelIn(panel, position);
890
+ }
891
+ else if (panel) {
892
+ try {
893
+ panel.translateY = 0;
894
+ panel.opacity = 1;
895
+ }
896
+ catch { }
897
+ }
898
+ }
899
+ else if (justDismissed) {
900
+ if (isToast && panel && typeof panel.animate === 'function') {
901
+ animateLivePanelOut(panel, previousPosition, () => {
902
+ try {
903
+ refs.overlay.visibility = 'collapse';
904
+ }
905
+ catch { }
906
+ });
907
+ }
908
+ else {
909
+ refs.overlay.visibility = 'collapse';
910
+ }
911
+ }
912
+ else {
913
+ refs.overlay.visibility = visible ? 'visible' : 'collapse';
914
+ }
915
+ if (typeof refs.wasVisible !== 'undefined')
916
+ refs.wasVisible = visible;
917
+ if (typeof refs.currentPosition !== 'undefined')
918
+ refs.currentPosition = position;
919
+ }
920
+ /**
921
+ * Slide-in animation for the in-tree toast panel.
922
+ *
923
+ * NativeScript's `View.animate({ translate, opacity, duration, curve })`
924
+ * is widely available across Core versions, so we don't depend on any
925
+ * specific curve enum being importable here. We use a moderate-to-snappy
926
+ * 320ms ease-out which feels close to a UIView spring without needing
927
+ * platform-specific APIs.
928
+ */
929
+ function animateLivePanelIn(panel, position) {
930
+ if (!panel || typeof panel.animate !== 'function')
931
+ return;
932
+ try {
933
+ const startY = position === 'bottom' ? 80 : -80;
934
+ panel.translateY = startY;
935
+ panel.opacity = 0;
936
+ const result = panel.animate({
937
+ translate: { x: 0, y: 0 },
938
+ opacity: 1,
939
+ duration: 320,
940
+ curve: 'easeOut',
941
+ });
942
+ if (result && typeof result.catch === 'function') {
943
+ result.catch(() => {
944
+ try {
945
+ panel.translateY = 0;
946
+ panel.opacity = 1;
947
+ }
948
+ catch { }
949
+ });
950
+ }
951
+ }
952
+ catch {
953
+ try {
954
+ panel.translateY = 0;
955
+ panel.opacity = 1;
956
+ }
957
+ catch { }
958
+ }
959
+ }
960
+ function animateLivePanelOut(panel, position, onComplete) {
961
+ if (!panel || typeof panel.animate !== 'function') {
962
+ onComplete();
963
+ return;
964
+ }
965
+ try {
966
+ const targetY = position === 'bottom' ? 80 : -80;
967
+ const result = panel.animate({
968
+ translate: { x: 0, y: targetY },
969
+ opacity: 0,
970
+ duration: 220,
971
+ curve: 'easeIn',
972
+ });
973
+ const finish = () => {
974
+ try {
975
+ panel.translateY = 0;
976
+ panel.opacity = 1;
977
+ }
978
+ catch { }
979
+ onComplete();
980
+ };
981
+ if (result && typeof result.then === 'function') {
982
+ result.then(finish, finish);
983
+ }
984
+ else {
985
+ finish();
986
+ }
987
+ }
988
+ catch {
989
+ onComplete();
990
+ }
991
+ }
992
+ // pure helpers for iOS window promotion. Factored out so the layout
993
+ // math and window-level selection stay unit-testable without booting a
994
+ // simulator. See `dev-overlay.spec.ts`.
995
+ /**
996
+ * Returns the UIWindow level we use for the live/connection overlay. We lift
997
+ * above `UIWindowLevelAlert` so system alerts (and any app-presented modal)
998
+ * stack underneath. When the platform does not expose `UIWindowLevelAlert`
999
+ * we fall back to the documented constant value (2000).
1000
+ */
1001
+ export function computeIosOverlayWindowLevel(baseAlert) {
1002
+ if (typeof baseAlert === 'number' && Number.isFinite(baseAlert)) {
1003
+ return baseAlert + 1;
1004
+ }
1005
+ return 2000 + 1;
1006
+ }
1007
+ /**
1008
+ * Layout math for the live overlay when it runs inside its own UIWindow.
1009
+ * Pure, deterministic and independent of UIKit so we can verify the rules
1010
+ * (max panel width, position-aware placement, safe-area clamping, sane
1011
+ * defaults) from tests.
1012
+ *
1013
+ * `position` controls where the panel sits vertically:
1014
+ * - 'top': hugs `safeInsets.top + toastVerticalInset` so the chip
1015
+ * sits just below the notch / Dynamic Island.
1016
+ * - 'bottom': hugs `viewHeight - safeInsets.bottom - panelHeight -
1017
+ * toastVerticalInset` so the chip sits just above the
1018
+ * home indicator / nav bar.
1019
+ * - 'center': original modal placement (vertically centered, clamped
1020
+ * so it never crosses the top safe-area inset).
1021
+ */
1022
+ export function computeIosOverlayLayout(input) {
1023
+ const viewWidth = Math.max(0, Number(input.viewWidth) || 0);
1024
+ const viewHeight = Math.max(0, Number(input.viewHeight) || 0);
1025
+ const safeInsets = {
1026
+ top: Math.max(0, Number(input.safeInsets?.top ?? 0) || 0),
1027
+ bottom: Math.max(0, Number(input.safeInsets?.bottom ?? 0) || 0),
1028
+ left: Math.max(0, Number(input.safeInsets?.left ?? 0) || 0),
1029
+ right: Math.max(0, Number(input.safeInsets?.right ?? 0) || 0),
1030
+ };
1031
+ const titleHeight = Math.max(0, Number(input.titleHeight) || 0);
1032
+ const statusHeight = Math.max(0, Number(input.statusHeight) || 0);
1033
+ const horizontalMargin = Math.max(0, Number(input.horizontalMargin ?? 24));
1034
+ const maxPanelWidth = Math.max(0, Number(input.maxPanelWidth ?? 340));
1035
+ const panelPadding = Math.max(0, Number(input.panelPadding ?? 16));
1036
+ const interLabelSpacing = Math.max(0, Number(input.interLabelSpacing ?? 10));
1037
+ const minTopInset = Math.max(0, Number(input.minTopInset ?? 20));
1038
+ // Default to 'center' on the pure function so the existing
1039
+ // snapshot/layout tests remain stable; the runtime call site
1040
+ // (layoutIosOverlayRefs) reads the configured position from
1041
+ // `getHmrDevOverlayPosition()` and forwards it explicitly.
1042
+ const position = input.position ?? 'center';
1043
+ // Distance between the panel and the safe-area edge in toast
1044
+ // modes. 8pt mirrors the typical iOS notification chip inset and
1045
+ // keeps the chip from hugging the notch / home indicator.
1046
+ const toastVerticalInset = Math.max(0, Number(input.toastVerticalInset ?? 8));
1047
+ const available = Math.max(0, viewWidth - 2 * horizontalMargin - safeInsets.left - safeInsets.right);
1048
+ const panelWidth = Math.min(maxPanelWidth, available);
1049
+ const innerWidth = Math.max(0, panelWidth - 2 * panelPadding);
1050
+ const spacing = titleHeight > 0 && statusHeight > 0 ? interLabelSpacing : 0;
1051
+ const panelHeight = panelPadding * 2 + titleHeight + spacing + statusHeight;
1052
+ const panelX = Math.max(0, (viewWidth - panelWidth) / 2);
1053
+ let panelY;
1054
+ if (position === 'top') {
1055
+ // Pin to the top safe-area inset (just below notch / Dynamic
1056
+ // Island). Clamp non-negative for fully-NaN input.
1057
+ panelY = Math.max(0, safeInsets.top + toastVerticalInset);
1058
+ }
1059
+ else if (position === 'bottom') {
1060
+ // Pin to the bottom safe-area inset (just above home indicator
1061
+ // / nav bar). If the panel can't fit between the safe-area
1062
+ // insets we fall back to the top safe-area edge so the chip is
1063
+ // always visible (rather than getting clipped off-screen).
1064
+ const desired = viewHeight - safeInsets.bottom - panelHeight - toastVerticalInset;
1065
+ panelY = Math.max(safeInsets.top + minTopInset, desired);
1066
+ }
1067
+ else {
1068
+ // Center vertically, but never cross the top safe-area inset
1069
+ // (notch/Dynamic Island). Original modal placement.
1070
+ const centered = (viewHeight - panelHeight) / 2;
1071
+ panelY = Math.max(safeInsets.top + minTopInset, centered);
1072
+ }
1073
+ return {
1074
+ backdrop: { x: 0, y: 0, width: viewWidth, height: viewHeight },
1075
+ panel: { x: panelX, y: panelY, width: panelWidth, height: panelHeight },
1076
+ title: { x: panelPadding, y: panelPadding, width: innerWidth, height: titleHeight },
1077
+ status: {
1078
+ x: panelPadding,
1079
+ y: panelPadding + titleHeight + spacing,
1080
+ width: innerWidth,
1081
+ height: statusHeight,
1082
+ },
1083
+ };
1084
+ }
1085
+ /**
1086
+ * Returns the iOS UIKit symbols we rely on if we're running on an iOS runtime
1087
+ * with the metadata bridge available. Returns null on Android, web, or in
1088
+ * tests so callers can gracefully fall back to the in-tree overlay.
1089
+ */
1090
+ function getIosOverlayHost() {
1091
+ const g = getOverlayGlobal();
1092
+ if (typeof g.UIWindow === 'undefined' || typeof g.UIApplication === 'undefined' || typeof g.UIViewController === 'undefined' || typeof g.UIView === 'undefined' || typeof g.UILabel === 'undefined' || typeof g.UIColor === 'undefined' || typeof g.UIFont === 'undefined' || typeof g.UIScreen === 'undefined') {
1093
+ return null;
1094
+ }
1095
+ return {
1096
+ UIWindow: g.UIWindow,
1097
+ UIViewController: g.UIViewController,
1098
+ UIView: g.UIView,
1099
+ UILabel: g.UILabel,
1100
+ UIColor: g.UIColor,
1101
+ UIFont: g.UIFont,
1102
+ UIApplication: g.UIApplication,
1103
+ UIScreen: g.UIScreen,
1104
+ UIWindowLevelAlert: typeof g.UIWindowLevelAlert === 'number' ? g.UIWindowLevelAlert : undefined,
1105
+ };
1106
+ }
1107
+ /**
1108
+ * Walks UIApplication.sharedApplication windows and returns the first active
1109
+ * UIWindowScene we can locate. On iOS 13+ every UIWindow is attached to a
1110
+ * scene, and we must initialise our overlay window the same way or the OS
1111
+ * will silently refuse to render it. Returns null when no scene is found
1112
+ * (older iOS versions or non-UI environments).
1113
+ */
1114
+ function findActiveWindowScene(host) {
1115
+ try {
1116
+ const app = host.UIApplication.sharedApplication;
1117
+ const windows = app?.windows;
1118
+ if (!windows || typeof windows.count !== 'number')
1119
+ return null;
1120
+ for (let i = 0; i < windows.count; i++) {
1121
+ const w = windows.objectAtIndex(i);
1122
+ const scene = w && w.windowScene;
1123
+ if (scene)
1124
+ return scene;
1125
+ }
1126
+ }
1127
+ catch { }
1128
+ return null;
1129
+ }
1130
+ function buildIosOverlayRefs(state) {
1131
+ const host = getIosOverlayHost();
1132
+ if (!host)
1133
+ return null;
1134
+ // Without a scene we can't build a modern UIWindow that actually renders.
1135
+ // Fall back to the in-tree overlay rather than show nothing.
1136
+ const scene = findActiveWindowScene(host);
1137
+ if (!scene) {
1138
+ if (state.verbose) {
1139
+ console.info('[ns-hmr-overlay] no active UIWindowScene; skipping iOS overlay promotion');
1140
+ }
1141
+ return null;
1142
+ }
1143
+ try {
1144
+ const { UIWindow, UIViewController, UIView, UILabel, UIColor, UIFont } = host;
1145
+ const window = UIWindow.alloc().initWithWindowScene(scene);
1146
+ window.windowLevel = computeIosOverlayWindowLevel(host.UIWindowLevelAlert ?? null);
1147
+ window.backgroundColor = UIColor.clearColor;
1148
+ window.hidden = true;
1149
+ const controller = UIViewController.new();
1150
+ controller.view.backgroundColor = UIColor.clearColor;
1151
+ window.rootViewController = controller;
1152
+ // UIViewAutoresizing bit masks. We mirror the UIKit constants here to
1153
+ // avoid depending on symbols the metadata bridge does not always
1154
+ // expose as top-level globals.
1155
+ const FLEXIBLE_LEFT_MARGIN = 1 << 0;
1156
+ const FLEXIBLE_WIDTH = 1 << 1;
1157
+ const FLEXIBLE_RIGHT_MARGIN = 1 << 2;
1158
+ const FLEXIBLE_TOP_MARGIN = 1 << 3;
1159
+ const FLEXIBLE_HEIGHT = 1 << 4;
1160
+ const FLEXIBLE_BOTTOM_MARGIN = 1 << 5;
1161
+ const backdrop = UIView.new();
1162
+ backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0, 0, 0.35);
1163
+ backdrop.autoresizingMask = FLEXIBLE_WIDTH | FLEXIBLE_HEIGHT;
1164
+ controller.view.addSubview(backdrop);
1165
+ const panel = UIView.new();
1166
+ panel.backgroundColor = UIColor.whiteColor;
1167
+ panel.autoresizingMask = FLEXIBLE_LEFT_MARGIN | FLEXIBLE_RIGHT_MARGIN | FLEXIBLE_TOP_MARGIN | FLEXIBLE_BOTTOM_MARGIN;
1168
+ try {
1169
+ panel.layer.cornerRadius = 14;
1170
+ panel.layer.masksToBounds = true;
1171
+ }
1172
+ catch { }
1173
+ controller.view.addSubview(panel);
1174
+ const titleLabel = UILabel.new();
1175
+ titleLabel.numberOfLines = 0;
1176
+ titleLabel.textAlignment = 1; // NSTextAlignmentCenter
1177
+ titleLabel.font = UIFont.boldSystemFontOfSize(16);
1178
+ titleLabel.textColor = UIColor.blackColor;
1179
+ panel.addSubview(titleLabel);
1180
+ const statusLabel = UILabel.new();
1181
+ statusLabel.numberOfLines = 0;
1182
+ statusLabel.textAlignment = 1;
1183
+ statusLabel.font = UIFont.systemFontOfSize(13);
1184
+ statusLabel.textColor = UIColor.darkGrayColor;
1185
+ panel.addSubview(statusLabel);
1186
+ // Subtle drop-shadow so the toast chip reads against light app
1187
+ // content (white-on-white is invisible). The error / centered
1188
+ // branches still get the dim backdrop, so the shadow is mostly
1189
+ // a no-op for them — but it's a one-time setup.
1190
+ try {
1191
+ panel.layer.shadowColor = UIColor.blackColor.CGColor;
1192
+ panel.layer.shadowOpacity = 0.18;
1193
+ panel.layer.shadowRadius = 8;
1194
+ panel.layer.shadowOffset = { width: 0, height: 2 };
1195
+ panel.layer.masksToBounds = false;
1196
+ }
1197
+ catch { }
1198
+ // `wasVisible` / `currentPosition` are mutated by
1199
+ // applySnapshotToIosRefs when the snapshot triggers a slide-in
1200
+ // or slide-out. They start in the "hidden" state so the very
1201
+ // first visible snapshot animates in cleanly.
1202
+ return {
1203
+ window,
1204
+ controller,
1205
+ backdrop,
1206
+ panel,
1207
+ titleLabel,
1208
+ statusLabel,
1209
+ wasVisible: false,
1210
+ currentPosition: getHmrDevOverlayPosition(),
1211
+ };
1212
+ }
1213
+ catch (err) {
1214
+ console.warn('[ns-hmr-overlay] iOS overlay construction failed:', err?.message || err);
1215
+ return null;
1216
+ }
1217
+ }
1218
+ function ensureIosOverlayRefs(state) {
1219
+ if (state.iosRefs)
1220
+ return state.iosRefs;
1221
+ if (state.iosBuildFailed)
1222
+ return null;
1223
+ const built = buildIosOverlayRefs(state);
1224
+ if (built) {
1225
+ state.iosRefs = built;
1226
+ }
1227
+ else {
1228
+ // Remember failure so we don't hammer construction on every snapshot
1229
+ // update — the in-tree path will take over for this session.
1230
+ state.iosBuildFailed = true;
1231
+ }
1232
+ return state.iosRefs;
1233
+ }
1234
+ function layoutIosOverlayRefs(refs, position) {
1235
+ try {
1236
+ const bounds = refs.controller.view.bounds;
1237
+ const viewWidth = Number(bounds?.size?.width) || 0;
1238
+ const viewHeight = Number(bounds?.size?.height) || 0;
1239
+ const raw = refs.controller.view.safeAreaInsets;
1240
+ const safeInsets = raw
1241
+ ? {
1242
+ top: Number(raw.top) || 0,
1243
+ bottom: Number(raw.bottom) || 0,
1244
+ left: Number(raw.left) || 0,
1245
+ right: Number(raw.right) || 0,
1246
+ }
1247
+ : { top: 0, bottom: 0, left: 0, right: 0 };
1248
+ // Ask UIKit what the labels want given the panel inner width. We use a
1249
+ // generous height bound so nothing clips on long reconnect strings.
1250
+ const panelPadding = 16;
1251
+ const horizontalMargin = 24;
1252
+ const maxPanelWidth = 340;
1253
+ const innerWidth = Math.max(0, Math.min(maxPanelWidth, viewWidth - 2 * horizontalMargin - safeInsets.left - safeInsets.right) - 2 * panelPadding);
1254
+ const titleFit = refs.titleLabel.sizeThatFits({ width: innerWidth, height: 10000 }) || { height: 0 };
1255
+ const statusFit = refs.statusLabel.sizeThatFits({ width: innerWidth, height: 10000 }) || { height: 0 };
1256
+ const layout = computeIosOverlayLayout({
1257
+ viewWidth,
1258
+ viewHeight,
1259
+ safeInsets,
1260
+ titleHeight: Number(titleFit.height) || 0,
1261
+ statusHeight: Number(statusFit.height) || 0,
1262
+ maxPanelWidth,
1263
+ horizontalMargin,
1264
+ panelPadding,
1265
+ position,
1266
+ });
1267
+ const toCgRect = (rect) => ({
1268
+ origin: { x: rect.x, y: rect.y },
1269
+ size: { width: rect.width, height: rect.height },
1270
+ });
1271
+ refs.backdrop.frame = toCgRect(layout.backdrop);
1272
+ refs.panel.frame = toCgRect(layout.panel);
1273
+ refs.titleLabel.frame = toCgRect(layout.title);
1274
+ refs.statusLabel.frame = toCgRect(layout.status);
1275
+ return layout;
1276
+ }
1277
+ catch (err) {
1278
+ console.warn('[ns-hmr-overlay] iOS overlay layout failed:', err?.message || err);
1279
+ return null;
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Slide-in animation for the iOS toast panel. Off-screen start frame
1284
+ * lives just above (top) or below (bottom) the visible area; the panel
1285
+ * snaps to its target frame with a spring so the motion feels physical
1286
+ * without the heavy "settle" overshoot of a hard spring (damping 0.85
1287
+ * lands quickly with a small overshoot).
1288
+ */
1289
+ function animateIosPanelIn(refs, position, layout) {
1290
+ const g = getOverlayGlobal();
1291
+ const UIView = g?.UIView;
1292
+ if (!UIView)
1293
+ return;
1294
+ try {
1295
+ const targetFrame = {
1296
+ origin: { x: layout.panel.x, y: layout.panel.y },
1297
+ size: { width: layout.panel.width, height: layout.panel.height },
1298
+ };
1299
+ // Off-screen start: distance includes a small fudge so the
1300
+ // shadow blur tail isn't visible at t=0.
1301
+ const startY = position === 'bottom' ? layout.backdrop.height + 24 : -(layout.panel.height + 24);
1302
+ refs.panel.frame = {
1303
+ origin: { x: layout.panel.x, y: startY },
1304
+ size: { width: layout.panel.width, height: layout.panel.height },
1305
+ };
1306
+ refs.panel.alpha = 0;
1307
+ try {
1308
+ if (typeof UIView.animateWithDurationDelayUsingSpringWithDampingInitialSpringVelocityOptionsAnimationsCompletion === 'function') {
1309
+ UIView.animateWithDurationDelayUsingSpringWithDampingInitialSpringVelocityOptionsAnimationsCompletion(0.42, 0, 0.85, 0.7, 0, () => {
1310
+ refs.panel.frame = targetFrame;
1311
+ refs.panel.alpha = 1;
1312
+ }, null);
1313
+ }
1314
+ else if (typeof UIView.animateWithDurationAnimations === 'function') {
1315
+ UIView.animateWithDurationAnimations(0.32, () => {
1316
+ refs.panel.frame = targetFrame;
1317
+ refs.panel.alpha = 1;
1318
+ });
1319
+ }
1320
+ else {
1321
+ refs.panel.frame = targetFrame;
1322
+ refs.panel.alpha = 1;
1323
+ }
1324
+ }
1325
+ catch {
1326
+ refs.panel.frame = targetFrame;
1327
+ refs.panel.alpha = 1;
1328
+ }
1329
+ }
1330
+ catch { }
1331
+ }
1332
+ /**
1333
+ * Slide-out animation for the iOS toast panel. Mirrors animateIosPanelIn:
1334
+ * the panel travels to the nearest off-screen edge while fading out so
1335
+ * the dismissal still feels intentional even on fast HMR cycles.
1336
+ */
1337
+ function animateIosPanelOut(refs, position, onComplete) {
1338
+ const g = getOverlayGlobal();
1339
+ const UIView = g?.UIView;
1340
+ const currentFrame = refs.panel?.frame;
1341
+ if (!UIView || !currentFrame) {
1342
+ onComplete();
1343
+ return;
1344
+ }
1345
+ try {
1346
+ const bounds = refs.controller?.view?.bounds;
1347
+ const viewHeight = Number(bounds?.size?.height) || 0;
1348
+ const targetY = position === 'bottom' ? viewHeight + 24 : -(Number(currentFrame.size?.height) + 24);
1349
+ const startFrame = currentFrame;
1350
+ const targetFrame = {
1351
+ origin: { x: Number(startFrame.origin?.x) || 0, y: targetY },
1352
+ size: startFrame.size,
1353
+ };
1354
+ try {
1355
+ if (typeof UIView.animateWithDurationDelayOptionsAnimationsCompletion === 'function') {
1356
+ // UIViewAnimationOptionCurveEaseIn = 1 << 16 — accelerate
1357
+ // out so the dismissal doesn't drag on screen.
1358
+ UIView.animateWithDurationDelayOptionsAnimationsCompletion(0.22, 0, 1 << 16, () => {
1359
+ refs.panel.frame = targetFrame;
1360
+ refs.panel.alpha = 0;
1361
+ }, () => onComplete());
1362
+ }
1363
+ else if (typeof UIView.animateWithDurationAnimationsCompletion === 'function') {
1364
+ UIView.animateWithDurationAnimationsCompletion(0.22, () => {
1365
+ refs.panel.frame = targetFrame;
1366
+ refs.panel.alpha = 0;
1367
+ }, () => onComplete());
1368
+ }
1369
+ else {
1370
+ refs.panel.alpha = 0;
1371
+ onComplete();
1372
+ }
1373
+ }
1374
+ catch {
1375
+ refs.panel.alpha = 0;
1376
+ onComplete();
1377
+ }
1378
+ }
1379
+ catch {
1380
+ onComplete();
1381
+ }
1382
+ }
1383
+ function applySnapshotToIosRefs(refs, snapshot) {
1384
+ if (!refs)
1385
+ return false;
1386
+ try {
1387
+ // 'update' mode rides the same dedicated UIWindow as
1388
+ // 'connection' so the HMR apply overlay always stacks above
1389
+ // modals/sheets/system alerts. The window is constructed
1390
+ // lazily (ensureIosOverlayRefs) and reused for the lifetime of
1391
+ // the dev session.
1392
+ const visible = snapshot.visible && (snapshot.mode === 'connection' || snapshot.mode === 'update');
1393
+ const wasVisible = !!refs.wasVisible;
1394
+ const position = getHmrDevOverlayPosition();
1395
+ const previousPosition = refs.currentPosition;
1396
+ const isToast = position !== 'center';
1397
+ // Touches pass through the overlay window in toast mode so
1398
+ // the user can keep tapping the app while the HMR chip is
1399
+ // shown. In centered mode we keep the blocking
1400
+ // behaviour (the dim backdrop is itself a hint to wait).
1401
+ try {
1402
+ refs.window.userInteractionEnabled = !isToast;
1403
+ }
1404
+ catch { }
1405
+ if (!visible) {
1406
+ // Animate out before hiding the window so the dismissal
1407
+ // has a discoverable motion. Only animate when previously
1408
+ // visible and in toast mode — centered modal hides instantly.
1409
+ if (wasVisible && isToast) {
1410
+ animateIosPanelOut(refs, previousPosition, () => {
1411
+ try {
1412
+ refs.window.hidden = true;
1413
+ }
1414
+ catch { }
1415
+ });
1416
+ }
1417
+ else {
1418
+ refs.window.hidden = true;
1419
+ }
1420
+ refs.wasVisible = false;
1421
+ refs.currentPosition = position;
1422
+ return true;
1423
+ }
1424
+ refs.window.hidden = false;
1425
+ refs.titleLabel.text = snapshot.title || '';
1426
+ refs.statusLabel.text = formatStatusText(snapshot);
1427
+ const host = getIosOverlayHost();
1428
+ if (host) {
1429
+ const { UIColor } = host;
1430
+ const isError = snapshot.tone === 'error';
1431
+ const isSuccess = snapshot.tone === 'success';
1432
+ try {
1433
+ if (isError) {
1434
+ // Red panel + dark red text (existing UX).
1435
+ refs.panel.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(1, 0.96, 0.96, 1);
1436
+ refs.titleLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.7, 0.1, 0.06, 1);
1437
+ refs.statusLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.7, 0.1, 0.06, 0.9);
1438
+ }
1439
+ else if (isSuccess) {
1440
+ // Slightly more saturated green panel + dark-green
1441
+ // text. The previous 0.94/0.99/0.95 background was
1442
+ // nearly indistinguishable from white on most
1443
+ // devices; this bump keeps long detail strings
1444
+ // readable while making the apply event obviously
1445
+ // "happening right now".
1446
+ refs.panel.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0.9, 0.97, 0.91, 1);
1447
+ refs.titleLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.05, 0.43, 0.18, 1);
1448
+ refs.statusLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.05, 0.43, 0.18, 1);
1449
+ }
1450
+ else {
1451
+ // Default (info / warn) — existing connection look.
1452
+ refs.panel.backgroundColor = UIColor.whiteColor;
1453
+ refs.titleLabel.textColor = UIColor.blackColor;
1454
+ refs.statusLabel.textColor = UIColor.darkGrayColor;
1455
+ }
1456
+ // Backdrop dims only in centered mode; toast mode keeps
1457
+ // the rest of the app fully visible/usable. Errors get
1458
+ // a slightly stronger dim in centered mode because the
1459
+ // user MUST notice them.
1460
+ if (isToast) {
1461
+ refs.backdrop.backgroundColor = UIColor.clearColor;
1462
+ }
1463
+ else if (isError) {
1464
+ refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0, 0, 0.35);
1465
+ }
1466
+ else if (isSuccess) {
1467
+ refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0.15, 0.05, 0.28);
1468
+ }
1469
+ else {
1470
+ refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0, 0, 0.35);
1471
+ }
1472
+ }
1473
+ catch { }
1474
+ }
1475
+ const layout = layoutIosOverlayRefs(refs, position);
1476
+ // Slide-in animation only fires on the actual hidden→visible
1477
+ // transition (or on a position swap — e.g. dev toggling top
1478
+ // to bottom mid-cycle). Subsequent updates within the same
1479
+ // visible cycle just refresh text/colours without re-animating.
1480
+ const positionChanged = previousPosition !== position;
1481
+ const justAppeared = !wasVisible || positionChanged;
1482
+ if (justAppeared && isToast && layout) {
1483
+ animateIosPanelIn(refs, position, layout);
1484
+ }
1485
+ else if (justAppeared && !isToast) {
1486
+ // Centered modal: ensure alpha is reset to 1 in case a
1487
+ // previous toast-mode dismissal left it at 0.
1488
+ try {
1489
+ refs.panel.alpha = 1;
1490
+ }
1491
+ catch { }
1492
+ }
1493
+ refs.wasVisible = true;
1494
+ refs.currentPosition = position;
1495
+ return true;
1496
+ }
1497
+ catch (err) {
1498
+ console.warn('[ns-hmr-overlay] iOS overlay apply failed:', err?.message || err);
1499
+ return false;
1500
+ }
596
1501
  }
597
1502
  function applyRuntimeSnapshot(snapshot) {
598
1503
  const state = getRuntimeState();
@@ -602,15 +1507,108 @@ function applyRuntimeSnapshot(snapshot) {
602
1507
  updateBootStatusLabel(snapshot);
603
1508
  }
604
1509
  applySnapshotToBootRefs(state.bootRefs, snapshot);
605
- if (snapshot.visible && snapshot.mode === 'connection') {
606
- const liveRefs = ensureLiveOverlayRefs(snapshot);
607
- applySnapshotToLiveRefs(liveRefs, snapshot);
1510
+ // prefer the dedicated UIWindow
1511
+ // path so the live/update overlays always stack on top of modals,
1512
+ // sheets, and other windows. Fall back to the in-tree overlay when
1513
+ // iOS APIs aren't available (Android, tests, or when scene
1514
+ // construction fails).
1515
+ let handledByIos = false;
1516
+ // Both 'connection' and 'update' use the small-panel surface
1517
+ // (UIWindow on iOS, in-tree overlay everywhere else). 'boot' uses
1518
+ // the placeholder root via applySnapshotToBootRefs above; 'hidden'
1519
+ // hides everything.
1520
+ const wantsOverlay = snapshot.visible && (snapshot.mode === 'connection' || snapshot.mode === 'update');
1521
+ if (getIosOverlayHost()) {
1522
+ if (wantsOverlay) {
1523
+ const iosRefs = ensureIosOverlayRefs(state);
1524
+ handledByIos = applySnapshotToIosRefs(iosRefs, snapshot);
1525
+ }
1526
+ else if (state.iosRefs) {
1527
+ handledByIos = applySnapshotToIosRefs(state.iosRefs, snapshot);
1528
+ }
608
1529
  }
609
- else {
610
- applySnapshotToLiveRefs(state.liveRefs, snapshot);
1530
+ if (!handledByIos) {
1531
+ if (wantsOverlay) {
1532
+ const liveRefs = ensureLiveOverlayRefs(snapshot);
1533
+ applySnapshotToLiveRefs(liveRefs, snapshot);
1534
+ }
1535
+ else {
1536
+ applySnapshotToLiveRefs(state.liveRefs, snapshot);
1537
+ }
611
1538
  }
612
1539
  return state.snapshot;
613
1540
  }
1541
+ // How long the 'complete' frame stays on screen before we auto-hide.
1542
+ // The original 350ms was too tight: many HMR cycles complete in
1543
+ // 50–250ms, so the *total* overlay lifetime (received → complete +
1544
+ // 350ms) was often under 500ms, which is faster than the human eye
1545
+ // can comfortably register. 600ms gives the user time to read the
1546
+ // "Total Xms" line and confirm visually that something happened.
1547
+ const UPDATE_AUTO_HIDE_MS = 600;
1548
+ // Minimum perceptible duration for an entire update overlay cycle
1549
+ // (from 'received' to hide). If the cycle finished in 50ms (e.g., a
1550
+ // tiny HTML edit on a warm cache), we still hold for ~MIN_VISIBLE_MS
1551
+ // total before hiding so the overlay is actually seen. Combined with
1552
+ // UPDATE_AUTO_HIDE_MS, the *effective* hold-after-complete =
1553
+ // max(UPDATE_AUTO_HIDE_MS, MIN_VISIBLE_MS - elapsed-since-received).
1554
+ const UPDATE_MIN_VISIBLE_MS = 800;
1555
+ function clearUpdateAutoHideTimer(state) {
1556
+ if (state.updateAutoHideTimer) {
1557
+ try {
1558
+ clearTimeout(state.updateAutoHideTimer);
1559
+ }
1560
+ catch { }
1561
+ state.updateAutoHideTimer = null;
1562
+ }
1563
+ }
1564
+ function scheduleUpdateAutoHide(state) {
1565
+ clearUpdateAutoHideTimer(state);
1566
+ // Compute how much longer we need to hold the overlay so that the
1567
+ // total cycle visibility is at least UPDATE_MIN_VISIBLE_MS. For
1568
+ // fast cycles (50ms reboot) this stretches the hide; for slow
1569
+ // cycles (>UPDATE_MIN_VISIBLE_MS) it falls back to the standard
1570
+ // UPDATE_AUTO_HIDE_MS so we don't truncate the celebratory hold.
1571
+ const startedAt = state.updateCycleStartedAt || 0;
1572
+ const elapsed = startedAt > 0 ? Math.max(0, Date.now() - startedAt) : 0;
1573
+ const minRemainder = elapsed > 0 ? Math.max(0, UPDATE_MIN_VISIBLE_MS - elapsed) : UPDATE_MIN_VISIBLE_MS;
1574
+ const holdMs = Math.max(UPDATE_AUTO_HIDE_MS, minRemainder);
1575
+ try {
1576
+ state.updateAutoHideTimer = setTimeout(() => {
1577
+ state.updateAutoHideTimer = null;
1578
+ // Critical: only auto-hide if we're still on the 'complete'
1579
+ // frame. If a new HMR cycle has rotated the snapshot back
1580
+ // to 'update' / 'received' (e.g., user saved twice in
1581
+ // quick succession), the new cycle owns the overlay and
1582
+ // our timer must not steal it.
1583
+ const current = state.snapshot;
1584
+ if (current.mode === 'update' && current.tone === 'success' && current.progress === 100) {
1585
+ state.updateCycleStartedAt = 0;
1586
+ applyRuntimeSnapshot({ ...DEFAULT_SNAPSHOT });
1587
+ }
1588
+ }, holdMs);
1589
+ }
1590
+ catch {
1591
+ // setTimeout missing (extremely rare; some test envs). Fall
1592
+ // back to immediate hide so we never leave the overlay visible
1593
+ // forever after a 'complete'.
1594
+ state.updateCycleStartedAt = 0;
1595
+ applyRuntimeSnapshot({ ...DEFAULT_SNAPSHOT });
1596
+ }
1597
+ }
1598
+ function logUpdateStageTransition(state, stage, info) {
1599
+ if (!state.verbose)
1600
+ return;
1601
+ try {
1602
+ const detail = info?.detail || '';
1603
+ const progress = typeof info?.progress === 'number' ? info.progress : null;
1604
+ const progressTag = progress !== null ? ` (${Math.round(progress)}%)` : '';
1605
+ // Single-line breadcrumb so a developer can correlate
1606
+ // overlay frames with the [ns-hmr][angular] timing log when
1607
+ // debugging "I don't see the overlay" reports.
1608
+ console.info(`[ns-hmr-overlay] update stage=${stage}${progressTag}${detail ? ` detail=${detail}` : ''}`);
1609
+ }
1610
+ catch { }
1611
+ }
614
1612
  function createOverlayApi() {
615
1613
  return {
616
1614
  ensureBootPage(verbose) {
@@ -626,12 +1624,74 @@ function createOverlayApi() {
626
1624
  return state.bootRefs?.page || null;
627
1625
  },
628
1626
  setBootStage(stage, info) {
629
- return applyRuntimeSnapshot(createBootOverlaySnapshot(stage, info));
1627
+ // A boot transition cancels any pending HMR auto-hide so
1628
+ // the boot phase always wins.
1629
+ const state = getRuntimeState();
1630
+ clearUpdateAutoHideTimer(state);
1631
+ state.updateCycleStartedAt = 0;
1632
+ const next = createBootOverlaySnapshot(stage, info);
1633
+ // Monotonic boot-progress ratchet: boot stages can fire out of
1634
+ // order across boot paths (native `__nsStartDevSession` vs the
1635
+ // http-bootloader fallback) and individual bases were tuned
1636
+ // independently, so clamp boot→boot transitions to never go
1637
+ // backwards. Non-boot snapshots (error/ready) bypass — they
1638
+ // genuinely want to reset the visual.
1639
+ if (next.mode === 'boot' && state.snapshot.mode === 'boot' && typeof next.progress === 'number' && typeof state.snapshot.progress === 'number' && next.progress < state.snapshot.progress) {
1640
+ next.progress = state.snapshot.progress;
1641
+ }
1642
+ return applyRuntimeSnapshot(next);
630
1643
  },
631
1644
  setConnectionStage(stage, info) {
1645
+ const state = getRuntimeState();
1646
+ clearUpdateAutoHideTimer(state);
1647
+ state.updateCycleStartedAt = 0;
632
1648
  return applyRuntimeSnapshot(createConnectionOverlaySnapshot(stage, info));
633
1649
  },
1650
+ setUpdateStage(stage, info) {
1651
+ const state = getRuntimeState();
1652
+ // Each new in-progress stage cancels any pending auto-hide
1653
+ // from a previous cycle. Without this, two saves in quick
1654
+ // succession could see cycle-2's progress overlay yanked
1655
+ // off by cycle-1's already-scheduled hide.
1656
+ clearUpdateAutoHideTimer(state);
1657
+ // Stamp the cycle start on 'received', but distinguish
1658
+ // between two cases:
1659
+ //
1660
+ // (a) Re-assertion of the SAME cycle (e.g., the server
1661
+ // emits both `ns:hmr-pending` AND `ns:angular-update`,
1662
+ // both of which call `setUpdateStage('received')`).
1663
+ // We must PRESERVE the original timestamp so the
1664
+ // minimum-visible-window math measures the FIRST
1665
+ // 'received' the user actually saw.
1666
+ //
1667
+ // (b) Genuinely-new cycle starting either from a hidden
1668
+ // overlay OR while the previous cycle is still on
1669
+ // its 'complete' frame (pre auto-hide). In both
1670
+ // sub-cases we MUST stamp a fresh start so the
1671
+ // new cycle's auto-hide math is sane.
1672
+ //
1673
+ // We treat the previous snapshot as "in-progress for the
1674
+ // same cycle" iff mode==='update' AND progress!==100.
1675
+ // 'complete' frames are a sign that the cycle finished;
1676
+ // any subsequent 'received' is a NEW cycle.
1677
+ if (stage === 'received') {
1678
+ const prev = state.snapshot;
1679
+ const isMidCycleReassertion = prev.mode === 'update' && prev.progress !== 100;
1680
+ if (!isMidCycleReassertion) {
1681
+ state.updateCycleStartedAt = Date.now();
1682
+ }
1683
+ }
1684
+ logUpdateStageTransition(state, stage, info);
1685
+ const snapshot = applyRuntimeSnapshot(createUpdateOverlaySnapshot(stage, info));
1686
+ if (stage === 'complete') {
1687
+ scheduleUpdateAutoHide(state);
1688
+ }
1689
+ return snapshot;
1690
+ },
634
1691
  hide() {
1692
+ const state = getRuntimeState();
1693
+ clearUpdateAutoHideTimer(state);
1694
+ state.updateCycleStartedAt = 0;
635
1695
  applyRuntimeSnapshot({ ...DEFAULT_SNAPSHOT });
636
1696
  },
637
1697
  getSnapshot() {
@@ -657,6 +1717,14 @@ export function setHmrBootStage(stage, info) {
657
1717
  export function setHmrConnectionStage(stage, info) {
658
1718
  return ensureHmrDevOverlayRuntimeInstalled().setConnectionStage(stage, info);
659
1719
  }
1720
+ // Public entry point for driving the HMR-applying overlay. Callers
1721
+ // walk through stages (received → evicting → reimporting → rebooting
1722
+ // → complete); 'complete' auto-hides after a short interval.
1723
+ // Soft-fails (no-op) if the runtime overlay was never installed
1724
+ // (e.g., production builds, test environments).
1725
+ export function setHmrUpdateStage(stage, info) {
1726
+ return ensureHmrDevOverlayRuntimeInstalled().setUpdateStage(stage, info);
1727
+ }
660
1728
  export function hideHmrDevOverlay(reason) {
661
1729
  void reason;
662
1730
  ensureHmrDevOverlayRuntimeInstalled().hide(reason);