@ait-co/devtools 0.1.73 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/devtools-opener-BbUXBzgA.js.map +1 -1
  2. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -1
  3. package/dist/devtools-opener-D84kZFtR.js.map +1 -1
  4. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -1
  5. package/dist/mcp/cli.js +191 -76
  6. package/dist/mcp/cli.js.map +1 -1
  7. package/dist/mcp/server.js +1 -1
  8. package/dist/mock/index.d.ts +50 -2
  9. package/dist/mock/index.d.ts.map +1 -1
  10. package/dist/mock/index.js +1210 -1110
  11. package/dist/mock/index.js.map +1 -1
  12. package/dist/panel/index.js +828 -820
  13. package/dist/panel/index.js.map +1 -1
  14. package/dist/{qr-http-server-Ditd2ndz.js → qr-http-server-CDO6o2nr.js} +69 -12
  15. package/dist/qr-http-server-CDO6o2nr.js.map +1 -0
  16. package/dist/{qr-http-server-0uN5jxLW.cjs → qr-http-server-D0v9ooAD.cjs} +69 -12
  17. package/dist/qr-http-server-D0v9ooAD.cjs.map +1 -0
  18. package/dist/{qr-http-server-TQG61eI4.js → qr-http-server-DznDIcJF.js} +69 -12
  19. package/dist/qr-http-server-DznDIcJF.js.map +1 -0
  20. package/dist/{qr-http-server-BTjpFS3p.cjs → qr-http-server-jMC1nVqY.cjs} +69 -12
  21. package/dist/qr-http-server-jMC1nVqY.cjs.map +1 -0
  22. package/dist/{tunnel-BXAWl2tI.cjs → tunnel-D7f-0enB.cjs} +3 -2
  23. package/dist/{tunnel-BXAWl2tI.cjs.map → tunnel-D7f-0enB.cjs.map} +1 -1
  24. package/dist/{tunnel-BxGnLAat.js → tunnel-km3KkZrF.js} +3 -2
  25. package/dist/{tunnel-BxGnLAat.js.map → tunnel-km3KkZrF.js.map} +1 -1
  26. package/dist/unplugin/index.cjs +1 -1
  27. package/dist/unplugin/index.js +1 -1
  28. package/dist/unplugin/tunnel.cjs +2 -1
  29. package/dist/unplugin/tunnel.cjs.map +1 -1
  30. package/dist/unplugin/tunnel.d.cts.map +1 -1
  31. package/dist/unplugin/tunnel.d.ts.map +1 -1
  32. package/dist/unplugin/tunnel.js +2 -1
  33. package/dist/unplugin/tunnel.js.map +1 -1
  34. package/package.json +1 -1
  35. package/dist/qr-http-server-0uN5jxLW.cjs.map +0 -1
  36. package/dist/qr-http-server-BTjpFS3p.cjs.map +0 -1
  37. package/dist/qr-http-server-Ditd2ndz.js.map +0 -1
  38. package/dist/qr-http-server-TQG61eI4.js.map +0 -1
