@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.
- package/dist/devtools-opener-BbUXBzgA.js.map +1 -1
- package/dist/devtools-opener-Bp671YXu.cjs.map +1 -1
- package/dist/devtools-opener-D84kZFtR.js.map +1 -1
- package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -1
- package/dist/mcp/cli.js +77 -37
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mock/index.d.ts +50 -2
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +1210 -1110
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +822 -820
- package/dist/panel/index.js.map +1 -1
- package/dist/{qr-http-server-Bn2ciFuC.js → qr-http-server-CDO6o2nr.js} +19 -7
- package/dist/qr-http-server-CDO6o2nr.js.map +1 -0
- package/dist/{qr-http-server-DNGVwI0P.cjs → qr-http-server-D0v9ooAD.cjs} +19 -7
- package/dist/qr-http-server-D0v9ooAD.cjs.map +1 -0
- package/dist/{qr-http-server-Cpc4jfTA.js → qr-http-server-DznDIcJF.js} +19 -7
- package/dist/qr-http-server-DznDIcJF.js.map +1 -0
- package/dist/{qr-http-server-BqZ8c0Bp.cjs → qr-http-server-jMC1nVqY.cjs} +19 -7
- package/dist/qr-http-server-jMC1nVqY.cjs.map +1 -0
- package/dist/{tunnel-CAxygywQ.cjs → tunnel-D7f-0enB.cjs} +2 -2
- package/dist/{tunnel-CAxygywQ.cjs.map → tunnel-D7f-0enB.cjs.map} +1 -1
- package/dist/{tunnel-BuymAS3O.js → tunnel-km3KkZrF.js} +2 -2
- package/dist/{tunnel-BuymAS3O.js.map → tunnel-km3KkZrF.js.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/qr-http-server-Bn2ciFuC.js.map +0 -1
- package/dist/qr-http-server-BqZ8c0Bp.cjs.map +0 -1
- package/dist/qr-http-server-Cpc4jfTA.js.map +0 -1
- package/dist/qr-http-server-DNGVwI0P.cjs.map +0 -1
package/dist/panel/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
30744
|
+
children: ["v", "0.1.75"]
|
|
30743
30745
|
}),
|
|
30744
30746
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
30745
30747
|
type: "button",
|