@ait-co/devtools 0.1.3 → 0.1.5

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.
@@ -92,7 +92,17 @@ const DEFAULT_STATE = {
92
92
  images: [],
93
93
  clipboardText: ""
94
94
  },
95
- panelEditable: true
95
+ panelEditable: true,
96
+ viewport: {
97
+ preset: "none",
98
+ orientation: "auto",
99
+ appOrientation: null,
100
+ landscapeSide: "left",
101
+ customWidth: 402,
102
+ customHeight: 874,
103
+ frame: false,
104
+ aitNavBar: true
105
+ }
96
106
  };
97
107
  function generateDeviceId() {
98
108
  const stored = localStorage.getItem("__ait_device_id");
@@ -560,6 +570,143 @@ const PANEL_STYLES = `
560
570
  color: #e53e3e; /* readable on both light (#fff) and dark (#1a1a2e) panel backgrounds */
561
571
  }
562
572
 
573
+ /* Viewport tab status rows */
574
+ .ait-status-row {
575
+ display: flex;
576
+ justify-content: space-between;
577
+ align-items: baseline;
578
+ font-size: 11px;
579
+ color: #888;
580
+ padding: 3px 0;
581
+ border-bottom: 1px dashed #2a2a4a;
582
+ gap: 8px;
583
+ }
584
+ .ait-status-row:last-child { border-bottom: none; }
585
+ .ait-status-row .ait-status-value {
586
+ font-family: 'SF Mono', 'Menlo', monospace;
587
+ color: #95e6cb;
588
+ font-size: 11px;
589
+ text-align: right;
590
+ word-break: break-word;
591
+ }
592
+
593
+ /* === Viewport simulation === */
594
+ /* Static rules. Dynamic per-preset values (width/height, navbar top offset)
595
+ are still injected via a separate <style id="__ait-viewport-style">. */
596
+ html.ait-viewport-active {
597
+ background: #0a0a14;
598
+ min-height: 100dvh;
599
+ }
600
+ html.ait-viewport-active body {
601
+ position: relative;
602
+ /* isolation: isolate creates a stacking context so notch/navbar z-index
603
+ cannot escape body and paint over the floating Panel toggle. */
604
+ isolation: isolate;
605
+ margin: 24px auto;
606
+ overflow: auto;
607
+ background: #fff;
608
+ box-sizing: border-box;
609
+ }
610
+ html.ait-viewport-framed body {
611
+ border-radius: 36px;
612
+ box-shadow:
613
+ 0 0 0 10px #1a1a2e,
614
+ 0 0 0 12px #3a3a5a,
615
+ 0 24px 48px rgba(0,0,0,0.5);
616
+ }
617
+
618
+ /* Notch / Dynamic Island / punch-hole overlay (top of body) */
619
+ .ait-notch {
620
+ position: absolute;
621
+ top: 0;
622
+ left: 50%;
623
+ transform: translateX(-50%);
624
+ background: #000;
625
+ z-index: 10;
626
+ pointer-events: none;
627
+ }
628
+ .ait-notch-dynamic-island { top: 11px; width: 126px; height: 37px; border-radius: 20px; }
629
+ .ait-notch-pill {
630
+ width: 160px; height: 30px;
631
+ border-bottom-left-radius: 20px; border-bottom-right-radius: 20px;
632
+ }
633
+ .ait-notch-punch-hole { top: 10px; width: 12px; height: 12px; border-radius: 50%; }
634
+
635
+ /* Home indicator pill (bottom of body, iPhones with safe-area bottom > 0) */
636
+ .ait-home-indicator {
637
+ position: absolute;
638
+ bottom: 8px;
639
+ left: 50%;
640
+ transform: translateX(-50%);
641
+ width: 134px;
642
+ height: 5px;
643
+ border-radius: 3px;
644
+ background: rgba(0, 0, 0, 0.85);
645
+ z-index: 10;
646
+ pointer-events: none;
647
+ }
648
+
649
+ /* Apps in Toss host nav bar — sits directly below the OS status bar */
650
+ .ait-navbar {
651
+ position: absolute;
652
+ left: 0;
653
+ right: 0;
654
+ height: 48px; /* AIT_NAV_BAR_HEIGHT */
655
+ background: rgba(255, 255, 255, 0.92);
656
+ backdrop-filter: blur(8px);
657
+ display: flex;
658
+ align-items: center;
659
+ justify-content: space-between;
660
+ padding: 0 12px;
661
+ box-sizing: border-box;
662
+ font: 500 15px -apple-system, BlinkMacSystemFont, 'Pretendard', sans-serif;
663
+ color: #1a1a1a;
664
+ z-index: 10;
665
+ }
666
+ .ait-navbar-title {
667
+ display: flex;
668
+ align-items: center;
669
+ gap: 6px;
670
+ flex: 1;
671
+ margin-left: 4px;
672
+ overflow: hidden;
673
+ }
674
+ .ait-navbar-icon {
675
+ width: 22px;
676
+ height: 22px;
677
+ border-radius: 6px;
678
+ background: linear-gradient(135deg, #3182f6, #7c3aed);
679
+ flex-shrink: 0;
680
+ }
681
+ .ait-navbar-name {
682
+ font-size: 15px;
683
+ font-weight: 600;
684
+ white-space: nowrap;
685
+ overflow: hidden;
686
+ text-overflow: ellipsis;
687
+ }
688
+ .ait-navbar-actions {
689
+ display: flex;
690
+ align-items: center;
691
+ background: rgba(0, 0, 0, 0.05);
692
+ border-radius: 999px;
693
+ padding: 4px 8px;
694
+ gap: 4px;
695
+ }
696
+ .ait-navbar-btn {
697
+ background: none;
698
+ border: none;
699
+ padding: 2px 8px;
700
+ font: inherit;
701
+ font-size: 18px;
702
+ color: inherit;
703
+ line-height: 1;
704
+ cursor: pointer;
705
+ }
706
+ .ait-navbar-btn:hover { color: #3182f6; }
707
+ .ait-navbar-back { padding: 0 8px; font-size: 24px; }
708
+ .ait-navbar-divider { width: 1px; height: 16px; background: rgba(0, 0, 0, 0.15); }
709
+
563
710
  @media (max-width: 720px) {
564
711
  .ait-panel.open {
565
712
  position: fixed;
@@ -1323,12 +1470,638 @@ function renderStorageTab(refreshPanel) {
1323
1470
  return container;
1324
1471
  }
1325
1472
  //#endregion
1473
+ //#region src/mock/navigation/index.ts
1474
+ async function closeView() {
1475
+ console.log("[@ait-co/devtools] closeView called");
1476
+ window.history.back();
1477
+ }
1478
+ async function requestReview() {
1479
+ console.log("[@ait-co/devtools] requestReview called");
1480
+ }
1481
+ requestReview.isSupported = () => true;
1482
+ async function getServerTime() {
1483
+ return Date.now();
1484
+ }
1485
+ getServerTime.isSupported = () => true;
1486
+ //#endregion
1487
+ //#region src/panel/viewport.ts
1488
+ /**
1489
+ * Viewport 시뮬레이션 유틸
1490
+ *
1491
+ * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
1492
+ * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
1493
+ * 동적 값(width/height, navbar top offset)만 별도 `<style>` 엘리먼트로 관리한다.
1494
+ */
1495
+ const VIEWPORT_STORAGE_KEY = "__ait_viewport";
1496
+ /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
1497
+ const VIEWPORT_CUSTOM_MAX = 4096;
1498
+ const NONE_PRESET = {
1499
+ id: "none",
1500
+ label: "None (full window)",
1501
+ width: 0,
1502
+ height: 0,
1503
+ dpr: 1,
1504
+ notch: "none",
1505
+ safeAreaTop: 0,
1506
+ safeAreaBottom: 0
1507
+ };
1508
+ /**
1509
+ * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
1510
+ * iPhone 17 시리즈는 2025-09 출시. iPhone Air와 Galaxy S26 시리즈는 2026-04 기준 미출시라
1511
+ * 추정 값(`(est)` 라벨 표기). 실제 출시 후 값을 갱신한다.
1512
+ *
1513
+ * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
1514
+ * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
1515
+ */
1516
+ const VIEWPORT_PRESETS = [
1517
+ NONE_PRESET,
1518
+ {
1519
+ id: "iphone-se-3",
1520
+ label: "iPhone SE (3rd gen)",
1521
+ width: 375,
1522
+ height: 667,
1523
+ dpr: 2,
1524
+ notch: "none",
1525
+ safeAreaTop: 20,
1526
+ safeAreaBottom: 0
1527
+ },
1528
+ {
1529
+ id: "iphone-16e",
1530
+ label: "iPhone 16e",
1531
+ width: 390,
1532
+ height: 844,
1533
+ dpr: 3,
1534
+ notch: "notch",
1535
+ safeAreaTop: 47,
1536
+ safeAreaBottom: 34
1537
+ },
1538
+ {
1539
+ id: "iphone-17",
1540
+ label: "iPhone 17",
1541
+ width: 402,
1542
+ height: 874,
1543
+ dpr: 3,
1544
+ notch: "dynamic-island",
1545
+ safeAreaTop: 59,
1546
+ safeAreaBottom: 34
1547
+ },
1548
+ {
1549
+ id: "iphone-air",
1550
+ label: "iPhone Air (est)",
1551
+ width: 420,
1552
+ height: 912,
1553
+ dpr: 3,
1554
+ notch: "dynamic-island",
1555
+ safeAreaTop: 59,
1556
+ safeAreaBottom: 34
1557
+ },
1558
+ {
1559
+ id: "iphone-17-pro",
1560
+ label: "iPhone 17 Pro",
1561
+ width: 402,
1562
+ height: 874,
1563
+ dpr: 3,
1564
+ notch: "dynamic-island",
1565
+ safeAreaTop: 59,
1566
+ safeAreaBottom: 34
1567
+ },
1568
+ {
1569
+ id: "iphone-17-pro-max",
1570
+ label: "iPhone 17 Pro Max",
1571
+ width: 440,
1572
+ height: 956,
1573
+ dpr: 3,
1574
+ notch: "dynamic-island",
1575
+ safeAreaTop: 62,
1576
+ safeAreaBottom: 34
1577
+ },
1578
+ {
1579
+ id: "galaxy-s26",
1580
+ label: "Galaxy S26 (est)",
1581
+ width: 384,
1582
+ height: 832,
1583
+ dpr: 3,
1584
+ notch: "punch-hole-center",
1585
+ safeAreaTop: 32,
1586
+ safeAreaBottom: 0
1587
+ },
1588
+ {
1589
+ id: "galaxy-s26-plus",
1590
+ label: "Galaxy S26+ (est)",
1591
+ width: 412,
1592
+ height: 915,
1593
+ dpr: 3,
1594
+ notch: "punch-hole-center",
1595
+ safeAreaTop: 32,
1596
+ safeAreaBottom: 0
1597
+ },
1598
+ {
1599
+ id: "galaxy-s26-ultra",
1600
+ label: "Galaxy S26 Ultra (est)",
1601
+ width: 412,
1602
+ height: 915,
1603
+ dpr: 3.5,
1604
+ notch: "punch-hole-center",
1605
+ safeAreaTop: 40,
1606
+ safeAreaBottom: 0
1607
+ },
1608
+ {
1609
+ id: "galaxy-z-flip7",
1610
+ label: "Galaxy Z Flip7",
1611
+ width: 412,
1612
+ height: 990,
1613
+ dpr: 3,
1614
+ notch: "punch-hole-center",
1615
+ safeAreaTop: 36,
1616
+ safeAreaBottom: 0
1617
+ },
1618
+ {
1619
+ id: "galaxy-z-fold7-folded",
1620
+ label: "Galaxy Z Fold7 (folded)",
1621
+ width: 384,
1622
+ height: 870,
1623
+ dpr: 3,
1624
+ notch: "punch-hole-center",
1625
+ safeAreaTop: 32,
1626
+ safeAreaBottom: 0
1627
+ },
1628
+ {
1629
+ id: "galaxy-z-fold7-unfolded",
1630
+ label: "Galaxy Z Fold7 (unfolded)",
1631
+ width: 768,
1632
+ height: 884,
1633
+ dpr: 2.625,
1634
+ notch: "punch-hole-center",
1635
+ safeAreaTop: 32,
1636
+ safeAreaBottom: 0
1637
+ },
1638
+ {
1639
+ id: "custom",
1640
+ label: "Custom",
1641
+ width: 0,
1642
+ height: 0,
1643
+ dpr: 1,
1644
+ notch: "none",
1645
+ safeAreaTop: 0,
1646
+ safeAreaBottom: 0
1647
+ }
1648
+ ];
1649
+ function getPreset(id) {
1650
+ return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
1651
+ }
1652
+ /**
1653
+ * 실제로 화면에 표시될 orientation을 결정한다.
1654
+ *
1655
+ * - Panel `orientation === 'auto'`: 앱이 마지막으로 SDK로 요청한 값
1656
+ * (`appOrientation`)을 따른다. 호출 전이면 portrait.
1657
+ * - Panel `orientation === 'portrait' | 'landscape'`: Panel 값이 우선.
1658
+ */
1659
+ function effectiveOrientation(state) {
1660
+ if (state.orientation === "auto") return state.appOrientation ?? "portrait";
1661
+ return state.orientation;
1662
+ }
1663
+ /**
1664
+ * 선택된 뷰포트의 실제 width/height를 계산한다.
1665
+ * preset === 'custom'이면 customWidth/customHeight, 그 외에는 preset의 값.
1666
+ * effective orientation이 landscape이면 width/height를 swap한다.
1667
+ */
1668
+ function resolveViewportSize(state) {
1669
+ if (state.preset === "none") return {
1670
+ width: 0,
1671
+ height: 0
1672
+ };
1673
+ const base = state.preset === "custom" ? {
1674
+ width: state.customWidth,
1675
+ height: state.customHeight
1676
+ } : getPreset(state.preset);
1677
+ return effectiveOrientation(state) === "landscape" ? {
1678
+ width: base.height,
1679
+ height: base.width
1680
+ } : {
1681
+ width: base.width,
1682
+ height: base.height
1683
+ };
1684
+ }
1685
+ /**
1686
+ * 프리셋 + landscape 여부 + landscape side로부터 OS-level safe-area insets를 계산한다.
1687
+ *
1688
+ * - Portrait: preset의 `safeAreaTop`, `safeAreaBottom`을 그대로 사용.
1689
+ * - Landscape iPhone(notch/Dynamic Island): 노치가 한쪽으로 가므로 `landscapeSide`에
1690
+ * 따라 left 또는 right에만 인셋을 준다 (실 기기 동작과 일치). top은 0,
1691
+ * home-indicator는 bottom에 유지.
1692
+ * - Android punch-hole(status bar): landscape 시에도 top에 status bar가 유지된다.
1693
+ */
1694
+ function computeSafeAreaInsets(preset, landscape, side) {
1695
+ if (preset.id === "none" || preset.id === "custom") return {
1696
+ top: 0,
1697
+ bottom: 0,
1698
+ left: 0,
1699
+ right: 0
1700
+ };
1701
+ if (!landscape) return {
1702
+ top: preset.safeAreaTop,
1703
+ bottom: preset.safeAreaBottom,
1704
+ left: 0,
1705
+ right: 0
1706
+ };
1707
+ if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
1708
+ top: 0,
1709
+ bottom: preset.safeAreaBottom,
1710
+ left: side === "left" ? preset.safeAreaTop : 0,
1711
+ right: side === "right" ? preset.safeAreaTop : 0
1712
+ };
1713
+ return {
1714
+ top: preset.safeAreaTop,
1715
+ bottom: preset.safeAreaBottom,
1716
+ left: 0,
1717
+ right: 0
1718
+ };
1719
+ }
1720
+ /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
1721
+ function syncSafeAreaFromViewport(state) {
1722
+ if (state.preset === "none" || state.preset === "custom") return;
1723
+ const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide);
1724
+ const current = aitState.state.safeAreaInsets;
1725
+ if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
1726
+ aitState.update({ safeAreaInsets: next });
1727
+ }
1728
+ const STYLE_ELEMENT_ID = "__ait-viewport-style";
1729
+ const NOTCH_ELEMENT_ID = "__ait-viewport-notch";
1730
+ const HOME_INDICATOR_ID = "__ait-viewport-home-indicator";
1731
+ const NAV_BAR_ELEMENT_ID = "__ait-viewport-navbar";
1732
+ let bodyScrollHintEmitted = false;
1733
+ function ensureStyleElement() {
1734
+ if (typeof document === "undefined") return null;
1735
+ let el = document.getElementById(STYLE_ELEMENT_ID);
1736
+ if (!el) {
1737
+ el = document.createElement("style");
1738
+ el.id = STYLE_ELEMENT_ID;
1739
+ document.head.appendChild(el);
1740
+ }
1741
+ return el;
1742
+ }
1743
+ function removeById(id) {
1744
+ const el = document.getElementById(id);
1745
+ if (el) el.remove();
1746
+ }
1747
+ function removeNotchElement() {
1748
+ removeById(NOTCH_ELEMENT_ID);
1749
+ }
1750
+ function removeHomeIndicator() {
1751
+ removeById(HOME_INDICATOR_ID);
1752
+ }
1753
+ function removeNavBarElement() {
1754
+ removeById(NAV_BAR_ELEMENT_ID);
1755
+ }
1756
+ /**
1757
+ * Apps in Toss host nav bar 렌더. OS status bar 아래에 48px 높이로 쌓인다.
1758
+ * 구성: 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`), 우측 `⋯` + 구분선 + `×`.
1759
+ *
1760
+ * `env(safe-area-inset-top)`에는 이 높이가 포함되지 않으므로 (공식 SDK 확인),
1761
+ * 오버레이는 preset.safeAreaTop만큼 아래로 내려서 그린다.
1762
+ *
1763
+ * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
1764
+ * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
1765
+ */
1766
+ function renderNavBar(preset, displayName) {
1767
+ removeNavBarElement();
1768
+ const el = h("div", {
1769
+ id: NAV_BAR_ELEMENT_ID,
1770
+ className: "ait-navbar",
1771
+ "aria-hidden": "true"
1772
+ });
1773
+ el.style.top = `${preset.safeAreaTop}px`;
1774
+ const backBtn = h("button", {
1775
+ className: "ait-navbar-btn ait-navbar-back",
1776
+ type: "button",
1777
+ "aria-label": "Back"
1778
+ });
1779
+ backBtn.textContent = "‹";
1780
+ backBtn.addEventListener("click", () => {
1781
+ aitState.trigger("backEvent");
1782
+ });
1783
+ const moreBtn = h("button", {
1784
+ className: "ait-navbar-btn",
1785
+ type: "button",
1786
+ "aria-label": "More"
1787
+ });
1788
+ moreBtn.textContent = "⋯";
1789
+ const closeBtn = h("button", {
1790
+ className: "ait-navbar-btn",
1791
+ type: "button",
1792
+ "aria-label": "Close"
1793
+ });
1794
+ closeBtn.textContent = "×";
1795
+ closeBtn.addEventListener("click", () => {
1796
+ closeView().catch((err) => console.error("[@ait-co/devtools] navbar close failed:", err));
1797
+ });
1798
+ const nameSpan = h("span", { className: "ait-navbar-name" });
1799
+ nameSpan.textContent = displayName;
1800
+ el.append(backBtn, h("div", { className: "ait-navbar-title" }, h("span", { className: "ait-navbar-icon" }), nameSpan), h("div", { className: "ait-navbar-actions" }, moreBtn, h("span", { className: "ait-navbar-divider" }), closeBtn));
1801
+ document.body.appendChild(el);
1802
+ }
1803
+ /**
1804
+ * 현재 preset의 notch/Dynamic Island/punch-hole을 body 상단에 시각적으로 렌더한다.
1805
+ * landscape 시에는 노치가 한쪽 변에 있는 것이 실제 기기 동작이지만, 시뮬레이터에서는
1806
+ * landscape에서 오버레이를 그리지 않는다 (safeAreaInsets의 left/right로 이미 반영).
1807
+ */
1808
+ function renderNotchOverlay(preset) {
1809
+ removeNotchElement();
1810
+ if (preset.notch === "none") return;
1811
+ const notch = h("div", {
1812
+ id: NOTCH_ELEMENT_ID,
1813
+ className: `ait-notch ${preset.notch === "dynamic-island" ? "ait-notch-dynamic-island" : preset.notch === "notch" ? "ait-notch-pill" : "ait-notch-punch-hole"}`,
1814
+ "aria-hidden": "true"
1815
+ });
1816
+ document.body.appendChild(notch);
1817
+ }
1818
+ /** brand 이름만 바뀐 경우 nav bar 전체를 다시 만들지 않고 텍스트 노드만 교체한다. */
1819
+ function refreshNavBarBrand(displayName) {
1820
+ const name = document.querySelector(`#${NAV_BAR_ELEMENT_ID} .ait-navbar-name`);
1821
+ if (name) name.textContent = displayName;
1822
+ }
1823
+ function renderHomeIndicator() {
1824
+ removeHomeIndicator();
1825
+ const el = h("div", {
1826
+ id: HOME_INDICATOR_ID,
1827
+ className: "ait-home-indicator",
1828
+ "aria-hidden": "true"
1829
+ });
1830
+ document.body.appendChild(el);
1831
+ }
1832
+ /**
1833
+ * DOM에 뷰포트 제약을 적용한다.
1834
+ * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
1835
+ * - body의 width/height는 preset 값으로, navbar top offset은 safeAreaTop으로 인라인 주입
1836
+ */
1837
+ function applyViewport(state) {
1838
+ if (typeof document === "undefined") return;
1839
+ const html = document.documentElement;
1840
+ const style = ensureStyleElement();
1841
+ if (!style) return;
1842
+ const size = resolveViewportSize(state);
1843
+ if (state.preset === "none" || size.width === 0 || size.height === 0) {
1844
+ html.classList.remove("ait-viewport-active");
1845
+ html.classList.remove("ait-viewport-framed");
1846
+ style.textContent = "";
1847
+ removeNotchElement();
1848
+ removeHomeIndicator();
1849
+ removeNavBarElement();
1850
+ return;
1851
+ }
1852
+ if (!bodyScrollHintEmitted) {
1853
+ bodyScrollHintEmitted = true;
1854
+ console.info("[@ait-co/devtools] Viewport simulation active — scroll happens on body, not window. See README \"Known limitations\" for details.");
1855
+ }
1856
+ html.classList.add("ait-viewport-active");
1857
+ html.classList.toggle("ait-viewport-framed", state.frame);
1858
+ const preset = state.preset === "custom" ? null : getPreset(state.preset);
1859
+ const landscape = effectiveOrientation(state) === "landscape";
1860
+ style.textContent = `
1861
+ html.ait-viewport-active body {
1862
+ width: ${size.width}px;
1863
+ max-width: ${size.width}px;
1864
+ min-height: ${size.height}px;
1865
+ max-height: ${size.height}px;
1866
+ }
1867
+ `;
1868
+ if (preset && state.frame && !landscape) renderNotchOverlay(preset);
1869
+ else removeNotchElement();
1870
+ if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
1871
+ else removeHomeIndicator();
1872
+ if (preset && state.aitNavBar && !landscape) renderNavBar(preset, aitState.state.brand.displayName);
1873
+ else removeNavBarElement();
1874
+ }
1875
+ function isViewportPresetId(v) {
1876
+ return typeof v === "string" && VIEWPORT_PRESETS.some((p) => p.id === v);
1877
+ }
1878
+ function isViewportOrientation(v) {
1879
+ return v === "auto" || v === "portrait" || v === "landscape";
1880
+ }
1881
+ function isAppOrientation(v) {
1882
+ return v === null || v === "portrait" || v === "landscape";
1883
+ }
1884
+ function isLandscapeSide(v) {
1885
+ return v === "left" || v === "right";
1886
+ }
1887
+ /** 1 이상의 정수 + VIEWPORT_CUSTOM_MAX 이하인지 검사. sessionStorage 보호용. */
1888
+ function isValidCustomDimension(v) {
1889
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 4096;
1890
+ }
1891
+ /** Custom 입력에서 사용. 잘린 정수 + 클램프된 안전한 값 또는 null 반환. */
1892
+ function clampCustomDimension(raw) {
1893
+ if (!Number.isFinite(raw)) return null;
1894
+ const n = Math.floor(raw);
1895
+ if (n < 1) return null;
1896
+ return Math.min(n, VIEWPORT_CUSTOM_MAX);
1897
+ }
1898
+ /**
1899
+ * sessionStorage에 저장된 뷰포트 상태를 읽어서 현재 state에 merge한다.
1900
+ * 값이 없거나 파싱 실패 시 no-op.
1901
+ */
1902
+ function loadViewportFromStorage() {
1903
+ if (typeof sessionStorage === "undefined") return null;
1904
+ const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
1905
+ if (!raw) return null;
1906
+ try {
1907
+ const parsed = JSON.parse(raw);
1908
+ if (typeof parsed !== "object" || parsed === null) return null;
1909
+ const obj = parsed;
1910
+ const next = {};
1911
+ if (isViewportPresetId(obj.preset)) next.preset = obj.preset;
1912
+ if (isViewportOrientation(obj.orientation)) next.orientation = obj.orientation;
1913
+ if (isAppOrientation(obj.appOrientation)) next.appOrientation = obj.appOrientation;
1914
+ if (isLandscapeSide(obj.landscapeSide)) next.landscapeSide = obj.landscapeSide;
1915
+ if (isValidCustomDimension(obj.customWidth)) next.customWidth = obj.customWidth;
1916
+ if (isValidCustomDimension(obj.customHeight)) next.customHeight = obj.customHeight;
1917
+ if (typeof obj.frame === "boolean") next.frame = obj.frame;
1918
+ if (typeof obj.aitNavBar === "boolean") next.aitNavBar = obj.aitNavBar;
1919
+ return next;
1920
+ } catch {
1921
+ return null;
1922
+ }
1923
+ }
1924
+ function saveViewportToStorage(state) {
1925
+ if (typeof sessionStorage === "undefined") return;
1926
+ try {
1927
+ sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(state));
1928
+ } catch {}
1929
+ }
1930
+ let viewportInitialized = false;
1931
+ let viewportUnsubscribe = null;
1932
+ /**
1933
+ * Panel mount 시 호출. sessionStorage 복원 → aitState에 반영 → DOM 적용.
1934
+ * aitState 변경을 구독해서 DOM / storage / safe-area insets를 자동 동기화한다.
1935
+ *
1936
+ * Idempotent: 두 번째 호출은 기존 unsubscribe를 그대로 반환한다 (HMR / 재mount 안전).
1937
+ * 테스트는 반환된 unsubscribe를 afterEach에서 호출해 cleanup해야 한다.
1938
+ */
1939
+ function initViewport() {
1940
+ if (typeof window === "undefined") return () => {};
1941
+ if (viewportInitialized && viewportUnsubscribe) return viewportUnsubscribe;
1942
+ const restored = loadViewportFromStorage();
1943
+ if (restored) aitState.patch("viewport", restored);
1944
+ applyViewport(aitState.state.viewport);
1945
+ syncSafeAreaFromViewport(aitState.state.viewport);
1946
+ let lastViewportJson = JSON.stringify(aitState.state.viewport);
1947
+ let lastBrandName = aitState.state.brand.displayName;
1948
+ const unsubscribeFn = aitState.subscribe(() => {
1949
+ const vp = aitState.state.viewport;
1950
+ const brandName = aitState.state.brand.displayName;
1951
+ const json = JSON.stringify(vp);
1952
+ const viewportChanged = json !== lastViewportJson;
1953
+ if (!viewportChanged && !(brandName !== lastBrandName)) return;
1954
+ lastViewportJson = json;
1955
+ lastBrandName = brandName;
1956
+ if (viewportChanged) {
1957
+ applyViewport(vp);
1958
+ saveViewportToStorage(vp);
1959
+ syncSafeAreaFromViewport(vp);
1960
+ } else refreshNavBarBrand(brandName);
1961
+ });
1962
+ viewportInitialized = true;
1963
+ viewportUnsubscribe = () => {
1964
+ unsubscribeFn();
1965
+ viewportInitialized = false;
1966
+ viewportUnsubscribe = null;
1967
+ };
1968
+ return viewportUnsubscribe;
1969
+ }
1970
+ //#endregion
1971
+ //#region src/panel/tabs/viewport.ts
1972
+ function renderViewportTab() {
1973
+ const s = aitState.state;
1974
+ const vp = s.viewport;
1975
+ const disabled = !s.panelEditable;
1976
+ const container = h("div");
1977
+ if (disabled) container.appendChild(monitoringNotice());
1978
+ const presetSelect = h("select", { className: "ait-select" });
1979
+ if (disabled) presetSelect.disabled = true;
1980
+ for (const preset of VIEWPORT_PRESETS) {
1981
+ const label = preset.id === "none" || preset.id === "custom" ? preset.label : `${preset.label} (${preset.width}×${preset.height})`;
1982
+ const option = h("option", { value: preset.id }, label);
1983
+ if (preset.id === vp.preset) option.selected = true;
1984
+ presetSelect.appendChild(option);
1985
+ }
1986
+ presetSelect.addEventListener("change", () => {
1987
+ const id = presetSelect.value;
1988
+ const patch = { preset: id };
1989
+ if (id === "custom") {
1990
+ const current = getPreset(vp.preset);
1991
+ if (current.width > 0) patch.customWidth = current.width;
1992
+ if (current.height > 0) patch.customHeight = current.height;
1993
+ }
1994
+ aitState.patch("viewport", patch);
1995
+ });
1996
+ const orientationSelect = h("select", { className: "ait-select" });
1997
+ if (disabled) orientationSelect.disabled = true;
1998
+ for (const opt of [
1999
+ "auto",
2000
+ "portrait",
2001
+ "landscape"
2002
+ ]) {
2003
+ const option = h("option", { value: opt }, opt);
2004
+ if (opt === vp.orientation) option.selected = true;
2005
+ orientationSelect.appendChild(option);
2006
+ }
2007
+ orientationSelect.addEventListener("change", () => {
2008
+ aitState.patch("viewport", { orientation: orientationSelect.value });
2009
+ });
2010
+ const landscapeSideSelect = h("select", { className: "ait-select" });
2011
+ if (disabled) landscapeSideSelect.disabled = true;
2012
+ for (const opt of ["left", "right"]) {
2013
+ const option = h("option", { value: opt }, opt);
2014
+ if (opt === vp.landscapeSide) option.selected = true;
2015
+ landscapeSideSelect.appendChild(option);
2016
+ }
2017
+ landscapeSideSelect.addEventListener("change", () => {
2018
+ aitState.patch("viewport", { landscapeSide: landscapeSideSelect.value });
2019
+ });
2020
+ const customRow = h("div", { className: "ait-section" });
2021
+ if (vp.preset === "custom") {
2022
+ const widthInput = h("input", {
2023
+ className: "ait-input",
2024
+ type: "number",
2025
+ min: "1",
2026
+ value: String(vp.customWidth)
2027
+ });
2028
+ const heightInput = h("input", {
2029
+ className: "ait-input",
2030
+ type: "number",
2031
+ min: "1",
2032
+ value: String(vp.customHeight)
2033
+ });
2034
+ if (disabled) {
2035
+ widthInput.disabled = true;
2036
+ heightInput.disabled = true;
2037
+ }
2038
+ widthInput.addEventListener("change", () => {
2039
+ const clamped = clampCustomDimension(Number(widthInput.value));
2040
+ if (clamped !== null) {
2041
+ aitState.patch("viewport", { customWidth: clamped });
2042
+ widthInput.value = String(clamped);
2043
+ }
2044
+ });
2045
+ heightInput.addEventListener("change", () => {
2046
+ const clamped = clampCustomDimension(Number(heightInput.value));
2047
+ if (clamped !== null) {
2048
+ aitState.patch("viewport", { customHeight: clamped });
2049
+ heightInput.value = String(clamped);
2050
+ }
2051
+ });
2052
+ customRow.append(h("div", { className: "ait-section-title" }, "Custom size"), h("div", { className: "ait-row" }, h("label", {}, "Width (px)"), widthInput), h("div", { className: "ait-row" }, h("label", {}, "Height (px)"), heightInput));
2053
+ }
2054
+ const frameCheckbox = h("input", { type: "checkbox" });
2055
+ frameCheckbox.checked = vp.frame;
2056
+ if (disabled) frameCheckbox.disabled = true;
2057
+ frameCheckbox.addEventListener("change", () => {
2058
+ aitState.patch("viewport", { frame: frameCheckbox.checked });
2059
+ });
2060
+ const navBarCheckbox = h("input", { type: "checkbox" });
2061
+ navBarCheckbox.checked = vp.aitNavBar;
2062
+ if (disabled) navBarCheckbox.disabled = true;
2063
+ navBarCheckbox.addEventListener("change", () => {
2064
+ aitState.patch("viewport", { aitNavBar: navBarCheckbox.checked });
2065
+ });
2066
+ const size = resolveViewportSize(vp);
2067
+ const statusEl = h("div", { className: "ait-section" });
2068
+ if (vp.preset === "none" || size.width === 0) statusEl.appendChild(h("div", { style: "color:#888;font-size:11px" }, "No viewport constraint — body fills the window."));
2069
+ else {
2070
+ const preset = vp.preset === "custom" ? null : getPreset(vp.preset);
2071
+ const effOrient = effectiveOrientation(vp);
2072
+ const landscape = effOrient === "landscape";
2073
+ const rows = [];
2074
+ const dpr = preset?.dpr ?? 1;
2075
+ const physW = Math.round(size.width * dpr);
2076
+ const physH = Math.round(size.height * dpr);
2077
+ const orientDisplay = vp.orientation === "auto" ? `${effOrient} (auto)` : effOrient;
2078
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, "CSS / physical"), h("span", { className: "ait-status-value" }, `${size.width}×${size.height}@${dpr}x | ${physW}×${physH} ${orientDisplay}`)));
2079
+ if (preset) {
2080
+ const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide);
2081
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, "Safe area"), h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`)));
2082
+ }
2083
+ if (vp.aitNavBar && !landscape) rows.push(h("div", { className: "ait-status-row" }, h("span", {}, "AIT nav bar"), h("span", { className: "ait-status-value" }, `48px (excl. SafeArea)`)));
2084
+ for (const row of rows) statusEl.appendChild(row);
2085
+ }
2086
+ const deviceSection = h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Device"), h("div", { className: "ait-row" }, h("label", {}, "Preset"), presetSelect), h("div", { className: "ait-row" }, h("label", {}, "Orientation"), orientationSelect));
2087
+ if (effectiveOrientation(vp) === "landscape" && vp.preset !== "none" && vp.preset !== "custom") {
2088
+ const notch = getPreset(vp.preset).notch;
2089
+ if (notch === "notch" || notch === "dynamic-island") deviceSection.appendChild(h("div", { className: "ait-row" }, h("label", {}, "Notch side"), landscapeSideSelect));
2090
+ }
2091
+ container.append(deviceSection, customRow, h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Appearance"), h("div", { className: "ait-row" }, h("label", {}, "Show frame"), frameCheckbox), h("div", { className: "ait-row" }, h("label", {}, "Show Apps in Toss nav bar"), navBarCheckbox)), statusEl);
2092
+ return container;
2093
+ }
2094
+ //#endregion
1326
2095
  //#region src/panel/tabs/index.ts
1327
2096
  const TABS = [
1328
2097
  {
1329
2098
  id: "env",
1330
2099
  label: "Environment"
1331
2100
  },
2101
+ {
2102
+ id: "viewport",
2103
+ label: "Viewport"
2104
+ },
1332
2105
  {
1333
2106
  id: "permissions",
1334
2107
  label: "Permissions"
@@ -1364,6 +2137,7 @@ function createTabRenderers(refreshPanel) {
1364
2137
  permissions: renderPermissionsTab,
1365
2138
  location: renderLocationTab,
1366
2139
  device: renderDeviceTab,
2140
+ viewport: renderViewportTab,
1367
2141
  iap: renderIapTab,
1368
2142
  events: renderEventsTab,
1369
2143
  analytics: renderAnalyticsTab,
@@ -1537,6 +2311,7 @@ function mount() {
1537
2311
  if (typeof document === "undefined") return;
1538
2312
  if (document.querySelector(".ait-panel-toggle")) return;
1539
2313
  setDeviceRefreshPanel(refreshPanel);
2314
+ initViewport();
1540
2315
  const style = document.createElement("style");
1541
2316
  style.textContent = PANEL_STYLES;
1542
2317
  document.head.appendChild(style);
@@ -1564,7 +2339,7 @@ function mount() {
1564
2339
  mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
1565
2340
  refreshPanel();
1566
2341
  });
1567
- const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.3`), closeBtn);
2342
+ const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.5`), closeBtn);
1568
2343
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
1569
2344
  tabsEl = h("div", { className: "ait-panel-tabs" });
1570
2345
  for (const tab of TABS) {
@@ -1603,7 +2378,7 @@ function mount() {
1603
2378
  });
1604
2379
  aitState.subscribe(() => {
1605
2380
  try {
1606
- if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device")) refreshPanel();
2381
+ if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport")) refreshPanel();
1607
2382
  } catch (err) {
1608
2383
  console.error("[@ait-co/devtools] Error in subscribe callback:", err);
1609
2384
  }