@ait-co/devtools 0.1.31 → 0.1.33

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.
@@ -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",
@@ -84,6 +90,10 @@ const en = {
84
90
  "device.prompt.label.lng": "Lng",
85
91
  "device.prompt.send": "Send",
86
92
  "device.prompt.cancel": "Cancel",
93
+ "device.section.haptic": "Haptic",
94
+ "device.haptic.lastCall": "Last haptic",
95
+ "device.haptic.noneYet": "(none yet)",
96
+ "device.haptic.trigger": "Trigger haptic",
87
97
  "viewport.section.device": "Device",
88
98
  "viewport.row.preset": "Preset",
89
99
  "viewport.row.orientation": "Orientation",
@@ -99,7 +109,7 @@ const en = {
99
109
  "viewport.status.cssPhysical": "CSS / physical",
100
110
  "viewport.status.safeArea": "Safe area",
101
111
  "viewport.status.aitNavBar": "AIT nav bar",
102
- "viewport.status.aitNavBarValue": "{height}px (excl. SafeArea) · {type}",
112
+ "viewport.status.aitNavBarValue": "{height}px SafeArea top · {type}",
103
113
  "viewport.orientation.autoSuffix": "{orient} (auto)",
104
114
  "iap.section.simulator": "IAP Simulator",
105
115
  "iap.row.nextResult": "Next Purchase Result",
@@ -119,6 +129,9 @@ const en = {
119
129
  "events.row.tossLoginIntegrated": "Toss Login Integrated",
120
130
  "analytics.section.log": "Analytics Log ({count})",
121
131
  "analytics.btn.clear": "Clear",
132
+ "analytics.calls.section": "SDK Calls ({count})",
133
+ "analytics.calls.btn.clear": "Clear",
134
+ "analytics.calls.empty": "(no SDK calls yet)",
122
135
  "storage.section.title": "Storage ({count} items)",
123
136
  "storage.btn.clearAll": "Clear All",
124
137
  "storage.empty": "No items in storage",
@@ -143,6 +156,13 @@ const en = {
143
156
  "ads.section.fullScreenAd": "FullScreenAd",
144
157
  "ads.btn.load": "Load",
145
158
  "ads.btn.show": "Show",
159
+ "ads.section.tossAdsBanner": "TossAds Banner",
160
+ "ads.row.rewardUnitType": "Reward unit type",
161
+ "ads.row.rewardAmount": "Reward amount",
162
+ "ads.btn.render": "Render",
163
+ "ads.btn.noFill": "No-fill",
164
+ "ads.btn.click": "Click",
165
+ "ads.btn.destroy": "Destroy",
146
166
  "notifications.section.title": "requestNotificationAgreement",
147
167
  "notifications.option.newAgreement": "newAgreement (first-time agree)",
148
168
  "notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
@@ -186,6 +206,12 @@ const ko = {
186
206
  "env.section.safeArea": "Safe Area Insets",
187
207
  "env.row.safeArea.top": "Top",
188
208
  "env.row.safeArea.bottom": "Bottom",
209
+ "env.section.navigation": "Navigation",
210
+ "env.row.iosSwipeGesture": "iOS swipe-back",
211
+ "env.value.iosSwipeGesture.unset": "미호출",
212
+ "env.value.iosSwipeGesture.enabled": "enabled",
213
+ "env.value.iosSwipeGesture.disabled": "disabled",
214
+ "env.hint.iosSwipeGesture": "setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.",
189
215
  "env.telemetry.section": "Telemetry",
190
216
  "env.telemetry.t0Row": "익명 사용 신호 (Tier 0)",
191
217
  "env.telemetry.t0On": "On",
@@ -235,6 +261,10 @@ const ko = {
235
261
  "device.prompt.label.lng": "Lng",
236
262
  "device.prompt.send": "Send",
237
263
  "device.prompt.cancel": "Cancel",
264
+ "device.section.haptic": "Haptic",
265
+ "device.haptic.lastCall": "마지막 haptic",
266
+ "device.haptic.noneYet": "(아직 없음)",
267
+ "device.haptic.trigger": "Haptic 트리거",
238
268
  "viewport.section.device": "Device",
239
269
  "viewport.row.preset": "Preset",
240
270
  "viewport.row.orientation": "Orientation",
@@ -250,7 +280,7 @@ const ko = {
250
280
  "viewport.status.cssPhysical": "CSS / physical",
251
281
  "viewport.status.safeArea": "Safe area",
252
282
  "viewport.status.aitNavBar": "AIT nav bar",
253
- "viewport.status.aitNavBarValue": "{height}px (excl. SafeArea) · {type}",
283
+ "viewport.status.aitNavBarValue": "{height}px SafeArea top · {type}",
254
284
  "viewport.orientation.autoSuffix": "{orient} (auto)",
255
285
  "iap.section.simulator": "IAP Simulator",
256
286
  "iap.row.nextResult": "Next Purchase Result",
@@ -270,6 +300,9 @@ const ko = {
270
300
  "events.row.tossLoginIntegrated": "Toss Login Integrated",
271
301
  "analytics.section.log": "Analytics Log ({count})",
272
302
  "analytics.btn.clear": "Clear",
303
+ "analytics.calls.section": "SDK Calls ({count})",
304
+ "analytics.calls.btn.clear": "Clear",
305
+ "analytics.calls.empty": "(아직 SDK 호출 없음)",
273
306
  "storage.section.title": "Storage ({count} items)",
274
307
  "storage.btn.clearAll": "Clear All",
275
308
  "storage.empty": "저장된 항목이 없습니다",
@@ -294,6 +327,13 @@ const ko = {
294
327
  "ads.section.fullScreenAd": "FullScreenAd",
295
328
  "ads.btn.load": "Load",
296
329
  "ads.btn.show": "Show",
330
+ "ads.section.tossAdsBanner": "TossAds 배너",
331
+ "ads.row.rewardUnitType": "리워드 단위 타입",
332
+ "ads.row.rewardAmount": "리워드 수량",
333
+ "ads.btn.render": "Render",
334
+ "ads.btn.noFill": "No-fill",
335
+ "ads.btn.click": "Click",
336
+ "ads.btn.destroy": "Destroy",
297
337
  "notifications.section.title": "requestNotificationAgreement",
298
338
  "notifications.option.newAgreement": "newAgreement (최초 동의)",
299
339
  "notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
@@ -380,6 +420,8 @@ function t(key, vars) {
380
420
  }
381
421
  //#endregion
382
422
  //#region src/mock/state.ts
423
+ /** SDK 호출 로그 ring buffer 상한 */
424
+ const SDK_CALL_LOG_MAX = 200;
383
425
  const DEFAULT_STATE = {
384
426
  platform: "ios",
385
427
  environment: "sandbox",
@@ -395,6 +437,7 @@ const DEFAULT_STATE = {
395
437
  primaryColor: "#3182F6"
396
438
  },
397
439
  networkStatus: "WIFI",
440
+ navigation: { iosSwipeGestureEnabled: null },
398
441
  permissions: {
399
442
  clipboard: "allowed",
400
443
  contacts: "allowed",
@@ -416,7 +459,7 @@ const DEFAULT_STATE = {
416
459
  accessLocation: "FINE"
417
460
  },
418
461
  safeAreaInsets: {
419
- top: 47,
462
+ top: 54,
420
463
  bottom: 34,
421
464
  left: 0,
422
465
  right: 0
@@ -456,7 +499,9 @@ const DEFAULT_STATE = {
456
499
  isLoaded: false,
457
500
  nextEvent: "loaded",
458
501
  forceNoFill: false,
459
- lastEvent: null
502
+ lastEvent: null,
503
+ rewardUnitType: "coins",
504
+ rewardAmount: 10
460
505
  },
461
506
  game: {
462
507
  profile: {
@@ -466,6 +511,7 @@ const DEFAULT_STATE = {
466
511
  leaderboardScores: []
467
512
  },
468
513
  analyticsLog: [],
514
+ sdkCallLog: [],
469
515
  deviceModes: {
470
516
  camera: "mock",
471
517
  photos: "mock",
@@ -578,6 +624,19 @@ var AitStateManager = class {
578
624
  };
579
625
  this._notify();
580
626
  }
627
+ /**
628
+ * SDK 호출 로그 추가 (ring buffer, 상한 SDK_CALL_LOG_MAX).
629
+ * `observe()`가 호출하고, proxy의 KNOWN_UNIMPLEMENTED 경로도 직접 호출한다.
630
+ */
631
+ logSdkCall(entry) {
632
+ const log = this._state.sdkCallLog;
633
+ const next = log.length >= SDK_CALL_LOG_MAX ? log.slice(1 - SDK_CALL_LOG_MAX) : log;
634
+ this._state = {
635
+ ...this._state,
636
+ sdkCallLog: [...next, entry]
637
+ };
638
+ this._notify();
639
+ }
581
640
  /** 이벤트 트리거 (backEvent, homeEvent 등) */
582
641
  trigger(event) {
583
642
  window.dispatchEvent(new CustomEvent(`__ait:${event}`));
@@ -1050,7 +1109,7 @@ function readGlobalString(key) {
1050
1109
  }
1051
1110
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
1052
1111
  function getVersion() {
1053
- return "0.1.31";
1112
+ return "0.1.33";
1054
1113
  }
1055
1114
  let panelVisibleSince = null;
1056
1115
  let accumulatedMs = 0;
@@ -1587,28 +1646,39 @@ const PANEL_STYLES = `
1587
1646
  }
1588
1647
  html.ait-viewport-framed body {
1589
1648
  border-radius: 36px;
1649
+ /* Reserve the status bar strip above the WebView so the notch sits outside
1650
+ the body (OS notch is outside the WebView; env top=0). */
1651
+ margin-top: 74px;
1590
1652
  box-shadow:
1591
1653
  0 0 0 10px #1a1a2e,
1592
1654
  0 0 0 12px #3a3a5a,
1593
1655
  0 24px 48px rgba(0,0,0,0.5);
1594
1656
  }
1595
1657
 
1596
- /* Notch / Dynamic Island / punch-hole overlay (top of body) */
1658
+ /* Notch / Dynamic Island / punch-hole drawn in the status bar strip ABOVE
1659
+ the WebView (negative top puts it in the reserved margin), so it never
1660
+ overlaps the nav bar (which sits at the body's top edge). */
1597
1661
  .ait-notch {
1598
1662
  position: absolute;
1599
- top: 0;
1600
1663
  left: 50%;
1601
1664
  transform: translateX(-50%);
1602
1665
  background: #000;
1603
1666
  z-index: 10;
1604
1667
  pointer-events: none;
1605
1668
  }
1606
- .ait-notch-dynamic-island { top: 11px; width: 126px; height: 37px; border-radius: 20px; }
1669
+ .ait-notch-dynamic-island {
1670
+ top: -44px;
1671
+ width: 126px; height: 37px; border-radius: 20px;
1672
+ }
1607
1673
  .ait-notch-pill {
1674
+ top: -50px;
1608
1675
  width: 160px; height: 30px;
1609
1676
  border-bottom-left-radius: 20px; border-bottom-right-radius: 20px;
1610
1677
  }
1611
- .ait-notch-punch-hole { top: 10px; width: 12px; height: 12px; border-radius: 50%; }
1678
+ .ait-notch-punch-hole {
1679
+ top: -40px;
1680
+ width: 12px; height: 12px; border-radius: 50%;
1681
+ }
1612
1682
 
1613
1683
  /* Home indicator pill (bottom of body, iPhones with safe-area bottom > 0) */
1614
1684
  .ait-home-indicator {
@@ -1624,12 +1694,16 @@ const PANEL_STYLES = `
1624
1694
  pointer-events: none;
1625
1695
  }
1626
1696
 
1627
- /* Apps in Toss host nav bar — sits directly below the OS status bar */
1697
+ /* Apps in Toss host nav bar — sits at the top of the WebView (body). The OS
1698
+ notch lives outside the WebView (env top=0), so the nav bar bottom is the
1699
+ content's top edge; applyViewport gives body padding-top = nav bar height
1700
+ so content starts exactly below it. */
1628
1701
  .ait-navbar {
1629
1702
  position: absolute;
1703
+ top: 0;
1630
1704
  left: 0;
1631
1705
  right: 0;
1632
- height: 48px; /* AIT_NAV_BAR_HEIGHT */
1706
+ height: 54px; /* AIT_NAV_BAR_HEIGHT_PARTNER (relay 실측) */
1633
1707
  background: rgba(255, 255, 255, 0.92);
1634
1708
  backdrop-filter: blur(8px);
1635
1709
  display: flex;
@@ -2045,6 +2119,75 @@ const _fetchContacts = async (options) => {
2045
2119
  };
2046
2120
  withPermission(_fetchContacts, "contacts");
2047
2121
  //#endregion
2122
+ //#region src/mock/device/haptic.ts
2123
+ /**
2124
+ * Haptic Feedback & saveBase64Data mock
2125
+ *
2126
+ * generateHapticFeedback — 영역 3 (하드웨어 API 관측):
2127
+ * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
2128
+ * - `typeof navigator.vibrate === 'function'` 가드 — API 없는 환경에서 throw 없이 skip.
2129
+ * - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
2130
+ * - 시그니처 불변 — __typecheck.ts의 Assert<Mock, Original> 통과.
2131
+ */
2132
+ /**
2133
+ * HapticFeedbackType 10종 → navigator.vibrate 패턴 매핑.
2134
+ * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
2135
+ */
2136
+ const HAPTIC_VIBRATE_PATTERN = {
2137
+ tickWeak: 10,
2138
+ tap: 20,
2139
+ tickMedium: 30,
2140
+ softMedium: 40,
2141
+ basicWeak: 15,
2142
+ basicMedium: 50,
2143
+ success: [
2144
+ 10,
2145
+ 40,
2146
+ 10
2147
+ ],
2148
+ error: [
2149
+ 40,
2150
+ 30,
2151
+ 40
2152
+ ],
2153
+ wiggle: [
2154
+ 20,
2155
+ 20,
2156
+ 20,
2157
+ 20,
2158
+ 20
2159
+ ],
2160
+ confetti: [
2161
+ 10,
2162
+ 20,
2163
+ 10,
2164
+ 20,
2165
+ 10,
2166
+ 20,
2167
+ 10
2168
+ ]
2169
+ };
2170
+ async function generateHapticFeedback(options) {
2171
+ const timestamp = Date.now();
2172
+ aitState.logAnalytics({
2173
+ type: "haptic",
2174
+ params: { hapticType: options.type }
2175
+ });
2176
+ const pattern = HAPTIC_VIBRATE_PATTERN[options.type] ?? 30;
2177
+ const vibrated = typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
2178
+ aitState.logSdkCall({
2179
+ method: "generateHapticFeedback",
2180
+ args: [{ type: options.type }],
2181
+ timestamp,
2182
+ status: "resolved",
2183
+ result: {
2184
+ hapticType: options.type,
2185
+ vibrated
2186
+ },
2187
+ fidelity: "partial"
2188
+ });
2189
+ }
2190
+ //#endregion
2048
2191
  //#region src/mock/device/location.ts
2049
2192
  /**
2050
2193
  * Location mock (getCurrentLocation, startUpdateLocation)
@@ -2156,12 +2299,35 @@ withPermission(_startUpdateLocation, "geolocation");
2156
2299
  * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
2157
2300
  * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
2158
2301
  * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
2302
+ *
2303
+ * ## KNOWN_UNIMPLEMENTED 정책
2304
+ * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
2305
+ * 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
2306
+ * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
2159
2307
  */
2160
2308
  const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
2309
+ /**
2310
+ * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
2311
+ * 새 API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
2312
+ * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
2313
+ */
2314
+ const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
2161
2315
  function createMockProxy(moduleName, implementations) {
2162
2316
  return new Proxy(implementations, { get(target, prop) {
2163
2317
  if (typeof prop === "symbol") return void 0;
2164
2318
  if (prop in target) return target[prop];
2319
+ const name = String(prop);
2320
+ if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
2321
+ console.warn(`[@ait-co/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
2322
+ aitState.logSdkCall({
2323
+ method: `${moduleName}.${name}`,
2324
+ args,
2325
+ timestamp: Date.now(),
2326
+ status: "resolved",
2327
+ result: void 0,
2328
+ fidelity: "inert"
2329
+ });
2330
+ };
2165
2331
  throw new Error(`[@ait-co/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
2166
2332
  } });
2167
2333
  }
@@ -2369,19 +2535,127 @@ function renderDeviceTab() {
2369
2535
  });
2370
2536
  if (disabled) clearImagesBtn.disabled = true;
2371
2537
  container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("device.section.mockImages", { count: images.length })), imageGrid, h("div", { className: "ait-btn-row" }, addBtn, defaultsBtn, clearImagesBtn)));
2538
+ container.appendChild(renderHapticSection());
2372
2539
  return container;
2373
2540
  }
2541
+ function renderHapticSection() {
2542
+ const lastResult = [...aitState.state.sdkCallLog].reverse().find((e) => e.method === "generateHapticFeedback")?.result;
2543
+ const lastCallValue = lastResult ? `${lastResult.hapticType} (vibrated: ${String(lastResult.vibrated)})` : t("device.haptic.noneYet");
2544
+ const lastCallRow = h("div", { className: "ait-row" }, h("label", {}, t("device.haptic.lastCall")), h("span", { className: "ait-value" }, lastCallValue));
2545
+ const hapticTypes = Object.keys(HAPTIC_VIBRATE_PATTERN);
2546
+ const triggerSection = h("div", { className: "ait-section-subtitle" }, t("device.haptic.trigger"));
2547
+ const btnRow = h("div", { className: "ait-btn-row" }, ...hapticTypes.map((type) => {
2548
+ const btn = h("button", {
2549
+ className: "ait-btn-secondary",
2550
+ "data-testid": `haptic-${type}-btn`
2551
+ }, type);
2552
+ btn.addEventListener("click", () => {
2553
+ generateHapticFeedback({ type }).then(() => {
2554
+ refreshPanel$1();
2555
+ });
2556
+ });
2557
+ return btn;
2558
+ }));
2559
+ return h("div", {
2560
+ className: "ait-section",
2561
+ "data-testid": "section-haptic"
2562
+ }, h("div", { className: "ait-section-title" }, t("device.section.haptic")), lastCallRow, triggerSection, btnRow);
2563
+ }
2564
+ //#endregion
2565
+ //#region src/mock/observe.ts
2566
+ /**
2567
+ * fn을 observe로 감싼다.
2568
+ *
2569
+ * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
2570
+ * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
2571
+ * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
2572
+ * @returns fn과 동일한 타입의 래퍼 함수
2573
+ */
2574
+ function observe(apiName, fidelity, fn) {
2575
+ return (...args) => {
2576
+ const timestamp = Date.now();
2577
+ const safeArgs = args.map((a) => safeSerialize(a));
2578
+ const result = fn(...args);
2579
+ if (result instanceof Promise) {
2580
+ aitState.logSdkCall({
2581
+ method: apiName,
2582
+ args: safeArgs,
2583
+ timestamp,
2584
+ status: "pending",
2585
+ fidelity
2586
+ });
2587
+ result.then((value) => {
2588
+ aitState.logSdkCall({
2589
+ method: apiName,
2590
+ args: safeArgs,
2591
+ timestamp,
2592
+ status: "resolved",
2593
+ result: safeSerialize(value),
2594
+ fidelity
2595
+ });
2596
+ }, (err) => {
2597
+ aitState.logSdkCall({
2598
+ method: apiName,
2599
+ args: safeArgs,
2600
+ timestamp,
2601
+ status: "rejected",
2602
+ error: err instanceof Error ? err.message : String(err),
2603
+ fidelity
2604
+ });
2605
+ });
2606
+ return result;
2607
+ }
2608
+ aitState.logSdkCall({
2609
+ method: apiName,
2610
+ args: safeArgs,
2611
+ timestamp,
2612
+ status: "resolved",
2613
+ result: safeSerialize(result),
2614
+ fidelity
2615
+ });
2616
+ return result;
2617
+ };
2618
+ }
2619
+ /**
2620
+ * 값을 JSON-safe한 형태로 변환한다.
2621
+ * - null / primitive — 그대로.
2622
+ * - 함수 — `'[Function: name]'` 문자열.
2623
+ * - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
2624
+ */
2625
+ function safeSerialize(value) {
2626
+ if (value === null || value === void 0) return value;
2627
+ if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
2628
+ if (typeof value !== "object") return value;
2629
+ try {
2630
+ return JSON.parse(JSON.stringify(value));
2631
+ } catch {
2632
+ return "[unserializable]";
2633
+ }
2634
+ }
2374
2635
  //#endregion
2375
2636
  //#region src/mock/ads/index.ts
2376
2637
  /**
2377
2638
  * 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
2639
+ *
2640
+ * 변경 이력 (#196):
2641
+ * - slot 레지스트리로 TossAds destroy/destroyAll 누수 수정 (🟡→🟢)
2642
+ * - attachBanner BannerSlotCallbacks 발화 (onAdRendered/onAdImpression/onNoFill 등)
2643
+ * - initialize onInitialized/onInitializationFailed 발화
2644
+ * - AdMob reward 파라미터화 (state.ads.rewardUnitType/rewardAmount)
2645
+ * - 모든 호출 observe()로 sdkCallLog에 기록
2378
2646
  */
2379
2647
  function withIsSupported(fn) {
2380
2648
  fn.isSupported = () => true;
2381
2649
  return fn;
2382
2650
  }
2651
+ const _slotRegistry = /* @__PURE__ */ new Map();
2652
+ let _slotCounter = 0;
2653
+ function _nextSlotId(adGroupId) {
2654
+ _slotCounter += 1;
2655
+ return `mock-slot-${adGroupId}-${_slotCounter}`;
2656
+ }
2383
2657
  const GoogleAdMob = createMockProxy("GoogleAdMob", {
2384
- loadAppsInTossAdMob: withIsSupported((args) => {
2658
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
2385
2659
  setTimeout(() => {
2386
2660
  if (aitState.state.ads.forceNoFill) {
2387
2661
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -2394,8 +2668,8 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2394
2668
  });
2395
2669
  }, 200);
2396
2670
  return () => {};
2397
- }),
2398
- showAppsInTossAdMob: withIsSupported((args) => {
2671
+ })),
2672
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
2399
2673
  if (!aitState.state.ads.isLoaded) {
2400
2674
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
2401
2675
  return () => {};
@@ -2404,11 +2678,12 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2404
2678
  setTimeout(() => args.onEvent({ type: "show" }), 100);
2405
2679
  setTimeout(() => args.onEvent({ type: "impression" }), 150);
2406
2680
  setTimeout(() => {
2681
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
2407
2682
  args.onEvent({
2408
2683
  type: "userEarnedReward",
2409
2684
  data: {
2410
- unitType: "coins",
2411
- unitAmount: 10
2685
+ unitType: rewardUnitType,
2686
+ unitAmount: rewardAmount
2412
2687
  }
2413
2688
  });
2414
2689
  }, 1e3);
@@ -2417,14 +2692,18 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2417
2692
  aitState.patch("ads", { isLoaded: false });
2418
2693
  }, 1500);
2419
2694
  return () => {};
2420
- }),
2421
- isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
2695
+ })),
2696
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
2422
2697
  });
2423
- createMockProxy("TossAds", {
2424
- initialize: withIsSupported((_options) => {
2425
- console.log("[@ait-co/devtools] TossAds.initialize (mock)");
2426
- }),
2427
- attach: withIsSupported((_adGroupId, target, _options) => {
2698
+ const TossAds = createMockProxy("TossAds", {
2699
+ initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
2700
+ if (aitState.state.ads.forceNoFill) {
2701
+ options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
2702
+ return;
2703
+ }
2704
+ options.callbacks?.onInitialized?.();
2705
+ })),
2706
+ attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
2428
2707
  const el = typeof target === "string" ? document.querySelector(target) : target;
2429
2708
  if (el) {
2430
2709
  const placeholder = document.createElement("div");
@@ -2432,21 +2711,76 @@ createMockProxy("TossAds", {
2432
2711
  placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
2433
2712
  el.appendChild(placeholder);
2434
2713
  }
2435
- }),
2436
- attachBanner: withIsSupported((_adGroupId, target, _options) => {
2714
+ })),
2715
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
2437
2716
  const el = typeof target === "string" ? document.querySelector(target) : target;
2717
+ const slotId = _nextSlotId(adGroupId);
2718
+ const placeholder = document.createElement("div");
2719
+ const theme = options?.theme ?? "auto";
2720
+ const variant = options?.variant ?? "card";
2721
+ const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
2722
+ const bg = isDark ? "#1a1a1a" : "#f0f0f0";
2723
+ const textColor = isDark ? "#aaa" : "#666";
2724
+ const borderColor = isDark ? "#555" : "#999";
2725
+ const height = variant === "expanded" ? "120px" : "60px";
2726
+ placeholder.dataset.aitSlotId = slotId;
2727
+ placeholder.style.cssText = `background:${bg};border:1px dashed ${borderColor};padding:8px 12px;text-align:center;color:${textColor};font-size:12px;min-height:${height};display:flex;align-items:center;justify-content:center;`;
2728
+ placeholder.textContent = `[@ait-co/devtools] Banner Ad (${variant})`;
2438
2729
  if (el) {
2439
- const placeholder = document.createElement("div");
2440
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
2441
- placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
2442
2730
  el.appendChild(placeholder);
2731
+ _slotRegistry.set(slotId, placeholder);
2443
2732
  }
2444
- return { destroy: () => {} };
2445
- }),
2446
- destroy: withIsSupported((_slotId) => {}),
2447
- destroyAll: withIsSupported(() => {})
2733
+ const destroySlot = () => {
2734
+ const registered = _slotRegistry.get(slotId);
2735
+ if (registered) {
2736
+ registered.remove();
2737
+ _slotRegistry.delete(slotId);
2738
+ }
2739
+ };
2740
+ setTimeout(() => {
2741
+ if (aitState.state.ads.forceNoFill) {
2742
+ options?.callbacks?.onNoFill?.({
2743
+ slotId,
2744
+ adGroupId,
2745
+ adMetadata: {}
2746
+ });
2747
+ options?.callbacks?.onAdFailedToRender?.({
2748
+ slotId,
2749
+ adGroupId,
2750
+ adMetadata: {},
2751
+ error: {
2752
+ code: 0,
2753
+ message: "No fill"
2754
+ }
2755
+ });
2756
+ return;
2757
+ }
2758
+ const eventPayload = {
2759
+ slotId,
2760
+ adGroupId,
2761
+ adMetadata: {
2762
+ creativeId: `mock-creative-${slotId}`,
2763
+ requestId: `mock-req-${slotId}`
2764
+ }
2765
+ };
2766
+ options?.callbacks?.onAdRendered?.(eventPayload);
2767
+ options?.callbacks?.onAdImpression?.(eventPayload);
2768
+ }, 100);
2769
+ return { destroy: destroySlot };
2770
+ })),
2771
+ destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
2772
+ const el = _slotRegistry.get(slotId);
2773
+ if (el) {
2774
+ el.remove();
2775
+ _slotRegistry.delete(slotId);
2776
+ }
2777
+ })),
2778
+ destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
2779
+ for (const el of _slotRegistry.values()) el.remove();
2780
+ _slotRegistry.clear();
2781
+ }))
2448
2782
  });
