@ait-co/devtools 0.1.32 → 0.1.34

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.
@@ -90,6 +90,10 @@ const en = {
90
90
  "device.prompt.label.lng": "Lng",
91
91
  "device.prompt.send": "Send",
92
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",
93
97
  "viewport.section.device": "Device",
94
98
  "viewport.row.preset": "Preset",
95
99
  "viewport.row.orientation": "Orientation",
@@ -125,6 +129,9 @@ const en = {
125
129
  "events.row.tossLoginIntegrated": "Toss Login Integrated",
126
130
  "analytics.section.log": "Analytics Log ({count})",
127
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)",
128
135
  "storage.section.title": "Storage ({count} items)",
129
136
  "storage.btn.clearAll": "Clear All",
130
137
  "storage.empty": "No items in storage",
@@ -149,6 +156,13 @@ const en = {
149
156
  "ads.section.fullScreenAd": "FullScreenAd",
150
157
  "ads.btn.load": "Load",
151
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",
152
166
  "notifications.section.title": "requestNotificationAgreement",
153
167
  "notifications.option.newAgreement": "newAgreement (first-time agree)",
154
168
  "notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
@@ -247,6 +261,10 @@ const ko = {
247
261
  "device.prompt.label.lng": "Lng",
248
262
  "device.prompt.send": "Send",
249
263
  "device.prompt.cancel": "Cancel",
264
+ "device.section.haptic": "Haptic",
265
+ "device.haptic.lastCall": "마지막 haptic",
266
+ "device.haptic.noneYet": "(아직 없음)",
267
+ "device.haptic.trigger": "Haptic 트리거",
250
268
  "viewport.section.device": "Device",
251
269
  "viewport.row.preset": "Preset",
252
270
  "viewport.row.orientation": "Orientation",
@@ -282,6 +300,9 @@ const ko = {
282
300
  "events.row.tossLoginIntegrated": "Toss Login Integrated",
283
301
  "analytics.section.log": "Analytics Log ({count})",
284
302
  "analytics.btn.clear": "Clear",
303
+ "analytics.calls.section": "SDK Calls ({count})",
304
+ "analytics.calls.btn.clear": "Clear",
305
+ "analytics.calls.empty": "(아직 SDK 호출 없음)",
285
306
  "storage.section.title": "Storage ({count} items)",
286
307
  "storage.btn.clearAll": "Clear All",
287
308
  "storage.empty": "저장된 항목이 없습니다",
@@ -306,6 +327,13 @@ const ko = {
306
327
  "ads.section.fullScreenAd": "FullScreenAd",
307
328
  "ads.btn.load": "Load",
308
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",
309
337
  "notifications.section.title": "requestNotificationAgreement",
310
338
  "notifications.option.newAgreement": "newAgreement (최초 동의)",
311
339
  "notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
@@ -392,6 +420,8 @@ function t(key, vars) {
392
420
  }
393
421
  //#endregion
394
422
  //#region src/mock/state.ts
423
+ /** SDK 호출 로그 ring buffer 상한 */
424
+ const SDK_CALL_LOG_MAX = 200;
395
425
  const DEFAULT_STATE = {
396
426
  platform: "ios",
397
427
  environment: "sandbox",
@@ -469,7 +499,9 @@ const DEFAULT_STATE = {
469
499
  isLoaded: false,
470
500
  nextEvent: "loaded",
471
501
  forceNoFill: false,
472
- lastEvent: null
502
+ lastEvent: null,
503
+ rewardUnitType: "coins",
504
+ rewardAmount: 10
473
505
  },
474
506
  game: {
475
507
  profile: {
@@ -479,6 +511,7 @@ const DEFAULT_STATE = {
479
511
  leaderboardScores: []
480
512
  },
481
513
  analyticsLog: [],
514
+ sdkCallLog: [],
482
515
  deviceModes: {
483
516
  camera: "mock",
484
517
  photos: "mock",
@@ -591,6 +624,19 @@ var AitStateManager = class {
591
624
  };
592
625
  this._notify();
593
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
+ }
594
640
  /** 이벤트 트리거 (backEvent, homeEvent 등) */
595
641
  trigger(event) {
596
642
  window.dispatchEvent(new CustomEvent(`__ait:${event}`));
@@ -1063,7 +1109,7 @@ function readGlobalString(key) {
1063
1109
  }
1064
1110
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
1065
1111
  function getVersion() {
1066
- return "0.1.32";
1112
+ return "0.1.34";
1067
1113
  }
1068
1114
  let panelVisibleSince = null;
1069
1115
  let accumulatedMs = 0;
@@ -2073,6 +2119,75 @@ const _fetchContacts = async (options) => {
2073
2119
  };
2074
2120
  withPermission(_fetchContacts, "contacts");
2075
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
2076
2191
  //#region src/mock/device/location.ts
2077
2192
  /**
2078
2193
  * Location mock (getCurrentLocation, startUpdateLocation)
@@ -2184,12 +2299,35 @@ withPermission(_startUpdateLocation, "geolocation");
2184
2299
  * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
2185
2300
  * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
2186
2301
  * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
2302
+ *
2303
+ * ## KNOWN_UNIMPLEMENTED 정책
2304
+ * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
2305
+ * 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
2306
+ * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
2187
2307
  */
2188
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([]);
2189
2315
  function createMockProxy(moduleName, implementations) {
2190
2316
  return new Proxy(implementations, { get(target, prop) {
2191
2317
  if (typeof prop === "symbol") return void 0;
2192
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
+ };
2193
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}`);
2194
2332
  } });
2195
2333
  }
@@ -2397,19 +2535,127 @@ function renderDeviceTab() {
2397
2535
  });
2398
2536
  if (disabled) clearImagesBtn.disabled = true;
2399
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());
2400
2539
  return container;
2401
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
+ }
2402
2635
  //#endregion
2403
2636
  //#region src/mock/ads/index.ts
2404
2637
  /**
2405
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에 기록
2406
2646
  */
2407
2647
  function withIsSupported(fn) {
2408
2648
  fn.isSupported = () => true;
2409
2649
  return fn;
2410
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
+ }
2411
2657
  const GoogleAdMob = createMockProxy("GoogleAdMob", {
2412
- loadAppsInTossAdMob: withIsSupported((args) => {
2658
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
2413
2659
  setTimeout(() => {
2414
2660
  if (aitState.state.ads.forceNoFill) {
2415
2661
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -2422,8 +2668,8 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2422
2668
  });
2423
2669
  }, 200);
2424
2670
  return () => {};
2425
- }),
2426
- showAppsInTossAdMob: withIsSupported((args) => {
2671
+ })),
2672
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
2427
2673
  if (!aitState.state.ads.isLoaded) {
2428
2674
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
2429
2675
  return () => {};
@@ -2432,11 +2678,12 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2432
2678
  setTimeout(() => args.onEvent({ type: "show" }), 100);
2433
2679
  setTimeout(() => args.onEvent({ type: "impression" }), 150);
2434
2680
  setTimeout(() => {
2681
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
2435
2682
  args.onEvent({
2436
2683
  type: "userEarnedReward",
2437
2684
  data: {
2438
- unitType: "coins",
2439
- unitAmount: 10
2685
+ unitType: rewardUnitType,
2686
+ unitAmount: rewardAmount
2440
2687
  }
2441
2688
  });
2442
2689
  }, 1e3);
@@ -2445,14 +2692,18 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
2445
2692
  aitState.patch("ads", { isLoaded: false });
2446
2693
  }, 1500);
2447
2694
  return () => {};
2448
- }),
2449
- isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
2695
+ })),
2696
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
2450
2697
  });
2451
- createMockProxy("TossAds", {
2452
- initialize: withIsSupported((_options) => {
2453
- console.log("[@ait-co/devtools] TossAds.initialize (mock)");
2454
- }),
2455
- 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) => {
2456
2707
  const el = typeof target === "string" ? document.querySelector(target) : target;
2457
2708
  if (el) {
2458
2709
  const placeholder = document.createElement("div");
@@ -2460,21 +2711,76 @@ createMockProxy("TossAds", {
2460
2711
  placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
2461
2712
  el.appendChild(placeholder);
2462
2713
  }
2463
- }),
2464
- attachBanner: withIsSupported((_adGroupId, target, _options) => {
2714
+ })),
2715
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
2465
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})`;
2466
2729
  if (el) {
2467
- const placeholder = document.createElement("div");
2468
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
2469
- placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
2470
2730
  el.appendChild(placeholder);
2731
+ _slotRegistry.set(slotId, placeholder);
2732
+ }
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);
2471
2776
  }
2472
- return { destroy: () => {} };
2473
- }),
2474
- destroy: withIsSupported((_slotId) => {}),
2475
- destroyAll: withIsSupported(() => {})
2777
+ })),
2778
+ destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
2779
+ for (const el of _slotRegistry.values()) el.remove();
2780
+ _slotRegistry.clear();
2781
+ }))
2476
2782
  });
