@ait-co/devtools 0.1.74 → 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 (34) 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 +77 -37
  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 +822 -820
  13. package/dist/panel/index.js.map +1 -1
  14. package/dist/{qr-http-server-Bn2ciFuC.js → qr-http-server-CDO6o2nr.js} +19 -7
  15. package/dist/qr-http-server-CDO6o2nr.js.map +1 -0
  16. package/dist/{qr-http-server-DNGVwI0P.cjs → qr-http-server-D0v9ooAD.cjs} +19 -7
  17. package/dist/qr-http-server-D0v9ooAD.cjs.map +1 -0
  18. package/dist/{qr-http-server-Cpc4jfTA.js → qr-http-server-DznDIcJF.js} +19 -7
  19. package/dist/qr-http-server-DznDIcJF.js.map +1 -0
  20. package/dist/{qr-http-server-BqZ8c0Bp.cjs → qr-http-server-jMC1nVqY.cjs} +19 -7
  21. package/dist/qr-http-server-jMC1nVqY.cjs.map +1 -0
  22. package/dist/{tunnel-CAxygywQ.cjs → tunnel-D7f-0enB.cjs} +2 -2
  23. package/dist/{tunnel-CAxygywQ.cjs.map → tunnel-D7f-0enB.cjs.map} +1 -1
  24. package/dist/{tunnel-BuymAS3O.js → tunnel-km3KkZrF.js} +2 -2
  25. package/dist/{tunnel-BuymAS3O.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 +1 -1
  29. package/dist/unplugin/tunnel.js +1 -1
  30. package/package.json +1 -1
  31. package/dist/qr-http-server-Bn2ciFuC.js.map +0 -1
  32. package/dist/qr-http-server-BqZ8c0Bp.cjs.map +0 -1
  33. package/dist/qr-http-server-Cpc4jfTA.js.map +0 -1
  34. package/dist/qr-http-server-DNGVwI0P.cjs.map +0 -1
@@ -25424,6 +25424,7 @@ const en = {
25424
25424
  "launcher.diagNo": "no",
25425
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.",
25426
25426
  "launcher.navbar.defaultTitle": "Mini App",
25427
+ "launcher.navbar.back": "Back",
25427
25428
  "launcher.navbar.menu": "Menu",
25428
25429
  "launcher.navbar.close": "Close",
25429
25430
  "launcher.navbar.menuRescan": "Rescan",
@@ -25659,6 +25660,7 @@ const ko = {
25659
25660
  "launcher.diagNo": "아니요",
25660
25661
  "launcher.letterboxDetected": "표시 영역이 {pt}pt 부족합니다 — iOS standalone letterbox로 보입니다. 런처를 홈 화면에서 제거 후 다시 설치하면 해소될 수 있어요.",
25661
25662
  "launcher.navbar.defaultTitle": "미니앱",
25663
+ "launcher.navbar.back": "뒤로가기",
25662
25664
  "launcher.navbar.menu": "메뉴",
25663
25665
  "launcher.navbar.close": "닫기",
25664
25666
  "launcher.navbar.menuRescan": "다시 스캔",
@@ -26488,7 +26490,7 @@ function readGlobalString(key) {
26488
26490
  }
26489
26491
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
26490
26492
  function getVersion() {
26491
- return "0.1.74";
26493
+ return "0.1.75";
26492
26494
  }
26493
26495
  let panelVisibleSince = null;
26494
26496
  let accumulatedMs = 0;
@@ -29012,822 +29014,6 @@ function syncDeviceEmulation(preset, landscape) {
29012
29014
  applyDeviceEmulation(profile);
29013
29015
  if (aitState.state.platform !== profile.platform) aitState.update({ platform: profile.platform });
29014
29016
  }
29015
- //#endregion
29016
- //#region src/panel/viewport.ts
29017
- /**
29018
- * Viewport 시뮬레이션 유틸
29019
- *
29020
- * Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
29021
- * `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
29022
- * 동적 값(width/height)만 별도 `<style>` 엘리먼트로 관리한다.
29023
- *
29024
- * body `padding-top`은 주입하지 않는다: 실기기에서 토스 native nav bar는 WebView viewport
29025
- * 밖이라 콘텐츠는 top=0부터 시작한다(devtools#275).
29026
- */
29027
- const VIEWPORT_STORAGE_KEY = "__ait_viewport";
29028
- /** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
29029
- const VIEWPORT_CUSTOM_MAX = 4096;
29030
- const NONE_PRESET = {
29031
- id: "none",
29032
- label: "None (full window)",
29033
- width: 0,
29034
- height: 0,
29035
- dpr: 1,
29036
- notch: "none",
29037
- notchInset: 0,
29038
- navBarHeight: 0,
29039
- safeAreaBottom: 0
29040
- };
29041
- const CUSTOM_PRESET = {
29042
- id: "custom",
29043
- label: "Custom",
29044
- width: 0,
29045
- height: 0,
29046
- dpr: 1,
29047
- notch: "none",
29048
- notchInset: 0,
29049
- navBarHeight: 0,
29050
- safeAreaBottom: 0
29051
- };
29052
- /** Shorthands used when building preset provenance entries. */
29053
- const EXTRAPOLATED = { source: "extrapolated" };
29054
- const PLACEHOLDER = { source: "placeholder" };
29055
- /**
29056
- * Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
29057
- * iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
29058
- * Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
29059
- * 측정치를 사용.
29060
- *
29061
- * safe-area 모델 (devtools#190 relay 실측 반영):
29062
- * - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
29063
- * 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
29064
- * 값은 portrait SDK top에는 들어가지 않는다.
29065
- * - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
29066
- * 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
29067
- * - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
29068
- * iPhone 15 Pro 실측 bottom 34와 일치.
29069
- *
29070
- * 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
29071
- * 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
29072
- *
29073
- * iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
29074
- * 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
29075
- *
29076
- * safeAreaProvenance: 각 preset의 safe-area 값 신뢰도 출처.
29077
- * - `measured` — relay 실기기 세션(measure_safe_area)으로 직접 확인한 값.
29078
- * 현재 iPhone 15 Pro portrait iOS partner만 해당 (devtools#190).
29079
- * - `extrapolated` — Apple 스펙/같은 시리즈 기기에서 유추한 값.
29080
- * - `placeholder` — 연결 기기 없이 추정한 값. QA ground truth로 쓰지 말 것.
29081
- * `measure_safe_area` MCP 툴로 relay 세션에서 `measured`로 승급 필요.
29082
- */
29083
- const VIEWPORT_PRESETS = [
29084
- NONE_PRESET,
29085
- {
29086
- id: "iphone-se-3",
29087
- label: "iPhone SE (3rd gen)",
29088
- width: 375,
29089
- height: 667,
29090
- dpr: 2,
29091
- notch: "none",
29092
- notchInset: 20,
29093
- navBarHeight: 54,
29094
- safeAreaBottom: 0,
29095
- safeAreaProvenance: EXTRAPOLATED
29096
- },
29097
- {
29098
- id: "iphone-15-pro",
29099
- label: "iPhone 15 Pro",
29100
- width: 393,
29101
- height: 754,
29102
- screenHeight: 852,
29103
- dpr: 3,
29104
- notch: "dynamic-island",
29105
- notchInset: 59,
29106
- navBarHeight: 54,
29107
- safeAreaBottom: 34,
29108
- safeAreaBottomLandscape: 20,
29109
- safeAreaProvenance: {
29110
- source: "measured",
29111
- device: "iPhone 15 Pro",
29112
- date: "2026-05-28",
29113
- orientations: ["portrait", "landscape"]
29114
- }
29115
- },
29116
- {
29117
- id: "iphone-16e",
29118
- label: "iPhone 16e",
29119
- width: 390,
29120
- height: 844,
29121
- dpr: 3,
29122
- notch: "notch",
29123
- notchInset: 47,
29124
- navBarHeight: 54,
29125
- safeAreaBottom: 34,
29126
- safeAreaProvenance: EXTRAPOLATED
29127
- },
29128
- {
29129
- id: "iphone-17",
29130
- label: "iPhone 17",
29131
- width: 402,
29132
- height: 874,
29133
- dpr: 3,
29134
- notch: "dynamic-island",
29135
- notchInset: 59,
29136
- navBarHeight: 54,
29137
- safeAreaBottom: 34,
29138
- safeAreaProvenance: EXTRAPOLATED
29139
- },
29140
- {
29141
- id: "iphone-air",
29142
- label: "iPhone Air",
29143
- width: 420,
29144
- height: 912,
29145
- dpr: 3,
29146
- notch: "dynamic-island",
29147
- notchInset: 59,
29148
- navBarHeight: 54,
29149
- safeAreaBottom: 34,
29150
- safeAreaProvenance: EXTRAPOLATED
29151
- },
29152
- {
29153
- id: "iphone-17-pro",
29154
- label: "iPhone 17 Pro",
29155
- width: 402,
29156
- height: 874,
29157
- dpr: 3,
29158
- notch: "dynamic-island",
29159
- notchInset: 59,
29160
- navBarHeight: 54,
29161
- safeAreaBottom: 34,
29162
- safeAreaProvenance: EXTRAPOLATED
29163
- },
29164
- {
29165
- id: "iphone-17-pro-max",
29166
- label: "iPhone 17 Pro Max",
29167
- width: 440,
29168
- height: 956,
29169
- dpr: 3,
29170
- notch: "dynamic-island",
29171
- notchInset: 62,
29172
- navBarHeight: 54,
29173
- safeAreaBottom: 34,
29174
- safeAreaProvenance: EXTRAPOLATED
29175
- },
29176
- {
29177
- id: "galaxy-s26",
29178
- label: "Galaxy S26",
29179
- width: 360,
29180
- height: 773,
29181
- dpr: 3,
29182
- notch: "punch-hole-center",
29183
- notchInset: 32,
29184
- navBarHeight: 54,
29185
- safeAreaBottom: 0,
29186
- safeAreaProvenance: PLACEHOLDER
29187
- },
29188
- {
29189
- id: "galaxy-s26-plus",
29190
- label: "Galaxy S26+",
29191
- width: 480,
29192
- height: 1040,
29193
- dpr: 3,
29194
- notch: "punch-hole-center",
29195
- notchInset: 32,
29196
- navBarHeight: 54,
29197
- safeAreaBottom: 0,
29198
- safeAreaProvenance: PLACEHOLDER
29199
- },
29200
- {
29201
- id: "galaxy-s26-ultra",
29202
- label: "Galaxy S26 Ultra",
29203
- width: 480,
29204
- height: 1040,
29205
- dpr: 3,
29206
- notch: "punch-hole-center",
29207
- notchInset: 40,
29208
- navBarHeight: 54,
29209
- safeAreaBottom: 0,
29210
- safeAreaProvenance: PLACEHOLDER
29211
- },
29212
- {
29213
- id: "galaxy-z-flip7",
29214
- label: "Galaxy Z Flip7",
29215
- width: 412,
29216
- height: 990,
29217
- dpr: 3,
29218
- notch: "punch-hole-center",
29219
- notchInset: 36,
29220
- navBarHeight: 54,
29221
- safeAreaBottom: 0,
29222
- safeAreaProvenance: PLACEHOLDER
29223
- },
29224
- {
29225
- id: "galaxy-z-fold7-folded",
29226
- label: "Galaxy Z Fold7 (folded)",
29227
- width: 384,
29228
- height: 870,
29229
- dpr: 3,
29230
- notch: "punch-hole-center",
29231
- notchInset: 32,
29232
- navBarHeight: 54,
29233
- safeAreaBottom: 0,
29234
- safeAreaProvenance: PLACEHOLDER
29235
- },
29236
- {
29237
- id: "galaxy-z-fold7-unfolded",
29238
- label: "Galaxy Z Fold7 (unfolded)",
29239
- width: 768,
29240
- height: 884,
29241
- dpr: 2.625,
29242
- notch: "punch-hole-center",
29243
- notchInset: 32,
29244
- navBarHeight: 54,
29245
- safeAreaBottom: 0,
29246
- safeAreaProvenance: PLACEHOLDER
29247
- },
29248
- CUSTOM_PRESET
29249
- ];
29250
- function getPreset(id) {
29251
- return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
29252
- }
29253
- /**
29254
- * 실제로 화면에 표시될 orientation을 결정한다.
29255
- *
29256
- * - Panel `orientation === 'auto'`: 앱이 마지막으로 SDK로 요청한 값
29257
- * (`appOrientation`)을 따른다. 호출 전이면 portrait.
29258
- * - Panel `orientation === 'portrait' | 'landscape'`: Panel 값이 우선.
29259
- */
29260
- function effectiveOrientation(state) {
29261
- if (state.orientation === "auto") return state.appOrientation ?? "portrait";
29262
- return state.orientation;
29263
- }
29264
- /**
29265
- * 선택된 뷰포트의 실제 width/height를 계산한다.
29266
- * preset === 'custom'이면 customWidth/customHeight, 그 외에는 preset의 값.
29267
- * effective orientation이 landscape이면 width/height를 swap한다.
29268
- */
29269
- function resolveViewportSize(state) {
29270
- if (state.preset === "none") return {
29271
- width: 0,
29272
- height: 0
29273
- };
29274
- const base = state.preset === "custom" ? {
29275
- width: state.customWidth,
29276
- height: state.customHeight
29277
- } : getPreset(state.preset);
29278
- return effectiveOrientation(state) === "landscape" ? {
29279
- width: base.height,
29280
- height: base.width
29281
- } : {
29282
- width: base.width,
29283
- height: base.height
29284
- };
29285
- }
29286
- /**
29287
- * 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
29288
- * 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190, #198, #232, #275)에 맞춘 모델:
29289
- *
29290
- * - **Portrait top = 0** (partner/game 모두). 실측(devtools#275)에서 토스 native nav bar는
29291
- * partner WebView **viewport 밖**에 그려진다. SDK가 반환하는 `top=54`는 호스트 nav bar
29292
- * 높이에 대한 정보용 값이고, WebView 좌표계에서 콘텐츠는 top=0부터 시작한다. 소비자가
29293
- * 이 값을 `padding-top`으로 적용하면 실기기에서 잉여 공간이 생긴다(double-count).
29294
- * mock은 top=0을 반환해 소비자 코드가 실기기와 같은 결과를 내도록 한다.
29295
- * `game` type 측정은 아직 미진행이지만 동일하게 top=0을 반환한다(추후 실측으로 갱신).
29296
- * - **Bottom = `safeAreaBottom`** (portrait home-indicator, 실측 34).
29297
- * landscape는 `safeAreaBottomLandscape`가 정의돼 있으면 그 값을 사용한다
29298
- * (iPhone 15 Pro landscape 실측 20 — portrait 34와 다름).
29299
- * - **Landscape iPhone(notch/Dynamic Island)**: CSS env()와 SDK SafeAreaInsets 모두
29300
- * `left = right = notchInset`(양쪽 대칭)을 반환한다. 물리적 노치는 한쪽으로 가지만
29301
- * OS가 양쪽 모두에 같은 inset을 부여하므로 landscapeSide mental model은 틀렸다
29302
- * (2026-05-28 iPhone 15 Pro relay 실측 #198/#232: left=right=59). top=0(landscape에서
29303
- * 토스 앱이 partner nav bar를 숨김, #232 실측 확인).
29304
- * - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
29305
- * 유지된다.
29306
- */
29307
- function computeSafeAreaInsets(preset, landscape) {
29308
- if (preset.id === "none" || preset.id === "custom") return {
29309
- top: 0,
29310
- bottom: 0,
29311
- left: 0,
29312
- right: 0
29313
- };
29314
- if (!landscape) return {
29315
- top: 0,
29316
- bottom: preset.safeAreaBottom,
29317
- left: 0,
29318
- right: 0
29319
- };
29320
- const landscapeBottom = preset.safeAreaBottomLandscape !== void 0 ? preset.safeAreaBottomLandscape : preset.safeAreaBottom;
29321
- if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
29322
- top: 0,
29323
- bottom: landscapeBottom,
29324
- left: preset.notchInset,
29325
- right: preset.notchInset
29326
- };
29327
- return {
29328
- top: preset.notchInset,
29329
- bottom: landscapeBottom,
29330
- left: 0,
29331
- right: 0
29332
- };
29333
- }
29334
- /** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
29335
- function syncSafeAreaFromViewport(state) {
29336
- if (state.preset === "none" || state.preset === "custom") return;
29337
- const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape");
29338
- const current = aitState.state.safeAreaInsets;
29339
- if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
29340
- aitState.update({ safeAreaInsets: next });
29341
- }
29342
- const STYLE_ELEMENT_ID = "__ait-viewport-style";
29343
- const NOTCH_ELEMENT_ID = "__ait-viewport-notch";
29344
- const HOME_INDICATOR_ID = "__ait-viewport-home-indicator";
29345
- const NAV_BAR_ELEMENT_ID = "__ait-viewport-navbar";
29346
- let bodyScrollHintEmitted = false;
29347
- function ensureStyleElement() {
29348
- if (typeof document === "undefined") return null;
29349
- let el = document.getElementById(STYLE_ELEMENT_ID);
29350
- if (!el) {
29351
- el = document.createElement("style");
29352
- el.id = STYLE_ELEMENT_ID;
29353
- document.head.appendChild(el);
29354
- }
29355
- return el;
29356
- }
29357
- function removeById(id) {
29358
- const el = document.getElementById(id);
29359
- if (el) el.remove();
29360
- }
29361
- function removeNotchElement() {
29362
- removeById(NOTCH_ELEMENT_ID);
29363
- }
29364
- function removeHomeIndicator() {
29365
- removeById(HOME_INDICATOR_ID);
29366
- }
29367
- function removeNavBarElement() {
29368
- removeById(NAV_BAR_ELEMENT_ID);
29369
- }
29370
- /**
29371
- * Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
29372
- *
29373
- * 변형(SDK `webViewProps.type`과 의미 일치):
29374
- * - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
29375
- * 우측 `⋯` + 구분선 + `×`.
29376
- * - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
29377
- *
29378
- * 이 오버레이는 **시각 참고용 frame 장식**이다. 실기기에서 토스 native nav bar는 WebView
29379
- * viewport 밖에 그려지므로(devtools#275), mock의 nav bar 오버레이가 콘텐츠 위에 overlap
29380
- * 되는 것이 실제 동작과 일치한다 — body에 `padding-top`을 주입하지 않는다.
29381
- * 시각 notch 오버레이는 body 밖 위쪽에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
29382
- *
29383
- * 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
29384
- * 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
29385
- */
29386
- function renderNavBar(displayName, type) {
29387
- removeNavBarElement();
29388
- const el = h("div", {
29389
- id: NAV_BAR_ELEMENT_ID,
29390
- className: `ait-navbar ait-navbar-${type}`,
29391
- "aria-hidden": "true"
29392
- });
29393
- const moreBtn = h("button", {
29394
- className: "ait-navbar-btn",
29395
- type: "button",
29396
- "aria-label": "More"
29397
- });
29398
- moreBtn.textContent = "⋯";
29399
- const closeBtn = h("button", {
29400
- className: "ait-navbar-btn",
29401
- type: "button",
29402
- "aria-label": "Close"
29403
- });
29404
- closeBtn.textContent = "×";
29405
- closeBtn.addEventListener("click", () => {
29406
- closeView().catch((err) => console.error("[@ait-co/devtools] navbar close failed:", err));
29407
- });
29408
- const actions = h("div", { className: "ait-navbar-actions" }, moreBtn, h("span", { className: "ait-navbar-divider" }), closeBtn);
29409
- if (type === "game") el.append(actions);
29410
- else {
29411
- const backBtn = h("button", {
29412
- className: "ait-navbar-btn ait-navbar-back",
29413
- type: "button",
29414
- "aria-label": "Back"
29415
- });
29416
- backBtn.textContent = "‹";
29417
- backBtn.addEventListener("click", () => {
29418
- aitState.trigger("backEvent");
29419
- });
29420
- const nameSpan = h("span", { className: "ait-navbar-name" });
29421
- nameSpan.textContent = displayName;
29422
- el.append(backBtn, h("div", { className: "ait-navbar-title" }, h("span", { className: "ait-navbar-icon" }), nameSpan), actions);
29423
- }
29424
- document.body.appendChild(el);
29425
- }
29426
- /**
29427
- * 현재 preset의 notch/Dynamic Island/punch-hole을 body 상단에 시각적으로 렌더한다.
29428
- * landscape 시에는 노치가 한쪽 변에 있는 것이 실제 기기 동작이지만, 시뮬레이터에서는
29429
- * landscape에서 오버레이를 그리지 않는다 (safeAreaInsets의 left/right로 이미 반영).
29430
- */
29431
- function renderNotchOverlay(preset) {
29432
- removeNotchElement();
29433
- if (preset.notch === "none") return;
29434
- const notch = h("div", {
29435
- id: NOTCH_ELEMENT_ID,
29436
- className: `ait-notch ${preset.notch === "dynamic-island" ? "ait-notch-dynamic-island" : preset.notch === "notch" ? "ait-notch-pill" : "ait-notch-punch-hole"}`,
29437
- "aria-hidden": "true"
29438
- });
29439
- document.body.appendChild(notch);
29440
- }
29441
- /** brand 이름만 바뀐 경우 nav bar 전체를 다시 만들지 않고 텍스트 노드만 교체한다. */
29442
- function refreshNavBarBrand(displayName) {
29443
- const name = document.querySelector(`#${NAV_BAR_ELEMENT_ID} .ait-navbar-name`);
29444
- if (name) name.textContent = displayName;
29445
- }
29446
- function renderHomeIndicator() {
29447
- removeHomeIndicator();
29448
- const el = h("div", {
29449
- id: HOME_INDICATOR_ID,
29450
- className: "ait-home-indicator",
29451
- "aria-hidden": "true"
29452
- });
29453
- document.body.appendChild(el);
29454
- }
29455
- /**
29456
- * 모든 viewport DOM mutation을 원복하고 aitState 구독도 해제한다.
29457
- * 외부 consumer가 패널을 동적으로 제거할 때 호출. 호출 후에는 aitState 변경이
29458
- * DOM에 반영되지 않으므로 안전하게 panel을 떼어낼 수 있다.
29459
- */
29460
- function disposeViewport() {
29461
- if (typeof document === "undefined") return;
29462
- if (viewportUnsubscribe) viewportUnsubscribe();
29463
- const html = document.documentElement;
29464
- html.classList.remove("ait-viewport-active");
29465
- html.classList.remove("ait-viewport-framed");
29466
- removeById(STYLE_ELEMENT_ID);
29467
- removeNotchElement();
29468
- removeHomeIndicator();
29469
- removeNavBarElement();
29470
- revertDeviceEmulation();
29471
- bodyScrollHintEmitted = false;
29472
- }
29473
- /**
29474
- * DOM에 뷰포트 제약을 적용한다.
29475
- * - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
29476
- * - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
29477
- */
29478
- function applyViewport(state) {
29479
- if (typeof document === "undefined") return;
29480
- const html = document.documentElement;
29481
- const style = ensureStyleElement();
29482
- if (!style) return;
29483
- const size = resolveViewportSize(state);
29484
- if (state.preset === "none" || size.width === 0 || size.height === 0) {
29485
- html.classList.remove("ait-viewport-active");
29486
- html.classList.remove("ait-viewport-framed");
29487
- style.textContent = "";
29488
- removeNotchElement();
29489
- removeHomeIndicator();
29490
- removeNavBarElement();
29491
- syncDeviceEmulation(null, false);
29492
- return;
29493
- }
29494
- if (!bodyScrollHintEmitted) {
29495
- bodyScrollHintEmitted = true;
29496
- console.info("[@ait-co/devtools] Viewport simulation active — scroll happens on body, not window. See README \"Known limitations\" for details.");
29497
- }
29498
- html.classList.add("ait-viewport-active");
29499
- html.classList.toggle("ait-viewport-framed", state.frame);
29500
- const preset = state.preset === "custom" ? null : getPreset(state.preset);
29501
- const landscape = effectiveOrientation(state) === "landscape";
29502
- syncDeviceEmulation(preset, landscape);
29503
- style.textContent = `
29504
- html.ait-viewport-active body {
29505
- width: ${size.width}px;
29506
- max-width: ${size.width}px;
29507
- min-height: ${size.height}px;
29508
- max-height: ${size.height}px;
29509
- }
29510
- `;
29511
- if (preset && state.frame && !landscape) renderNotchOverlay(preset);
29512
- else removeNotchElement();
29513
- if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
29514
- else removeHomeIndicator();
29515
- if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
29516
- else removeNavBarElement();
29517
- }
29518
- function isViewportPresetId(v) {
29519
- return typeof v === "string" && VIEWPORT_PRESETS.some((p) => p.id === v);
29520
- }
29521
- function isViewportOrientation(v) {
29522
- return v === "auto" || v === "portrait" || v === "landscape";
29523
- }
29524
- function isAppOrientation(v) {
29525
- return v === null || v === "portrait" || v === "landscape";
29526
- }
29527
- /** 1 이상의 정수 + VIEWPORT_CUSTOM_MAX 이하인지 검사. sessionStorage 보호용. */
29528
- function isValidCustomDimension(v) {
29529
- return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 4096;
29530
- }
29531
- /** Custom 입력에서 사용. 잘린 정수 + 클램프된 안전한 값 또는 null 반환. */
29532
- function clampCustomDimension(raw) {
29533
- if (!Number.isFinite(raw)) return null;
29534
- const n = Math.floor(raw);
29535
- if (n < 1) return null;
29536
- return Math.min(n, VIEWPORT_CUSTOM_MAX);
29537
- }
29538
- /**
29539
- * sessionStorage에 저장된 뷰포트 상태를 읽어서 현재 state에 merge한다.
29540
- * 값이 없거나 파싱 실패 시 no-op.
29541
- */
29542
- function loadViewportFromStorage() {
29543
- if (typeof sessionStorage === "undefined") return null;
29544
- const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
29545
- if (!raw) return null;
29546
- try {
29547
- const parsed = JSON.parse(raw);
29548
- if (typeof parsed !== "object" || parsed === null) return null;
29549
- const obj = parsed;
29550
- const next = {};
29551
- if (isViewportPresetId(obj.preset)) next.preset = obj.preset;
29552
- if (isViewportOrientation(obj.orientation)) next.orientation = obj.orientation;
29553
- if (isAppOrientation(obj.appOrientation)) next.appOrientation = obj.appOrientation;
29554
- if (isValidCustomDimension(obj.customWidth)) next.customWidth = obj.customWidth;
29555
- if (isValidCustomDimension(obj.customHeight)) next.customHeight = obj.customHeight;
29556
- if (typeof obj.frame === "boolean") next.frame = obj.frame;
29557
- if (typeof obj.aitNavBar === "boolean") next.aitNavBar = obj.aitNavBar;
29558
- if (obj.aitNavBarType === "partner" || obj.aitNavBarType === "game") next.aitNavBarType = obj.aitNavBarType;
29559
- return next;
29560
- } catch {
29561
- return null;
29562
- }
29563
- }
29564
- function saveViewportToStorage(state) {
29565
- if (typeof sessionStorage === "undefined") return;
29566
- try {
29567
- sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(state));
29568
- } catch {}
29569
- }
29570
- let viewportInitialized = false;
29571
- let viewportUnsubscribe = null;
29572
- /**
29573
- * Panel mount 시 호출. sessionStorage 복원 → aitState에 반영 → DOM 적용.
29574
- * aitState 변경을 구독해서 DOM / storage / safe-area insets를 자동 동기화한다.
29575
- *
29576
- * Idempotent: 두 번째 호출은 기존 unsubscribe를 그대로 반환한다 (HMR / 재mount 안전).
29577
- * 테스트는 반환된 unsubscribe를 afterEach에서 호출해 cleanup해야 한다.
29578
- */
29579
- function initViewport() {
29580
- if (typeof window === "undefined") return () => {};
29581
- if (viewportInitialized && viewportUnsubscribe) return viewportUnsubscribe;
29582
- const restored = loadViewportFromStorage();
29583
- if (restored) aitState.patch("viewport", restored);
29584
- applyViewport(aitState.state.viewport);
29585
- syncSafeAreaFromViewport(aitState.state.viewport);
29586
- let lastViewportJson = JSON.stringify(aitState.state.viewport);
29587
- let lastBrandName = aitState.state.brand.displayName;
29588
- const unsubscribeFn = aitState.subscribe(() => {
29589
- const vp = aitState.state.viewport;
29590
- const brandName = aitState.state.brand.displayName;
29591
- const json = JSON.stringify(vp);
29592
- const viewportChanged = json !== lastViewportJson;
29593
- if (!viewportChanged && !(brandName !== lastBrandName)) return;
29594
- lastViewportJson = json;
29595
- lastBrandName = brandName;
29596
- if (viewportChanged) {
29597
- applyViewport(vp);
29598
- saveViewportToStorage(vp);
29599
- syncSafeAreaFromViewport(vp);
29600
- } else refreshNavBarBrand(brandName);
29601
- });
29602
- viewportInitialized = true;
29603
- viewportUnsubscribe = () => {
29604
- unsubscribeFn();
29605
- viewportInitialized = false;
29606
- viewportUnsubscribe = null;
29607
- };
29608
- return viewportUnsubscribe;
29609
- }
29610
- //#endregion
29611
- //#region src/panel/tabs/viewport.ts
29612
- /**
29613
- * Renders a small inline provenance badge for safe-area values.
29614
- * - `measured` — no badge (confirmed value)
29615
- * - `extrapolated` — "(추정치)" in muted gray
29616
- * - `placeholder` — "(미측정)" in amber
29617
- */
29618
- function provenanceBadge(provenance) {
29619
- if (!provenance || provenance.source === "measured") return null;
29620
- const text = provenance.source === "placeholder" ? "(미측정)" : "(추정치)";
29621
- const color = provenance.source === "placeholder" ? "#b45309" : "#888";
29622
- const badge = h("span", {
29623
- className: "ait-provenance-badge",
29624
- title: provenance.source === "placeholder" ? "safe-area 값이 미실측 추정치입니다. relay 세션에서 measure_safe_area로 실측 후 승급하세요." : "safe-area 값이 기기 스펙에서 유추한 추정치입니다. relay 세션에서 measure_safe_area로 확인하세요."
29625
- });
29626
- badge.textContent = text;
29627
- badge.style.cssText = `font-size:10px;color:${color};margin-left:4px`;
29628
- return badge;
29629
- }
29630
- function renderViewportTab() {
29631
- const s = aitState.state;
29632
- const vp = s.viewport;
29633
- const disabled = !s.panelEditable;
29634
- const container = h("div");
29635
- if (disabled) container.appendChild(monitoringNotice());
29636
- const presetSelect = h("select", { className: "ait-select" });
29637
- if (disabled) presetSelect.disabled = true;
29638
- for (const preset of VIEWPORT_PRESETS) {
29639
- const label = preset.id === "none" || preset.id === "custom" ? preset.label : `${preset.label} (${preset.width}×${preset.height})`;
29640
- const option = h("option", { value: preset.id }, label);
29641
- if (preset.id === vp.preset) option.selected = true;
29642
- presetSelect.appendChild(option);
29643
- }
29644
- presetSelect.addEventListener("change", () => {
29645
- const id = presetSelect.value;
29646
- const patch = { preset: id };
29647
- if (id === "custom") {
29648
- const current = getPreset(vp.preset);
29649
- if (current.width > 0) patch.customWidth = current.width;
29650
- if (current.height > 0) patch.customHeight = current.height;
29651
- }
29652
- aitState.patch("viewport", patch);
29653
- });
29654
- const orientationSelect = h("select", { className: "ait-select" });
29655
- if (disabled) orientationSelect.disabled = true;
29656
- for (const opt of [
29657
- "auto",
29658
- "portrait",
29659
- "landscape"
29660
- ]) {
29661
- const option = h("option", { value: opt }, opt);
29662
- if (opt === vp.orientation) option.selected = true;
29663
- orientationSelect.appendChild(option);
29664
- }
29665
- orientationSelect.addEventListener("change", () => {
29666
- aitState.patch("viewport", { orientation: orientationSelect.value });
29667
- });
29668
- const customRow = h("div", { className: "ait-section" });
29669
- if (vp.preset === "custom") {
29670
- const widthInput = h("input", {
29671
- className: "ait-input",
29672
- type: "number",
29673
- min: "1",
29674
- value: String(vp.customWidth)
29675
- });
29676
- const heightInput = h("input", {
29677
- className: "ait-input",
29678
- type: "number",
29679
- min: "1",
29680
- value: String(vp.customHeight)
29681
- });
29682
- if (disabled) {
29683
- widthInput.disabled = true;
29684
- heightInput.disabled = true;
29685
- }
29686
- widthInput.addEventListener("change", () => {
29687
- const clamped = clampCustomDimension(Number(widthInput.value));
29688
- if (clamped !== null) {
29689
- aitState.patch("viewport", { customWidth: clamped });
29690
- widthInput.value = String(clamped);
29691
- }
29692
- });
29693
- heightInput.addEventListener("change", () => {
29694
- const clamped = clampCustomDimension(Number(heightInput.value));
29695
- if (clamped !== null) {
29696
- aitState.patch("viewport", { customHeight: clamped });
29697
- heightInput.value = String(clamped);
29698
- }
29699
- });
29700
- 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));
29701
- }
29702
- const frameCheckbox = h("input", { type: "checkbox" });
29703
- frameCheckbox.checked = vp.frame;
29704
- if (disabled) frameCheckbox.disabled = true;
29705
- frameCheckbox.addEventListener("change", () => {
29706
- aitState.patch("viewport", { frame: frameCheckbox.checked });
29707
- });
29708
- const navBarCheckbox = h("input", { type: "checkbox" });
29709
- navBarCheckbox.checked = vp.aitNavBar;
29710
- if (disabled) navBarCheckbox.disabled = true;
29711
- navBarCheckbox.addEventListener("change", () => {
29712
- aitState.patch("viewport", { aitNavBar: navBarCheckbox.checked });
29713
- });
29714
- const navBarTypeSelect = h("select", { className: "ait-select" });
29715
- if (disabled || !vp.aitNavBar) navBarTypeSelect.disabled = true;
29716
- for (const opt of ["partner", "game"]) {
29717
- const option = h("option", { value: opt }, opt);
29718
- if (opt === vp.aitNavBarType) option.selected = true;
29719
- navBarTypeSelect.appendChild(option);
29720
- }
29721
- navBarTypeSelect.addEventListener("change", () => {
29722
- aitState.patch("viewport", { aitNavBarType: navBarTypeSelect.value });
29723
- });
29724
- const size = resolveViewportSize(vp);
29725
- const statusEl = h("div", { className: "ait-section" });
29726
- if (vp.preset === "none" || size.width === 0) statusEl.appendChild(h("div", { style: "color:#888;font-size:11px" }, t("viewport.status.noConstraint")));
29727
- else {
29728
- const preset = vp.preset === "custom" ? null : getPreset(vp.preset);
29729
- const effOrient = effectiveOrientation(vp);
29730
- const landscape = effOrient === "landscape";
29731
- const rows = [];
29732
- const dpr = preset?.dpr ?? 1;
29733
- const physW = Math.round(size.width * dpr);
29734
- const physH = Math.round(size.height * dpr);
29735
- const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
29736
- 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}`)));
29737
- if (preset) {
29738
- const insets = computeSafeAreaInsets(preset, landscape);
29739
- const safeAreaValueEl = h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`);
29740
- const badge = provenanceBadge(preset.safeAreaProvenance);
29741
- if (badge) safeAreaValueEl.appendChild(badge);
29742
- rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), safeAreaValueEl));
29743
- }
29744
- if (vp.aitNavBar && !landscape) {
29745
- const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
29746
- rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
29747
- height: navBarTop,
29748
- type: vp.aitNavBarType
29749
- }))));
29750
- }
29751
- for (const row of rows) statusEl.appendChild(row);
29752
- }
29753
- 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));
29754
- 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);
29755
- return container;
29756
- }
29757
- //#endregion
29758
- //#region src/panel/tabs/index.ts
29759
- const TAB_DEFS = [
29760
- {
29761
- id: "env",
29762
- labelKey: "panel.tab.env"
29763
- },
29764
- {
29765
- id: "presets",
29766
- labelKey: "panel.tab.presets"
29767
- },
29768
- {
29769
- id: "viewport",
29770
- labelKey: "panel.tab.viewport"
29771
- },
29772
- {
29773
- id: "permissions",
29774
- labelKey: "panel.tab.permissions"
29775
- },
29776
- {
29777
- id: "notifications",
29778
- labelKey: "panel.tab.notifications"
29779
- },
29780
- {
29781
- id: "location",
29782
- labelKey: "panel.tab.location"
29783
- },
29784
- {
29785
- id: "device",
29786
- labelKey: "panel.tab.device"
29787
- },
29788
- {
29789
- id: "iap",
29790
- labelKey: "panel.tab.iap"
29791
- },
29792
- {
29793
- id: "ads",
29794
- labelKey: "panel.tab.ads"
29795
- },
29796
- {
29797
- id: "events",
29798
- labelKey: "panel.tab.events"
29799
- },
29800
- {
29801
- id: "analytics",
29802
- labelKey: "panel.tab.analytics"
29803
- },
29804
- {
29805
- id: "storage",
29806
- labelKey: "panel.tab.storage"
29807
- }
29808
- ];
29809
- function getTabs() {
29810
- return TAB_DEFS.map((def) => ({
29811
- id: def.id,
29812
- label: t(def.labelKey)
29813
- }));
29814
- }
29815
- function createTabRenderers(refreshPanel) {
29816
- return {
29817
- env: renderEnvironmentTab,
29818
- presets: () => renderPresetsTab(refreshPanel),
29819
- permissions: renderPermissionsTab,
29820
- notifications: renderNotificationsTab,
29821
- location: renderLocationTab,
29822
- device: renderDeviceTab,
29823
- viewport: renderViewportTab,
29824
- iap: renderIapTab,
29825
- ads: renderAdsTab,
29826
- events: renderEventsTab,
29827
- analytics: renderAnalyticsTab,
29828
- storage: () => renderStorageTab(refreshPanel)
29829
- };
29830
- }
29831
29017
  const PANEL_STYLES = `
29832
29018
  .ait-panel-toggle {
29833
29019
  position: fixed;
@@ -30400,8 +29586,824 @@ const PANEL_STYLES = `
30400
29586
  .ait-panel-close {
30401
29587
  display: block;
30402
29588
  }
30403
- }
30404
- `;
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
+ }
30405
30407
  //#endregion
30406
30408
  //#region src/panel/use-draggable.ts
30407
30409
  /**
@@ -30739,7 +30741,7 @@ function Panel() {
30739
30741
  color: "#666",
30740
30742
  fontWeight: 400
30741
30743
  },
30742
- children: ["v", "0.1.74"]
30744
+ children: ["v", "0.1.75"]
30743
30745
  }),
30744
30746
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
30745
30747
  type: "button",