2449
- const loadFullScreenAd = withIsSupported((args) => {
2783
+ const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
2450
2784
  setTimeout(() => {
2451
2785
  if (aitState.state.ads.forceNoFill) {
2452
2786
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -2459,8 +2793,8 @@ const loadFullScreenAd = withIsSupported((args) => {
2459
2793
  });
2460
2794
  }, 200);
2461
2795
  return () => {};
2462
- });
2463
- const showFullScreenAd = withIsSupported((args) => {
2796
+ }));
2797
+ const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
2464
2798
  if (!aitState.state.ads.isLoaded) {
2465
2799
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
2466
2800
  return () => {};
@@ -2468,7 +2802,7 @@ const showFullScreenAd = withIsSupported((args) => {
2468
2802
  setTimeout(() => args.onEvent({ type: "show" }), 100);
2469
2803
  setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
2470
2804
  return () => {};
2471
- });
2805
+ }));
2472
2806
  //#endregion
2473
2807
  //#region src/panel/tabs/ads.ts
2474
2808
  function recordEvent(type) {
@@ -2503,6 +2837,57 @@ function adSection(title, onLoad, onShow, disabled) {
2503
2837
  showBtn.addEventListener("click", onShow);
2504
2838
  return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, title), h("div", { className: "ait-btn-row" }, loadBtn, showBtn));