@@ -25378,6 +25378,9 @@ const en = {
25378
25378
  "dashboard.pages.empty": "No attached pages",
25379
25379
  "dashboard.url.copy": "Copy",
25380
25380
  "dashboard.url.copied": "Copied",
25381
+ "dashboard.inspector.section": "Inspector",
25382
+ "dashboard.inspector.open": "Open inspector",
25383
+ "dashboard.inspector.waiting": "Inspector URL pending — appears after a page attaches",
25381
25384
  "attach.title": "AIT Debug Session — QR Scan",
25382
25385
  "attach.deployment": "deployment: {label}",
25383
25386
  "attach.steps.section": "How to scan",
@@ -25421,6 +25424,7 @@ const en = {
25421
25424
  "launcher.diagNo": "no",
25422
25425
  "launcher.letterboxDetected": "Display area is {pt}pt short — likely an iOS standalone letterbox. Removing and re-adding the launcher to the home screen may fix it.",
25423
25426
  "launcher.navbar.defaultTitle": "Mini App",
25427
+ "launcher.navbar.back": "Back",
25424
25428
  "launcher.navbar.menu": "Menu",
25425
25429
  "launcher.navbar.close": "Close",
25426
25430
  "launcher.navbar.menuRescan": "Rescan",
@@ -25610,6 +25614,9 @@ const ko = {
25610
25614
  "dashboard.pages.empty": "attach된 페이지 없음",
25611
25615
  "dashboard.url.copy": "복사",
25612
25616
  "dashboard.url.copied": "복사됨",
25617
+ "dashboard.inspector.section": "인스펙터",
25618
+ "dashboard.inspector.open": "인스펙터 열기",
25619
+ "dashboard.inspector.waiting": "인스펙터 URL 대기 중 (페이지 attach 후 표시됩니다)",
25613
25620
  "attach.title": "AIT 디버그 세션 — QR 스캔",
25614
25621
  "attach.deployment": "deployment: {label}",
25615
25622
  "attach.steps.section": "스캔 절차",
@@ -25653,6 +25660,7 @@ const ko = {
25653
25660
  "launcher.diagNo": "아니요",
25654
25661
  "launcher.letterboxDetected": "표시 영역이 {pt}pt 부족합니다 — iOS standalone letterbox로 보입니다. 런처를 홈 화면에서 제거 후 다시 설치하면 해소될 수 있어요.",
25655
25662
  "launcher.navbar.defaultTitle": "미니앱",
25663
+ "launcher.navbar.back": "뒤로가기",
25656
25664
  "launcher.navbar.menu": "메뉴",
25657
25665
  "launcher.navbar.close": "닫기",
25658
25666
  "launcher.navbar.menuRescan": "다시 스캔",
@@ -26482,7 +26490,7 @@ function readGlobalString(key) {
26482
26490
  }
26483
26491
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
26484
26492
  function getVersion() {
26485
- return "0.1.73";
26493
+ return "0.1.75";
26486
26494
  }
26487
26495
  let panelVisibleSince = null;
26488
26496
  let accumulatedMs = 0;
@@ -29006,822 +29014,6 @@ function syncDeviceEmulation(preset, landscape) {
29006
29014
  applyDeviceEmulation(profile);
29007
29015
  if (aitState.state.platform !== profile.platform) aitState.update({ platform: profile.platform });
29008
29016
  }
29009
- //#endregion
29010
- //#region src/panel/viewport.ts
29011
- /**
29012
- * Viewport 시뮬레이션 유틸
29013
- *
29014
- * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
29015
- * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
29016
- * 동적 값(width/height)만 별도 `<style>` 엘리먼트로 관리한다.
29017
- *
29018
- * body `padding-top`은 주입하지 않는다: 실기기에서 토스 native nav bar는 WebView viewport
29019
- * 밖이라 콘텐츠는 top=0부터 시작한다(devtools#275).
29020
- */
29021
- const VIEWPORT_STORAGE_KEY = "__ait_viewport";
29022
- /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
29023
- const VIEWPORT_CUSTOM_MAX = 4096;
29024
- const NONE_PRESET = {
29025
- id: "none",
29026
- label: "None (full window)",
29027
- width: 0,
29028
- height: 0,
29029
- dpr: 1,
29030
- notch: "none",
29031
- notchInset: 0,
29032
- navBarHeight: 0,
29033
- safeAreaBottom: 0
29034
- };
29035
- const CUSTOM_PRESET = {
29036
- id: "custom",
29037
- label: "Custom",
29038
- width: 0,
29039
- height: 0,
29040
- dpr: 1,
29041
- notch: "none",
29042
- notchInset: 0,
29043
- navBarHeight: 0,
29044
- safeAreaBottom: 0
29045
- };
29046
- /** Shorthands used when building preset provenance entries. */
29047
- const EXTRAPOLATED = { source: "extrapolated" };
29048
- const PLACEHOLDER = { source: "placeholder" };
29049
- /**
29050
- * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
29051
- * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
29052
- * Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
29053
- * 측정치를 사용.
29054
- *
29055
- * safe-area 모델 (devtools#190 relay 실측 반영):
29056
- * - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
29057
- * 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
29058
- * 값은 portrait SDK top에는 들어가지 않는다.
29059
- * - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
29060
- * 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
29061
- * - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
29062
- * iPhone 15 Pro 실측 bottom 34와 일치.
29063
- *
29064
- * 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
29065
- * 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
29066
- *
29067
- * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
29068
- * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
29069
- *
29070
- * safeAreaProvenance: 각 preset의 safe-area 값 신뢰도 출처.
29071
- * - `measured` — relay 실기기 세션(measure_safe_area)으로 직접 확인한 값.
29072
- * 현재 iPhone 15 Pro portrait iOS partner만 해당 (devtools#190).
29073
- * - `extrapolated` — Apple 스펙/같은 시리즈 기기에서 유추한 값.
29074
- * - `placeholder` — 연결 기기 없이 추정한 값. QA ground truth로 쓰지 말 것.
29075
- * `measure_safe_area` MCP 툴로 relay 세션에서 `measured`로 승급 필요.
29076
- */
29077
- const VIEWPORT_PRESETS = [
29078
- NONE_PRESET,
29079
- {
29080
- id: "iphone-se-3",
29081
- label: "iPhone SE (3rd gen)",
29082
- width: 375,
29083
- height: 667,
29084
- dpr: 2,
29085
- notch: "none",
29086
- notchInset: 20,
29087
- navBarHeight: 54,
29088
- safeAreaBottom: 0,
29089
- safeAreaProvenance: EXTRAPOLATED
29090
- },
29091
- {
29092
- id: "iphone-15-pro",
29093
- label: "iPhone 15 Pro",
29094
- width: 393,
29095
- height: 754,
29096
- screenHeight: 852,
29097
- dpr: 3,
29098
- notch: "dynamic-island",
29099
- notchInset: 59,
29100
- navBarHeight: 54,
29101
- safeAreaBottom: 34,
29102
- safeAreaBottomLandscape: 20,
29103
- safeAreaProvenance: {
29104
- source: "measured",
29105
- device: "iPhone 15 Pro",
29106
- date: "2026-05-28",
29107
- orientations: ["portrait", "landscape"]
29108
- }
29109
- },
29110
- {
29111
- id: "iphone-16e",
29112
- label: "iPhone 16e",
29113
- width: 390,
29114
- height: 844,
29115
- dpr: 3,
29116
- notch: "notch",
29117
- notchInset: 47,
29118
- navBarHeight: 54,
29119
- safeAreaBottom: 34,
29120
- safeAreaProvenance: EXTRAPOLATED
29121
- },
29122
- {
29123
- id: "iphone-17",
29124
- label: "iPhone 17",
29125
- width: 402,
29126
- height: 874,
29127
- dpr: 3,
29128
- notch: "dynamic-island",
29129
- notchInset: 59,
29130
- navBarHeight: 54,
29131
- safeAreaBottom: 34,
29132
- safeAreaProvenance: EXTRAPOLATED
29133
- },
29134
- {
29135
- id: "iphone-air",
29136
- label: "iPhone Air",
29137
- width: 420,
29138
- height: 912,
29139
- dpr: 3,
29140
- notch: "dynamic-island",
29141
- notchInset: 59,
29142
- navBarHeight: 54,
29143
- safeAreaBottom: 34,
29144
- safeAreaProvenance: EXTRAPOLATED
29145
- },
29146
- {
29147
- id: "iphone-17-pro",
29148
- label: "iPhone 17 Pro",
29149
- width: 402,
29150
- height: 874,
29151
- dpr: 3,
29152
- notch: "dynamic-island",
29153
- notchInset: 59,
29154
- navBarHeight: 54,
29155
- safeAreaBottom: 34,
29156
- safeAreaProvenance: EXTRAPOLATED
29157
- },
29158
- {
29159
- id: "iphone-17-pro-max",
29160
- label: "iPhone 17 Pro Max",
29161
- width: 440,
29162
- height: 956,
29163
- dpr: 3,
29164
- notch: "dynamic-island",
29165
- notchInset: 62,
29166
- navBarHeight: 54,
29167
- safeAreaBottom: 34,
29168
- safeAreaProvenance: EXTRAPOLATED
29169
- },
29170
- {
29171
- id: "galaxy-s26",
29172
- label: "Galaxy S26",
29173
- width: 360,
29174
- height: 773,
29175
- dpr: 3,
29176
- notch: "punch-hole-center",
29177
- notchInset: 32,
29178
- navBarHeight: 54,
29179
- safeAreaBottom: 0,
29180
- safeAreaProvenance: PLACEHOLDER
29181
- },
29182
- {
29183
- id: "galaxy-s26-plus",
29184
- label: "Galaxy S26+",
29185
- width: 480,
29186
- height: 1040,
29187
- dpr: 3,
29188
- notch: "punch-hole-center",
29189
- notchInset: 32,
29190
- navBarHeight: 54,
29191
- safeAreaBottom: 0,
29192
- safeAreaProvenance: PLACEHOLDER
29193
- },
29194
- {
29195
- id: "galaxy-s26-ultra",
29196
- label: "Galaxy S26 Ultra",
29197
- width: 480,
29198
- height: 1040,
29199
- dpr: 3,
29200
- notch: "punch-hole-center",
29201
- notchInset: 40,
29202
- navBarHeight: 54,
29203
- safeAreaBottom: 0,
29204
- safeAreaProvenance: PLACEHOLDER
29205
- },
29206
- {
29207
- id: "galaxy-z-flip7",
29208
- label: "Galaxy Z Flip7",
29209
- width: 412,
29210
- height: 990,
29211
- dpr: 3,
29212
- notch: "punch-hole-center",
29213
- notchInset: 36,
29214
- navBarHeight: 54,
29215
- safeAreaBottom: 0,
29216
- safeAreaProvenance: PLACEHOLDER
29217
- },
29218
- {
29219
- id: "galaxy-z-fold7-folded",
29220
- label: "Galaxy Z Fold7 (folded)",
29221
- width: 384,
29222
- height: 870,
29223
- dpr: 3,
29224
- notch: "punch-hole-center",
29225
- notchInset: 32,
29226
- navBarHeight: 54,
29227
- safeAreaBottom: 0,
29228
- safeAreaProvenance: PLACEHOLDER
29229
- },
29230
- {
29231
- id: "galaxy-z-fold7-unfolded",
29232
- label: "Galaxy Z Fold7 (unfolded)",
29233
- width: 768,
29234
- height: 884,
29235
- dpr: 2.625,
29236
- notch: "punch-hole-center",
29237
- notchInset: 32,
29238
- navBarHeight: 54,
29239
- safeAreaBottom: 0,
29240
- safeAreaProvenance: PLACEHOLDER
29241
- },
29242
- CUSTOM_PRESET
29243
- ];
29244
- function getPreset(id) {
29245
- return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
29246
- }
29247
- /**
29248
- * 실제로 화면에 표시될 orientation을 결정한다.
29249
- *
29250
- * - Panel `orientation === 'auto'`: 앱이 마지막으로 SDK로 요청한 값
29251
- * (`appOrientation`)을 따른다. 호출 전이면 portrait.
29252
- * - Panel `orientation === 'portrait' | 'landscape'`: Panel 값이 우선.
29253
- */
29254
- function effectiveOrientation(state) {
29255
- if (state.orientation === "auto") return state.appOrientation ?? "portrait";
29256
- return state.orientation;
29257
- }
29258
- /**
29259
- * 선택된 뷰포트의 실제 width/height를 계산한다.
29260
- * preset === 'custom'이면 customWidth/customHeight, 그 외에는 preset의 값.
29261
- * effective orientation이 landscape이면 width/height를 swap한다.
29262
- */
29263
- function resolveViewportSize(state) {
29264
- if (state.preset === "none") return {
29265
- width: 0,
29266
- height: 0
29267
- };
29268
- const base = state.preset === "custom" ? {
29269
- width: state.customWidth,
29270
- height: state.customHeight
29271
- } : getPreset(state.preset);
29272
- return effectiveOrientation(state) === "landscape" ? {
29273
- width: base.height,
29274
- height: base.width
29275
- } : {
29276
- width: base.width,
29277
- height: base.height
29278
- };
29279
- }
29280
- /**
29281
- * 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
29282
- * 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190, #198, #232, #275)에 맞춘 모델:
29283
- *
29284
- * - **Portrait top = 0** (partner/game 모두). 실측(devtools#275)에서 토스 native nav bar는
29285
- * partner WebView **viewport 밖**에 그려진다. SDK가 반환하는 `top=54`는 호스트 nav bar
29286
- * 높이에 대한 정보용 값이고, WebView 좌표계에서 콘텐츠는 top=0부터 시작한다. 소비자가
29287
- * 이 값을 `padding-top`으로 적용하면 실기기에서 잉여 공간이 생긴다(double-count).
29288
- * mock은 top=0을 반환해 소비자 코드가 실기기와 같은 결과를 내도록 한다.
29289
- * `game` type 측정은 아직 미진행이지만 동일하게 top=0을 반환한다(추후 실측으로 갱신).
29290
- * - **Bottom = `safeAreaBottom`** (portrait home-indicator, 실측 34).
29291
- * landscape는 `safeAreaBottomLandscape`가 정의돼 있으면 그 값을 사용한다
29292
- * (iPhone 15 Pro landscape 실측 20 — portrait 34와 다름).
29293
- * - **Landscape iPhone(notch/Dynamic Island)**: CSS env()와 SDK SafeAreaInsets 모두
29294
- * `left = right = notchInset`(양쪽 대칭)을 반환한다. 물리적 노치는 한쪽으로 가지만
29295
- * OS가 양쪽 모두에 같은 inset을 부여하므로 landscapeSide mental model은 틀렸다
29296
- * (2026-05-28 iPhone 15 Pro relay 실측 #198/#232: left=right=59). top=0(landscape에서
29297
- * 토스 앱이 partner nav bar를 숨김, #232 실측 확인).
29298
- * - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
29299
- * 유지된다.
29300
- */
29301
- function computeSafeAreaInsets(preset, landscape) {
29302
- if (preset.id === "none" || preset.id === "custom") return {
29303
- top: 0,
29304
- bottom: 0,
29305
- left: 0,
29306
- right: 0
29307
- };
29308
- if (!landscape) return {
29309
- top: 0,
29310
- bottom: preset.safeAreaBottom,
29311
- left: 0,
29312
- right: 0
29313
- };
29314
- const landscapeBottom = preset.safeAreaBottomLandscape !== void 0 ? preset.safeAreaBottomLandscape : preset.safeAreaBottom;
29315
- if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
29316
- top: 0,
29317
- bottom: landscapeBottom,
29318
- left: preset.notchInset,
29319
- right: preset.notchInset
29320
- };
29321
- return {
29322
- top: preset.notchInset,
29323
- bottom: landscapeBottom,
29324
- left: 0,
29325
- right: 0
29326
- };
29327
- }
29328
- /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
29329
- function syncSafeAreaFromViewport(state) {
29330
- if (state.preset === "none" || state.preset === "custom") return;
29331
- const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape");
29332
- const current = aitState.state.safeAreaInsets;
29333
- if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
29334
- aitState.update({ safeAreaInsets: next });
29335
- }
29336
- const STYLE_ELEMENT_ID = "__ait-viewport-style";
29337
- const NOTCH_ELEMENT_ID = "__ait-viewport-notch";
29338
- const HOME_INDICATOR_ID = "__ait-viewport-home-indicator";
29339
- const NAV_BAR_ELEMENT_ID = "__ait-viewport-navbar";
29340
- let bodyScrollHintEmitted = false;
29341
- function ensureStyleElement() {
29342
- if (typeof document === "undefined") return null;
29343
- let el = document.getElementById(STYLE_ELEMENT_ID);
29344
- if (!el) {
29345
- el = document.createElement("style");
29346
- el.id = STYLE_ELEMENT_ID;
29347
- document.head.appendChild(el);
29348
- }
29349
- return el;
29350
- }
29351
- function removeById(id) {
29352
- const el = document.getElementById(id);
29353
- if (el) el.remove();
29354
- }
29355
- function removeNotchElement() {
29356
- removeById(NOTCH_ELEMENT_ID);
29357
- }
29358
- function removeHomeIndicator() {
29359
- removeById(HOME_INDICATOR_ID);
29360
- }
29361
- function removeNavBarElement() {
29362
- removeById(NAV_BAR_ELEMENT_ID);
29363
- }
29364
- /**
29365
- * Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
29366
- *
29367
- * 변형(SDK `webViewProps.type`과 의미 일치):
29368
- * - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
29369
- * 우측 `⋯` + 구분선 + `×`.
29370
- * - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
29371
- *
29372
- * 이 오버레이는 **시각 참고용 frame 장식**이다. 실기기에서 토스 native nav bar는 WebView
29373
- * viewport 밖에 그려지므로(devtools#275), mock의 nav bar 오버레이가 콘텐츠 위에 overlap
29374
- * 되는 것이 실제 동작과 일치한다 — body에 `padding-top`을 주입하지 않는다.
29375
- * 시각 notch 오버레이는 body 밖 위쪽에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
29376
- *
29377
- * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
29378
- * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
29379
- */
29380
- function renderNavBar(displayName, type) {
29381
- removeNavBarElement();
29382
- const el = h("div", {
29383
- id: NAV_BAR_ELEMENT_ID,
29384
- className: `ait-navbar ait-navbar-${type}`,
29385
- "aria-hidden": "true"
29386
- });
29387
- const moreBtn = h("button", {
29388
- className: "ait-navbar-btn",
29389
- type: "button",
29390
- "aria-label": "More"
29391
- });
29392
- moreBtn.textContent = "⋯";
29393
- const closeBtn = h("button", {
29394
- className: "ait-navbar-btn",
29395
- type: "button",
29396
- "aria-label": "Close"
29397
- });
29398
- closeBtn.textContent = "×";
29399
- closeBtn.addEventListener("click", () => {
29400
- closeView().catch((err) => console.error("[@ait-co/devtools] navbar close failed:", err));
29401
- });
29402
- const actions = h("div", { className: "ait-navbar-actions" }, moreBtn, h("span", { className: "ait-navbar-divider" }), closeBtn);
29403
- if (type === "game") el.append(actions);
29404
- else {
29405
- const backBtn = h("button", {
29406
- className: "ait-navbar-btn ait-navbar-back",
29407
- type: "button",
29408
- "aria-label": "Back"
29409
- });
29410
- backBtn.textContent = "‹";
29411
- backBtn.addEventListener("click", () => {
29412
- aitState.trigger("backEvent");
29413
- });
29414
- const nameSpan = h("span", { className: "ait-navbar-name" });
29415
- nameSpan.textContent = displayName;
29416
- el.append(backBtn, h("div", { className: "ait-navbar-title" }, h("span", { className: "ait-navbar-icon" }), nameSpan), actions);
29417
- }
29418
- document.body.appendChild(el);
29419
- }
29420
- /**
29421
- * 현재 preset의 notch/Dynamic Island/punch-hole을 body 상단에 시각적으로 렌더한다.
29422
- * landscape 시에는 노치가 한쪽 변에 있는 것이 실제 기기 동작이지만, 시뮬레이터에서는
29423
- * landscape에서 오버레이를 그리지 않는다 (safeAreaInsets의 left/right로 이미 반영).
29424
- */
29425
- function renderNotchOverlay(preset) {
29426
- removeNotchElement();
29427
- if (preset.notch === "none") return;
29428
- const notch = h("div", {
29429
- id: NOTCH_ELEMENT_ID,
29430
- className: `ait-notch ${preset.notch === "dynamic-island" ? "ait-notch-dynamic-island" : preset.notch === "notch" ? "ait-notch-pill" : "ait-notch-punch-hole"}`,
29431
- "aria-hidden": "true"
29432
- });
29433
- document.body.appendChild(notch);
29434
- }
29435
- /** brand 이름만 바뀐 경우 nav bar 전체를 다시 만들지 않고 텍스트 노드만 교체한다. */
29436
- function refreshNavBarBrand(displayName) {
29437
- const name = document.querySelector(`#${NAV_BAR_ELEMENT_ID} .ait-navbar-name`);
29438
- if (name) name.textContent = displayName;
29439
- }
29440
- function renderHomeIndicator() {
29441
- removeHomeIndicator();
29442
- const el = h("div", {
29443
- id: HOME_INDICATOR_ID,
29444
- className: "ait-home-indicator",
29445
- "aria-hidden": "true"
29446
- });
29447
- document.body.appendChild(el);
29448
- }
29449
- /**
29450
- * 모든 viewport DOM mutation을 원복하고 aitState 구독도 해제한다.
29451
- * 외부 consumer가 패널을 동적으로 제거할 때 호출. 호출 후에는 aitState 변경이
29452
- * DOM에 반영되지 않으므로 안전하게 panel을 떼어낼 수 있다.
29453
- */
29454
- function disposeViewport() {
29455
- if (typeof document === "undefined") return;
29456
- if (viewportUnsubscribe) viewportUnsubscribe();
29457
- const html = document.documentElement;
29458
- html.classList.remove("ait-viewport-active");
29459
- html.classList.remove("ait-viewport-framed");
29460
- removeById(STYLE_ELEMENT_ID);
29461
- removeNotchElement();
29462
- removeHomeIndicator();
29463
- removeNavBarElement();
29464
- revertDeviceEmulation();
29465
- bodyScrollHintEmitted = false;
29466
- }
29467
- /**
29468
- * DOM에 뷰포트 제약을 적용한다.
29469
- * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
29470
- * - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
29471
- */
29472
- function applyViewport(state) {
29473
- if (typeof document === "undefined") return;
29474
- const html = document.documentElement;
29475
- const style = ensureStyleElement();
29476
- if (!style) return;
29477
- const size = resolveViewportSize(state);
29478
- if (state.preset === "none" || size.width === 0 || size.height === 0) {
29479
- html.classList.remove("ait-viewport-active");
29480
- html.classList.remove("ait-viewport-framed");
29481
- style.textContent = "";
29482
- removeNotchElement();
29483
- removeHomeIndicator();
29484
- removeNavBarElement();
29485
- syncDeviceEmulation(null, false);
29486
- return;
29487
- }
29488
- if (!bodyScrollHintEmitted) {
29489
- bodyScrollHintEmitted = true;
29490
- console.info("[@ait-co/devtools] Viewport simulation active — scroll happens on body, not window. See README \"Known limitations\" for details.");
29491
- }
29492
- html.classList.add("ait-viewport-active");
29493
- html.classList.toggle("ait-viewport-framed", state.frame);
29494
- const preset = state.preset === "custom" ? null : getPreset(state.preset);
29495
- const landscape = effectiveOrientation(state) === "landscape";
29496
- syncDeviceEmulation(preset, landscape);
29497
- style.textContent = `
29498
- html.ait-viewport-active body {
29499
- width: ${size.width}px;
29500
- max-width: ${size.width}px;
29501
- min-height: ${size.height}px;
29502
- max-height: ${size.height}px;
29503
- }
29504
- `;
29505
- if (preset && state.frame && !landscape) renderNotchOverlay(preset);
29506
- else removeNotchElement();
29507
- if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
29508
- else removeHomeIndicator();
29509
- if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
29510
- else removeNavBarElement();
29511
- }
29512
- function isViewportPresetId(v) {
29513
- return typeof v === "string" && VIEWPORT_PRESETS.some((p) => p.id === v);
29514
- }
29515
- function isViewportOrientation(v) {
29516
- return v === "auto" || v === "portrait" || v === "landscape";
29517
- }
29518
- function isAppOrientation(v) {
29519
- return v === null || v === "portrait" || v === "landscape";
29520
- }
29521
- /** 1 이상의 정수 + VIEWPORT_CUSTOM_MAX 이하인지 검사. sessionStorage 보호용. */
29522
- function isValidCustomDimension(v) {
29523
- return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 4096;
29524
- }
29525
- /** Custom 입력에서 사용. 잘린 정수 + 클램프된 안전한 값 또는 null 반환. */
29526
- function clampCustomDimension(raw) {
29527
- if (!Number.isFinite(raw)) return null;
29528
- const n = Math.floor(raw);
29529
- if (n < 1) return null;
29530
- return Math.min(n, VIEWPORT_CUSTOM_MAX);
29531
- }
29532
- /**
29533
- * sessionStorage에 저장된 뷰포트 상태를 읽어서 현재 state에 merge한다.
29534
- * 값이 없거나 파싱 실패 시 no-op.
29535
- */
29536
- function loadViewportFromStorage() {
29537
- if (typeof sessionStorage === "undefined") return null;
29538
- const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
29539
- if (!raw) return null;
29540
- try {
29541
- const parsed = JSON.parse(raw);
29542
- if (typeof parsed !== "object" || parsed === null) return null;
29543
- const obj = parsed;
29544
- const next = {};
29545
- if (isViewportPresetId(obj.preset)) next.preset = obj.preset;
29546
- if (isViewportOrientation(obj.orientation)) next.orientation = obj.orientation;
29547
- if (isAppOrientation(obj.appOrientation)) next.appOrientation = obj.appOrientation;
29548
- if (isValidCustomDimension(obj.customWidth)) next.customWidth = obj.customWidth;
29549
- if (isValidCustomDimension(obj.customHeight)) next.customHeight = obj.customHeight;
29550
- if (typeof obj.frame === "boolean") next.frame = obj.frame;
29551
- if (typeof obj.aitNavBar === "boolean") next.aitNavBar = obj.aitNavBar;
29552
- if (obj.aitNavBarType === "partner" || obj.aitNavBarType === "game") next.aitNavBarType = obj.aitNavBarType;
29553
- return next;
29554
- } catch {
29555
- return null;
29556
- }
29557
- }
29558
- function saveViewportToStorage(state) {
29559
- if (typeof sessionStorage === "undefined") return;
29560
- try {
29561
- sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(state));
29562
- } catch {}
29563
- }
29564
- let viewportInitialized = false;
29565
- let viewportUnsubscribe = null;
29566
- /**
29567
- * Panel mount 시 호출. sessionStorage 복원 → aitState에 반영 → DOM 적용.
29568
- * aitState 변경을 구독해서 DOM / storage / safe-area insets를 자동 동기화한다.
29569
- *
29570
- * Idempotent: 두 번째 호출은 기존 unsubscribe를 그대로 반환한다 (HMR / 재mount 안전).
29571
- * 테스트는 반환된 unsubscribe를 afterEach에서 호출해 cleanup해야 한다.
29572
- */
29573
- function initViewport() {
29574
- if (typeof window === "undefined") return () => {};
29575
- if (viewportInitialized && viewportUnsubscribe) return viewportUnsubscribe;
29576
- const restored = loadViewportFromStorage();
29577
- if (restored) aitState.patch("viewport", restored);
29578
- applyViewport(aitState.state.viewport);
29579
- syncSafeAreaFromViewport(aitState.state.viewport);
29580
- let lastViewportJson = JSON.stringify(aitState.state.viewport);
29581
- let lastBrandName = aitState.state.brand.displayName;
29582
- const unsubscribeFn = aitState.subscribe(() => {
29583
- const vp = aitState.state.viewport;
29584
- const brandName = aitState.state.brand.displayName;
29585
- const json = JSON.stringify(vp);
29586
- const viewportChanged = json !== lastViewportJson;
29587
- if (!viewportChanged && !(brandName !== lastBrandName)) return;
29588
- lastViewportJson = json;
29589
- lastBrandName = brandName;
29590
- if (viewportChanged) {
29591
- applyViewport(vp);
29592
- saveViewportToStorage(vp);
29593
- syncSafeAreaFromViewport(vp);
29594
- } else refreshNavBarBrand(brandName);
29595
- });
29596
- viewportInitialized = true;
29597
- viewportUnsubscribe = () => {
29598
- unsubscribeFn();
29599
- viewportInitialized = false;
29600
- viewportUnsubscribe = null;
29601
- };
29602
- return viewportUnsubscribe;
29603
- }
29604
- //#endregion
29605
- //#region src/panel/tabs/viewport.ts
29606
- /**
29607
- * Renders a small inline provenance badge for safe-area values.
29608
- * - `measured` — no badge (confirmed value)
29609
- * - `extrapolated` — "(추정치)" in muted gray
29610
- * - `placeholder` — "(미측정)" in amber
29611
- */
29612
- function provenanceBadge(provenance) {
29613
- if (!provenance || provenance.source === "measured") return null;
29614
- const text = provenance.source === "placeholder" ? "(미측정)" : "(추정치)";
29615
- const color = provenance.source === "placeholder" ? "#b45309" : "#888";
29616
- const badge = h("span", {
29617
- className: "ait-provenance-badge",
29618
- title: provenance.source === "placeholder" ? "safe-area 값이 미실측 추정치입니다. relay 세션에서 measure_safe_area로 실측 후 승급하세요." : "safe-area 값이 기기 스펙에서 유추한 추정치입니다. relay 세션에서 measure_safe_area로 확인하세요."
29619
- });
29620
- badge.textContent = text;
29621
- badge.style.cssText = `font-size:10px;color:${color};margin-left:4px`;
29622
- return badge;
29623
- }
29624
- function renderViewportTab() {
29625
- const s = aitState.state;
29626
- const vp = s.viewport;
29627
- const disabled = !s.panelEditable;
29628
- const container = h("div");
29629
- if (disabled) container.appendChild(monitoringNotice());
29630
- const presetSelect = h("select", { className: "ait-select" });
29631
- if (disabled) presetSelect.disabled = true;
29632
- for (const preset of VIEWPORT_PRESETS) {
29633
- const label = preset.id === "none" || preset.id === "custom" ? preset.label : `${preset.label} (${preset.width}×${preset.height})`;
29634
- const option = h("option", { value: preset.id }, label);
29635
- if (preset.id === vp.preset) option.selected = true;
29636
- presetSelect.appendChild(option);
29637
- }
29638
- presetSelect.addEventListener("change", () => {
29639
- const id = presetSelect.value;
29640
- const patch = { preset: id };
29641
- if (id === "custom") {
29642
- const current = getPreset(vp.preset);
29643
- if (current.width > 0) patch.customWidth = current.width;
29644
- if (current.height > 0) patch.customHeight = current.height;
29645
- }
29646
- aitState.patch("viewport", patch);
29647
- });
29648
- const orientationSelect = h("select", { className: "ait-select" });
29649
- if (disabled) orientationSelect.disabled = true;
29650
- for (const opt of [
29651
- "auto",
29652
- "portrait",
29653
- "landscape"
29654
- ]) {
29655
- const option = h("option", { value: opt }, opt);
29656
- if (opt === vp.orientation) option.selected = true;
29657
- orientationSelect.appendChild(option);
29658
- }
29659
- orientationSelect.addEventListener("change", () => {
29660
- aitState.patch("viewport", { orientation: orientationSelect.value });
29661
- });
29662
- const customRow = h("div", { className: "ait-section" });
29663
- if (vp.preset === "custom") {
29664
- const widthInput = h("input", {
29665
- className: "ait-input",
29666
- type: "number",
29667
- min: "1",
29668
- value: String(vp.customWidth)
29669
- });
29670
- const heightInput = h("input", {
29671
- className: "ait-input",
29672
- type: "number",
29673
- min: "1",
29674
- value: String(vp.customHeight)
29675
- });
29676
- if (disabled) {
29677
- widthInput.disabled = true;
29678
- heightInput.disabled = true;
29679
- }
29680
- widthInput.addEventListener("change", () => {
29681
- const clamped = clampCustomDimension(Number(widthInput.value));
29682
- if (clamped !== null) {
29683
- aitState.patch("viewport", { customWidth: clamped });
29684
- widthInput.value = String(clamped);
29685
- }
29686
- });
29687
- heightInput.addEventListener("change", () => {
29688
- const clamped = clampCustomDimension(Number(heightInput.value));
29689
- if (clamped !== null) {
29690
- aitState.patch("viewport", { customHeight: clamped });
29691
- heightInput.value = String(clamped);
29692
- }
29693
- });
29694
- customRow.append(h("div", { className: "ait-section-title" }, t("viewport.section.custom")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.width")), widthInput), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.height")), heightInput));
29695
- }
29696
- const frameCheckbox = h("input", { type: "checkbox" });
29697
- frameCheckbox.checked = vp.frame;
29698
- if (disabled) frameCheckbox.disabled = true;
29699
- frameCheckbox.addEventListener("change", () => {
29700
- aitState.patch("viewport", { frame: frameCheckbox.checked });
29701
- });
29702
- const navBarCheckbox = h("input", { type: "checkbox" });
29703
- navBarCheckbox.checked = vp.aitNavBar;
29704
- if (disabled) navBarCheckbox.disabled = true;
29705
- navBarCheckbox.addEventListener("change", () => {
29706
- aitState.patch("viewport", { aitNavBar: navBarCheckbox.checked });
29707
- });
29708
- const navBarTypeSelect = h("select", { className: "ait-select" });
29709
- if (disabled || !vp.aitNavBar) navBarTypeSelect.disabled = true;
29710
- for (const opt of ["partner", "game"]) {
29711
- const option = h("option", { value: opt }, opt);
29712
- if (opt === vp.aitNavBarType) option.selected = true;
29713
- navBarTypeSelect.appendChild(option);
29714
- }
29715
- navBarTypeSelect.addEventListener("change", () => {
29716
- aitState.patch("viewport", { aitNavBarType: navBarTypeSelect.value });
29717
- });
29718
- const size = resolveViewportSize(vp);
29719
- const statusEl = h("div", { className: "ait-section" });
29720
- if (vp.preset === "none" || size.width === 0) statusEl.appendChild(h("div", { style: "color:#888;font-size:11px" }, t("viewport.status.noConstraint")));
29721
- else {
29722
- const preset = vp.preset === "custom" ? null : getPreset(vp.preset);
29723
- const effOrient = effectiveOrientation(vp);
29724
- const landscape = effOrient === "landscape";
29725
- const rows = [];
29726
- const dpr = preset?.dpr ?? 1;
29727
- const physW = Math.round(size.width * dpr);
29728
- const physH = Math.round(size.height * dpr);
29729
- const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
29730
- 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}`)));
29731
- if (preset) {
29732
- const insets = computeSafeAreaInsets(preset, landscape);
29733
- const safeAreaValueEl = h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`);
29734
- const badge = provenanceBadge(preset.safeAreaProvenance);
29735
- if (badge) safeAreaValueEl.appendChild(badge);
29736
- rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), safeAreaValueEl));
29737
- }
29738
- if (vp.aitNavBar && !landscape) {
29739
- const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
29740
- rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
29741
- height: navBarTop,
29742
- type: vp.aitNavBarType
29743
- }))));
29744
- }
29745
- for (const row of rows) statusEl.appendChild(row);
29746
- }
29747
- 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));
29748
- container.append(deviceSection, customRow, h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("viewport.section.appearance")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.showFrame")), frameCheckbox), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.showAitNavBar")), navBarCheckbox), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.navBarType")), navBarTypeSelect)), statusEl);
29749
- return container;
29750
- }
29751
- //#endregion
29752
- //#region src/panel/tabs/index.ts
29753
- const TAB_DEFS = [
29754
- {
29755
- id: "env",
29756
- labelKey: "panel.tab.env"
29757
- },
29758
- {
29759
- id: "presets",
29760
- labelKey: "panel.tab.presets"
29761
- },
29762
- {
29763
- id: "viewport",
29764
- labelKey: "panel.tab.viewport"
29765
- },
29766
- {
29767
- id: "permissions",
29768
- labelKey: "panel.tab.permissions"
29769
- },
29770
- {
29771
- id: "notifications",
29772
- labelKey: "panel.tab.notifications"
29773
- },
29774
- {
29775
- id: "location",
29776
- labelKey: "panel.tab.location"
29777
- },
29778
- {
29779
- id: "device",
29780
- labelKey: "panel.tab.device"
29781
- },
29782
- {
29783
- id: "iap",
29784
- labelKey: "panel.tab.iap"
29785
- },
29786
- {
29787
- id: "ads",
29788
- labelKey: "panel.tab.ads"
29789
- },
29790
- {
29791
- id: "events",
29792
- labelKey: "panel.tab.events"
29793
- },
29794
- {
29795
- id: "analytics",
29796
- labelKey: "panel.tab.analytics"
29797
- },
29798
- {
29799
- id: "storage",
29800
- labelKey: "panel.tab.storage"
29801
- }
29802
- ];
29803
- function getTabs() {
29804
- return TAB_DEFS.map((def) => ({
29805
- id: def.id,
29806
- label: t(def.labelKey)
29807
- }));
29808
- }
29809
- function createTabRenderers(refreshPanel) {
29810
- return {
29811
- env: renderEnvironmentTab,
29812
- presets: () => renderPresetsTab(refreshPanel),
29813
- permissions: renderPermissionsTab,
29814
- notifications: renderNotificationsTab,
29815
- location: renderLocationTab,
29816
- device: renderDeviceTab,
29817
- viewport: renderViewportTab,
29818
- iap: renderIapTab,
29819
- ads: renderAdsTab,
29820
- events: renderEventsTab,
29821
- analytics: renderAnalyticsTab,
29822
- storage: () => renderStorageTab(refreshPanel)
29823
- };
29824
- }
29825
29017
  const PANEL_STYLES = `
29826
29018
  .ait-panel-toggle {
29827
29019
  position: fixed;
@@ -30394,8 +29586,824 @@ const PANEL_STYLES = `
30394
29586
  .ait-panel-close {
30395
29587
  display: block;
30396
29588
  }
