@ait-co/devtools 0.1.30 → 0.1.32

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/panel/index.ts"],"mappings":";;;;;;;iBA2OS,KAAA,CAAA;;;;;;;;;iBA+LA,YAAA,CAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/panel/index.ts"],"mappings":";;;;;;;iBA2OS,KAAA,CAAA;;;;;;;;;iBAoMA,YAAA,CAAA"}
@@ -35,6 +35,12 @@ const en = {
35
35
  "env.section.safeArea": "Safe Area Insets",
36
36
  "env.row.safeArea.top": "Top",
37
37
  "env.row.safeArea.bottom": "Bottom",
38
+ "env.section.navigation": "Navigation",
39
+ "env.row.iosSwipeGesture": "iOS swipe-back",
40
+ "env.value.iosSwipeGesture.unset": "not called",
41
+ "env.value.iosSwipeGesture.enabled": "enabled",
42
+ "env.value.iosSwipeGesture.disabled": "disabled",
43
+ "env.hint.iosSwipeGesture": "Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.",
38
44
  "env.telemetry.section": "Telemetry",
39
45
  "env.telemetry.t0Row": "Anonymous usage signal (Tier 0)",
40
46
  "env.telemetry.t0On": "On",
@@ -99,7 +105,7 @@ const en = {
99
105
  "viewport.status.cssPhysical": "CSS / physical",
100
106
  "viewport.status.safeArea": "Safe area",
101
107
  "viewport.status.aitNavBar": "AIT nav bar",
102
- "viewport.status.aitNavBarValue": "{height}px (excl. SafeArea) · {type}",
108
+ "viewport.status.aitNavBarValue": "{height}px SafeArea top · {type}",
103
109
  "viewport.orientation.autoSuffix": "{orient} (auto)",
104
110
  "iap.section.simulator": "IAP Simulator",
105
111
  "iap.row.nextResult": "Next Purchase Result",
@@ -186,6 +192,12 @@ const ko = {
186
192
  "env.section.safeArea": "Safe Area Insets",
187
193
  "env.row.safeArea.top": "Top",
188
194
  "env.row.safeArea.bottom": "Bottom",
195
+ "env.section.navigation": "Navigation",
196
+ "env.row.iosSwipeGesture": "iOS swipe-back",
197
+ "env.value.iosSwipeGesture.unset": "미호출",
198
+ "env.value.iosSwipeGesture.enabled": "enabled",
199
+ "env.value.iosSwipeGesture.disabled": "disabled",
200
+ "env.hint.iosSwipeGesture": "setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.",
189
201
  "env.telemetry.section": "Telemetry",
190
202
  "env.telemetry.t0Row": "익명 사용 신호 (Tier 0)",
191
203
  "env.telemetry.t0On": "On",
@@ -250,7 +262,7 @@ const ko = {
250
262
  "viewport.status.cssPhysical": "CSS / physical",
251
263
  "viewport.status.safeArea": "Safe area",
252
264
  "viewport.status.aitNavBar": "AIT nav bar",
253
- "viewport.status.aitNavBarValue": "{height}px (excl. SafeArea) · {type}",
265
+ "viewport.status.aitNavBarValue": "{height}px SafeArea top · {type}",
254
266
  "viewport.orientation.autoSuffix": "{orient} (auto)",
255
267
  "iap.section.simulator": "IAP Simulator",
256
268
  "iap.row.nextResult": "Next Purchase Result",
@@ -395,6 +407,7 @@ const DEFAULT_STATE = {
395
407
  primaryColor: "#3182F6"
396
408
  },
397
409
  networkStatus: "WIFI",
410
+ navigation: { iosSwipeGestureEnabled: null },
398
411
  permissions: {
399
412
  clipboard: "allowed",
400
413
  contacts: "allowed",
@@ -416,7 +429,7 @@ const DEFAULT_STATE = {
416
429
  accessLocation: "FINE"
417
430
  },
418
431
  safeAreaInsets: {
419
- top: 47,
432
+ top: 54,
420
433
  bottom: 34,
421
434
  left: 0,
422
435
  right: 0
@@ -1050,7 +1063,7 @@ function readGlobalString(key) {
1050
1063
  }
1051
1064
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
1052
1065
  function getVersion() {
1053
- return "0.1.30";
1066
+ return "0.1.32";
1054
1067
  }
1055
1068
  let panelVisibleSince = null;
1056
1069
  let accumulatedMs = 0;
@@ -1587,28 +1600,39 @@ const PANEL_STYLES = `
1587
1600
  }
1588
1601
  html.ait-viewport-framed body {
1589
1602
  border-radius: 36px;
1603
+ /* Reserve the status bar strip above the WebView so the notch sits outside
1604
+ the body (OS notch is outside the WebView; env top=0). */
1605
+ margin-top: 74px;
1590
1606
  box-shadow:
1591
1607
  0 0 0 10px #1a1a2e,
1592
1608
  0 0 0 12px #3a3a5a,
1593
1609
  0 24px 48px rgba(0,0,0,0.5);
1594
1610
  }
1595
1611
 
1596
- /* Notch / Dynamic Island / punch-hole overlay (top of body) */
1612
+ /* Notch / Dynamic Island / punch-hole drawn in the status bar strip ABOVE
1613
+ the WebView (negative top puts it in the reserved margin), so it never
1614
+ overlaps the nav bar (which sits at the body's top edge). */
1597
1615
  .ait-notch {
1598
1616
  position: absolute;
1599
- top: 0;
1600
1617
  left: 50%;
1601
1618
  transform: translateX(-50%);
1602
1619
  background: #000;
1603
1620
  z-index: 10;
1604
1621
  pointer-events: none;
1605
1622
  }
1606
- .ait-notch-dynamic-island { top: 11px; width: 126px; height: 37px; border-radius: 20px; }
1623
+ .ait-notch-dynamic-island {
1624
+ top: -44px;
1625
+ width: 126px; height: 37px; border-radius: 20px;
1626
+ }
1607
1627
  .ait-notch-pill {
1628
+ top: -50px;
1608
1629
  width: 160px; height: 30px;
1609
1630
  border-bottom-left-radius: 20px; border-bottom-right-radius: 20px;
1610
1631
  }
1611
- .ait-notch-punch-hole { top: 10px; width: 12px; height: 12px; border-radius: 50%; }
1632
+ .ait-notch-punch-hole {
1633
+ top: -40px;
1634
+ width: 12px; height: 12px; border-radius: 50%;
1635
+ }
1612
1636
 
1613
1637
  /* Home indicator pill (bottom of body, iPhones with safe-area bottom > 0) */
1614
1638
  .ait-home-indicator {
@@ -1624,12 +1648,16 @@ const PANEL_STYLES = `
1624
1648
  pointer-events: none;
1625
1649
  }
1626
1650
 
1627
- /* Apps in Toss host nav bar — sits directly below the OS status bar */
1651
+ /* Apps in Toss host nav bar — sits at the top of the WebView (body). The OS
1652
+ notch lives outside the WebView (env top=0), so the nav bar bottom is the
1653
+ content's top edge; applyViewport gives body padding-top = nav bar height
1654
+ so content starts exactly below it. */
1628
1655
  .ait-navbar {
1629
1656
  position: absolute;
1657
+ top: 0;
1630
1658
  left: 0;
1631
1659
  right: 0;
1632
- height: 48px; /* AIT_NAV_BAR_HEIGHT */
1660
+ height: 54px; /* AIT_NAV_BAR_HEIGHT_PARTNER (relay 실측) */
1633
1661
  background: rgba(255, 255, 255, 0.92);
1634
1662
  backdrop-filter: blur(8px);
1635
1663
  display: flex;
@@ -2590,9 +2618,20 @@ function renderEnvironmentTab() {
2590
2618
  "OFFLINE",
2591
2619
  "WWAN",
2592
2620
  "UNKNOWN"
2593
- ], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.safeArea")), inputRow(t("env.row.safeArea.top"), String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow(t("env.row.safeArea.bottom"), String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildLanguageSection(), buildTelemetrySection());
2621
+ ], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.safeArea")), inputRow(t("env.row.safeArea.top"), String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow(t("env.row.safeArea.bottom"), String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildNavigationSection(), buildLanguageSection(), buildTelemetrySection());
2594
2622
  return container;
2595
2623
  }
2624
+ /**
2625
+ * Navigation 동작 관측 (read-only) — real(토스 WebView)에서 native bridge로 발화하는
2626
+ * no-op API의 마지막 호출값을 보여준다. Environment를 toss로 바꾸면 toss-gated 가드
2627
+ * (예: sdk-example `useDisableIosSwipeGestureInToss`)가 돌면서 이 값이 토글되므로,
2628
+ * "toss 진입 → 가드 실행 → 관측 가능한 state 변화" 루프를 패널에서 한눈에 확인할 수 있다.
2629
+ */
2630
+ function buildNavigationSection() {
2631
+ const swipe = aitState.state.navigation.iosSwipeGestureEnabled;
2632
+ const valueText = swipe === null ? t("env.value.iosSwipeGesture.unset") : swipe ? t("env.value.iosSwipeGesture.enabled") : t("env.value.iosSwipeGesture.disabled");
2633
+ return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.navigation")), h("div", { className: "ait-row" }, h("label", {}, t("env.row.iosSwipeGesture")), h("span", { style: `font-family:'SF Mono','Menlo',monospace;font-size:12px;color:${swipe === null ? "#888" : "#95e6cb"}` }, valueText)), h("div", { style: "font-size:11px;color:#666;margin-top:4px" }, t("env.hint.iosSwipeGesture")));
2634
+ }
2596
2635
  function buildLanguageSection() {
2597
2636
  const current = getLocale();
2598
2637
  const select = h("select", { className: "ait-select" });
@@ -3311,13 +3350,114 @@ async function getServerTime() {
3311
3350
  }
3312
3351
  getServerTime.isSupported = () => true;
3313
3352
  //#endregion
3353
+ //#region src/panel/device-emulation.ts
3354
+ /**
3355
+ * 기기 preset ↔ 브라우저 특성 정합
3356
+ *
3357
+ * Viewport preset이 active일 때(`none`/`custom` 아님), 그 preset이 주장하는 기기와
3358
+ * `navigator.userAgent`·`navigator.platform`·`window.devicePixelRatio`·`screen.*`를
3359
+ * 일치시킨다. 특정 기기 frame 가상환경을 제공하는 이상 UA/DPR만 호스트 데스크톱 값으로
3360
+ * 남으면 비일관적이기 때문이다 (#190).
3361
+ *
3362
+ * 한계 — page-JS override는 **JS 읽기값만** 바꾼다. 실 CSS media query(`@media`),
3363
+ * 실제 터치 이벤트, 엔진 레벨 레이아웃은 호스트 브라우저 값이 그대로다. 픽셀/입력 단위
3364
+ * 완전 emulation이 필요하면 Chrome DevTools device-mode(또는 CDP)를 쓴다. preset이
3365
+ * `none`/`custom`이면 override를 걸지 않아 일반 dev의 호스트 환경을 건드리지 않는다.
3366
+ *
3367
+ * 구현 — 대상 속성은 setter가 없어도 `configurable: true`이므로 `Object.defineProperty`로
3368
+ * getter를 덮어쓸 수 있다. 원복을 위해 최초 override 직전의 디스크립터를 저장한다.
3369
+ */
3370
+ /** preset id → 플랫폼. Apple 계열은 ios, 그 외(Galaxy)는 android. */
3371
+ function platformForPreset(presetId) {
3372
+ return presetId.startsWith("iphone") || presetId.startsWith("ipad") ? "ios" : "android";
3373
+ }
3374
+ /**
3375
+ * preset + 토스 앱 버전으로 기기 프로필을 합성한다.
3376
+ *
3377
+ * UA는 표준 모바일 UA 뒤에 `AppsInToss TossApp/<appVersion>` 토큰을 붙인다 — #171
3378
+ * 실측(`AppsInToss TossApp/5.261.0`)에서 확인된 토스 WebView UA 형태.
3379
+ *
3380
+ * @param preset portrait 기준 width/height/dpr를 가진 device preset
3381
+ * @param appVersion `aitState.state.appVersion` (UA suffix의 버전 토큰)
3382
+ * @param landscape true면 screen width/height를 swap
3383
+ */
3384
+ function buildDeviceProfile(preset, appVersion, landscape) {
3385
+ const platform = platformForPreset(preset.id);
3386
+ const tossToken = `AppsInToss TossApp/${appVersion}`;
3387
+ const baseUa = platform === "ios" ? "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" : "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
3388
+ const physWidth = Math.round(preset.width * preset.dpr);
3389
+ const physHeight = Math.round(preset.height * preset.dpr);
3390
+ return {
3391
+ platform,
3392
+ userAgent: `${baseUa} ${tossToken}`,
3393
+ navigatorPlatform: platform === "ios" ? "iPhone" : "Linux armv8l",
3394
+ devicePixelRatio: preset.dpr,
3395
+ screenWidth: landscape ? physHeight : physWidth,
3396
+ screenHeight: landscape ? physWidth : physHeight
3397
+ };
3398
+ }
3399
+ let savedDescriptors = null;
3400
+ function override(target, prop, value, saved) {
3401
+ if (!saved.some((s) => s.target === target && s.prop === prop)) saved.push({
3402
+ target,
3403
+ prop,
3404
+ descriptor: Object.getOwnPropertyDescriptor(target, prop)
3405
+ });
3406
+ try {
3407
+ Object.defineProperty(target, prop, {
3408
+ configurable: true,
3409
+ get: () => value
3410
+ });
3411
+ } catch {}
3412
+ }
3413
+ /**
3414
+ * 기기 프로필을 현재 페이지의 `navigator`/`window`/`screen`에 적용한다.
3415
+ * 호출 시 직전 디스크립터를 저장하므로 `revertDeviceEmulation()`으로 원복 가능.
3416
+ */
3417
+ function applyDeviceEmulation(profile) {
3418
+ if (typeof navigator === "undefined" || typeof window === "undefined") return;
3419
+ revertDeviceEmulation();
3420
+ const saved = [];
3421
+ override(navigator, "userAgent", profile.userAgent, saved);
3422
+ override(navigator, "platform", profile.navigatorPlatform, saved);
3423
+ override(window, "devicePixelRatio", profile.devicePixelRatio, saved);
3424
+ if (typeof screen !== "undefined") {
3425
+ override(screen, "width", profile.screenWidth, saved);
3426
+ override(screen, "height", profile.screenHeight, saved);
3427
+ }
3428
+ savedDescriptors = saved;
3429
+ }
3430
+ /** `applyDeviceEmulation`이 덮어쓴 속성을 원래 디스크립터로 되돌린다. */
3431
+ function revertDeviceEmulation() {
3432
+ if (!savedDescriptors) return;
3433
+ for (const { target, prop, descriptor } of savedDescriptors) try {
3434
+ if (descriptor) Object.defineProperty(target, prop, descriptor);
3435
+ else delete target[prop];
3436
+ } catch {}
3437
+ savedDescriptors = null;
3438
+ }
3439
+ /**
3440
+ * Viewport state를 받아 preset이 active면 emulation 적용, 아니면 원복.
3441
+ * `applyViewport`가 매 viewport 변경마다 호출한다.
3442
+ */
3443
+ function syncDeviceEmulation(preset, landscape) {
3444
+ if (!preset || preset.id === "none" || preset.id === "custom") {
3445
+ revertDeviceEmulation();
3446
+ return;
3447
+ }
3448
+ const profile = buildDeviceProfile(preset, aitState.state.appVersion, landscape);
3449
+ applyDeviceEmulation(profile);
3450
+ if (aitState.state.platform !== profile.platform) aitState.update({ platform: profile.platform });
3451
+ }
3452
+ //#endregion
3314
3453
  //#region src/panel/viewport.ts
3315
3454
  /**
3316
3455
  * Viewport 시뮬레이션 유틸
3317
3456
  *
3318
3457
  * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
3319
3458
  * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
3320
- * 동적 값(width/height, navbar top offset)만 별도 `<style>` 엘리먼트로 관리한다.
3459
+ * 동적 값(width/height, 콘텐츠 push용 body padding-top)만 별도 `<style>` 엘리먼트로
3460
+ * 관리한다.
3321
3461
  */
3322
3462
  const VIEWPORT_STORAGE_KEY = "__ait_viewport";
3323
3463
  /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
@@ -3329,14 +3469,27 @@ const NONE_PRESET = {
3329
3469
  height: 0,
3330
3470
  dpr: 1,
3331
3471
  notch: "none",
3332
- safeAreaTop: 0,
3472
+ notchInset: 0,
3473
+ navBarHeight: 0,
3333
3474
  safeAreaBottom: 0
3334
3475
  };
3335
3476
  /**
3336
3477
  * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
3337
3478
  * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
3338
3479
  * Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
3339
- * 측정치를 사용. safe area는 토스 호스트 환경 실측 필요 — S25 값으로 잠정.
3480
+ * 측정치를 사용.
3481
+ *
3482
+ * safe-area 모델 (devtools#190 relay 실측 반영):
3483
+ * - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
3484
+ * 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
3485
+ * 값은 portrait SDK top에는 들어가지 않는다.
3486
+ * - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
3487
+ * 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
3488
+ * - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
3489
+ * iPhone 15 Pro 실측 bottom 34와 일치.
3490
+ *
3491
+ * 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
3492
+ * 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
3340
3493
  *
3341
3494
  * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
3342
3495
  * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
@@ -3350,9 +3503,21 @@ const VIEWPORT_PRESETS = [
3350
3503
  height: 667,
3351
3504
  dpr: 2,
3352
3505
  notch: "none",
3353
- safeAreaTop: 20,
3506
+ notchInset: 20,
3507
+ navBarHeight: 54,
3354
3508
  safeAreaBottom: 0
3355
3509
  },
3510
+ {
3511
+ id: "iphone-15-pro",
3512
+ label: "iPhone 15 Pro",
3513
+ width: 393,
3514
+ height: 852,
3515
+ dpr: 3,
3516
+ notch: "dynamic-island",
3517
+ notchInset: 59,
3518
+ navBarHeight: 54,
3519
+ safeAreaBottom: 34
3520
+ },
3356
3521
  {
3357
3522
  id: "iphone-16e",
3358
3523
  label: "iPhone 16e",
@@ -3360,7 +3525,8 @@ const VIEWPORT_PRESETS = [
3360
3525
  height: 844,
3361
3526
  dpr: 3,
3362
3527
  notch: "notch",
3363
- safeAreaTop: 47,
3528
+ notchInset: 47,
3529
+ navBarHeight: 54,
3364
3530
  safeAreaBottom: 34
3365
3531
  },
3366
3532
  {
@@ -3370,7 +3536,8 @@ const VIEWPORT_PRESETS = [
3370
3536
  height: 874,
3371
3537
  dpr: 3,
3372
3538
  notch: "dynamic-island",
3373
- safeAreaTop: 59,
3539
+ notchInset: 59,
3540
+ navBarHeight: 54,
3374
3541
  safeAreaBottom: 34
3375
3542
  },
3376
3543
  {
@@ -3380,7 +3547,8 @@ const VIEWPORT_PRESETS = [
3380
3547
  height: 912,
3381
3548
  dpr: 3,
3382
3549
  notch: "dynamic-island",
3383
- safeAreaTop: 59,
3550
+ notchInset: 59,
3551
+ navBarHeight: 54,
3384
3552
  safeAreaBottom: 34
3385
3553
  },
3386
3554
  {
@@ -3390,7 +3558,8 @@ const VIEWPORT_PRESETS = [
3390
3558
  height: 874,
3391
3559
  dpr: 3,
3392
3560
  notch: "dynamic-island",
3393
- safeAreaTop: 59,
3561
+ notchInset: 59,
3562
+ navBarHeight: 54,
3394
3563
  safeAreaBottom: 34
3395
3564
  },
3396
3565
  {
@@ -3400,7 +3569,8 @@ const VIEWPORT_PRESETS = [
3400
3569
  height: 956,
3401
3570
  dpr: 3,
3402
3571
  notch: "dynamic-island",
3403
- safeAreaTop: 62,
3572
+ notchInset: 62,
3573
+ navBarHeight: 54,
3404
3574
  safeAreaBottom: 34
3405
3575
  },
3406
3576
  {
@@ -3410,7 +3580,8 @@ const VIEWPORT_PRESETS = [
3410
3580
  height: 773,
3411
3581
  dpr: 3,
3412
3582
  notch: "punch-hole-center",
3413
- safeAreaTop: 32,
3583
+ notchInset: 32,
3584
+ navBarHeight: 54,
3414
3585
  safeAreaBottom: 0
3415
3586
  },
3416
3587
  {
@@ -3420,7 +3591,8 @@ const VIEWPORT_PRESETS = [
3420
3591
  height: 1040,
3421
3592
  dpr: 3,
3422
3593
  notch: "punch-hole-center",
3423
- safeAreaTop: 32,
3594
+ notchInset: 32,
3595
+ navBarHeight: 54,
3424
3596
  safeAreaBottom: 0
3425
3597
  },
3426
3598
  {
@@ -3430,7 +3602,8 @@ const VIEWPORT_PRESETS = [
3430
3602
  height: 1040,
3431
3603
  dpr: 3,
3432
3604
  notch: "punch-hole-center",
3433
- safeAreaTop: 40,
3605
+ notchInset: 40,
3606
+ navBarHeight: 54,
3434
3607
  safeAreaBottom: 0
3435
3608
  },
3436
3609
  {
@@ -3440,7 +3613,8 @@ const VIEWPORT_PRESETS = [
3440
3613
  height: 990,
3441
3614
  dpr: 3,
3442
3615
  notch: "punch-hole-center",
3443
- safeAreaTop: 36,
3616
+ notchInset: 36,
3617
+ navBarHeight: 54,
3444
3618
  safeAreaBottom: 0
3445
3619
  },
3446
3620
  {
@@ -3450,7 +3624,8 @@ const VIEWPORT_PRESETS = [
3450
3624
  height: 870,
3451
3625
  dpr: 3,
3452
3626
  notch: "punch-hole-center",
3453
- safeAreaTop: 32,
3627
+ notchInset: 32,
3628
+ navBarHeight: 54,
3454
3629
  safeAreaBottom: 0
3455
3630
  },
3456
3631
  {
@@ -3460,7 +3635,8 @@ const VIEWPORT_PRESETS = [
3460
3635
  height: 884,
3461
3636
  dpr: 2.625,
3462
3637
  notch: "punch-hole-center",
3463
- safeAreaTop: 32,
3638
+ notchInset: 32,
3639
+ navBarHeight: 54,
3464
3640
  safeAreaBottom: 0
3465
3641
  },
3466
3642
  {
@@ -3470,7 +3646,8 @@ const VIEWPORT_PRESETS = [
3470
3646
  height: 0,
3471
3647
  dpr: 1,
3472
3648
  notch: "none",
3473
- safeAreaTop: 0,
3649
+ notchInset: 0,
3650
+ navBarHeight: 0,
3474
3651
  safeAreaBottom: 0
3475
3652
  }
3476
3653
  ];
@@ -3511,23 +3688,30 @@ function resolveViewportSize(state) {
3511
3688
  };
3512
3689
  }
3513
3690
  /**
3514
- * 프리셋 + landscape 여부 + landscape side로부터 OS-level safe-area insets를 계산한다.
3691
+ * 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
3692
+ * 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190)에 맞춘 모델:
3515
3693
  *
3516
- * - Portrait: preset의 `safeAreaTop`, `safeAreaBottom`을 그대로 사용.
3517
- * - Landscape iPhone(notch/Dynamic Island): 노치가 한쪽으로 가므로 `landscapeSide`에
3518
- * 따라 left 또는 right에만 인셋을 준다 (실 기기 동작과 일치). top 0,
3519
- * home-indicator는 bottom에 유지.
3520
- * - Android punch-hole(status bar): landscape 시에도 top에 status bar가 유지된다.
3694
+ * - **Portrait top = 토스 nav bar 높이** (OS 노치가 아니다). 실측에서
3695
+ * `env(safe-area-inset-top)` = 0, `SafeAreaInsets.get().top` = 54 였고, 그 54는 호스트
3696
+ * nav bar다. 따라서 nav bar가 있고 `partner` type일 때만 `navBarHeight`를 top 준다.
3697
+ * `game`(투명 오버레이, 콘텐츠 안 밀어냄) 또는 nav bar 미표시면 top = 0.
3698
+ * - **Bottom = `safeAreaBottom`** (home-indicator). 실측 34와 일치.
3699
+ * - **Landscape iPhone(notch/Dynamic Island)**: 노치가 한쪽으로 가므로 `landscapeSide`에
3700
+ * 따라 left/right 한쪽에만 `notchInset`을 준다. top은 0(landscape nav bar 거동은
3701
+ * 미실측 — portrait 모델만 확정), home-indicator는 bottom에 유지.
3702
+ * - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
3703
+ * 유지된다.
3521
3704
  */
3522
- function computeSafeAreaInsets(preset, landscape, side) {
3705
+ function computeSafeAreaInsets(preset, landscape, side, navBarVisible, navBarType) {
3523
3706
  if (preset.id === "none" || preset.id === "custom") return {
3524
3707
  top: 0,
3525
3708
  bottom: 0,
3526
3709
  left: 0,
3527
3710
  right: 0
3528
3711
  };
3712
+ const navBarTop = navBarVisible && navBarType === "partner" ? preset.navBarHeight : 0;
3529
3713
  if (!landscape) return {
3530
- top: preset.safeAreaTop,
3714
+ top: navBarTop,
3531
3715
  bottom: preset.safeAreaBottom,
3532
3716
  left: 0,
3533
3717
  right: 0
@@ -3535,11 +3719,11 @@ function computeSafeAreaInsets(preset, landscape, side) {
3535
3719
  if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
3536
3720
  top: 0,
3537
3721
  bottom: preset.safeAreaBottom,
3538
- left: side === "left" ? preset.safeAreaTop : 0,
3539
- right: side === "right" ? preset.safeAreaTop : 0
3722
+ left: side === "left" ? preset.notchInset : 0,
3723
+ right: side === "right" ? preset.notchInset : 0
3540
3724
  };
3541
3725
  return {
3542
- top: preset.safeAreaTop,
3726
+ top: preset.notchInset,
3543
3727
  bottom: preset.safeAreaBottom,
3544
3728
  left: 0,
3545
3729
  right: 0
@@ -3548,7 +3732,7 @@ function computeSafeAreaInsets(preset, landscape, side) {
3548
3732
  /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
3549
3733
  function syncSafeAreaFromViewport(state) {
3550
3734
  if (state.preset === "none" || state.preset === "custom") return;
3551
- const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide);
3735
+ const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide, state.aitNavBar, state.aitNavBarType);
3552
3736
  const current = aitState.state.safeAreaInsets;
3553
3737
  if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
3554
3738
  aitState.update({ safeAreaInsets: next });
@@ -3582,27 +3766,30 @@ function removeNavBarElement() {
3582
3766
  removeById(NAV_BAR_ELEMENT_ID);
3583
3767
  }
3584
3768
  /**
3585
- * Apps in Toss host nav bar 렌더. OS status bar 아래에 48px 높이로 쌓인다.
3769
+ * Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
3586
3770
  *
3587
3771
  * 변형(SDK `webViewProps.type`과 의미 일치):
3588
3772
  * - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
3589
3773
  * 우측 `⋯` + 구분선 + `×`.
3590
3774
  * - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
3591
3775
  *
3592
- * `env(safe-area-inset-top)`에는 높이가 포함되지 않으므로 (SDK 동작 확인),
3593
- * 오버레이는 preset.safeAreaTop만큼 아래로 내려서 그린다.
3776
+ * nav bar는 WebView(body) 좌표계의 최상단(top 0)에 앉는다 실기기에서 OS notch는
3777
+ * WebView 밖(status bar)이라 `env(safe-area-inset-top)`이 0이고, WebView 콘텐츠 영역은
3778
+ * nav bar 바로 아래(= SDK `SafeAreaInsets.get().top` = `navBarHeight`)에서 시작한다.
3779
+ * 콘텐츠를 그만큼 밀어내는 건 `applyViewport`의 body `padding-top`이 담당하므로, nav bar
3780
+ * 바닥과 콘텐츠 시작이 정확히 맞물린다. 시각 notch 오버레이는 body 밖 위쪽(status bar
3781
+ * 영역)에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
3594
3782
  *
3595
3783
  * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
3596
3784
  * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
3597
3785
  */
3598
- function renderNavBar(preset, displayName, type) {
3786
+ function renderNavBar(displayName, type) {
3599
3787
  removeNavBarElement();
3600
3788
  const el = h("div", {
3601
3789
  id: NAV_BAR_ELEMENT_ID,
3602
3790
  className: `ait-navbar ait-navbar-${type}`,
3603
3791
  "aria-hidden": "true"
3604
3792
  });
3605
- el.style.top = `${preset.safeAreaTop}px`;
3606
3793
  const moreBtn = h("button", {
3607
3794
  className: "ait-navbar-btn",
3608
3795
  type: "button",
@@ -3680,12 +3867,13 @@ function disposeViewport() {
3680
3867
  removeNotchElement();
3681
3868
  removeHomeIndicator();
3682
3869
  removeNavBarElement();
3870
+ revertDeviceEmulation();
3683
3871
  bodyScrollHintEmitted = false;
3684
3872
  }
3685
3873
  /**
3686
3874
  * DOM에 뷰포트 제약을 적용한다.
3687
3875
  * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
3688
- * - body의 width/height는 preset 값으로, navbar top offset은 safeAreaTop으로 인라인 주입
3876
+ * - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
3689
3877
  */
3690
3878
  function applyViewport(state) {
3691
3879
  if (typeof document === "undefined") return;
@@ -3700,6 +3888,7 @@ function applyViewport(state) {
3700
3888
  removeNotchElement();
3701
3889
  removeHomeIndicator();
3702
3890
  removeNavBarElement();
3891
+ syncDeviceEmulation(null, false);
3703
3892
  return;
3704
3893
  }
3705
3894
  if (!bodyScrollHintEmitted) {
@@ -3710,19 +3899,22 @@ function applyViewport(state) {
3710
3899
  html.classList.toggle("ait-viewport-framed", state.frame);
3711
3900
  const preset = state.preset === "custom" ? null : getPreset(state.preset);
3712
3901
  const landscape = effectiveOrientation(state) === "landscape";
3902
+ syncDeviceEmulation(preset, landscape);
3903
+ const contentTop = preset ? computeSafeAreaInsets(preset, landscape, state.landscapeSide, state.aitNavBar, state.aitNavBarType).top : 0;
3713
3904
  style.textContent = `
3714
3905
  html.ait-viewport-active body {
3715
3906
  width: ${size.width}px;
3716
3907
  max-width: ${size.width}px;
3717
3908
  min-height: ${size.height}px;
3718
3909
  max-height: ${size.height}px;
3910
+ padding-top: ${contentTop}px;
3719
3911
  }
3720
3912
  `;
3721
3913
  if (preset && state.frame && !landscape) renderNotchOverlay(preset);
3722
3914
  else removeNotchElement();
3723
3915
  if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
3724
3916
  else removeHomeIndicator();
3725
- if (preset && state.aitNavBar && !landscape) renderNavBar(preset, aitState.state.brand.displayName, state.aitNavBarType);
3917
+ if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
3726
3918
  else removeNavBarElement();
3727
3919
  }
3728
3920
  function isViewportPresetId(v) {
@@ -3941,13 +4133,16 @@ function renderViewportTab() {
3941
4133
  const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
3942
4134
  rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.cssPhysical")), h("span", { className: "ait-status-value" }, `${size.width}×${size.height}@${dpr}x | ${physW}×${physH} ${orientDisplay}`)));
3943
4135
  if (preset) {
3944
- const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide);
4136
+ const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide, vp.aitNavBar, vp.aitNavBarType);
3945
4137
  rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`)));
3946
4138
  }
3947
- if (vp.aitNavBar && !landscape) rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
3948
- height: 48,
3949
- type: vp.aitNavBarType
3950
- }))));
4139
+ if (vp.aitNavBar && !landscape) {
4140
+ const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
4141
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
4142
+ height: navBarTop,
4143
+ type: vp.aitNavBarType
4144
+ }))));
4145
+ }
3951
4146
  for (const row of rows) statusEl.appendChild(row);
3952
4147
  }
3953
4148
  const deviceSection = h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("viewport.section.device")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.preset")), presetSelect), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.orientation")), orientationSelect));
@@ -4242,7 +4437,7 @@ function mount() {
4242
4437
  mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
4243
4438
  refreshPanel();
4244
4439
  });
4245
- 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.30`), closeBtn);
4440
+ 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.32`), closeBtn);
4246
4441
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
4247
4442
  tabsEl = h("div", { className: "ait-panel-tabs" });
4248
4443
  for (const tab of getTabs()) {
@@ -4284,7 +4479,7 @@ function mount() {
4284
4479
  window.addEventListener("resize", resizeHandler);
4285
4480
  aitStateUnsubscribe = aitState.subscribe(() => {
4286
4481
  try {
4287
- if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
4482
+ if (isOpen && (currentTab === "env" || currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
4288
4483
  } catch (err) {
4289
4484
  console.error("[@ait-co/devtools] Error in subscribe callback:", err);
4290
4485
  }