2477
- const loadFullScreenAd = withIsSupported((args) => {
2783
+ const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
2478
2784
  setTimeout(() => {
2479
2785
  if (aitState.state.ads.forceNoFill) {
2480
2786
  args.onError(/* @__PURE__ */ new Error("No fill"));
@@ -2487,8 +2793,8 @@ const loadFullScreenAd = withIsSupported((args) => {
2487
2793
  });
2488
2794
  }, 200);
2489
2795
  return () => {};
2490
- });
2491
- const showFullScreenAd = withIsSupported((args) => {
2796
+ }));
2797
+ const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
2492
2798
  if (!aitState.state.ads.isLoaded) {
2493
2799
  args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
2494
2800
  return () => {};
@@ -2496,7 +2802,7 @@ const showFullScreenAd = withIsSupported((args) => {
2496
2802
  setTimeout(() => args.onEvent({ type: "show" }), 100);
2497
2803
  setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
2498
2804
  return () => {};
2499
- });
2805
+ }));
2500
2806
  //#endregion
2501
2807
  //#region src/panel/tabs/ads.ts
2502
2808
  function recordEvent(type) {
@@ -2531,6 +2837,57 @@ function adSection(title, onLoad, onShow, disabled) {
2531
2837
  showBtn.addEventListener("click", onShow);
2532
2838
  return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, title), h("div", { className: "ait-btn-row" }, loadBtn, showBtn));
