@nativescript/vite 8.0.0-alpha.12 → 8.0.0-alpha.14

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 (49) hide show
  1. package/configuration/angular.js +12 -8
  2. package/configuration/angular.js.map +1 -1
  3. package/helpers/main-entry.d.ts +2 -1
  4. package/helpers/main-entry.js +27 -0
  5. package/helpers/main-entry.js.map +1 -1
  6. package/hmr/client/css-handler.d.ts +1 -0
  7. package/hmr/client/css-handler.js +33 -5
  8. package/hmr/client/css-handler.js.map +1 -1
  9. package/hmr/client/css-update-overlay.d.ts +18 -0
  10. package/hmr/client/css-update-overlay.js +27 -0
  11. package/hmr/client/css-update-overlay.js.map +1 -0
  12. package/hmr/client/index.js +20 -0
  13. package/hmr/client/index.js.map +1 -1
  14. package/hmr/entry-runtime.d.ts +1 -0
  15. package/hmr/entry-runtime.js +62 -5
  16. package/hmr/entry-runtime.js.map +1 -1
  17. package/hmr/frameworks/angular/client/index.js +16 -16
  18. package/hmr/frameworks/angular/client/index.js.map +1 -1
  19. package/hmr/server/vite-plugin.js +24 -4
  20. package/hmr/server/vite-plugin.js.map +1 -1
  21. package/hmr/server/websocket-angular-hot-update.js +3 -3
  22. package/hmr/server/websocket-angular-hot-update.js.map +1 -1
  23. package/hmr/server/websocket-core-bridge.js +12 -1
  24. package/hmr/server/websocket-core-bridge.js.map +1 -1
  25. package/hmr/server/websocket-css-hot-update.d.ts +33 -0
  26. package/hmr/server/websocket-css-hot-update.js +65 -0
  27. package/hmr/server/websocket-css-hot-update.js.map +1 -0
  28. package/hmr/server/websocket-served-module-helpers.js +19 -6
  29. package/hmr/server/websocket-served-module-helpers.js.map +1 -1
  30. package/hmr/server/websocket.d.ts +1 -0
  31. package/hmr/server/websocket.js +87 -48
  32. package/hmr/server/websocket.js.map +1 -1
  33. package/hmr/shared/runtime/boot-placeholder-ui.d.ts +69 -0
  34. package/hmr/shared/runtime/boot-placeholder-ui.js +101 -0
  35. package/hmr/shared/runtime/boot-placeholder-ui.js.map +1 -0
  36. package/hmr/shared/runtime/boot-progress.d.ts +40 -0
  37. package/hmr/shared/runtime/boot-progress.js +128 -0
  38. package/hmr/shared/runtime/boot-progress.js.map +1 -0
  39. package/hmr/shared/runtime/boot-timeline.d.ts +1 -0
  40. package/hmr/shared/runtime/boot-timeline.js +1 -0
  41. package/hmr/shared/runtime/boot-timeline.js.map +1 -1
  42. package/hmr/shared/runtime/dev-overlay.d.ts +30 -2
  43. package/hmr/shared/runtime/dev-overlay.js +539 -43
  44. package/hmr/shared/runtime/dev-overlay.js.map +1 -1
  45. package/hmr/shared/runtime/root-placeholder.js +317 -47
  46. package/hmr/shared/runtime/root-placeholder.js.map +1 -1
  47. package/hmr/shared/runtime/session-bootstrap.js +132 -18
  48. package/hmr/shared/runtime/session-bootstrap.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { BOOT_PLACEHOLDER_MOTION, computeBootProgressFillScale, formatBootDetailLine, formatBootPrimaryLine } from './boot-placeholder-ui.js';
2
+ const DEFAULT_OVERLAY_POSITION = 'top';
1
3
  const BOOT_TITLE = 'NativeScript Vite preparing dev session...';