2505
2839
  }
2840
+ /** TossAds 배너 인터랙티브 섹션 — Render/No-fill/Click/Destroy 버튼 */
2841
+ function tossAdsBannerSection(disabled) {
2842
+ const mountTarget = h("div", { style: "min-height:60px;background:#111;border-radius:4px;margin-bottom:6px;overflow:hidden;" });
2843
+ const renderBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.render"));
2844
+ const noFillBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.noFill"));
2845
+ const clickBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.click"));
2846
+ const destroyBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.destroy"));
2847
+ if (disabled) {
2848
+ renderBtn.disabled = true;
2849
+ noFillBtn.disabled = true;
2850
+ clickBtn.disabled = true;
2851
+ destroyBtn.disabled = true;
2852
+ }
2853
+ let currentHandle = null;
2854
+ renderBtn.addEventListener("click", () => {
2855
+ currentHandle?.destroy();
2856
+ currentHandle = null;
2857
+ mountTarget.innerHTML = "";
2858
+ currentHandle = TossAds.attachBanner("mock-banner-group", mountTarget, {
2859
+ theme: "auto",
2860
+ variant: "card",
2861
+ callbacks: {
2862
+ onAdRendered: (p) => recordEvent(`onAdRendered(${p.slotId})`),
2863
+ onAdImpression: (p) => recordEvent(`onAdImpression(${p.slotId})`),
2864
+ onAdClicked: (p) => recordEvent(`onAdClicked(${p.slotId})`),
2865
+ onAdFailedToRender: (p) => recordEvent(`error: onAdFailedToRender(${p.slotId})`),
2866
+ onNoFill: (p) => recordEvent(`error: onNoFill(${p.slotId})`)
2867
+ }
2868
+ });
2869
+ });
2870
+ noFillBtn.addEventListener("click", () => {
2871
+ currentHandle?.destroy();
2872
+ currentHandle = null;
2873
+ mountTarget.innerHTML = "";
2874
+ const slotId = `mock-slot-no-fill-${Date.now()}`;
2875
+ recordEvent(`error: onNoFill(${slotId})`);
2876
+ recordEvent(`error: onAdFailedToRender(${slotId})`);
2877
+ });
2878
+ clickBtn.addEventListener("click", () => {
2879
+ recordEvent("banner:clicked");
2880
+ });
2881
+ destroyBtn.addEventListener("click", () => {
2882
+ if (currentHandle) {
2883
+ currentHandle.destroy();
2884
+ currentHandle = null;
2885
+ mountTarget.innerHTML = "";
2886
+ recordEvent("banner:destroyed");
2887
+ } else recordError("No banner to destroy");
2888
+ });
2889
+ return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.tossAdsBanner")), mountTarget, h("div", { className: "ait-btn-row" }, renderBtn, noFillBtn, clickBtn, destroyBtn));
2890
+ }
2506
2891
  function renderAdsTab() {
2507
2892
  const s = aitState.state;
2508
2893
  const disabled = !s.panelEditable;
@@ -2517,7 +2902,10 @@ function renderAdsTab() {
2517
2902
  forceNoFillCb.addEventListener("change", () => {
2518
2903
  aitState.patch("ads", { forceNoFill: forceNoFillCb.checked });
2519
2904
  });
2520
- container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.state")), statusRow(t("ads.row.isLoaded"), String(s.ads.isLoaded)), h("div", { className: "ait-row" }, h("label", {}, t("ads.row.forceNoFill")), forceNoFillCb), lastEventLine()), adSection(t("ads.section.googleAdMob"), () => {
2905
+ container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.state")), statusRow(t("ads.row.isLoaded"), String(s.ads.isLoaded)), h("div", { className: "ait-row" }, h("label", {}, t("ads.row.forceNoFill")), forceNoFillCb), inputRow(t("ads.row.rewardUnitType"), s.ads.rewardUnitType, (v) => aitState.patch("ads", { rewardUnitType: v }), disabled), inputRow(t("ads.row.rewardAmount"), String(s.ads.rewardAmount), (v) => {
2906
+ const n = Number(v);
2907
+ if (!Number.isNaN(n)) aitState.patch("ads", { rewardAmount: n });
2908
+ }, disabled), lastEventLine()), adSection(t("ads.section.googleAdMob"), () => {
2521
2909
  GoogleAdMob.loadAppsInTossAdMob({
2522
2910
  onEvent: (e) => recordEvent(e.type),
2523
2911
  onError: (err) => recordError(err.message)
@@ -2528,12 +2916,13 @@ function renderAdsTab() {
2528
2916
  onError: (err) => recordError(err.message)
2529
2917
  });
2530
2918
  }, disabled), adSection(t("ads.section.tossAds"), () => {
2531
- if (aitState.state.ads.forceNoFill) {
2532
- recordError("No fill");
2533
- return;
2534
- }
2535
- aitState.patch("ads", { isLoaded: true });
2536
- recordEvent("loaded");
2919
+ TossAds.initialize({ callbacks: {
2920
+ onInitialized: () => {
2921
+ aitState.patch("ads", { isLoaded: true });
2922
+ recordEvent("loaded");
2923
+ },
2924
+ onInitializationFailed: (err) => recordError(err.message)
2925
+ } });
2537
2926
  }, () => {
2538
2927
  if (!aitState.state.ads.isLoaded) {
2539
2928
  recordError("Ad not loaded");
@@ -2544,7 +2933,7 @@ function renderAdsTab() {
2544
2933
  recordEvent("dismissed");
2545
2934
  aitState.patch("ads", { isLoaded: false });
2546
2935
  }, 1500);
2547
- }, disabled), adSection(t("ads.section.fullScreenAd"), () => {
2936
+ }, disabled), tossAdsBannerSection(disabled), adSection(t("ads.section.fullScreenAd"), () => {
2548
2937
  loadFullScreenAd({
2549
2938
  onEvent: (e) => recordEvent(e.type),
2550
2939
  onError: (err) => recordError(err.message)
@@ -2559,19 +2948,37 @@ function renderAdsTab() {
2559
2948
  }
2560
2949
  //#endregion
2561
2950
  //#region src/panel/tabs/analytics.ts
2951
+ const FIDELITY_BADGE = {
2952
+ faithful: "🟢",
2953
+ partial: "🟡",
2954
+ inert: "🔴"
2955
+ };
2562
2956
  function renderAnalyticsTab() {
2563
2957
  const disabled = !aitState.state.panelEditable;
2564
2958
  const container = h("div");
2565
2959
  if (disabled) container.appendChild(monitoringNotice());
2566
2960
  const logs = aitState.state.analyticsLog;
2567
- const clearBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.btn.clear"));
2568
- if (disabled) clearBtn.disabled = true;
2569
- clearBtn.addEventListener("click", () => {
2961
+ const clearAnalyticsBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.btn.clear"));
2962
+ if (disabled) clearAnalyticsBtn.disabled = true;
2963
+ clearAnalyticsBtn.addEventListener("click", () => {
2570
2964
  aitState.update({ analyticsLog: [] });
2571
2965
  });
2572
- container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.section.log", { count: logs.length })), clearBtn), ...logs.slice(-30).reverse().map((entry) => {
2966
+ container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.section.log", { count: logs.length })), clearAnalyticsBtn), ...logs.slice(-30).reverse().map((entry) => {
2573
2967
  return h("div", { className: "ait-log-entry" }, h("span", { className: "ait-log-time" }, new Date(entry.timestamp).toLocaleTimeString()), h("span", { className: "ait-log-type" }, entry.type), JSON.stringify(entry.params));
2574
2968
  })));
2969
+ const calls = aitState.state.sdkCallLog;
2970
+ const clearCallsBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.calls.btn.clear"));
2971
+ if (disabled) clearCallsBtn.disabled = true;
2972
+ clearCallsBtn.addEventListener("click", () => {
2973
+ aitState.update({ sdkCallLog: [] });
2974
+ });
2975
+ container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.calls.section", { count: calls.length })), clearCallsBtn), calls.length === 0 ? h("div", { className: "ait-log-entry ait-log-empty" }, t("analytics.calls.empty")) : h("div", {}, ...calls.slice(-50).reverse().map((call) => {
2976
+ const badge = FIDELITY_BADGE[call.fidelity] ?? "⬜";
2977
+ const time = new Date(call.timestamp).toLocaleTimeString();
2978
+ const argsStr = call.args.length > 0 ? `(${call.args.map((a) => JSON.stringify(a)).join(", ")})` : "()";
2979
+ const statusSuffix = call.status === "rejected" ? ` ✗ ${call.error ?? "error"}` : call.status === "pending" ? " …" : "";
2980
+ return h("div", { className: `ait-log-entry ait-sdk-call ait-sdk-call-${call.fidelity}` }, h("span", { className: "ait-log-badge" }, badge), h("span", { className: "ait-log-method" }, call.method), h("span", { className: "ait-log-args" }, argsStr), h("span", { className: "ait-log-time" }, ` · ${time}`), statusSuffix ? h("span", { className: "ait-log-status" }, statusSuffix) : "");
2981
+ }))));
2575
2982
  return container;