2533
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
+ }
2534
2891
  function renderAdsTab() {
2535
2892
  const s = aitState.state;
2536
2893
  const disabled = !s.panelEditable;
@@ -2545,7 +2902,10 @@ function renderAdsTab() {
2545
2902
  forceNoFillCb.addEventListener("change", () => {
2546
2903
  aitState.patch("ads", { forceNoFill: forceNoFillCb.checked });
2547
2904
  });
2548
- 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"), () => {
2549
2909
  GoogleAdMob.loadAppsInTossAdMob({
2550
2910
  onEvent: (e) => recordEvent(e.type),
2551
2911
  onError: (err) => recordError(err.message)
@@ -2556,12 +2916,13 @@ function renderAdsTab() {
2556
2916
  onError: (err) => recordError(err.message)
2557
2917
  });
2558
2918
  }, disabled), adSection(t("ads.section.tossAds"), () => {
2559
- if (aitState.state.ads.forceNoFill) {
2560
- recordError("No fill");
2561
- return;
2562
- }
2563
- aitState.patch("ads", { isLoaded: true });
2564
- 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
+ } });
2565
2926
  }, () => {
2566
2927
  if (!aitState.state.ads.isLoaded) {
2567
2928
  recordError("Ad not loaded");
@@ -2572,7 +2933,7 @@ function renderAdsTab() {
2572
2933
  recordEvent("dismissed");
2573
2934
  aitState.patch("ads", { isLoaded: false });
2574
2935
  }, 1500);
2575
- }, disabled), adSection(t("ads.section.fullScreenAd"), () => {
2936
+ }, disabled), tossAdsBannerSection(disabled), adSection(t("ads.section.fullScreenAd"), () => {
2576
2937
  loadFullScreenAd({
2577
2938
  onEvent: (e) => recordEvent(e.type),
2578
2939
  onError: (err) => recordError(err.message)
@@ -2587,19 +2948,37 @@ function renderAdsTab() {
2587
2948
  }
2588
2949
  //#endregion
2589
2950
  //#region src/panel/tabs/analytics.ts
2951
+ const FIDELITY_BADGE = {
2952
+ faithful: "🟢",
2953
+ partial: "🟡",
2954
+ inert: "🔴"
2955
+ };
2590
2956
  function renderAnalyticsTab() {
2591
2957
  const disabled = !aitState.state.panelEditable;
2592
2958
  const container = h("div");
2593
2959
  if (disabled) container.appendChild(monitoringNotice());
2594
2960
  const logs = aitState.state.analyticsLog;
2595
- const clearBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.btn.clear"));
2596
- if (disabled) clearBtn.disabled = true;
2597
- 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", () => {
2598
2964
  aitState.update({ analyticsLog: [] });
2599
2965
  });
2600
- 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) => {
2601
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));
2602
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
+ }))));
2603
2982
  return container;
