@ait-co/devtools 0.1.14 → 0.1.16

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.
@@ -70,6 +70,7 @@ const DEFAULT_STATE = {
70
70
  userKeyHash: "mock-user-hash-abc123",
71
71
  anonymousKeyHash: "mock-anon-hash-xyz789"
72
72
  },
73
+ notification: { nextResult: "newAgreement" },
73
74
  ads: {
74
75
  isLoaded: false,
75
76
  nextEvent: "loaded",
@@ -219,6 +220,445 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
219
220
  const aitState = globalRef[SINGLETON_KEY];
220
221
  if (typeof window !== "undefined") window.__ait = aitState;
221
222
  //#endregion
223
+ //#region src/telemetry/consent-toast.ts
224
+ /**
225
+ * Consent toast UI — vanilla DOM, fixed bottom-right.
226
+ *
227
+ * Ko-only for now — devtools has no i18n layer.
228
+ * TODO: revisit when/if an i18n layer is added to the panel.
229
+ *
230
+ * Shows once per "undecided + reprompt window cleared" session.
231
+ * Calls onAccept / onDeny callbacks; caller is responsible for persisting state.
232
+ */
233
+ const TOAST_ID = "__ait-telemetry-toast";
234
+ const TOAST_STYLES = `
235
+ #${TOAST_ID} {
236
+ position: fixed;
237
+ z-index: 100001;
238
+ bottom: 80px;
239
+ right: 16px;
240
+ width: 280px;
241
+ background: #1a1a2e;
242
+ border: 1px solid #3a3a5a;
243
+ border-radius: 10px;
244
+ padding: 14px 16px;
245
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
246
+ font-family: -apple-system, BlinkMacSystemFont, 'Pretendard', sans-serif;
247
+ font-size: 13px;
248
+ color: #e0e0e0;
249
+ box-sizing: border-box;
250
+ }
251
+ #${TOAST_ID} .ait-toast-header {
252
+ font-size: 13px;
253
+ font-weight: 600;
254
+ color: #e0e0e0;
255
+ margin-bottom: 6px;
256
+ }
257
+ #${TOAST_ID} .ait-toast-body {
258
+ font-size: 12px;
259
+ color: #aaa;
260
+ margin-bottom: 12px;
261
+ line-height: 1.5;
262
+ }
263
+ #${TOAST_ID} .ait-toast-actions {
264
+ display: flex;
265
+ gap: 8px;
266
+ align-items: center;
267
+ justify-content: flex-end;
268
+ }
269
+ #${TOAST_ID} .ait-toast-btn-primary {
270
+ background: #3182F6;
271
+ color: white;
272
+ border: none;
273
+ border-radius: 4px;
274
+ padding: 6px 12px;
275
+ font-size: 12px;
276
+ cursor: pointer;
277
+ font-family: inherit;
278
+ }
279
+ #${TOAST_ID} .ait-toast-btn-primary:hover { background: #1b6ef3; }
280
+ #${TOAST_ID} .ait-toast-btn-secondary {
281
+ background: #2a2a4a;
282
+ color: #e0e0e0;
283
+ border: 1px solid #3a3a5a;
284
+ border-radius: 4px;
285
+ padding: 6px 12px;
286
+ font-size: 12px;
287
+ cursor: pointer;
288
+ font-family: inherit;
289
+ }
290
+ #${TOAST_ID} .ait-toast-btn-secondary:hover { background: #3a3a5a; }
291
+ #${TOAST_ID} .ait-toast-link {
292
+ font-size: 11px;
293
+ color: #666;
294
+ text-decoration: none;
295
+ margin-right: auto;
296
+ }
297
+ #${TOAST_ID} .ait-toast-link:hover { color: #aaa; }
298
+ `;
299
+ function injectStyles() {
300
+ if (document.getElementById(`${TOAST_ID}-style`)) return;
301
+ const style = document.createElement("style");
302
+ style.id = `${TOAST_ID}-style`;
303
+ style.textContent = TOAST_STYLES;
304
+ document.head.appendChild(style);
305
+ }
306
+ function removeToast() {
307
+ document.getElementById(TOAST_ID)?.remove();
308
+ document.getElementById(`${TOAST_ID}-style`)?.remove();
309
+ }
310
+ /**
311
+ * Renders and shows the consent toast.
312
+ * If the toast is already visible, does nothing.
313
+ */
314
+ function showConsentToast({ onAccept, onDeny }) {
315
+ if (document.getElementById(TOAST_ID)) return;
316
+ injectStyles();
317
+ const toast = document.createElement("div");
318
+ toast.id = TOAST_ID;
319
+ const header = document.createElement("div");
320
+ header.className = "ait-toast-header";
321
+ header.textContent = "익명 사용 통계를 보낼까요?";
322
+ const body = document.createElement("div");
323
+ body.className = "ait-toast-body";
324
+ body.textContent = "도구 개선을 위해 익명 이벤트만 수집해요. 언제든 환경 탭에서 끌 수 있어요.";
325
+ const learnMore = document.createElement("a");
326
+ learnMore.className = "ait-toast-link";
327
+ learnMore.href = "https://docs.aitc.dev/privacy";
328
+ learnMore.target = "_blank";
329
+ learnMore.rel = "noopener noreferrer";
330
+ learnMore.textContent = "더 알아보기";
331
+ const yesBtn = document.createElement("button");
332
+ yesBtn.className = "ait-toast-btn-primary";
333
+ yesBtn.textContent = "Yes, send";
334
+ yesBtn.addEventListener("click", () => {
335
+ removeToast();
336
+ onAccept();
337
+ });
338
+ const noBtn = document.createElement("button");
339
+ noBtn.className = "ait-toast-btn-secondary";
340
+ noBtn.textContent = "No, thanks";
341
+ noBtn.addEventListener("click", () => {
342
+ removeToast();
343
+ onDeny();
344
+ });
345
+ const actions = document.createElement("div");
346
+ actions.className = "ait-toast-actions";
347
+ actions.append(learnMore, noBtn, yesBtn);
348
+ toast.append(header, body, actions);
349
+ document.body.appendChild(toast);
350
+ }
351
+ //#endregion
352
+ //#region src/telemetry/state.ts
353
+ const KEY_CONSENT = "__ait_telemetry:consent";
354
+ const KEY_REPROMPT_AFTER = "__ait_telemetry:reprompt_after";
355
+ const KEY_POLICY_VERSION = "__ait_telemetry:policy_version";
356
+ const KEY_ANON_ID = "__ait_telemetry:anon_id";
357
+ /**
358
+ * Current policy version. Bump this string whenever the privacy policy changes.
359
+ * Users who previously granted on an older version will be re-prompted once.
360
+ */
361
+ const CURRENT_POLICY_VERSION = "2026-05-12";
362
+ /** 30 days in milliseconds */
363
+ const THIRTY_DAYS_MS = 720 * 60 * 60 * 1e3;
364
+ function readConsentState() {
365
+ const raw = localStorage.getItem(KEY_CONSENT);
366
+ if (raw === "granted" || raw === "denied") return raw;
367
+ return "undecided";
368
+ }
369
+ function readRepromptAfter() {
370
+ const raw = localStorage.getItem(KEY_REPROMPT_AFTER);
371
+ if (raw === null) return 0;
372
+ const n = Number(raw);
373
+ return Number.isFinite(n) ? n : 0;
374
+ }
375
+ function readPolicyVersion() {
376
+ return localStorage.getItem(KEY_POLICY_VERSION);
377
+ }
378
+ /**
379
+ * Returns the stored anon_id, or generates + persists a new UUID v4 on first call.
380
+ * Once generated it is never overwritten.
381
+ */
382
+ function getOrCreateAnonId() {
383
+ const existing = localStorage.getItem(KEY_ANON_ID);
384
+ if (existing) return existing;
385
+ const id = crypto.randomUUID();
386
+ localStorage.setItem(KEY_ANON_ID, id);
387
+ return id;
388
+ }
389
+ /**
390
+ * Resolve effective consent, handling the policy-version bump rule:
391
+ * - If stored = "granted" but stored version ≠ CURRENT → revert to undecided
392
+ * - If stored = "denied" and version changed → stay denied (no re-prompt)
393
+ *
394
+ * Call this at init time to normalise state before checking whether to show a toast.
395
+ * Returns the effective ConsentState after applying the version-bump rule.
396
+ */
397
+ function resolveEffectiveConsent() {
398
+ const raw = localStorage.getItem(KEY_CONSENT);
399
+ if (raw === "granted") {
400
+ if (readPolicyVersion() !== "2026-05-12") {
401
+ localStorage.removeItem(KEY_CONSENT);
402
+ localStorage.removeItem(KEY_POLICY_VERSION);
403
+ return "undecided";
404
+ }
405
+ return "granted";
406
+ }
407
+ if (raw === "denied") return "denied";
408
+ return "undecided";
409
+ }
410
+ /**
411
+ * User clicked "Yes, send".
412
+ * Sets consent = granted, records policy version.
413
+ */
414
+ function acceptConsent() {
415
+ localStorage.setItem(KEY_CONSENT, "granted");
416
+ localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);
417
+ localStorage.removeItem(KEY_REPROMPT_AFTER);
418
+ }
419
+ /**
420
+ * User clicked "No, thanks".
421
+ * First denial: sets reprompt_after = now + 30 days.
422
+ * Second denial (reprompt_after was already set to a past finite value that triggered
423
+ * re-prompt): sets reprompt_after = MAX_SAFE_INTEGER → permanent silence.
424
+ */
425
+ function denyConsent() {
426
+ localStorage.setItem(KEY_CONSENT, "denied");
427
+ const existing = readRepromptAfter();
428
+ if (existing > 0 && existing < Number.MAX_SAFE_INTEGER) localStorage.setItem(KEY_REPROMPT_AFTER, String(Number.MAX_SAFE_INTEGER));
429
+ else localStorage.setItem(KEY_REPROMPT_AFTER, String(Date.now() + THIRTY_DAYS_MS));
430
+ }
431
+ /**
432
+ * Environment-tab toggle: free transition between granted/denied.
433
+ * Does NOT touch reprompt_after.
434
+ */
435
+ function setConsentViaToggle(granted) {
436
+ if (granted) {
437
+ localStorage.setItem(KEY_CONSENT, "granted");
438
+ localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);
439
+ } else localStorage.setItem(KEY_CONSENT, "denied");
440
+ }
441
+ /**
442
+ * Returns true if the toast should be shown now.
443
+ * Conditions:
444
+ * - undecided (no prior choice or policy bumped to a newer version)
445
+ * - denied + reprompt_after set + reprompt_after < now (one re-prompt after
446
+ * the configured silence window; `denyConsent` flips to permanent silence
447
+ * on the second denial by setting reprompt_after to MAX_SAFE_INTEGER).
448
+ */
449
+ function shouldShowToast() {
450
+ const state = resolveEffectiveConsent();
451
+ if (state === "undecided") {
452
+ const repromptAfter = readRepromptAfter();
453
+ if (repromptAfter === 0) return true;
454
+ return Date.now() > repromptAfter;
455
+ }
456
+ if (state === "denied") {
457
+ const repromptAfter = readRepromptAfter();
458
+ if (repromptAfter === 0 || repromptAfter >= Number.MAX_SAFE_INTEGER) return false;
459
+ return Date.now() > repromptAfter;
460
+ }
461
+ return false;
462
+ }
463
+ /**
464
+ * Sends the DELETE request to remove the user's data from the server, and
465
+ * rotates the local anon_id on success so any subsequent events are unlinkable
466
+ * from the deleted history.
467
+ */
468
+ async function deleteMyData(endpoint) {
469
+ const anonId = localStorage.getItem(KEY_ANON_ID);
470
+ if (!anonId) return false;
471
+ try {
472
+ if (!(await fetch(`${endpoint}/e?anon_id=${encodeURIComponent(anonId)}`, { method: "DELETE" })).ok) return false;
473
+ localStorage.setItem(KEY_ANON_ID, crypto.randomUUID());
474
+ return true;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+ //#endregion
480
+ //#region src/telemetry/send.ts
481
+ /**
482
+ * Telemetry send + retry.
483
+ *
484
+ * Rules:
485
+ * 1. If consent ≠ "granted" — drop silently.
486
+ * 2. POST event as JSON with 5 s timeout.
487
+ * 3. On network error or non-2xx: retry ONCE after 2 s. On second failure: drop.
488
+ * 4. console.debug on retry, development only (NODE_ENV !== "production").
489
+ * 5. For "session_duration": use sendBeacon if available, fall back to fetch keepalive.
490
+ *
491
+ * Max meta size: 256 bytes (JSON-serialized). Over-size meta is dropped to undefined.
492
+ */
493
+ /** Meta cap per server contract (JSON bytes). */
494
+ const META_BYTE_CAP = 256;
495
+ function sanitizeMeta(meta) {
496
+ if (meta === void 0) return void 0;
497
+ const serialized = JSON.stringify(meta);
498
+ if (new TextEncoder().encode(serialized).length > META_BYTE_CAP) return;
499
+ return meta;
500
+ }
501
+ async function doFetch(payload) {
502
+ const controller = new AbortController();
503
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
504
+ try {
505
+ return (await fetch(`${TELEMETRY_ENDPOINT}/e`, {
506
+ method: "POST",
507
+ headers: { "Content-Type": "application/json" },
508
+ body: JSON.stringify(payload),
509
+ signal: controller.signal
510
+ })).ok;
511
+ } catch {
512
+ return false;
513
+ } finally {
514
+ clearTimeout(timeoutId);
515
+ }
516
+ }
517
+ function delay(ms) {
518
+ return new Promise((resolve) => setTimeout(resolve, ms));
519
+ }
520
+ /**
521
+ * Send a telemetry event. Drops silently if consent is not "granted".
522
+ */
523
+ async function send(event, version, meta) {
524
+ if (readConsentState() !== "granted") return;
525
+ const payload = {
526
+ source: "devtools",
527
+ event,
528
+ anon_id: getOrCreateAnonId(),
529
+ version,
530
+ ts: Date.now(),
531
+ meta: sanitizeMeta(meta)
532
+ };
533
+ if (await doFetch(payload)) return;
534
+ if (process.env.NODE_ENV !== "production") console.debug("[@ait-co/devtools] telemetry: retrying after failure", event);
535
+ await delay(2e3);
536
+ await doFetch(payload);
537
+ }
538
+ /**
539
+ * Send the "session_duration" event via sendBeacon (unload-safe).
540
+ * Falls back to fetch with keepalive if sendBeacon is unavailable.
541
+ * No retry during page unload.
542
+ */
543
+ function sendBeaconEvent(event, version, meta) {
544
+ if (readConsentState() !== "granted") return;
545
+ const payload = {
546
+ source: "devtools",
547
+ event,
548
+ anon_id: getOrCreateAnonId(),
549
+ version,
550
+ ts: Date.now(),
551
+ meta: sanitizeMeta(meta)
552
+ };
553
+ const body = JSON.stringify(payload);
554
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
555
+ navigator.sendBeacon(`${TELEMETRY_ENDPOINT}/e`, new Blob([body], { type: "application/json" }));
556
+ return;
557
+ }
558
+ fetch(`${TELEMETRY_ENDPOINT}/e`, {
559
+ method: "POST",
560
+ headers: { "Content-Type": "application/json" },
561
+ body,
562
+ keepalive: true
563
+ }).catch(() => {});
564
+ }
565
+ //#endregion
566
+ //#region src/telemetry/index.ts
567
+ /**
568
+ * Telemetry client — internal to @ait-co/devtools.
569
+ *
570
+ * NOT exported from src/mock/index.ts — this is panel-internal only.
571
+ *
572
+ * Usage: import { telemetry } from './telemetry/index.js' (from panel code).
573
+ */
574
+ /**
575
+ * Telemetry ingest endpoint.
576
+ * Overridable at build time via define (e.g., for e2e / local dev).
577
+ * Do NOT expose this as a public env-var surface.
578
+ */
579
+ function readGlobalString(key) {
580
+ const val = globalThis[key];
581
+ return typeof val === "string" ? val : void 0;
582
+ }
583
+ const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
584
+ function getVersion() {
585
+ return readGlobalString("__VERSION__") ?? "0.0.0";
586
+ }
587
+ let panelVisibleSince = null;
588
+ let accumulatedMs = 0;
589
+ let pagehideWired = false;
590
+ function onPanelVisible() {
591
+ if (panelVisibleSince === null) panelVisibleSince = Date.now();
592
+ }
593
+ function onPanelHidden() {
594
+ if (panelVisibleSince !== null) {
595
+ accumulatedMs += Date.now() - panelVisibleSince;
596
+ panelVisibleSince = null;
597
+ }
598
+ }
599
+ function wirePagehide() {
600
+ if (pagehideWired) return;
601
+ pagehideWired = true;
602
+ window.addEventListener("pagehide", () => {
603
+ onPanelHidden();
604
+ if (accumulatedMs > 0) sendBeaconEvent("session_duration", getVersion(), { ms: accumulatedMs });
605
+ });
606
+ }
607
+ /**
608
+ * Call once after panel mounts.
609
+ * Handles: consent check, optional toast, panel_mount event, pagehide wiring.
610
+ */
611
+ function init() {
612
+ if (typeof window === "undefined" || typeof document === "undefined") return;
613
+ wirePagehide();
614
+ if (resolveEffectiveConsent() === "granted") {
615
+ getOrCreateAnonId();
616
+ send("panel_mount", getVersion());
617
+ return;
618
+ }
619
+ if (shouldShowToast()) {
620
+ const showToast = () => {
621
+ showConsentToast({
622
+ onAccept: () => {
623
+ acceptConsent();
624
+ getOrCreateAnonId();
625
+ send("panel_mount", getVersion());
626
+ },
627
+ onDeny: () => {
628
+ denyConsent();
629
+ }
630
+ });
631
+ };
632
+ if (typeof requestIdleCallback === "function") requestIdleCallback(showToast, { timeout: 3e3 });
633
+ else setTimeout(showToast, 1500);
634
+ }
635
+ }
636
+ /**
637
+ * Call when the panel is opened/toggled visible.
638
+ */
639
+ function onPanelOpen() {
640
+ send("panel_open", getVersion());
641
+ onPanelVisible();
642
+ }
643
+ /**
644
+ * Call when the panel is closed/hidden.
645
+ */
646
+ function onPanelClose() {
647
+ onPanelHidden();
648
+ }
649
+ /**
650
+ * Call when the user switches tabs.
651
+ */
652
+ function onTabView(tabId) {
653
+ send("tab_view", getVersion(), { tab: tabId });
654
+ }
655
+ const telemetry = {
656
+ init,
657
+ onPanelOpen,
658
+ onPanelClose,
659
+ onTabView
660
+ };
661
+ //#endregion
222
662
  //#region src/panel/helpers.ts
223
663
  /**
224
664
  * 공통 DOM 헬퍼 함수
@@ -1621,9 +2061,54 @@ function renderEnvironmentTab() {
1621
2061
  "OFFLINE",
1622
2062
  "WWAN",
1623
2063
  "UNKNOWN"
1624
- ], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Safe Area Insets"), inputRow("Top", String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow("Bottom", String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)));
2064
+ ], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Safe Area Insets"), inputRow("Top", String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow("Bottom", String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildTelemetrySection());
1625
2065
  return container;
1626
2066
  }
2067
+ function buildTelemetrySection() {
2068
+ const isGranted = readConsentState() === "granted";
2069
+ const statusLabel = h("span", { style: `font-size:12px;font-weight:600;color:${isGranted ? "#4ade80" : "#888"}` }, isGranted ? "On" : "Off");
2070
+ const toggleBtn = h("button", {
2071
+ className: "ait-btn ait-btn-sm",
2072
+ style: "font-size:11px"
2073
+ }, isGranted ? "Turn off" : "Turn on");
2074
+ toggleBtn.addEventListener("click", () => {
2075
+ setConsentViaToggle(!isGranted);
2076
+ window.dispatchEvent(new CustomEvent("__ait:panel-switch-tab", { detail: { tab: "env" } }));
2077
+ });
2078
+ const statusRow = h("div", { className: "ait-row" }, h("label", {}, "Telemetry"), h("span", { style: "display:flex;align-items:center;gap:8px" }, statusLabel, toggleBtn));
2079
+ const anonId = localStorage.getItem("__ait_telemetry:anon_id") ?? "(not yet set)";
2080
+ const anonIdEl = h("span", {
2081
+ style: "font-family:'SF Mono','Menlo',monospace;font-size:11px;color:#95e6cb;cursor:pointer",
2082
+ title: "Click to copy full anon_id"
2083
+ }, `anon_id: ${anonId.length > 8 ? `${anonId.slice(0, 8)}…` : anonId}`);
2084
+ anonIdEl.addEventListener("click", () => {
2085
+ navigator.clipboard.writeText(anonId).catch(() => {});
2086
+ });
2087
+ const deleteBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, "내 데이터 삭제");
2088
+ const deleteStatus = h("span", { style: "font-size:11px;color:#aaa" });
2089
+ deleteBtn.addEventListener("click", () => {
2090
+ deleteBtn.disabled = true;
2091
+ deleteStatus.textContent = "삭제 중…";
2092
+ deleteMyData(TELEMETRY_ENDPOINT).then((ok) => {
2093
+ deleteStatus.textContent = ok ? "삭제 완료" : "삭제 실패 (다시 시도해주세요)";
2094
+ deleteBtn.disabled = false;
2095
+ }).catch(() => {
2096
+ deleteStatus.textContent = "삭제 실패";
2097
+ deleteBtn.disabled = false;
2098
+ });
2099
+ });
2100
+ const privacyLink = h("a", {
2101
+ href: "https://docs.aitc.dev/privacy",
2102
+ target: "_blank",
2103
+ rel: "noopener noreferrer",
2104
+ style: "font-size:11px;color:#666;text-decoration:none"
2105
+ });
2106
+ privacyLink.textContent = "개인정보 처리방침";
2107
+ return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Telemetry"), statusRow, h("div", { style: "margin-bottom:6px" }, anonIdEl), h("div", {
2108
+ className: "ait-btn-row",
2109
+ style: "align-items:center;gap:8px;margin-top:6px"
2110
+ }, deleteBtn, deleteStatus), h("div", { style: "margin-top:8px" }, privacyLink));
2111
+ }
1627
2112
  //#endregion
1628
2113
  //#region src/panel/tabs/events.ts
1629
2114
  function renderEventsTab() {
@@ -1832,6 +2317,43 @@ function renderLocationTab() {
1832
2317
  return container;
1833
2318
  }
1834
2319
  //#endregion
2320
+ //#region src/panel/tabs/notifications.ts
2321
+ const RESULTS = [
2322
+ {
2323
+ value: "newAgreement",
2324
+ label: "newAgreement (first-time agree)"
2325
+ },
2326
+ {
2327
+ value: "alreadyAgreed",
2328
+ label: "alreadyAgreed (already opted-in)"
2329
+ },
2330
+ {
2331
+ value: "agreementRejected",
2332
+ label: "agreementRejected (user declined)"
2333
+ }
2334
+ ];
2335
+ function radioRow(name, current, option, disabled) {
2336
+ const input = h("input", {
2337
+ type: "radio",
2338
+ name,
2339
+ value: option.value
2340
+ });
2341
+ input.checked = current === option.value;
2342
+ if (disabled) input.disabled = true;
2343
+ input.addEventListener("change", () => {
2344
+ if (input.checked) aitState.patch("notification", { nextResult: option.value });
2345
+ });
2346
+ return h("label", { className: "ait-row" }, input, h("span", {}, option.label));
2347
+ }
2348
+ function renderNotificationsTab() {
2349
+ const s = aitState.state;
2350
+ const disabled = !s.panelEditable;
2351
+ const container = h("div");
2352
+ if (disabled) container.appendChild(monitoringNotice());
2353
+ container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "requestNotificationAgreement"), ...RESULTS.map((opt) => radioRow("ait-notification-result", s.notification.nextResult, opt, disabled))));
2354
+ return container;
2355
+ }
2356
+ //#endregion
1835
2357
  //#region src/panel/tabs/permissions.ts
1836
2358
  function renderPermissionsTab() {
1837
2359
  const s = aitState.state;
@@ -2888,6 +3410,10 @@ const TABS = [
2888
3410
  id: "permissions",
2889
3411
  label: "Permissions"
2890
3412
  },
3413
+ {
3414
+ id: "notifications",
3415
+ label: "Notifications"
3416
+ },
2891
3417
  {
2892
3418
  id: "location",
2893
3419
  label: "Location"
@@ -2922,6 +3448,7 @@ function createTabRenderers(refreshPanel) {
2922
3448
  env: renderEnvironmentTab,
2923
3449
  presets: () => renderPresetsTab(refreshPanel),
2924
3450
  permissions: renderPermissionsTab,
3451
+ notifications: renderNotificationsTab,
2925
3452
  location: renderLocationTab,
2926
3453
  device: renderDeviceTab,
2927
3454
  viewport: renderViewportTab,
@@ -3114,6 +3641,7 @@ function mount() {
3114
3641
  closeBtn.addEventListener("click", () => {
3115
3642
  isOpen = false;
3116
3643
  panelEl.classList.remove("open");
3644
+ telemetry.onPanelClose();
3117
3645
  });
3118
3646
  const mockBadge = h("span", {
3119
3647
  className: `ait-mock-badge ${aitState.state.panelEditable ? "ait-mock-badge-on" : "ait-mock-badge-off"}`,
@@ -3125,7 +3653,7 @@ function mount() {
3125
3653
  mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
3126
3654
  refreshPanel();
3127
3655
  });
3128
- 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.14`), closeBtn);
3656
+ 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.16`), closeBtn);
3129
3657
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
3130
3658
  tabsEl = h("div", { className: "ait-panel-tabs" });
3131
3659
  for (const tab of TABS) {
@@ -3135,6 +3663,7 @@ function mount() {
3135
3663
  }, tab.label);
3136
3664
  tabEl.addEventListener("click", () => {
3137
3665
  currentTab = tab.id;
3666
+ telemetry.onTabView(tab.id);
3138
3667
  refreshPanel();
3139
3668
  });
3140
3669
  tabsEl.appendChild(tabEl);
@@ -3150,7 +3679,8 @@ function mount() {
3150
3679
  if (isOpen) {
3151
3680
  updatePanelPosition(toggle);
3152
3681
  refreshPanel();
3153
- }
3682
+ telemetry.onPanelOpen();
3683
+ } else telemetry.onPanelClose();
3154
3684
  });
3155
3685
  let resizeRaf = 0;
3156
3686
  resizeHandler = () => {
@@ -3180,6 +3710,7 @@ function mount() {
3180
3710
  };
3181
3711
  window.addEventListener("__ait:panel-switch-tab", panelSwitchTabHandler);
3182
3712
  refreshPanel();
3713
+ telemetry.init();
3183
3714
  }
3184
3715
  /**
3185
3716
  * Pairs with `mount()` (and the existing `disposeViewport()`).