@ait-co/devtools 0.1.19 → 0.1.20

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @ait-co/devtools
2
2
 
3
+ **한국어** · [English](./README.en.md)
4
+
5
+ [![npm](https://img.shields.io/npm/v/@ait-co/devtools)](https://www.npmjs.com/package/@ait-co/devtools) [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue)](./LICENSE)
6
+
3
7
  ![@ait-co/devtools — SDK mock + DevTools panel for Apps In Toss mini-apps](./assets/og/image.png)
4
8
 
5
9
  `@apps-in-toss/web-framework` SDK의 mock 라이브러리입니다. `@apps-in-toss/web-bridge`, `@apps-in-toss/web-analytics` import도 함께 mock됩니다.
@@ -14,10 +18,6 @@
14
18
 
15
19
  라이브 데모: <https://devtools.aitc.dev/> (이 repo의 `e2e/fixture/`를 GitHub Pages에 그대로 배포한 self-contained 데모).
16
20
 
17
- ## Reference consumer
18
-
19
- [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example)이 devtools의 reference consumer다. 모든 SDK API를 인터랙티브하게 실행해볼 수 있는 카탈로그 앱으로, 웹 데모는 <https://sdk-example.aitc.dev/>에서 바로 확인할 수 있다. 새 mock을 추가하면 sdk-example의 카드에서 그대로 동작하는 게 1차 sanity check. 단, 이 repo의 E2E suite는 sdk-example을 clone하지 않고 **내부 자기완결 fixture(`e2e/fixture/`)** 로 운영한다 — sdk-example이 깨져도 devtools CI는 영향받지 않는다.
20
-
21
21
  ## 설치
22
22
 
23
23
  ```bash
@@ -34,6 +34,10 @@ pnpm add -D @ait-co/devtools
34
34
  > 안 되는" 상황을 방지하기 위한 의도적 동작입니다. 누락된 API는
35
35
  > [이슈](https://github.com/apps-in-toss-community/devtools/issues)로 알려주세요.
36
36
 
37
+ ## Reference consumer
38
+
39
+ [`sdk-example`](https://github.com/apps-in-toss-community/sdk-example)이 devtools의 reference consumer다. 모든 SDK API를 인터랙티브하게 실행해볼 수 있는 카탈로그 앱으로, 웹 데모는 <https://sdk-example.aitc.dev/>에서 바로 확인할 수 있다. 새 mock을 추가하면 sdk-example의 카드에서 그대로 동작하는 게 1차 sanity check. 단, 이 repo의 E2E suite는 sdk-example을 clone하지 않고 **내부 자기완결 fixture(`e2e/fixture/`)** 로 운영한다 — sdk-example이 깨져도 devtools CI는 영향받지 않는다.
40
+
37
41
  ## 번들러 설정
38
42
 
39
43
  ### Vite
@@ -215,15 +219,66 @@ if (process.env.NODE_ENV !== 'production') {
215
219
 
216
220
  데스크톱 크롬에서 잘 돌던 미니앱을 **실제 폰**에서 보고 싶을 때. Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, **계정 불필요**)로 노출하고, 폰에는 고정 URL의 launcher PWA를 한 번만 추가해 그 안에서 매번의 tunnel URL을 띄웁니다.
217
221
 
218
- 1. unplugin에 `tunnel: true` 추가:
219
- ```ts
220
- // vite.config.ts
221
- plugins: [aitDevtools.vite({ tunnel: true })]
222
- ```
223
- 2. `pnpm dev` (= `vite`) 실행. dev 서버가 listen하면 터미널에 공개 URL + ASCII QR이 출력됩니다. (첫 실행 시 `cloudflared` 바이너리를 자동 다운로드합니다.)
224
- 3. 폰에서 `https://devtools.aitc.dev/launcher/`를 열고 **홈 화면에 추가** (iOS Safari "공유 → 홈 화면에 추가", Android Chrome "앱 설치"). 이걸 한 번만 하면 됩니다 — launcher 자체는 URL이 바뀌지 않습니다.
225
- 4. launcher 아이콘으로 실행 → 카메라로 2번의 QR을 스캔(또는 URL을 붙여넣기) → dev 앱이 주소창 없는 풀스크린으로 뜹니다.
226
- 5. 다음 세션엔 `pnpm dev` URL 스캔만 하면 됩니다. launcher는 마지막 URL을 기억하고, "Rescan" 버튼으로 언제든 교체할 있습니다.
222
+ 셋업은 갈래입니다:
223
+
224
+ - **프로젝트당 1회** — `vite.config`에 옵션 + `package.json`에 pnpm 설정 + (선택) `dev:phone` 스크립트
225
+ - **폰당 1회** launcher PWA를 홈 화면에 추가
226
+ - **매 세션** — `pnpm dev:phone` (또는 `AIT_TUNNEL=1 pnpm dev`) 한 줄
227
+
228
+ ### 1. 프로젝트당 1회 셋업
229
+
230
+ (a) **`vite.config.ts`에 `tunnel` 옵션 추가** 항상 켜져 있어 매번 cloudflared가 떠도 괜찮으면 `tunnel: true`, 평소엔 끄고 명시할 때만 켜고 싶으면 env-gate 권장:
231
+
232
+ ```ts
233
+ // vite.config.ts
234
+ import { defineConfig } from 'vite';
235
+ import aitDevtools from '@ait-co/devtools/unplugin';
236
+
237
+ export default defineConfig({
238
+ plugins: [
239
+ aitDevtools.vite({
240
+ tunnel: !!process.env.AIT_TUNNEL, // 평소 OFF, AIT_TUNNEL=1 일 때만 ON
241
+ }),
242
+ ],
243
+ });
244
+ ```
245
+
246
+ > `process.env.AIT_TUNNEL`은 `vite.config.ts`를 로드하는 시점(= vite 프로세스 기동 시)에 평가됩니다. 따라서 env 변수는 **vite를 띄우기 전에** 설정되어 있어야 합니다 (아래 (c)의 `dev:phone` 스크립트가 이를 자동으로 해결합니다).
247
+
248
+ (b) **`package.json`에 pnpm 10+ 빌드 스크립트 허용** — pnpm은 보안상 dependency의 postinstall을 기본 차단합니다. `cloudflared`는 postinstall에서 바이너리(~38 MB)를 받으므로 명시 허용 필요:
249
+
250
+ ```json
251
+ {
252
+ "pnpm": {
253
+ "onlyBuiltDependencies": ["cloudflared"]
254
+ }
255
+ }
256
+ ```
257
+
258
+ > 명시하지 않아도 동작은 됩니다 — `tunnel.ts`가 첫 기동 시 `cloudflared.install()`을 lazy로 호출. 다만 `pnpm install`마다 "Ignored build scripts" 경고가 남고, 바이너리 다운로드가 첫 `pnpm dev` 시점으로 미뤄집니다. 참고: [`sdk-example#60`](https://github.com/apps-in-toss-community/sdk-example/pull/60).
259
+
260
+ (c) **(선택) `dev:phone` 스크립트** — env 변수 매번 타기 귀찮으면:
261
+
262
+ ```json
263
+ {
264
+ "scripts": {
265
+ "dev": "vite",
266
+ "dev:phone": "AIT_TUNNEL=1 vite"
267
+ }
268
+ }
269
+ ```
270
+
271
+ ### 2. 폰당 1회 셋업
272
+
273
+ 폰에서 `https://devtools.aitc.dev/launcher/`를 열고 **홈 화면에 추가**합니다 (iOS Safari "공유 → 홈 화면에 추가", Android Chrome "앱 설치"). launcher 자체는 URL이 바뀌지 않으니 한 번만 하면 됩니다.
274
+
275
+ ### 3. 매 세션
276
+
277
+ 1. 데스크톱에서 `pnpm dev:phone`을 실행합니다 (1-(c) 스크립트를 추가하지 않았다면 `AIT_TUNNEL=1 pnpm dev`). 터미널에 `https://*.trycloudflare.com` URL + ASCII QR이 출력됩니다.
278
+ 2. 폰의 launcher 아이콘 실행 → 카메라로 QR 스캔(또는 URL 붙여넣기) → 주소창 없는 풀스크린으로 dev 앱이 뜹니다.
279
+ 3. 다음 세션엔 새 URL을 스캔만 하면 됩니다. launcher는 마지막 URL을 기억하고, "Rescan" 버튼으로 언제든 교체할 수 있습니다.
280
+
281
+ ### 배경
227
282
 
228
283
  > **왜 launcher를 거치나요?** quick tunnel URL은 매 실행마다 바뀌므로 그 URL 자체를 PWA로 설치하면 다음 세션엔 죽은 링크가 됩니다. cross-origin으로 페이지를 전환하면 iOS/Android 모두 standalone(크롬리스)이 깨집니다. → 고정 URL의 launcher를 한 번 설치하고, 그 안의 `<iframe>`으로 그날의 dev 앱을 full-bleed로 보여주는 구조입니다.
229
284
  >
@@ -231,6 +286,10 @@ if (process.env.NODE_ENV !== 'production') {
231
286
  >
232
287
  > `tunnel` 옵션은 Vite dev 모드에서만 동작합니다 — production 빌드는 `forceEnable`이어도 터널을 띄우지 않습니다. 다른 번들러(Webpack/Rspack 등)에서는 무시됩니다. 이 옵션을 켜면 `cloudflared` / `qrcode-terminal`가 동적 import로만 로드되므로, 끄면 번들 그래프에 들어오지 않습니다.
233
288
 
289
+ ### 한 줄 셋업 (예정)
290
+
291
+ 위 "프로젝트당 1회" 단계(vite.config 패치 + `onlyBuiltDependencies` + `dev:phone` 스크립트)는 향후 [`agent-plugin`](https://github.com/apps-in-toss-community/agent-plugin)이 `/ait setup phone` 같은 단일 명령으로 흡수할 예정입니다 (명령 이름은 잠정). 이 README가 그 자동화의 명세서 역할을 하므로, 수동 셋업 단계가 줄어들어도 동작 모델 자체는 동일합니다.
292
+
234
293
  ## Device API 모드 시스템
235
294
 
236
295
  디바이스 관련 API(카메라, 위치, 클립보드 등)는 세 가지 모드로 동작합니다:
@@ -384,7 +443,7 @@ Landscape로 전환하면:
384
443
  **Show Apps in Toss nav bar** 토글(기본 on)을 켜면:
385
444
  - 토스 호스트의 상단 nav bar(뒤로가기 / 앱 아이콘·이름 / ⋯ / ×)를 48px 높이로 오버레이
386
445
  - status bar 바로 아래, safe area top 이후에 배치
387
- - **중요**: 이 48px는 `env(safe-area-inset-top)` 및 `SafeAreaInsets.get().top`에 **포함되지 않습니다** (공식 SDK 동작). 토스 공식 예제들도 `insets.top + 48` 패턴으로 보정합니다.
446
+ - **중요**: 이 48px는 `env(safe-area-inset-top)` 및 `SafeAreaInsets.get().top`에 **포함되지 않습니다** (SDK 동작). 토스 예제들도 `insets.top + 48` 패턴으로 보정합니다.
388
447
 
389
448
  ### 콘솔에서 직접 조작
390
449
 
@@ -768,6 +827,37 @@ import '@ait-co/devtools/panel';
768
827
  | `@ait-co/devtools/panel` | Floating DevTools Panel (import 시 자동 마운트) |
769
828
  | `@ait-co/devtools/unplugin` | 번들러 플러그인 (.vite, .webpack, .rspack, .esbuild, .rollup) |
770
829
 
830
+ ## 텔레메트리
831
+
832
+ devtools는 두 단계의 텔레메트리를 사용합니다.
833
+
834
+ ### Tier 0 — 익명 사용 신호 (기본 ON, opt-out)
835
+
836
+ 패널이 열릴 때 하루 1회 익명 ping을 전송합니다.
837
+
838
+ 수집 항목: `source`, `version`, `ts` — PII 없음, `anon_id` 없음. 서버가 IP+UA 기반 daily hash를 생성하지만 저장하지 않습니다.
839
+
840
+ 끄는 방법:
841
+ - 패널 Environment 탭 → "익명 사용 신호 (Tier 0)" 토글 OFF
842
+ - `localStorage.setItem('__ait_telemetry:t0_off', '1')` (콘솔에서 직접)
843
+ - 환경 변수: `AITC_TELEMETRY=off`
844
+
845
+ ### Tier 1 — 확장 텔레메트리 (기본 OFF, opt-in)
846
+
847
+ 패널 최초 실행 시 동의 토스트로 묻습니다. 동의한 경우에만 수집됩니다.
848
+
849
+ 수집 항목: `panel_open`, `tab_view`, `session_duration` 이벤트 + 익명 UUID(`anon_id`).
850
+
851
+ 끄는 방법:
852
+ - 패널 Environment 탭 → "확장 텔레메트리 (Tier 1)" 토글 OFF
853
+ - 수집된 데이터 삭제: 패널 Environment 탭 → "내 데이터 삭제"
854
+
855
+ 개인정보 처리방침: <https://docs.aitc.dev/privacy>
856
+
771
857
  ## 라이센스
772
858
 
773
859
  BSD 3-Clause
860
+
861
+ ---
862
+
863
+ 커뮤니티 오픈소스 프로젝트입니다.
@@ -36,7 +36,13 @@ const en = {
36
36
  "env.row.safeArea.top": "Top",
37
37
  "env.row.safeArea.bottom": "Bottom",
38
38
  "env.telemetry.section": "Telemetry",
39
- "env.telemetry.row": "Telemetry",
39
+ "env.telemetry.t0Row": "Anonymous usage signal (Tier 0)",
40
+ "env.telemetry.t0On": "On",
41
+ "env.telemetry.t0Off": "Off",
42
+ "env.telemetry.t0TurnOn": "Turn on",
43
+ "env.telemetry.t0TurnOff": "Turn off",
44
+ "env.telemetry.t0Desc": "Version + date only, no PII. Once per day. Helps improve the package.",
45
+ "env.telemetry.row": "Extended telemetry (Tier 1)",
40
46
  "env.telemetry.on": "On",
41
47
  "env.telemetry.off": "Off",
42
48
  "env.telemetry.turnOn": "Turn on",
@@ -49,7 +55,7 @@ const en = {
49
55
  "env.telemetry.deleted": "Deleted",
50
56
  "env.telemetry.deleteFailedRetry": "Delete failed (please retry)",
51
57
  "env.telemetry.deleteFailed": "Delete failed",
52
- "env.telemetry.privacyLink": "Privacy policy",
58
+ "env.telemetry.privacyLink": "Privacy policy",
53
59
  "env.section.language": "Language",
54
60
  "env.language.row": "Language",
55
61
  "env.language.ko": "한국어",
@@ -181,7 +187,13 @@ const ko = {
181
187
  "env.row.safeArea.top": "Top",
182
188
  "env.row.safeArea.bottom": "Bottom",
183
189
  "env.telemetry.section": "Telemetry",
184
- "env.telemetry.row": "Telemetry",
190
+ "env.telemetry.t0Row": "익명 사용 신호 (Tier 0)",
191
+ "env.telemetry.t0On": "On",
192
+ "env.telemetry.t0Off": "Off",
193
+ "env.telemetry.t0TurnOn": "Turn on",
194
+ "env.telemetry.t0TurnOff": "Turn off",
195
+ "env.telemetry.t0Desc": "버전·날짜만 수집, PII 없음. 하루 1회. 패키지 개선에 사용됩니다.",
196
+ "env.telemetry.row": "확장 텔레메트리 (Tier 1)",
185
197
  "env.telemetry.on": "On",
186
198
  "env.telemetry.off": "Off",
187
199
  "env.telemetry.turnOn": "Turn on",
@@ -194,7 +206,7 @@ const ko = {
194
206
  "env.telemetry.deleted": "삭제 완료",
195
207
  "env.telemetry.deleteFailedRetry": "삭제 실패 (다시 시도해주세요)",
196
208
  "env.telemetry.deleteFailed": "삭제 실패",
197
- "env.telemetry.privacyLink": "개인정보 처리방침",
209
+ "env.telemetry.privacyLink": "개인정보 처리방침",
198
210
  "env.section.language": "Language",
199
211
  "env.language.row": "Language",
200
212
  "env.language.ko": "한국어",
@@ -720,11 +732,56 @@ const KEY_CONSENT = "__ait_telemetry:consent";
720
732
  const KEY_REPROMPT_AFTER = "__ait_telemetry:reprompt_after";
721
733
  const KEY_POLICY_VERSION = "__ait_telemetry:policy_version";
722
734
  const KEY_ANON_ID = "__ait_telemetry:anon_id";
735
+ const KEY_T0_LAST_SENT = "__ait_telemetry:t0_last_sent";
736
+ const KEY_T0_OFF = "__ait_telemetry:t0_off";
737
+ /**
738
+ * Returns true if Tier 0 ping is enabled.
739
+ * Disabled when `localStorage.__ait_telemetry:t0_off = '1'`
740
+ * or `process.env.AITC_TELEMETRY === 'off'`.
741
+ */
742
+ function isTier0Enabled() {
743
+ if (typeof process !== "undefined" && process.env.AITC_TELEMETRY === "off") return false;
744
+ try {
745
+ return localStorage.getItem(KEY_T0_OFF) !== "1";
746
+ } catch {
747
+ return true;
748
+ }
749
+ }
750
+ /**
751
+ * Sets or clears the Tier 0 opt-out marker.
752
+ */
753
+ function setTier0Enabled(enabled) {
754
+ try {
755
+ if (enabled) localStorage.removeItem(KEY_T0_OFF);
756
+ else localStorage.setItem(KEY_T0_OFF, "1");
757
+ } catch {}
758
+ }
759
+ /**
760
+ * Returns true if Tier 0 has already been sent today (YYYY-MM-DD).
761
+ */
762
+ function hasSentTier0Today() {
763
+ try {
764
+ const stored = localStorage.getItem(KEY_T0_LAST_SENT);
765
+ if (!stored) return false;
766
+ return stored === (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
767
+ } catch {
768
+ return false;
769
+ }
770
+ }
771
+ /**
772
+ * Records that Tier 0 was sent today.
773
+ */
774
+ function markTier0Sent() {
775
+ try {
776
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
777
+ localStorage.setItem(KEY_T0_LAST_SENT, today);
778
+ } catch {}
779
+ }
723
780
  /**
724
781
  * Current policy version. Bump this string whenever the privacy policy changes.
725
782
  * Users who previously granted on an older version will be re-prompted once.
726
783
  */
727
- const CURRENT_POLICY_VERSION = "2026-05-12";
784
+ const CURRENT_POLICY_VERSION = "2026-05-18";
728
785
  /** 30 days in milliseconds */
729
786
  const THIRTY_DAYS_MS = 720 * 60 * 60 * 1e3;
730
787
  function readConsentState() {
@@ -763,7 +820,7 @@ function getOrCreateAnonId() {
763
820
  function resolveEffectiveConsent() {
764
821
  const raw = localStorage.getItem(KEY_CONSENT);
765
822
  if (raw === "granted") {
766
- if (readPolicyVersion() !== "2026-05-12") {
823
+ if (readPolicyVersion() !== "2026-05-18") {
767
824
  localStorage.removeItem(KEY_CONSENT);
768
825
  localStorage.removeItem(KEY_POLICY_VERSION);
769
826
  return "undecided";
@@ -889,6 +946,7 @@ function delay(ms) {
889
946
  async function send(event, version, meta) {
890
947
  if (readConsentState() !== "granted") return;
891
948
  const payload = {
949
+ tier: 1,
892
950
  source: "devtools",
893
951
  event,
894
952
  anon_id: getOrCreateAnonId(),
@@ -909,6 +967,7 @@ async function send(event, version, meta) {
909
967
  function sendBeaconEvent(event, version, meta) {
910
968
  if (readConsentState() !== "granted") return;
911
969
  const payload = {
970
+ tier: 1,
912
971
  source: "devtools",
913
972
  event,
914
973
  anon_id: getOrCreateAnonId(),
@@ -929,6 +988,49 @@ function sendBeaconEvent(event, version, meta) {
929
988
  }).catch(() => {});
930
989
  }
931
990
  //#endregion
991
+ //#region src/telemetry/tier0.ts
992
+ /**
993
+ * Tier 0 telemetry — opt-out, fire-and-forget daily ping.
994
+ *
995
+ * Payload: { tier: 0, source: 'devtools', ts: number, version: string }
996
+ * No anon_id. No event name. No meta.
997
+ *
998
+ * Rules:
999
+ * - Sent once per calendar day (localStorage daily marker).
1000
+ * - Skipped when __ait_telemetry:t0_off = '1' or AITC_TELEMETRY=off.
1001
+ * - 5 s timeout, no retry. Failure is silently dropped.
1002
+ */
1003
+ /**
1004
+ * Sends the Tier 0 daily ping if eligible.
1005
+ * Returns true if a ping was sent, false if skipped or failed.
1006
+ */
1007
+ async function sendTier0Ping(version) {
1008
+ if (!isTier0Enabled()) return false;
1009
+ if (hasSentTier0Today()) return false;
1010
+ const payload = {
1011
+ tier: 0,
1012
+ source: "devtools",
1013
+ ts: Date.now(),
1014
+ version
1015
+ };
1016
+ const controller = new AbortController();
1017
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
1018
+ try {
1019
+ await fetch(`${TELEMETRY_ENDPOINT}/e`, {
1020
+ method: "POST",
1021
+ headers: { "Content-Type": "application/json" },
1022
+ body: JSON.stringify(payload),
1023
+ signal: controller.signal
1024
+ });
1025
+ markTier0Sent();
1026
+ return true;
1027
+ } catch {
1028
+ return false;
1029
+ } finally {
1030
+ clearTimeout(timeoutId);
1031
+ }
1032
+ }
1033
+ //#endregion
932
1034
  //#region src/telemetry/index.ts
933
1035
  /**
934
1036
  * Telemetry client — internal to @ait-co/devtools.
@@ -948,7 +1050,7 @@ function readGlobalString(key) {
948
1050
  }
949
1051
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
950
1052
  function getVersion() {
951
- return "0.1.19";
1053
+ return "0.1.20";
952
1054
  }
953
1055
  let panelVisibleSince = null;
954
1056
  let accumulatedMs = 0;
@@ -972,11 +1074,12 @@ function wirePagehide() {
972
1074
  }
973
1075
  /**
974
1076
  * Call once after panel mounts.
975
- * Handles: consent check, optional toast, panel_mount event, pagehide wiring.
1077
+ * Handles: Tier 0 ping, consent check, optional toast, panel_mount event, pagehide wiring.
976
1078
  */
977
1079
  function init() {
978
1080
  if (typeof window === "undefined" || typeof document === "undefined") return;
979
1081
  wirePagehide();
1082
+ sendTier0Ping(getVersion());
980
1083
  if (resolveEffectiveConsent() === "granted") {
981
1084
  getOrCreateAnonId();
982
1085
  send("panel_mount", getVersion());
@@ -2450,6 +2553,18 @@ function buildLanguageSection() {
2450
2553
  return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.language")), h("div", { className: "ait-row" }, h("label", {}, t("env.language.row")), select));
2451
2554
  }
2452
2555
  function buildTelemetrySection() {
2556
+ const t0Enabled = isTier0Enabled();
2557
+ const t0StatusLabel = h("span", { style: `font-size:12px;font-weight:600;color:${t0Enabled ? "#4ade80" : "#888"}` }, t0Enabled ? t("env.telemetry.t0On") : t("env.telemetry.t0Off"));
2558
+ const t0ToggleBtn = h("button", {
2559
+ className: "ait-btn ait-btn-sm",
2560
+ style: "font-size:11px"
2561
+ }, t0Enabled ? t("env.telemetry.t0TurnOff") : t("env.telemetry.t0TurnOn"));
2562
+ t0ToggleBtn.addEventListener("click", () => {
2563
+ setTier0Enabled(!t0Enabled);
2564
+ window.dispatchEvent(new CustomEvent("__ait:panel-switch-tab", { detail: { tab: "env" } }));
2565
+ });
2566
+ const t0Row = h("div", { className: "ait-row" }, h("label", {}, t("env.telemetry.t0Row")), h("span", { style: "display:flex;align-items:center;gap:8px" }, t0StatusLabel, t0ToggleBtn));
2567
+ const t0Desc = h("div", { style: "font-size:11px;color:#666;margin-bottom:6px" }, t("env.telemetry.t0Desc"));
2453
2568
  const isGranted = readConsentState() === "granted";
2454
2569
  const statusLabel = h("span", { style: `font-size:12px;font-weight:600;color:${isGranted ? "#4ade80" : "#888"}` }, isGranted ? t("env.telemetry.on") : t("env.telemetry.off"));
2455
2570
  const toggleBtn = h("button", {
@@ -2492,7 +2607,7 @@ function buildTelemetrySection() {
2492
2607
  style: "font-size:11px;color:#666;text-decoration:none"
2493
2608
  });
2494
2609
  privacyLink.textContent = t("env.telemetry.privacyLink");
2495
- return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.telemetry.section")), statusRow, h("div", { style: "margin-bottom:6px" }, anonIdEl), h("div", {
2610
+ return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.telemetry.section")), t0Row, t0Desc, statusRow, h("div", { style: "margin-bottom:6px" }, anonIdEl), h("div", {
2496
2611
  className: "ait-btn-row",
2497
2612
  style: "align-items:center;gap:8px;margin-top:6px"
2498
2613
  }, deleteBtn, deleteStatus), h("div", { style: "margin-top:8px" }, privacyLink));
@@ -4052,7 +4167,7 @@ function mount() {
4052
4167
  mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
4053
4168
  refreshPanel();
4054
4169
  });
4055
- const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.19`), closeBtn);
4170
+ const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.20`), closeBtn);
4056
4171
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
4057
4172
  tabsEl = h("div", { className: "ait-panel-tabs" });
4058
4173
  for (const tab of getTabs()) {