2604
2983
  }
2605
2984
  //#endregion
@@ -3341,9 +3720,17 @@ async function closeView() {
3341
3720
  console.log("[@ait-co/devtools] closeView called");
3342
3721
  window.history.back();
3343
3722
  }
3344
- 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 () => {
3345
3732
  console.log("[@ait-co/devtools] requestReview called");
3346
- }
3733
+ });
3347
3734
  requestReview.isSupported = () => true;
3348
3735
  async function getServerTime() {
3349
3736
  return Date.now();
@@ -3473,6 +3860,20 @@ const NONE_PRESET = {
3473
3860
  navBarHeight: 0,
3474
3861
  safeAreaBottom: 0
3475
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,
3872
+ safeAreaBottom: 0
3873
+ };
3874
+ /** Shorthands used when building preset provenance entries. */
3875
+ const EXTRAPOLATED = { source: "extrapolated" };
3876
+ const PLACEHOLDER = { source: "placeholder" };
3476
3877
  /**
3477
3878
  * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
3478
3879
  * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
@@ -3493,6 +3894,13 @@ const NONE_PRESET = {
3493
3894
  *
3494
3895
  * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
3495
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`로 승급 필요.
3496
3904
  */
3497
3905
  const VIEWPORT_PRESETS = [
3498
3906
  NONE_PRESET,
@@ -3505,7 +3913,8 @@ const VIEWPORT_PRESETS = [
3505
3913
  notch: "none",
3506
3914
  notchInset: 20,
3507
3915
  navBarHeight: 54,
3508
- safeAreaBottom: 0
3916
+ safeAreaBottom: 0,
3917
+ safeAreaProvenance: EXTRAPOLATED
3509
3918
  },
3510
3919
  {
3511
3920
  id: "iphone-15-pro",
@@ -3516,7 +3925,12 @@ const VIEWPORT_PRESETS = [
3516
3925
  notch: "dynamic-island",
3517
3926
  notchInset: 59,
3518
3927
  navBarHeight: 54,
3519
- safeAreaBottom: 34
3928
+ safeAreaBottom: 34,
3929
+ safeAreaProvenance: {
3930
+ source: "measured",
3931
+ device: "iPhone 15 Pro",
3932
+ date: "2026-05-25"
3933
+ }
3520
3934
  },
3521
3935
  {
3522
3936
  id: "iphone-16e",
@@ -3527,7 +3941,8 @@ const VIEWPORT_PRESETS = [
3527
3941
  notch: "notch",
3528
3942
  notchInset: 47,
3529
3943
  navBarHeight: 54,
3530
- safeAreaBottom: 34
3944
+ safeAreaBottom: 34,
3945
+ safeAreaProvenance: EXTRAPOLATED
3531
3946
  },
3532
3947
  {
3533
3948
  id: "iphone-17",
@@ -3538,7 +3953,8 @@ const VIEWPORT_PRESETS = [
3538
3953
  notch: "dynamic-island",
3539
3954
  notchInset: 59,
3540
3955
  navBarHeight: 54,
3541
- safeAreaBottom: 34
3956
+ safeAreaBottom: 34,
3957
+ safeAreaProvenance: EXTRAPOLATED
3542
3958
  },
3543
3959
  {
3544
3960
  id: "iphone-air",
@@ -3549,7 +3965,8 @@ const VIEWPORT_PRESETS = [
3549
3965
  notch: "dynamic-island",
3550
3966
  notchInset: 59,
3551
3967
  navBarHeight: 54,
3552
- safeAreaBottom: 34
3968
+ safeAreaBottom: 34,
3969
+ safeAreaProvenance: EXTRAPOLATED
3553
3970
  },
3554
3971
  {
3555
3972
  id: "iphone-17-pro",
@@ -3560,7 +3977,8 @@ const VIEWPORT_PRESETS = [
3560
3977
  notch: "dynamic-island",
3561
3978
  notchInset: 59,
3562
3979
  navBarHeight: 54,
3563
- safeAreaBottom: 34
3980
+ safeAreaBottom: 34,
3981
+ safeAreaProvenance: EXTRAPOLATED
3564
3982
  },
3565
3983
  {
3566
3984
  id: "iphone-17-pro-max",
@@ -3571,7 +3989,8 @@ const VIEWPORT_PRESETS = [
3571
3989
  notch: "dynamic-island",
3572
3990
  notchInset: 62,
3573
3991
  navBarHeight: 54,
3574
- safeAreaBottom: 34
3992
+ safeAreaBottom: 34,
3993
+ safeAreaProvenance: EXTRAPOLATED
3575
3994
  },
3576
3995
  {
3577
3996
  id: "galaxy-s26",
@@ -3582,7 +4001,8 @@ const VIEWPORT_PRESETS = [
3582
4001
  notch: "punch-hole-center",
3583
4002
  notchInset: 32,
3584
4003
  navBarHeight: 54,
3585
- safeAreaBottom: 0
4004
+ safeAreaBottom: 0,
4005
+ safeAreaProvenance: PLACEHOLDER
3586
4006
  },
3587
4007
  {
3588
4008
  id: "galaxy-s26-plus",
@@ -3593,7 +4013,8 @@ const VIEWPORT_PRESETS = [
3593
4013
  notch: "punch-hole-center",
3594
4014
  notchInset: 32,
3595
4015
  navBarHeight: 54,
3596
- safeAreaBottom: 0
4016
+ safeAreaBottom: 0,
4017
+ safeAreaProvenance: PLACEHOLDER
3597
4018
  },
3598
4019
  {
3599
4020
  id: "galaxy-s26-ultra",
@@ -3604,7 +4025,8 @@ const VIEWPORT_PRESETS = [
3604
4025
  notch: "punch-hole-center",
3605
4026
  notchInset: 40,
3606
4027
  navBarHeight: 54,
3607
- safeAreaBottom: 0
4028
+ safeAreaBottom: 0,
4029
+ safeAreaProvenance: PLACEHOLDER
3608
4030
  },
3609
4031
  {
3610
4032
  id: "galaxy-z-flip7",
@@ -3615,7 +4037,8 @@ const VIEWPORT_PRESETS = [
3615
4037
  notch: "punch-hole-center",
3616
4038
  notchInset: 36,
3617
4039
  navBarHeight: 54,
3618
- safeAreaBottom: 0
4040
+ safeAreaBottom: 0,
4041
+ safeAreaProvenance: PLACEHOLDER
3619
4042
  },
3620
4043
  {
3621
4044
  id: "galaxy-z-fold7-folded",
@@ -3626,7 +4049,8 @@ const VIEWPORT_PRESETS = [
3626
4049
  notch: "punch-hole-center",
3627
4050
  notchInset: 32,
3628
4051
  navBarHeight: 54,
3629
- safeAreaBottom: 0
4052
+ safeAreaBottom: 0,
4053
+ safeAreaProvenance: PLACEHOLDER
3630
4054
  },
3631
4055
  {
3632
4056
  id: "galaxy-z-fold7-unfolded",
@@ -3637,19 +4061,10 @@ const VIEWPORT_PRESETS = [
3637
4061
  notch: "punch-hole-center",
3638
4062
  notchInset: 32,
3639
4063
  navBarHeight: 54,
3640
- safeAreaBottom: 0
4064
+ safeAreaBottom: 0,
4065
+ safeAreaProvenance: PLACEHOLDER
3641
4066
  },
3642
- {
3643
- id: "custom",
3644
- label: "Custom",
3645
- width: 0,
3646
- height: 0,
3647
- dpr: 1,
3648
- notch: "none",
3649
- notchInset: 0,
3650
- navBarHeight: 0,
3651
- safeAreaBottom: 0
3652
- }
4067
+ CUSTOM_PRESET
3653
4068
  ];
3654
4069
  function getPreset(id) {
3655
4070
  return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
@@ -4015,6 +4430,24 @@ function initViewport() {
4015
4430
  }
4016
4431
  //#endregion
4017
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
+ }
4018
4451
  function renderViewportTab() {
4019
4452
  const s = aitState.state;
4020
4453
  const vp = s.viewport;
@@ -4134,7 +4567,10 @@ function renderViewportTab() {
4134
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}`)));
4135
4568
  if (preset) {
4136
4569
  const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide, vp.aitNavBar, vp.aitNavBarType);
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}`)));
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));
4138
4574
  }
4139
4575
  if (vp.aitNavBar && !landscape) {
4140
4576
  const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
@@ -4437,7 +4873,7 @@ function mount() {
4437
4873
  mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
4438
4874
  refreshPanel();
4439
4875
  });
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);
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.34`), closeBtn);
4441
4877
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
4442
4878
  tabsEl = h("div", { className: "ait-panel-tabs" });
4443
4879
  for (const tab of getTabs()) {