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