2
4
  const DEFAULT_SNAPSHOT = {
3
5
  visible: false,
@@ -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__) {
@@ -146,7 +179,11 @@ export function createBootOverlaySnapshot(stage, info) {
146
179
  badge: 'BOOT',
147
180
  title: BOOT_TITLE,
148
181
  phase: 'Importing the app entry',
149
- 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,
150
187
  busy: true,
151
188
  blocking: true,
152
189
  tone: 'info',
@@ -533,10 +570,28 @@ function findBootStatusLabel() {
533
570
  catch { }
534
571
  return null;
535
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
+ }
536
581
  function updateBootStatusLabel(snapshot) {
537
- const newText = formatStatusText(snapshot) || 'Preparing the HTTP HMR bootstrap (4%)';
538
582
  const statusLabel = findBootStatusLabel();
583
+ const detailLabel = findBootDetailLabel();
584
+ const progressFill = findBootProgressFill();
539
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%)';
540
595
  if (!statusLabel) {
541
596
  if (activityIndicator) {
542
597
  try {
@@ -545,11 +600,16 @@ function updateBootStatusLabel(snapshot) {
545
600
  }
546
601
  catch { }
547
602
  }
603
+ applyBootProgressFill(progressFill, snapshot);
548
604
  return;
549
605
  }
550
606
  try {
551
- statusLabel.text = newText;
552
- statusLabel.color = asColor(snapshot.tone === 'error' ? '#b41810e6' : '#563e3fb1');
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);
553
613
  if (typeof statusLabel.requestLayout === 'function') {
554
614
  statusLabel.requestLayout();
555
615
  }
@@ -559,6 +619,15 @@ function updateBootStatusLabel(snapshot) {
559
619
  }
560
620
  }
561
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);
562
631
  if (activityIndicator) {
563
632
  try {
564
633
  activityIndicator.busy = !!snapshot.busy;
@@ -567,6 +636,50 @@ function updateBootStatusLabel(snapshot) {
567
636
  catch { }
568
637
  }
569
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
+ }
570
683
  function resolveActivePage() {
571
684
  try {
572
685
  const Frame = resolveCoreExport('Frame');
@@ -603,8 +716,18 @@ function buildLiveOverlayView(snapshot) {
603
716
  overlay.height = '100%';
604
717
  overlay.horizontalAlignment = 'stretch';
605
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 { }
606
727
  const panel = new StackLayout();
607
728
  panel.horizontalAlignment = 'center';
729
+ // Vertical alignment is overridden in applySnapshotToLiveRefs
730
+ // based on getHmrDevOverlayPosition(); 'middle' is the default
608
731
  panel.verticalAlignment = 'middle';
609
732
  panel.width = 320;
610
733
  panel.margin = 24;
@@ -626,6 +749,8 @@ function buildLiveOverlayView(snapshot) {
626
749
  overlay,
627
750
  titleLabel,
628
751
  statusLabel,
752
+ wasVisible: false,
753
+ currentPosition: getHmrDevOverlayPosition(),
629
754
  };
630
755
  applySnapshotToLiveRefs(refs, snapshot);
631
756
  return refs;
@@ -683,32 +808,42 @@ function applySnapshotToLiveRefs(refs, snapshot) {
683
808
  return;
684
809
  }
685
810
  // 'update' mode shares the live (in-tree) overlay chrome with
686
- // 'connection'. Both render a centered panel inside the page;
687
- // only the colours and text change with the snapshot's tone.
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.
688
814
  const visible = snapshot.visible && (snapshot.mode === 'connection' || snapshot.mode === 'update');
689
- refs.overlay.visibility = visible ? 'visible' : 'collapse';
690
- // Backdrop tints by tone:
691
- // error → red wash (matches existing UX)
692
- // success richer green wash; the previous 18% alpha was so
693
- // subtle on light app backgrounds that users couldn't
694
- // tell the overlay had even fired. 50% alpha keeps the
695
- // underlying app legible while making the apply event
696
- // unmistakable during the (often <300ms) cycle.
697
- // default → warm orange (existing connection-overlay look)
698
- const overlayBg = snapshot.tone === 'error' ? '#b4181068' : snapshot.tone === 'success' ? '#1f883d80' : '#a1771683';
699
- refs.overlay.backgroundColor = asColor(overlayBg);
815
+ const wasVisible = !!refs.wasVisible;
816
+ const position = getHmrDevOverlayPosition();
817
+ const previousPosition = refs.currentPosition || position;
818
+ const isToast = position !== 'center';
700
819
  refs.titleLabel.text = snapshot.title;
820
+ refs.statusLabel.text = formatStatusText(snapshot);
701
821
  const textColor = snapshot.tone === 'error' ? '#b41810e6' : snapshot.tone === 'success' ? '#0e6e2fff' : '#563e3fb1';
702
822
  refs.titleLabel.color = asColor(textColor);
703
- refs.statusLabel.text = formatStatusText(snapshot);
704
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;
705
844
  try {
706
- const panel = refs.titleLabel.parent;
845
+ panel = refs.titleLabel.parent;
707
846
  if (panel) {
708
- // Slightly richer green-tinted panel for HMR-apply so the
709
- // title/status text reads at a glance against the brighter
710
- // backdrop wash. White panel for connection/error keeps
711
- // existing UX intact.
712
847
  const panelBg = snapshot.tone === 'success' ? '#E6F8E9FF' : '#FFFFFFFF';
713
848
  panel.backgroundColor = asColor(panelBg);
714
849
  panel.opacity = 1;
@@ -717,9 +852,142 @@ function applySnapshotToLiveRefs(refs, snapshot) {
717
852
  panel.borderRadius = 12;
718
853
  }
719
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 { }
720
874
  }
721
875
  }
722
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
+ }
723
991
  }
724
992
  // pure helpers for iOS window promotion. Factored out so the layout
725
993
  // math and window-level selection stay unit-testable without booting a
@@ -739,8 +1007,17 @@ export function computeIosOverlayWindowLevel(baseAlert) {
739
1007
  /**
740
1008
  * Layout math for the live overlay when it runs inside its own UIWindow.
741
1009
  * Pure, deterministic and independent of UIKit so we can verify the rules
742
- * (max panel width, centered placement, safe-area clamping, sane defaults)
743
- * from tests.
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).
744
1021
  */
745
1022
  export function computeIosOverlayLayout(input) {
746
1023
  const viewWidth = Math.max(0, Number(input.viewWidth) || 0);
@@ -758,15 +1035,41 @@ export function computeIosOverlayLayout(input) {
758
1035
  const panelPadding = Math.max(0, Number(input.panelPadding ?? 16));
759
1036
  const interLabelSpacing = Math.max(0, Number(input.interLabelSpacing ?? 10));
760
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));
761
1047
  const available = Math.max(0, viewWidth - 2 * horizontalMargin - safeInsets.left - safeInsets.right);
762
1048
  const panelWidth = Math.min(maxPanelWidth, available);
763
1049
  const innerWidth = Math.max(0, panelWidth - 2 * panelPadding);
764
1050
  const spacing = titleHeight > 0 && statusHeight > 0 ? interLabelSpacing : 0;
765
1051
  const panelHeight = panelPadding * 2 + titleHeight + spacing + statusHeight;
766
1052
  const panelX = Math.max(0, (viewWidth - panelWidth) / 2);
767
- // Center vertically, but never cross the top safe-area inset (notch/Dynamic Island).
768
- const centered = (viewHeight - panelHeight) / 2;
769
- const panelY = Math.max(safeInsets.top + minTopInset, centered);
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
+ }
770
1073
  return {
771
1074
  backdrop: { x: 0, y: 0, width: viewWidth, height: viewHeight },
772
1075
  panel: { x: panelX, y: panelY, width: panelWidth, height: panelHeight },
@@ -880,7 +1183,32 @@ function buildIosOverlayRefs(state) {
880
1183
  statusLabel.font = UIFont.systemFontOfSize(13);
881
1184
  statusLabel.textColor = UIColor.darkGrayColor;
882
1185
  panel.addSubview(statusLabel);
883
- return { window, controller, backdrop, panel, titleLabel, 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
+ };
884
1212
  }
885
1213
  catch (err) {
886
1214
  console.warn('[ns-hmr-overlay] iOS overlay construction failed:', err?.message || err);
@@ -903,7 +1231,7 @@ function ensureIosOverlayRefs(state) {
903
1231
  }
904
1232
  return state.iosRefs;
905
1233
  }
906
- function layoutIosOverlayRefs(refs) {
1234
+ function layoutIosOverlayRefs(refs, position) {
907
1235
  try {
908
1236
  const bounds = refs.controller.view.bounds;
909
1237
  const viewWidth = Number(bounds?.size?.width) || 0;
@@ -934,6 +1262,7 @@ function layoutIosOverlayRefs(refs) {
934
1262
  maxPanelWidth,
935
1263
  horizontalMargin,
936
1264
  panelPadding,
1265
+ position,
937
1266
  });
938
1267
  const toCgRect = (rect) => ({
939
1268
  origin: { x: rect.x, y: rect.y },
@@ -943,9 +1272,112 @@ function layoutIosOverlayRefs(refs) {
943
1272
  refs.panel.frame = toCgRect(layout.panel);
944
1273
  refs.titleLabel.frame = toCgRect(layout.title);
945
1274
  refs.statusLabel.frame = toCgRect(layout.status);
1275
+ return layout;
946
1276
  }
947
1277
  catch (err) {
948
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();
949
1381
  }
950
1382
  }
951
1383
  function applySnapshotToIosRefs(refs, snapshot) {
@@ -958,9 +1390,38 @@ function applySnapshotToIosRefs(refs, snapshot) {
958
1390
  // lazily (ensureIosOverlayRefs) and reused for the lifetime of
959
1391
  // the dev session.
960
1392
  const visible = snapshot.visible && (snapshot.mode === 'connection' || snapshot.mode === 'update');
961
- refs.window.hidden = !visible;
962
- if (!visible)
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;
963
1422
  return true;
1423
+ }
1424
+ refs.window.hidden = false;
964
1425
  refs.titleLabel.text = snapshot.title || '';
965
1426
  refs.statusLabel.text = formatStatusText(snapshot);
966
1427
  const host = getIosOverlayHost();
@@ -974,9 +1435,6 @@ function applySnapshotToIosRefs(refs, snapshot) {
974
1435
  refs.panel.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(1, 0.96, 0.96, 1);
975
1436
  refs.titleLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.7, 0.1, 0.06, 1);
976
1437
  refs.statusLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.7, 0.1, 0.06, 0.9);
977
- // Slightly stronger dimming on errors; users need to
978
- // notice these.
979
- refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0, 0, 0.35);
980
1438
  }
981
1439
  else if (isSuccess) {
982
1440
  // Slightly more saturated green panel + dark-green
@@ -988,24 +1446,52 @@ function applySnapshotToIosRefs(refs, snapshot) {
988
1446
  refs.panel.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0.9, 0.97, 0.91, 1);
989
1447
  refs.titleLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.05, 0.43, 0.18, 1);
990
1448
  refs.statusLabel.textColor = UIColor.colorWithRedGreenBlueAlpha(0.05, 0.43, 0.18, 1);
991
- // Bumped from 0.12 to 0.28. The 0.12 wash was so
992
- // faint on bright app backgrounds that the overlay
993
- // was effectively invisible during a fast cycle.
994
- // 0.28 still keeps the app readable underneath but
995
- // makes the HMR event visually unmistakable.
996
- refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0.15, 0.05, 0.28);
997
1449
  }
998
1450
  else {
999
1451
  // Default (info / warn) — existing connection look.
1000
1452
  refs.panel.backgroundColor = UIColor.whiteColor;
1001
1453
  refs.titleLabel.textColor = UIColor.blackColor;
1002
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 {
1003
1470
  refs.backdrop.backgroundColor = UIColor.colorWithRedGreenBlueAlpha(0, 0, 0, 0.35);
1004
1471
  }
1005
1472
  }
1006
1473
  catch { }
1007
1474
  }
1008
- layoutIosOverlayRefs(refs);
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;
1009
1495
  return true;
1010
1496
  }
1011
1497
  catch (err) {
@@ -1143,7 +1629,17 @@ function createOverlayApi() {
1143
1629
  const state = getRuntimeState();
1144
1630
  clearUpdateAutoHideTimer(state);
1145
1631
  state.updateCycleStartedAt = 0;
1146
- return applyRuntimeSnapshot(createBootOverlaySnapshot(stage, info));
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);
1147
1643
  },
1148
1644
  setConnectionStage(stage, info) {
1149
1645
  const state = getRuntimeState();