2576
2983
  }
2577
2984
  //#endregion
@@ -2590,9 +2997,20 @@ function renderEnvironmentTab() {
2590
2997
  "OFFLINE",
2591
2998
  "WWAN",
2592
2999
  "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());
3000
+ ], 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
3001
  return container;
2595
3002
  }
3003
+ /**
3004
+ * Navigation 동작 관측 (read-only) — real(토스 WebView)에서 native bridge로 발화하는
3005
+ * no-op API의 마지막 호출값을 보여준다. Environment를 toss로 바꾸면 toss-gated 가드
3006
+ * (예: sdk-example `useDisableIosSwipeGestureInToss`)가 돌면서 이 값이 토글되므로,
3007
+ * "toss 진입 → 가드 실행 → 관측 가능한 state 변화" 루프를 패널에서 한눈에 확인할 수 있다.
3008
+ */
3009
+ function buildNavigationSection() {
3010
+ const swipe = aitState.state.navigation.iosSwipeGestureEnabled;
3011
+ const valueText = swipe === null ? t("env.value.iosSwipeGesture.unset") : swipe ? t("env.value.iosSwipeGesture.enabled") : t("env.value.iosSwipeGesture.disabled");
3012
+ 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")));
3013
+ }
2596
3014
  function buildLanguageSection() {
2597
3015
  const current = getLocale();
2598
3016
  const select = h("select", { className: "ait-select" });
@@ -3302,22 +3720,131 @@ async function closeView() {
3302
3720
  console.log("[@ait-co/devtools] closeView called");
3303
3721
  window.history.back();
3304
3722
  }
3305
- async function requestReview() {
3723
+ observe("setScreenAwakeMode", "inert", async (options) => {
3724
+ console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
3725
+ return { enabled: options.enabled };
3726
+ });
3727
+ observe("setSecureScreen", "inert", async (options) => {
3728
+ console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
3729
+ return { enabled: options.enabled };
3730
+ });
3731
+ const requestReview = observe("requestReview", "inert", async () => {
3306
3732
  console.log("[@ait-co/devtools] requestReview called");
3307
- }
3733
+ });
3308
3734
  requestReview.isSupported = () => true;
3309
3735
  async function getServerTime() {
3310
3736
  return Date.now();
3311
3737
  }
3312
3738
  getServerTime.isSupported = () => true;
3313
3739
  //#endregion
3740
+ //#region src/panel/device-emulation.ts
3741
+ /**
3742
+ * 기기 preset ↔ 브라우저 특성 정합
3743
+ *
3744
+ * Viewport preset이 active일 때(`none`/`custom` 아님), 그 preset이 주장하는 기기와
3745
+ * `navigator.userAgent`·`navigator.platform`·`window.devicePixelRatio`·`screen.*`를
3746
+ * 일치시킨다. 특정 기기 frame 가상환경을 제공하는 이상 UA/DPR만 호스트 데스크톱 값으로
3747
+ * 남으면 비일관적이기 때문이다 (#190).
3748
+ *
3749
+ * 한계 — page-JS override는 **JS 읽기값만** 바꾼다. 실 CSS media query(`@media`),
3750
+ * 실제 터치 이벤트, 엔진 레벨 레이아웃은 호스트 브라우저 값이 그대로다. 픽셀/입력 단위
3751
+ * 완전 emulation이 필요하면 Chrome DevTools device-mode(또는 CDP)를 쓴다. preset이
3752
+ * `none`/`custom`이면 override를 걸지 않아 일반 dev의 호스트 환경을 건드리지 않는다.
3753
+ *
3754
+ * 구현 — 대상 속성은 setter가 없어도 `configurable: true`이므로 `Object.defineProperty`로
3755
+ * getter를 덮어쓸 수 있다. 원복을 위해 최초 override 직전의 디스크립터를 저장한다.
3756
+ */
3757
+ /** preset id → 플랫폼. Apple 계열은 ios, 그 외(Galaxy)는 android. */
3758
+ function platformForPreset(presetId) {
3759
+ return presetId.startsWith("iphone") || presetId.startsWith("ipad") ? "ios" : "android";
3760
+ }
3761
+ /**
3762
+ * preset + 토스 앱 버전으로 기기 프로필을 합성한다.
3763
+ *
3764
+ * UA는 표준 모바일 UA 뒤에 `AppsInToss TossApp/<appVersion>` 토큰을 붙인다 — #171
3765
+ * 실측(`AppsInToss TossApp/5.261.0`)에서 확인된 토스 WebView UA 형태.
3766
+ *
3767
+ * @param preset portrait 기준 width/height/dpr를 가진 device preset
3768
+ * @param appVersion `aitState.state.appVersion` (UA suffix의 버전 토큰)
3769
+ * @param landscape true면 screen width/height를 swap
3770
+ */
3771
+ function buildDeviceProfile(preset, appVersion, landscape) {
3772
+ const platform = platformForPreset(preset.id);
3773
+ const tossToken = `AppsInToss TossApp/${appVersion}`;
3774
+ 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";
3775
+ const physWidth = Math.round(preset.width * preset.dpr);
3776
+ const physHeight = Math.round(preset.height * preset.dpr);
3777
+ return {
3778
+ platform,
3779
+ userAgent: `${baseUa} ${tossToken}`,
3780
+ navigatorPlatform: platform === "ios" ? "iPhone" : "Linux armv8l",
3781
+ devicePixelRatio: preset.dpr,
3782
+ screenWidth: landscape ? physHeight : physWidth,
3783
+ screenHeight: landscape ? physWidth : physHeight
3784
+ };
3785
+ }
3786
+ let savedDescriptors = null;
3787
+ function override(target, prop, value, saved) {
3788
+ if (!saved.some((s) => s.target === target && s.prop === prop)) saved.push({
3789
+ target,
3790
+ prop,
3791
+ descriptor: Object.getOwnPropertyDescriptor(target, prop)
3792
+ });
3793
+ try {
3794
+ Object.defineProperty(target, prop, {
3795
+ configurable: true,
3796
+ get: () => value
3797
+ });
3798
+ } catch {}
3799
+ }
3800
+ /**
3801
+ * 기기 프로필을 현재 페이지의 `navigator`/`window`/`screen`에 적용한다.
3802
+ * 호출 시 직전 디스크립터를 저장하므로 `revertDeviceEmulation()`으로 원복 가능.
3803
+ */
3804
+ function applyDeviceEmulation(profile) {
3805
+ if (typeof navigator === "undefined" || typeof window === "undefined") return;
3806
+ revertDeviceEmulation();
3807
+ const saved = [];
3808
+ override(navigator, "userAgent", profile.userAgent, saved);
3809
+ override(navigator, "platform", profile.navigatorPlatform, saved);
3810
+ override(window, "devicePixelRatio", profile.devicePixelRatio, saved);
3811
+ if (typeof screen !== "undefined") {
3812
+ override(screen, "width", profile.screenWidth, saved);
3813
+ override(screen, "height", profile.screenHeight, saved);
3814
+ }
3815
+ savedDescriptors = saved;
3816
+ }
3817
+ /** `applyDeviceEmulation`이 덮어쓴 속성을 원래 디스크립터로 되돌린다. */
3818
+ function revertDeviceEmulation() {
3819
+ if (!savedDescriptors) return;
3820
+ for (const { target, prop, descriptor } of savedDescriptors) try {
3821
+ if (descriptor) Object.defineProperty(target, prop, descriptor);
3822
+ else delete target[prop];
3823
+ } catch {}
3824
+ savedDescriptors = null;
3825
+ }
3826
+ /**
3827
+ * Viewport state를 받아 preset이 active면 emulation 적용, 아니면 원복.
3828
+ * `applyViewport`가 매 viewport 변경마다 호출한다.
3829
+ */
3830
+ function syncDeviceEmulation(preset, landscape) {
3831
+ if (!preset || preset.id === "none" || preset.id === "custom") {
3832
+ revertDeviceEmulation();
3833
+ return;
3834
+ }
3835
+ const profile = buildDeviceProfile(preset, aitState.state.appVersion, landscape);
3836
+ applyDeviceEmulation(profile);
3837
+ if (aitState.state.platform !== profile.platform) aitState.update({ platform: profile.platform });
3838
+ }
3839
+ //#endregion
3314
3840
  //#region src/panel/viewport.ts
3315
3841
  /**
3316
3842
  * Viewport 시뮬레이션 유틸
3317
3843
  *
3318
3844
  * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
3319
3845
  * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
3320
- * 동적 값(width/height, navbar top offset)만 별도 `<style>` 엘리먼트로 관리한다.
3846
+ * 동적 값(width/height, 콘텐츠 push용 body padding-top)만 별도 `<style>` 엘리먼트로
3847
+ * 관리한다.
3321
3848
  */
3322
3849
  const VIEWPORT_STORAGE_KEY = "__ait_viewport";
3323
3850
  /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
@@ -3329,17 +3856,51 @@ const NONE_PRESET = {
3329
3856
  height: 0,
3330
3857
  dpr: 1,
3331
3858
  notch: "none",
3332
- safeAreaTop: 0,
3859
+ notchInset: 0,
3860
+ navBarHeight: 0,
3861
+ safeAreaBottom: 0
3862
+ };
3863
+ const CUSTOM_PRESET = {
3864
+ id: "custom",
3865
+ label: "Custom",
3866
+ width: 0,
3867
+ height: 0,
3868
+ dpr: 1,
3869
+ notch: "none",
3870
+ notchInset: 0,
3871
+ navBarHeight: 0,
3333
3872
  safeAreaBottom: 0
3334
3873
  };
3874
+ /** Shorthands used when building preset provenance entries. */
3875
+ const EXTRAPOLATED = { source: "extrapolated" };
3876
+ const PLACEHOLDER = { source: "placeholder" };
3335
3877
  /**
3336
3878
  * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
3337
3879
  * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
3338
3880
  * Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
3339
- * 측정치를 사용. safe area는 토스 호스트 환경 실측 필요 — S25 값으로 잠정.
3881
+ * 측정치를 사용.
3882
+ *
3883
+ * safe-area 모델 (devtools#190 relay 실측 반영):
3884
+ * - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
3885
+ * 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
3886
+ * 값은 portrait SDK top에는 들어가지 않는다.
3887
+ * - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
3888
+ * 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
3889
+ * - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
3890
+ * iPhone 15 Pro 실측 bottom 34와 일치.
3891
+ *
3892
+ * 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
3893
+ * 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
3340
3894
  *
3341
3895
  * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
3342
3896
  * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
3897
+ *
3898
+ * safeAreaProvenance: 각 preset의 safe-area 값 신뢰도 출처.
3899
+ * - `measured` — relay 실기기 세션(measure_safe_area)으로 직접 확인한 값.
3900
+ * 현재 iPhone 15 Pro portrait iOS partner만 해당 (devtools#190).
3901
+ * - `extrapolated` — Apple 스펙/같은 시리즈 기기에서 유추한 값.
3902
+ * - `placeholder` — 연결 기기 없이 추정한 값. QA ground truth로 쓰지 말 것.
3903
+ * `measure_safe_area` MCP 툴로 relay 세션에서 `measured`로 승급 필요.
3343
3904
  */
3344
3905
  const VIEWPORT_PRESETS = [
3345
3906
  NONE_PRESET,
@@ -3350,8 +3911,26 @@ const VIEWPORT_PRESETS = [
3350
3911
  height: 667,
3351
3912
  dpr: 2,
3352
3913
  notch: "none",
3353
- safeAreaTop: 20,
3354
- safeAreaBottom: 0
3914
+ notchInset: 20,
3915
+ navBarHeight: 54,
3916
+ safeAreaBottom: 0,
3917
+ safeAreaProvenance: EXTRAPOLATED
3918
+ },
3919
+ {
3920
+ id: "iphone-15-pro",
3921
+ label: "iPhone 15 Pro",
3922
+ width: 393,
3923
+ height: 852,
3924
+ dpr: 3,
3925
+ notch: "dynamic-island",
3926
+ notchInset: 59,
3927
+ navBarHeight: 54,
3928
+ safeAreaBottom: 34,
3929
+ safeAreaProvenance: {
3930
+ source: "measured",
3931
+ device: "iPhone 15 Pro",
3932
+ date: "2026-05-25"
3933
+ }
3355
3934
  },
3356
3935
  {
3357
3936
  id: "iphone-16e",
@@ -3360,8 +3939,10 @@ const VIEWPORT_PRESETS = [
3360
3939
  height: 844,
3361
3940
  dpr: 3,
3362
3941
  notch: "notch",
3363
- safeAreaTop: 47,
3364
- safeAreaBottom: 34
3942
+ notchInset: 47,
3943
+ navBarHeight: 54,
3944
+ safeAreaBottom: 34,
3945
+ safeAreaProvenance: EXTRAPOLATED
3365
3946
  },
3366
3947
  {
3367
3948
  id: "iphone-17",
@@ -3370,8 +3951,10 @@ const VIEWPORT_PRESETS = [
3370
3951
  height: 874,
3371
3952
  dpr: 3,
3372
3953
  notch: "dynamic-island",
3373
- safeAreaTop: 59,
3374
- safeAreaBottom: 34
3954
+ notchInset: 59,
3955
+ navBarHeight: 54,
3956
+ safeAreaBottom: 34,
3957
+ safeAreaProvenance: EXTRAPOLATED
3375
3958
  },
3376
3959
  {
3377
3960
  id: "iphone-air",
@@ -3380,8 +3963,10 @@ const VIEWPORT_PRESETS = [
3380
3963
  height: 912,
3381
3964
  dpr: 3,
3382
3965
  notch: "dynamic-island",
3383
- safeAreaTop: 59,
3384
- safeAreaBottom: 34
3966
+ notchInset: 59,
3967
+ navBarHeight: 54,
3968
+ safeAreaBottom: 34,
3969
+ safeAreaProvenance: EXTRAPOLATED
3385
3970
  },
3386
3971
  {
3387
3972
  id: "iphone-17-pro",
@@ -3390,8 +3975,10 @@ const VIEWPORT_PRESETS = [
3390
3975
  height: 874,
3391
3976
  dpr: 3,
3392
3977
  notch: "dynamic-island",
3393
- safeAreaTop: 59,
3394
- safeAreaBottom: 34
3978
+ notchInset: 59,
3979
+ navBarHeight: 54,
3980
+ safeAreaBottom: 34,
3981
+ safeAreaProvenance: EXTRAPOLATED
3395
3982
  },
3396
3983
  {
3397
3984
  id: "iphone-17-pro-max",
@@ -3400,8 +3987,10 @@ const VIEWPORT_PRESETS = [
3400
3987
  height: 956,
3401
3988
  dpr: 3,
3402
3989
  notch: "dynamic-island",
3403
- safeAreaTop: 62,
3404
- safeAreaBottom: 34
3990
+ notchInset: 62,
3991
+ navBarHeight: 54,
3992
+ safeAreaBottom: 34,
3993
+ safeAreaProvenance: EXTRAPOLATED
3405
3994
  },
3406
3995
  {
3407
3996
  id: "galaxy-s26",
@@ -3410,8 +3999,10 @@ const VIEWPORT_PRESETS = [
3410
3999
  height: 773,
3411
4000
  dpr: 3,
3412
4001
  notch: "punch-hole-center",
3413
- safeAreaTop: 32,
3414
- safeAreaBottom: 0
4002
+ notchInset: 32,
4003
+ navBarHeight: 54,
4004
+ safeAreaBottom: 0,
4005
+ safeAreaProvenance: PLACEHOLDER
3415
4006
  },
3416
4007
  {
3417
4008
  id: "galaxy-s26-plus",
@@ -3420,8 +4011,10 @@ const VIEWPORT_PRESETS = [
3420
4011
  height: 1040,
3421
4012
  dpr: 3,
3422
4013
  notch: "punch-hole-center",
3423
- safeAreaTop: 32,
3424
- safeAreaBottom: 0
4014
+ notchInset: 32,
4015
+ navBarHeight: 54,
4016
+ safeAreaBottom: 0,
4017
+ safeAreaProvenance: PLACEHOLDER
3425
4018
  },
3426
4019
  {
3427
4020
  id: "galaxy-s26-ultra",
@@ -3430,8 +4023,10 @@ const VIEWPORT_PRESETS = [
3430
4023
  height: 1040,
3431
4024
  dpr: 3,
3432
4025
  notch: "punch-hole-center",
3433
- safeAreaTop: 40,
3434
- safeAreaBottom: 0
4026
+ notchInset: 40,
4027
+ navBarHeight: 54,
4028
+ safeAreaBottom: 0,
4029
+ safeAreaProvenance: PLACEHOLDER
3435
4030
  },
3436
4031
  {
3437
4032
  id: "galaxy-z-flip7",
@@ -3440,8 +4035,10 @@ const VIEWPORT_PRESETS = [
3440
4035
  height: 990,
3441
4036
  dpr: 3,
3442
4037
  notch: "punch-hole-center",
3443
- safeAreaTop: 36,
3444
- safeAreaBottom: 0
4038
+ notchInset: 36,
4039
+ navBarHeight: 54,
4040
+ safeAreaBottom: 0,
4041
+ safeAreaProvenance: PLACEHOLDER
3445
4042
  },
3446
4043
  {
3447
4044
  id: "galaxy-z-fold7-folded",
@@ -3450,8 +4047,10 @@ const VIEWPORT_PRESETS = [
3450
4047
  height: 870,
3451
4048
  dpr: 3,
3452
4049
  notch: "punch-hole-center",
3453
- safeAreaTop: 32,
3454
- safeAreaBottom: 0
4050
+ notchInset: 32,
4051
+ navBarHeight: 54,
4052
+ safeAreaBottom: 0,
4053
+ safeAreaProvenance: PLACEHOLDER
3455
4054
  },
3456
4055
  {
3457
4056
  id: "galaxy-z-fold7-unfolded",
@@ -3460,19 +4059,12 @@ const VIEWPORT_PRESETS = [
3460
4059
  height: 884,
3461
4060
  dpr: 2.625,
3462
4061
  notch: "punch-hole-center",
3463
- safeAreaTop: 32,
3464
- safeAreaBottom: 0
4062
+ notchInset: 32,
4063
+ navBarHeight: 54,
4064
+ safeAreaBottom: 0,
4065
+ safeAreaProvenance: PLACEHOLDER
3465
4066
  },
3466
- {
3467
- id: "custom",
3468
- label: "Custom",
3469
- width: 0,
3470
- height: 0,
3471
- dpr: 1,
3472
- notch: "none",
3473
- safeAreaTop: 0,
3474
- safeAreaBottom: 0
3475
- }
4067
+ CUSTOM_PRESET
3476
4068
  ];
3477
4069
  function getPreset(id) {
3478
4070
  return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
@@ -3511,23 +4103,30 @@ function resolveViewportSize(state) {
3511
4103
  };
3512
4104
  }
3513
4105
  /**
3514
- * 프리셋 + landscape 여부 + landscape side로부터 OS-level safe-area insets를 계산한다.
4106
+ * 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
4107
+ * 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190)에 맞춘 모델:
3515
4108
  *
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가 유지된다.
4109
+ * - **Portrait top = 토스 nav bar 높이** (OS 노치가 아니다). 실측에서
4110
+ * `env(safe-area-inset-top)` = 0, `SafeAreaInsets.get().top` = 54 였고, 그 54는 호스트
4111
+ * nav bar다. 따라서 nav bar가 있고 `partner` type일 때만 `navBarHeight`를 top 준다.
4112
+ * `game`(투명 오버레이, 콘텐츠 안 밀어냄) 또는 nav bar 미표시면 top = 0.
4113
+ * - **Bottom = `safeAreaBottom`** (home-indicator). 실측 34와 일치.
4114
+ * - **Landscape iPhone(notch/Dynamic Island)**: 노치가 한쪽으로 가므로 `landscapeSide`에
4115
+ * 따라 left/right 한쪽에만 `notchInset`을 준다. top은 0(landscape nav bar 거동은
4116
+ * 미실측 — portrait 모델만 확정), home-indicator는 bottom에 유지.
4117
+ * - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
4118
+ * 유지된다.
3521
4119
  */
3522
- function computeSafeAreaInsets(preset, landscape, side) {
4120
+ function computeSafeAreaInsets(preset, landscape, side, navBarVisible, navBarType) {
3523
4121
  if (preset.id === "none" || preset.id === "custom") return {
3524
4122
  top: 0,
3525
4123
  bottom: 0,
3526
4124
  left: 0,
3527
4125
  right: 0
3528
4126
  };
4127
+ const navBarTop = navBarVisible && navBarType === "partner" ? preset.navBarHeight : 0;
3529
4128
  if (!landscape) return {
3530
- top: preset.safeAreaTop,
4129
+ top: navBarTop,
3531
4130
  bottom: preset.safeAreaBottom,
3532
4131
  left: 0,
3533
4132
  right: 0
@@ -3535,11 +4134,11 @@ function computeSafeAreaInsets(preset, landscape, side) {
3535
4134
  if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
3536
4135
  top: 0,
3537
4136
  bottom: preset.safeAreaBottom,
3538
- left: side === "left" ? preset.safeAreaTop : 0,
3539
- right: side === "right" ? preset.safeAreaTop : 0
4137
+ left: side === "left" ? preset.notchInset : 0,
4138
+ right: side === "right" ? preset.notchInset : 0
3540
4139
  };
3541
4140
  return {
3542
- top: preset.safeAreaTop,
4141
+ top: preset.notchInset,
3543
4142
  bottom: preset.safeAreaBottom,
3544
4143
  left: 0,
3545
4144
  right: 0
@@ -3548,7 +4147,7 @@ function computeSafeAreaInsets(preset, landscape, side) {
3548
4147
  /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
3549
4148
  function syncSafeAreaFromViewport(state) {
3550
4149
  if (state.preset === "none" || state.preset === "custom") return;
3551
- const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide);
4150
+ const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide, state.aitNavBar, state.aitNavBarType);
3552
4151
  const current = aitState.state.safeAreaInsets;
3553
4152
  if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
3554
4153
  aitState.update({ safeAreaInsets: next });
@@ -3582,27 +4181,30 @@ function removeNavBarElement() {
3582
4181
  removeById(NAV_BAR_ELEMENT_ID);
3583
4182
  }
3584
4183
  /**
3585
- * Apps in Toss host nav bar 렌더. OS status bar 아래에 48px 높이로 쌓인다.
4184
+ * Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
3586
4185
  *
3587
4186
  * 변형(SDK `webViewProps.type`과 의미 일치):
3588
4187
  * - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
3589
4188
  * 우측 `⋯` + 구분선 + `×`.
3590
4189
  * - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
3591
4190
  *
3592
- * `env(safe-area-inset-top)`에는 높이가 포함되지 않으므로 (SDK 동작 확인),
3593
- * 오버레이는 preset.safeAreaTop만큼 아래로 내려서 그린다.
4191
+ * nav bar는 WebView(body) 좌표계의 최상단(top 0)에 앉는다 실기기에서 OS notch는
4192
+ * WebView 밖(status bar)이라 `env(safe-area-inset-top)`이 0이고, WebView 콘텐츠 영역은
4193
+ * nav bar 바로 아래(= SDK `SafeAreaInsets.get().top` = `navBarHeight`)에서 시작한다.
4194
+ * 콘텐츠를 그만큼 밀어내는 건 `applyViewport`의 body `padding-top`이 담당하므로, nav bar
4195
+ * 바닥과 콘텐츠 시작이 정확히 맞물린다. 시각 notch 오버레이는 body 밖 위쪽(status bar
4196
+ * 영역)에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
3594
4197
  *
3595
4198
  * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
3596
4199
  * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
3597
4200
  */
3598
- function renderNavBar(preset, displayName, type) {
4201
+ function renderNavBar(displayName, type) {
3599
4202
  removeNavBarElement();
3600
4203
  const el = h("div", {
3601
4204
  id: NAV_BAR_ELEMENT_ID,
3602
4205
  className: `ait-navbar ait-navbar-${type}`,
3603
4206
  "aria-hidden": "true"
3604
4207
  });
3605
- el.style.top = `${preset.safeAreaTop}px`;
3606
4208
  const moreBtn = h("button", {
3607
4209
  className: "ait-navbar-btn",
3608
4210
  type: "button",
@@ -3680,12 +4282,13 @@ function disposeViewport() {
3680
4282
  removeNotchElement();
3681
4283
  removeHomeIndicator();
3682
4284
  removeNavBarElement();
4285
+ revertDeviceEmulation();
3683
4286
  bodyScrollHintEmitted = false;
3684
4287
  }
3685
4288
  /**
3686
4289
  * DOM에 뷰포트 제약을 적용한다.
3687
4290
  * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
3688
- * - body의 width/height는 preset 값으로, navbar top offset은 safeAreaTop으로 인라인 주입
4291
+ * - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
3689
4292
  */
3690
4293
  function applyViewport(state) {
3691
4294
  if (typeof document === "undefined") return;
@@ -3700,6 +4303,7 @@ function applyViewport(state) {
3700
4303
  removeNotchElement();
3701
4304
  removeHomeIndicator();
3702
4305
  removeNavBarElement();
4306
+ syncDeviceEmulation(null, false);
3703
4307
  return;
3704
4308
  }
3705
4309
  if (!bodyScrollHintEmitted) {
@@ -3710,19 +4314,22 @@ function applyViewport(state) {
3710
4314
  html.classList.toggle("ait-viewport-framed", state.frame);
3711
4315
  const preset = state.preset === "custom" ? null : getPreset(state.preset);
3712
4316
  const landscape = effectiveOrientation(state) === "landscape";
4317
+ syncDeviceEmulation(preset, landscape);
4318
+ const contentTop = preset ? computeSafeAreaInsets(preset, landscape, state.landscapeSide, state.aitNavBar, state.aitNavBarType).top : 0;
3713
4319
  style.textContent = `
3714
4320
  html.ait-viewport-active body {
3715
4321
  width: ${size.width}px;
3716
4322
  max-width: ${size.width}px;
3717
4323
  min-height: ${size.height}px;
3718
4324
  max-height: ${size.height}px;
4325
+ padding-top: ${contentTop}px;
3719
4326
  }
3720
4327
  `;
3721
4328
  if (preset && state.frame && !landscape) renderNotchOverlay(preset);
3722
4329
  else removeNotchElement();
3723
4330
  if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
3724
4331
  else removeHomeIndicator();
3725
- if (preset && state.aitNavBar && !landscape) renderNavBar(preset, aitState.state.brand.displayName, state.aitNavBarType);
4332
+ if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
3726
4333
  else removeNavBarElement();
3727
4334
  }
3728
4335
  function isViewportPresetId(v) {
@@ -3823,6 +4430,24 @@ function initViewport() {
3823
4430
  }
3824
4431
  //#endregion
3825
4432
  //#region src/panel/tabs/viewport.ts
4433
+ /**
4434
+ * Renders a small inline provenance badge for safe-area values.
4435
+ * - `measured` — no badge (confirmed value)
4436
+ * - `extrapolated` — "(추정치)" in muted gray
4437
+ * - `placeholder` — "(미측정)" in amber
4438
+ */
4439
+ function provenanceBadge(provenance) {
4440
+ if (!provenance || provenance.source === "measured") return null;
4441
+ const text = provenance.source === "placeholder" ? "(미측정)" : "(추정치)";
4442
+ const color = provenance.source === "placeholder" ? "#b45309" : "#888";
4443
+ const badge = h("span", {
4444
+ className: "ait-provenance-badge",
4445
+ title: provenance.source === "placeholder" ? "safe-area 값이 미실측 추정치입니다. relay 세션에서 measure_safe_area로 실측 후 승급하세요." : "safe-area 값이 기기 스펙에서 유추한 추정치입니다. relay 세션에서 measure_safe_area로 확인하세요."
4446
+ });
4447
+ badge.textContent = text;
4448
+ badge.style.cssText = `font-size:10px;color:${color};margin-left:4px`;
4449
+ return badge;
4450
+ }
3826
4451
  function renderViewportTab() {
3827
4452
  const s = aitState.state;
3828
4453
  const vp = s.viewport;
@@ -3941,13 +4566,19 @@ function renderViewportTab() {
3941
4566
  const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
3942
4567
  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
4568
  if (preset) {
3944
- const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide);
3945
- 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}`)));
4569
+ const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide, vp.aitNavBar, vp.aitNavBarType);
4570
+ const safeAreaValueEl = h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`);
4571
+ const badge = provenanceBadge(preset.safeAreaProvenance);
4572
+ if (badge) safeAreaValueEl.appendChild(badge);
4573
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), safeAreaValueEl));
4574
+ }
4575
+ if (vp.aitNavBar && !landscape) {
4576
+ const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
4577
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
4578
+ height: navBarTop,
4579
+ type: vp.aitNavBarType
4580
+ }))));
3946
4581
  }
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
- }))));
3951
4582
  for (const row of rows) statusEl.appendChild(row);
3952
4583
  }
3953
4584
  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 +4873,7 @@ function mount() {
4242
4873
  mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
4243
4874
  refreshPanel();
4244
4875
  });
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.31`), closeBtn);
4876
+ 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.33`), closeBtn);
4246
4877
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
4247
4878
  tabsEl = h("div", { className: "ait-panel-tabs" });
4248
4879
  for (const tab of getTabs()) {
@@ -4284,7 +4915,7 @@ function mount() {
4284
4915
  window.addEventListener("resize", resizeHandler);
4285
4916
  aitStateUnsubscribe = aitState.subscribe(() => {
4286
4917
  try {
4287
- if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
4918
+ if (isOpen && (currentTab === "env" || currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
4288
4919
  } catch (err) {
4289
4920
  console.error("[@ait-co/devtools] Error in subscribe callback:", err);
4290
4921
  }