30397
- }
30398
- `;
29589
+ }
29590
+ `;
29591
+ //#endregion
29592
+ //#region src/panel/viewport.ts
29593
+ /**
29594
+ * Viewport 시뮬레이션 유틸
29595
+ *
29596
+ * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
29597
+ * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
29598
+ * 동적 값(width/height)만 별도 `<style>` 엘리먼트로 관리한다.
29599
+ *
29600
+ * body `padding-top`은 주입하지 않는다: 실기기에서 토스 native nav bar는 WebView viewport
29601
+ * 밖이라 콘텐츠는 top=0부터 시작한다(devtools#275).
29602
+ */
29603
+ const VIEWPORT_STORAGE_KEY = "__ait_viewport";
29604
+ /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
29605
+ const VIEWPORT_CUSTOM_MAX = 4096;
29606
+ const NONE_PRESET = {
29607
+ id: "none",
29608
+ label: "None (full window)",
29609
+ width: 0,
29610
+ height: 0,
29611
+ dpr: 1,
29612
+ notch: "none",
29613
+ notchInset: 0,
29614
+ navBarHeight: 0,
29615
+ safeAreaBottom: 0
29616
+ };
29617
+ const CUSTOM_PRESET = {
29618
+ id: "custom",
29619
+ label: "Custom",
29620
+ width: 0,
29621
+ height: 0,
29622
+ dpr: 1,
29623
+ notch: "none",
29624
+ notchInset: 0,
29625
+ navBarHeight: 0,
29626
+ safeAreaBottom: 0
29627
+ };
29628
+ /** Shorthands used when building preset provenance entries. */
29629
+ const EXTRAPOLATED = { source: "extrapolated" };
29630
+ const PLACEHOLDER = { source: "placeholder" };
29631
+ /**
29632
+ * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
29633
+ * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
29634
+ * Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
29635
+ * 측정치를 사용.
29636
+ *
29637
+ * safe-area 모델 (devtools#190 relay 실측 반영):
29638
+ * - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
29639
+ * 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
29640
+ * 값은 portrait SDK top에는 들어가지 않는다.
29641
+ * - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
29642
+ * 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
29643
+ * - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
29644
+ * iPhone 15 Pro 실측 bottom 34와 일치.
29645
+ *
29646
+ * 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
29647
+ * 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
29648
+ *
29649
+ * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
29650
+ * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
29651
+ *
29652
+ * safeAreaProvenance: 각 preset의 safe-area 값 신뢰도 출처.
29653
+ * - `measured` — relay 실기기 세션(measure_safe_area)으로 직접 확인한 값.
29654
+ * 현재 iPhone 15 Pro portrait iOS partner만 해당 (devtools#190).
29655
+ * - `extrapolated` — Apple 스펙/같은 시리즈 기기에서 유추한 값.
29656
+ * - `placeholder` — 연결 기기 없이 추정한 값. QA ground truth로 쓰지 말 것.
29657
+ * `measure_safe_area` MCP 툴로 relay 세션에서 `measured`로 승급 필요.
29658
+ */
29659
+ const VIEWPORT_PRESETS = [
29660
+ NONE_PRESET,
29661
+ {
29662
+ id: "iphone-se-3",
29663
+ label: "iPhone SE (3rd gen)",
29664
+ width: 375,
29665
+ height: 667,
29666
+ dpr: 2,
29667
+ notch: "none",
29668
+ notchInset: 20,
29669
+ navBarHeight: 54,
29670
+ safeAreaBottom: 0,
29671
+ safeAreaProvenance: EXTRAPOLATED
29672
+ },
29673
+ {
29674
+ id: "iphone-15-pro",
29675
+ label: "iPhone 15 Pro",
29676
+ width: 393,
29677
+ height: 754,
29678
+ screenHeight: 852,
29679
+ dpr: 3,
29680
+ notch: "dynamic-island",
29681
+ notchInset: 59,
29682
+ navBarHeight: 54,
29683
+ safeAreaBottom: 34,
29684
+ safeAreaBottomLandscape: 20,
29685
+ safeAreaProvenance: {
29686
+ source: "measured",
29687
+ device: "iPhone 15 Pro",
29688
+ date: "2026-05-28",
29689
+ orientations: ["portrait", "landscape"]
29690
+ }
29691
+ },
29692
+ {
29693
+ id: "iphone-16e",
29694
+ label: "iPhone 16e",
29695
+ width: 390,
29696
+ height: 844,
29697
+ dpr: 3,
29698
+ notch: "notch",
29699
+ notchInset: 47,
29700
+ navBarHeight: 54,
29701
+ safeAreaBottom: 34,
29702
+ safeAreaProvenance: EXTRAPOLATED
29703
+ },
29704
+ {
29705
+ id: "iphone-17",
29706
+ label: "iPhone 17",
29707
+ width: 402,
29708
+ height: 874,
29709
+ dpr: 3,
29710
+ notch: "dynamic-island",
29711
+ notchInset: 59,
29712
+ navBarHeight: 54,
29713
+ safeAreaBottom: 34,
29714
+ safeAreaProvenance: EXTRAPOLATED
29715
+ },
29716
+ {
29717
+ id: "iphone-air",
29718
+ label: "iPhone Air",
29719
+ width: 420,
29720
+ height: 912,
29721
+ dpr: 3,
29722
+ notch: "dynamic-island",
29723
+ notchInset: 59,
29724
+ navBarHeight: 54,
29725
+ safeAreaBottom: 34,
29726
+ safeAreaProvenance: EXTRAPOLATED
29727
+ },
29728
+ {
29729
+ id: "iphone-17-pro",
29730
+ label: "iPhone 17 Pro",
29731
+ width: 402,
29732
+ height: 874,
29733
+ dpr: 3,
29734
+ notch: "dynamic-island",
29735
+ notchInset: 59,
29736
+ navBarHeight: 54,
29737
+ safeAreaBottom: 34,
29738
+ safeAreaProvenance: EXTRAPOLATED
29739
+ },
29740
+ {
29741
+ id: "iphone-17-pro-max",
29742
+ label: "iPhone 17 Pro Max",
29743
+ width: 440,
29744
+ height: 956,
29745
+ dpr: 3,
29746
+ notch: "dynamic-island",
29747
+ notchInset: 62,
29748
+ navBarHeight: 54,
29749
+ safeAreaBottom: 34,
29750
+ safeAreaProvenance: EXTRAPOLATED
29751
+ },
29752
+ {
29753
+ id: "galaxy-s26",
29754
+ label: "Galaxy S26",
29755
+ width: 360,
29756
+ height: 773,
29757
+ dpr: 3,
29758
+ notch: "punch-hole-center",
29759
+ notchInset: 32,
29760
+ navBarHeight: 54,
29761
+ safeAreaBottom: 0,
29762
+ safeAreaProvenance: PLACEHOLDER
29763
+ },
29764
+ {
29765
+ id: "galaxy-s26-plus",
29766
+ label: "Galaxy S26+",
29767
+ width: 480,
29768
+ height: 1040,
29769
+ dpr: 3,
29770
+ notch: "punch-hole-center",
29771
+ notchInset: 32,
29772
+ navBarHeight: 54,
29773
+ safeAreaBottom: 0,
29774
+ safeAreaProvenance: PLACEHOLDER
29775
+ },
29776
+ {
29777
+ id: "galaxy-s26-ultra",
29778
+ label: "Galaxy S26 Ultra",
29779
+ width: 480,
29780
+ height: 1040,
29781
+ dpr: 3,
29782
+ notch: "punch-hole-center",
29783
+ notchInset: 40,
29784
+ navBarHeight: 54,
29785
+ safeAreaBottom: 0,
29786
+ safeAreaProvenance: PLACEHOLDER
29787
+ },
29788
+ {
29789
+ id: "galaxy-z-flip7",
29790
+ label: "Galaxy Z Flip7",
29791
+ width: 412,
29792
+ height: 990,
29793
+ dpr: 3,
29794
+ notch: "punch-hole-center",
29795
+ notchInset: 36,
29796
+ navBarHeight: 54,
29797
+ safeAreaBottom: 0,
29798
+ safeAreaProvenance: PLACEHOLDER
29799
+ },
29800
+ {
29801
+ id: "galaxy-z-fold7-folded",
29802
+ label: "Galaxy Z Fold7 (folded)",
29803
+ width: 384,
29804
+ height: 870,
29805
+ dpr: 3,
29806
+ notch: "punch-hole-center",
29807
+ notchInset: 32,
29808
+ navBarHeight: 54,
29809
+ safeAreaBottom: 0,
29810
+ safeAreaProvenance: PLACEHOLDER
29811
+ },
29812
+ {
29813
+ id: "galaxy-z-fold7-unfolded",
29814
+ label: "Galaxy Z Fold7 (unfolded)",
29815
+ width: 768,
29816
+ height: 884,
29817
+ dpr: 2.625,
29818
+ notch: "punch-hole-center",
29819
+ notchInset: 32,
29820
+ navBarHeight: 54,
29821
+ safeAreaBottom: 0,
29822
+ safeAreaProvenance: PLACEHOLDER
29823
+ },
29824
+ CUSTOM_PRESET
29825
+ ];
29826
+ function getPreset(id) {
29827
+ return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
29828
+ }
29829
+ /**
29830
+ * 실제로 화면에 표시될 orientation을 결정한다.
29831
+ *
29832
+ * - Panel `orientation === 'auto'`: 앱이 마지막으로 SDK로 요청한 값
29833
+ * (`appOrientation`)을 따른다. 호출 전이면 portrait.
29834
+ * - Panel `orientation === 'portrait' | 'landscape'`: Panel 값이 우선.
29835
+ */
29836
+ function effectiveOrientation(state) {
29837
+ if (state.orientation === "auto") return state.appOrientation ?? "portrait";
29838
+ return state.orientation;
29839
+ }
29840
+ /**
29841
+ * 선택된 뷰포트의 실제 width/height를 계산한다.
29842
+ * preset === 'custom'이면 customWidth/customHeight, 그 외에는 preset의 값.
29843
+ * effective orientation이 landscape이면 width/height를 swap한다.
29844
+ */
29845
+ function resolveViewportSize(state) {
29846
+ if (state.preset === "none") return {
29847
+ width: 0,
29848
+ height: 0
29849
+ };
29850
+ const base = state.preset === "custom" ? {
29851
+ width: state.customWidth,
29852
+ height: state.customHeight
29853
+ } : getPreset(state.preset);
29854
+ return effectiveOrientation(state) === "landscape" ? {
29855
+ width: base.height,
29856
+ height: base.width
29857
+ } : {
29858
+ width: base.width,
29859
+ height: base.height
29860
+ };
29861
+ }
29862
+ /**
29863
+ * 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
29864
+ * 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190, #198, #232, #275)에 맞춘 모델:
29865
+ *
29866
+ * - **Portrait top = 0** (partner/game 모두). 실측(devtools#275)에서 토스 native nav bar는
29867
+ * partner WebView **viewport 밖**에 그려진다. SDK가 반환하는 `top=54`는 호스트 nav bar
29868
+ * 높이에 대한 정보용 값이고, WebView 좌표계에서 콘텐츠는 top=0부터 시작한다. 소비자가
29869
+ * 이 값을 `padding-top`으로 적용하면 실기기에서 잉여 공간이 생긴다(double-count).
29870
+ * mock은 top=0을 반환해 소비자 코드가 실기기와 같은 결과를 내도록 한다.
29871
+ * `game` type 측정은 아직 미진행이지만 동일하게 top=0을 반환한다(추후 실측으로 갱신).
29872
+ * - **Bottom = `safeAreaBottom`** (portrait home-indicator, 실측 34).
29873
+ * landscape는 `safeAreaBottomLandscape`가 정의돼 있으면 그 값을 사용한다
29874
+ * (iPhone 15 Pro landscape 실측 20 — portrait 34와 다름).
29875
+ * - **Landscape iPhone(notch/Dynamic Island)**: CSS env()와 SDK SafeAreaInsets 모두
29876
+ * `left = right = notchInset`(양쪽 대칭)을 반환한다. 물리적 노치는 한쪽으로 가지만
29877
+ * OS가 양쪽 모두에 같은 inset을 부여하므로 landscapeSide mental model은 틀렸다
29878
+ * (2026-05-28 iPhone 15 Pro relay 실측 #198/#232: left=right=59). top=0(landscape에서
29879
+ * 토스 앱이 partner nav bar를 숨김, #232 실측 확인).
29880
+ * - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
29881
+ * 유지된다.
29882
+ */
29883
+ function computeSafeAreaInsets(preset, landscape) {
29884
+ if (preset.id === "none" || preset.id === "custom") return {
29885
+ top: 0,
29886
+ bottom: 0,
29887
+ left: 0,
29888
+ right: 0
29889
+ };
29890
+ if (!landscape) return {
29891
+ top: 0,
29892
+ bottom: preset.safeAreaBottom,
29893
+ left: 0,
29894
+ right: 0
29895
+ };
29896
+ const landscapeBottom = preset.safeAreaBottomLandscape !== void 0 ? preset.safeAreaBottomLandscape : preset.safeAreaBottom;
29897
+ if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
29898
+ top: 0,
29899
+ bottom: landscapeBottom,
29900
+ left: preset.notchInset,
29901
+ right: preset.notchInset
29902
+ };
29903
+ return {
29904
+ top: preset.notchInset,
29905
+ bottom: landscapeBottom,
29906
+ left: 0,
29907
+ right: 0
29908
+ };
29909
+ }
29910
+ /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
29911
+ function syncSafeAreaFromViewport(state) {
29912
+ if (state.preset === "none" || state.preset === "custom") return;
29913
+ const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape");
29914
+ const current = aitState.state.safeAreaInsets;
29915
+ if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
29916
+ aitState.update({ safeAreaInsets: next });
29917
+ }
29918
+ const STYLE_ELEMENT_ID = "__ait-viewport-style";
29919
+ const NOTCH_ELEMENT_ID = "__ait-viewport-notch";
29920
+ const HOME_INDICATOR_ID = "__ait-viewport-home-indicator";
29921
+ const NAV_BAR_ELEMENT_ID = "__ait-viewport-navbar";
29922
+ let bodyScrollHintEmitted = false;
29923
+ function ensureStyleElement() {
29924
+ if (typeof document === "undefined") return null;
29925
+ let el = document.getElementById(STYLE_ELEMENT_ID);
29926
+ if (!el) {
29927
+ el = document.createElement("style");
29928
+ el.id = STYLE_ELEMENT_ID;
29929
+ document.head.appendChild(el);
29930
+ }
29931
+ return el;
29932
+ }
29933
+ function removeById(id) {
29934
+ const el = document.getElementById(id);
29935
+ if (el) el.remove();
29936
+ }
29937
+ function removeNotchElement() {
29938
+ removeById(NOTCH_ELEMENT_ID);
29939
+ }
29940
+ function removeHomeIndicator() {
29941
+ removeById(HOME_INDICATOR_ID);
29942
+ }
29943
+ function removeNavBarElement() {
29944
+ removeById(NAV_BAR_ELEMENT_ID);
29945
+ }
29946
+ /**
29947
+ * Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
29948
+ *
29949
+ * 변형(SDK `webViewProps.type`과 의미 일치):
29950
+ * - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
29951
+ * 우측 `⋯` + 구분선 + `×`.
29952
+ * - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
29953
+ *
29954
+ * 이 오버레이는 **시각 참고용 frame 장식**이다. 실기기에서 토스 native nav bar는 WebView
29955
+ * viewport 밖에 그려지므로(devtools#275), mock의 nav bar 오버레이가 콘텐츠 위에 overlap
29956
+ * 되는 것이 실제 동작과 일치한다 — body에 `padding-top`을 주입하지 않는다.
29957
+ * 시각 notch 오버레이는 body 밖 위쪽에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
29958
+ *
29959
+ * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
29960
+ * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
29961
+ */
29962
+ function renderNavBar(displayName, type) {
29963
+ removeNavBarElement();
29964
+ const el = h("div", {
29965
+ id: NAV_BAR_ELEMENT_ID,
29966
+ className: `ait-navbar ait-navbar-${type}`,
29967
+ "aria-hidden": "true"
29968
+ });
29969
+ const moreBtn = h("button", {
29970
+ className: "ait-navbar-btn",
29971
+ type: "button",
29972
+ "aria-label": "More"
29973
+ });
29974
+ moreBtn.textContent = "⋯";
29975
+ const closeBtn = h("button", {
29976
+ className: "ait-navbar-btn",
29977
+ type: "button",
29978
+ "aria-label": "Close"
29979
+ });
29980
+ closeBtn.textContent = "×";
29981
+ closeBtn.addEventListener("click", () => {
29982
+ closeView().catch((err) => console.error("[@ait-co/devtools] navbar close failed:", err));
29983
+ });
29984
+ const actions = h("div", { className: "ait-navbar-actions" }, moreBtn, h("span", { className: "ait-navbar-divider" }), closeBtn);
29985
+ if (type === "game") el.append(actions);
29986
+ else {
29987
+ const backBtn = h("button", {
29988
+ className: "ait-navbar-btn ait-navbar-back",
29989
+ type: "button",
29990
+ "aria-label": "Back"
29991
+ });
29992
+ backBtn.textContent = "‹";
29993
+ backBtn.addEventListener("click", () => {
29994
+ aitState.trigger("backEvent");
29995
+ });
29996
+ const nameSpan = h("span", { className: "ait-navbar-name" });
29997
+ nameSpan.textContent = displayName;
29998
+ el.append(backBtn, h("div", { className: "ait-navbar-title" }, h("span", { className: "ait-navbar-icon" }), nameSpan), actions);
29999
+ }
30000
+ document.body.appendChild(el);
30001
+ }
30002
+ /**
30003
+ * 현재 preset의 notch/Dynamic Island/punch-hole을 body 상단에 시각적으로 렌더한다.
30004
+ * landscape 시에는 노치가 한쪽 변에 있는 것이 실제 기기 동작이지만, 시뮬레이터에서는
30005
+ * landscape에서 오버레이를 그리지 않는다 (safeAreaInsets의 left/right로 이미 반영).
30006
+ */
30007
+ function renderNotchOverlay(preset) {
30008
+ removeNotchElement();
30009
+ if (preset.notch === "none") return;
30010
+ const notch = h("div", {
30011
+ id: NOTCH_ELEMENT_ID,
30012
+ className: `ait-notch ${preset.notch === "dynamic-island" ? "ait-notch-dynamic-island" : preset.notch === "notch" ? "ait-notch-pill" : "ait-notch-punch-hole"}`,
30013
+ "aria-hidden": "true"
30014
+ });
30015
+ document.body.appendChild(notch);
30016
+ }
30017
+ /** brand 이름만 바뀐 경우 nav bar 전체를 다시 만들지 않고 텍스트 노드만 교체한다. */
30018
+ function refreshNavBarBrand(displayName) {
30019
+ const name = document.querySelector(`#${NAV_BAR_ELEMENT_ID} .ait-navbar-name`);
30020
+ if (name) name.textContent = displayName;
30021
+ }
30022
+ function renderHomeIndicator() {
30023
+ removeHomeIndicator();
30024
+ const el = h("div", {
30025
+ id: HOME_INDICATOR_ID,
30026
+ className: "ait-home-indicator",
30027
+ "aria-hidden": "true"
30028
+ });
30029
+ document.body.appendChild(el);
30030
+ }
30031
+ /**
30032
+ * 모든 viewport DOM mutation을 원복하고 aitState 구독도 해제한다.
30033
+ * 외부 consumer가 패널을 동적으로 제거할 때 호출. 호출 후에는 aitState 변경이
30034
+ * DOM에 반영되지 않으므로 안전하게 panel을 떼어낼 수 있다.
30035
+ */
30036
+ function disposeViewport() {
30037
+ if (typeof document === "undefined") return;
30038
+ if (viewportUnsubscribe) viewportUnsubscribe();
30039
+ const html = document.documentElement;
30040
+ html.classList.remove("ait-viewport-active");
30041
+ html.classList.remove("ait-viewport-framed");
30042
+ removeById(STYLE_ELEMENT_ID);
30043
+ removeNotchElement();
30044
+ removeHomeIndicator();
30045
+ removeNavBarElement();
30046
+ revertDeviceEmulation();
30047
+ bodyScrollHintEmitted = false;
30048
+ }
30049
+ /**
30050
+ * DOM에 뷰포트 제약을 적용한다.
30051
+ * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
30052
+ * - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
30053
+ */
30054
+ function applyViewport(state) {
30055
+ if (typeof document === "undefined") return;
30056
+ const html = document.documentElement;
30057
+ const style = ensureStyleElement();
30058
+ if (!style) return;
30059
+ const size = resolveViewportSize(state);
30060
+ if (state.preset === "none" || size.width === 0 || size.height === 0) {
30061
+ html.classList.remove("ait-viewport-active");
30062
+ html.classList.remove("ait-viewport-framed");
30063
+ style.textContent = "";
30064
+ removeNotchElement();
30065
+ removeHomeIndicator();
30066
+ removeNavBarElement();
30067
+ syncDeviceEmulation(null, false);
30068
+ return;
30069
+ }
30070
+ if (!bodyScrollHintEmitted) {
30071
+ bodyScrollHintEmitted = true;
30072
+ console.info("[@ait-co/devtools] Viewport simulation active — scroll happens on body, not window. See README \"Known limitations\" for details.");
30073
+ }
30074
+ html.classList.add("ait-viewport-active");
30075
+ html.classList.toggle("ait-viewport-framed", state.frame);
30076
+ const preset = state.preset === "custom" ? null : getPreset(state.preset);
30077
+ const landscape = effectiveOrientation(state) === "landscape";
30078
+ syncDeviceEmulation(preset, landscape);
30079
+ style.textContent = `
30080
+ html.ait-viewport-active body {
30081
+ width: ${size.width}px;
30082
+ max-width: ${size.width}px;
30083
+ min-height: ${size.height}px;
30084
+ max-height: ${size.height}px;
30085
+ }
30086
+ `;
30087
+ if (preset && state.frame && !landscape) renderNotchOverlay(preset);
30088
+ else removeNotchElement();
30089
+ if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
30090
+ else removeHomeIndicator();
30091
+ if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
30092
+ else removeNavBarElement();
30093
+ }
30094
+ function isViewportPresetId(v) {
30095
+ return typeof v === "string" && VIEWPORT_PRESETS.some((p) => p.id === v);
30096
+ }
30097
+ function isViewportOrientation(v) {
30098
+ return v === "auto" || v === "portrait" || v === "landscape";
30099
+ }
30100
+ function isAppOrientation(v) {
30101
+ return v === null || v === "portrait" || v === "landscape";
30102
+ }
30103
+ /** 1 이상의 정수 + VIEWPORT_CUSTOM_MAX 이하인지 검사. sessionStorage 보호용. */
30104
+ function isValidCustomDimension(v) {
30105
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 4096;
30106
+ }
30107
+ /** Custom 입력에서 사용. 잘린 정수 + 클램프된 안전한 값 또는 null 반환. */
30108
+ function clampCustomDimension(raw) {
30109
+ if (!Number.isFinite(raw)) return null;
30110
+ const n = Math.floor(raw);
30111
+ if (n < 1) return null;
30112
+ return Math.min(n, VIEWPORT_CUSTOM_MAX);
30113
+ }
30114
+ /**
30115
+ * sessionStorage에 저장된 뷰포트 상태를 읽어서 현재 state에 merge한다.
30116
+ * 값이 없거나 파싱 실패 시 no-op.
30117
+ */
30118
+ function loadViewportFromStorage() {
30119
+ if (typeof sessionStorage === "undefined") return null;
30120
+ const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
30121
+ if (!raw) return null;
30122
+ try {
30123
+ const parsed = JSON.parse(raw);
30124
+ if (typeof parsed !== "object" || parsed === null) return null;
30125
+ const obj = parsed;
30126
+ const next = {};
30127
+ if (isViewportPresetId(obj.preset)) next.preset = obj.preset;
30128
+ if (isViewportOrientation(obj.orientation)) next.orientation = obj.orientation;
30129
+ if (isAppOrientation(obj.appOrientation)) next.appOrientation = obj.appOrientation;
30130
+ if (isValidCustomDimension(obj.customWidth)) next.customWidth = obj.customWidth;
30131
+ if (isValidCustomDimension(obj.customHeight)) next.customHeight = obj.customHeight;
30132
+ if (typeof obj.frame === "boolean") next.frame = obj.frame;
30133
+ if (typeof obj.aitNavBar === "boolean") next.aitNavBar = obj.aitNavBar;
30134
+ if (obj.aitNavBarType === "partner" || obj.aitNavBarType === "game") next.aitNavBarType = obj.aitNavBarType;
30135
+ return next;
30136
+ } catch {
30137
+ return null;
30138
+ }
30139
+ }
30140
+ function saveViewportToStorage(state) {
30141
+ if (typeof sessionStorage === "undefined") return;
30142
+ try {
30143
+ sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(state));
30144
+ } catch {}
30145
+ }
30146
+ let viewportInitialized = false;
30147
+ let viewportUnsubscribe = null;
30148
+ /**
30149
+ * Panel mount 시 호출. sessionStorage 복원 → aitState에 반영 → DOM 적용.
30150
+ * aitState 변경을 구독해서 DOM / storage / safe-area insets를 자동 동기화한다.
30151
+ *
30152
+ * Idempotent: 두 번째 호출은 기존 unsubscribe를 그대로 반환한다 (HMR / 재mount 안전).
30153
+ * 테스트는 반환된 unsubscribe를 afterEach에서 호출해 cleanup해야 한다.
30154
+ */
30155
+ function initViewport() {
30156
+ if (typeof window === "undefined") return () => {};
30157
+ if (viewportInitialized && viewportUnsubscribe) return viewportUnsubscribe;
30158
+ const restored = loadViewportFromStorage();
30159
+ if (restored) aitState.patch("viewport", restored);
30160
+ applyViewport(aitState.state.viewport);
30161
+ syncSafeAreaFromViewport(aitState.state.viewport);
30162
+ let lastViewportJson = JSON.stringify(aitState.state.viewport);
30163
+ let lastBrandName = aitState.state.brand.displayName;
30164
+ const unsubscribeFn = aitState.subscribe(() => {
30165
+ const vp = aitState.state.viewport;
30166
+ const brandName = aitState.state.brand.displayName;
30167
+ const json = JSON.stringify(vp);
30168
+ const viewportChanged = json !== lastViewportJson;
30169
+ if (!viewportChanged && !(brandName !== lastBrandName)) return;
30170
+ lastViewportJson = json;
30171
+ lastBrandName = brandName;
30172
+ if (viewportChanged) {
30173
+ applyViewport(vp);
30174
+ saveViewportToStorage(vp);
30175
+ syncSafeAreaFromViewport(vp);
30176
+ } else refreshNavBarBrand(brandName);
30177
+ });
30178
+ viewportInitialized = true;
30179
+ viewportUnsubscribe = () => {
30180
+ unsubscribeFn();
30181
+ viewportInitialized = false;
30182
+ viewportUnsubscribe = null;
30183
+ };
30184
+ return viewportUnsubscribe;
30185
+ }
30186
+ //#endregion
30187
+ //#region src/panel/tabs/viewport.ts
30188
+ /**
30189
+ * Renders a small inline provenance badge for safe-area values.
30190
+ * - `measured` — no badge (confirmed value)
30191
+ * - `extrapolated` — "(추정치)" in muted gray
30192
+ * - `placeholder` — "(미측정)" in amber
30193
+ */
30194
+ function provenanceBadge(provenance) {
30195
+ if (!provenance || provenance.source === "measured") return null;
30196
+ const text = provenance.source === "placeholder" ? "(미측정)" : "(추정치)";
30197
+ const color = provenance.source === "placeholder" ? "#b45309" : "#888";
30198
+ const badge = h("span", {
30199
+ className: "ait-provenance-badge",
30200
+ title: provenance.source === "placeholder" ? "safe-area 값이 미실측 추정치입니다. relay 세션에서 measure_safe_area로 실측 후 승급하세요." : "safe-area 값이 기기 스펙에서 유추한 추정치입니다. relay 세션에서 measure_safe_area로 확인하세요."
30201
+ });
30202
+ badge.textContent = text;
30203
+ badge.style.cssText = `font-size:10px;color:${color};margin-left:4px`;
30204
+ return badge;
30205
+ }
30206
+ function renderViewportTab() {
30207
+ const s = aitState.state;
30208
+ const vp = s.viewport;
30209
+ const disabled = !s.panelEditable;
30210
+ const container = h("div");
30211
+ if (disabled) container.appendChild(monitoringNotice());
30212
+ const presetSelect = h("select", { className: "ait-select" });
30213
+ if (disabled) presetSelect.disabled = true;
30214
+ for (const preset of VIEWPORT_PRESETS) {
30215
+ const label = preset.id === "none" || preset.id === "custom" ? preset.label : `${preset.label} (${preset.width}×${preset.height})`;
30216
+ const option = h("option", { value: preset.id }, label);
30217
+ if (preset.id === vp.preset) option.selected = true;
30218
+ presetSelect.appendChild(option);
30219
+ }
30220
+ presetSelect.addEventListener("change", () => {
30221
+ const id = presetSelect.value;
30222
+ const patch = { preset: id };
30223
+ if (id === "custom") {
30224
+ const current = getPreset(vp.preset);
30225
+ if (current.width > 0) patch.customWidth = current.width;
30226
+ if (current.height > 0) patch.customHeight = current.height;
30227
+ }
30228
+ aitState.patch("viewport", patch);
30229
+ });
30230
+ const orientationSelect = h("select", { className: "ait-select" });
30231
+ if (disabled) orientationSelect.disabled = true;
30232
+ for (const opt of [
30233
+ "auto",
30234
+ "portrait",
30235
+ "landscape"
30236
+ ]) {
30237
+ const option = h("option", { value: opt }, opt);
30238
+ if (opt === vp.orientation) option.selected = true;
30239
+ orientationSelect.appendChild(option);
30240
+ }
30241
+ orientationSelect.addEventListener("change", () => {
30242
+ aitState.patch("viewport", { orientation: orientationSelect.value });
30243
+ });
30244
+ const customRow = h("div", { className: "ait-section" });
30245
+ if (vp.preset === "custom") {
30246
+ const widthInput = h("input", {
30247
+ className: "ait-input",
30248
+ type: "number",
30249
+ min: "1",
30250
+ value: String(vp.customWidth)
30251
+ });
30252
+ const heightInput = h("input", {
30253
+ className: "ait-input",
30254
+ type: "number",
30255
+ min: "1",
30256
+ value: String(vp.customHeight)
30257
+ });
30258
+ if (disabled) {
30259
+ widthInput.disabled = true;
30260
+ heightInput.disabled = true;
30261
+ }
30262
+ widthInput.addEventListener("change", () => {
30263
+ const clamped = clampCustomDimension(Number(widthInput.value));
30264
+ if (clamped !== null) {
30265
+ aitState.patch("viewport", { customWidth: clamped });
30266
+ widthInput.value = String(clamped);
30267
+ }
30268
+ });
30269
+ heightInput.addEventListener("change", () => {
30270
+ const clamped = clampCustomDimension(Number(heightInput.value));
30271
+ if (clamped !== null) {
30272
+ aitState.patch("viewport", { customHeight: clamped });
30273
+ heightInput.value = String(clamped);
30274
+ }
30275
+ });
30276
+ customRow.append(h("div", { className: "ait-section-title" }, t("viewport.section.custom")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.width")), widthInput), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.height")), heightInput));
30277
+ }
30278
+ const frameCheckbox = h("input", { type: "checkbox" });
30279
+ frameCheckbox.checked = vp.frame;
30280
+ if (disabled) frameCheckbox.disabled = true;
30281
+ frameCheckbox.addEventListener("change", () => {
30282
+ aitState.patch("viewport", { frame: frameCheckbox.checked });
30283
+ });
30284
+ const navBarCheckbox = h("input", { type: "checkbox" });
30285
+ navBarCheckbox.checked = vp.aitNavBar;
30286
+ if (disabled) navBarCheckbox.disabled = true;
30287
+ navBarCheckbox.addEventListener("change", () => {
30288
+ aitState.patch("viewport", { aitNavBar: navBarCheckbox.checked });
30289
+ });
30290
+ const navBarTypeSelect = h("select", { className: "ait-select" });
30291
+ if (disabled || !vp.aitNavBar) navBarTypeSelect.disabled = true;
30292
+ for (const opt of ["partner", "game"]) {
30293
+ const option = h("option", { value: opt }, opt);
30294
+ if (opt === vp.aitNavBarType) option.selected = true;
30295
+ navBarTypeSelect.appendChild(option);
30296
+ }
30297
+ navBarTypeSelect.addEventListener("change", () => {
30298
+ aitState.patch("viewport", { aitNavBarType: navBarTypeSelect.value });
30299
+ });
30300
+ const size = resolveViewportSize(vp);
30301
+ const statusEl = h("div", { className: "ait-section" });
30302
+ if (vp.preset === "none" || size.width === 0) statusEl.appendChild(h("div", { style: "color:#888;font-size:11px" }, t("viewport.status.noConstraint")));
30303
+ else {
30304
+ const preset = vp.preset === "custom" ? null : getPreset(vp.preset);
30305
+ const effOrient = effectiveOrientation(vp);
30306
+ const landscape = effOrient === "landscape";
30307
+ const rows = [];
30308
+ const dpr = preset?.dpr ?? 1;
30309
+ const physW = Math.round(size.width * dpr);
30310
+ const physH = Math.round(size.height * dpr);
30311
+ const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
30312
+ 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}`)));
30313
+ if (preset) {
30314
+ const insets = computeSafeAreaInsets(preset, landscape);
30315
+ const safeAreaValueEl = h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`);
30316
+ const badge = provenanceBadge(preset.safeAreaProvenance);
30317
+ if (badge) safeAreaValueEl.appendChild(badge);
30318
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), safeAreaValueEl));
30319
+ }
30320
+ if (vp.aitNavBar && !landscape) {
30321
+ const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
30322
+ rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
30323
+ height: navBarTop,
30324
+ type: vp.aitNavBarType
30325
+ }))));
30326
+ }
30327
+ for (const row of rows) statusEl.appendChild(row);
30328
+ }
30329
+ 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));
30330
+ container.append(deviceSection, customRow, h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("viewport.section.appearance")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.showFrame")), frameCheckbox), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.showAitNavBar")), navBarCheckbox), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.navBarType")), navBarTypeSelect)), statusEl);
30331
+ return container;
30332
+ }
30333
+ //#endregion
30334
+ //#region src/panel/tabs/index.ts
30335
+ const TAB_DEFS = [
30336
+ {
30337
+ id: "env",
30338
+ labelKey: "panel.tab.env"
30339
+ },
30340
+ {
30341
+ id: "presets",
30342
+ labelKey: "panel.tab.presets"
30343
+ },
30344
+ {
30345
+ id: "viewport",
30346
+ labelKey: "panel.tab.viewport"
30347
+ },
30348
+ {
30349
+ id: "permissions",
30350
+ labelKey: "panel.tab.permissions"
30351
+ },
30352
+ {
30353
+ id: "notifications",
30354
+ labelKey: "panel.tab.notifications"
30355
+ },
30356
+ {
30357
+ id: "location",
30358
+ labelKey: "panel.tab.location"
30359
+ },
30360
+ {
30361
+ id: "device",
30362
+ labelKey: "panel.tab.device"
30363
+ },
30364
+ {
30365
+ id: "iap",
30366
+ labelKey: "panel.tab.iap"
30367
+ },
30368
+ {
30369
+ id: "ads",
30370
+ labelKey: "panel.tab.ads"
30371
+ },
30372
+ {
30373
+ id: "events",
30374
+ labelKey: "panel.tab.events"
30375
+ },
30376
+ {
30377
+ id: "analytics",
30378
+ labelKey: "panel.tab.analytics"
30379
+ },
30380
+ {
30381
+ id: "storage",
30382
+ labelKey: "panel.tab.storage"
30383
+ }
30384
+ ];
30385
+ function getTabs() {
30386
+ return TAB_DEFS.map((def) => ({
30387
+ id: def.id,
30388
+ label: t(def.labelKey)
30389
+ }));
30390
+ }
30391
+ function createTabRenderers(refreshPanel) {
30392
+ return {
30393
+ env: renderEnvironmentTab,
30394
+ presets: () => renderPresetsTab(refreshPanel),
30395
+ permissions: renderPermissionsTab,
30396
+ notifications: renderNotificationsTab,
30397
+ location: renderLocationTab,
30398
+ device: renderDeviceTab,
30399
+ viewport: renderViewportTab,
30400
+ iap: renderIapTab,
30401
+ ads: renderAdsTab,
30402
+ events: renderEventsTab,
30403
+ analytics: renderAnalyticsTab,
30404
+ storage: () => renderStorageTab(refreshPanel)
30405
+ };
30406
+ }
30399
30407
  //#endregion
30400
30408
  //#region src/panel/use-draggable.ts
30401
30409
  /**
@@ -30733,7 +30741,7 @@ function Panel() {
30733
30741
  color: "#666",
30734
30742
  fontWeight: 400
30735
30743
  },
30736
- children: ["v", "0.1.73"]
30744
+ children: ["v", "0.1.75"]
30737
30745
  }),
30738
30746
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
30739
30747
  type: "button",