@ait-co/devtools 0.1.14 → 0.1.15

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,429 @@ 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: undecided AND (reprompt_after is 0 OR reprompt_after < now).
444
+ */
445
+ function shouldShowToast() {
446
+ if (resolveEffectiveConsent() !== "undecided") return false;
447
+ const repromptAfter = readRepromptAfter();
448
+ if (repromptAfter === 0) return true;
449
+ return Date.now() > repromptAfter;
450
+ }
451
+ /**
452
+ * Sends the DELETE request to remove the user's data from the server.
453
+ */
454
+ async function deleteMyData(endpoint) {
455
+ const anonId = localStorage.getItem(KEY_ANON_ID);
456
+ if (!anonId) return false;
457
+ try {
458
+ return (await fetch(`${endpoint}?anon_id=${encodeURIComponent(anonId)}`, { method: "DELETE" })).ok;
459
+ } catch {
460
+ return false;
461
+ }
462
+ }
463
+ //#endregion
464
+ //#region src/telemetry/send.ts
465
+ /**
466
+ * Telemetry send + retry.
467
+ *
468
+ * Rules:
469
+ * 1. If consent ≠ "granted" — drop silently.
470
+ * 2. POST event as JSON with 5 s timeout.
471
+ * 3. On network error or non-2xx: retry ONCE after 2 s. On second failure: drop.
472
+ * 4. console.debug on retry, development only (NODE_ENV !== "production").
473
+ * 5. For "session_duration": use sendBeacon if available, fall back to fetch keepalive.
474
+ *
475
+ * Max meta size: 256 bytes (JSON-serialized). Over-size meta is dropped to undefined.
476
+ */
477
+ /** Meta cap per server contract (JSON bytes). */
478
+ const META_BYTE_CAP = 256;
479
+ function sanitizeMeta(meta) {
480
+ if (meta === void 0) return void 0;
481
+ const serialized = JSON.stringify(meta);
482
+ if (new TextEncoder().encode(serialized).length > META_BYTE_CAP) return;
483
+ return meta;
484
+ }
485
+ async function doFetch(payload) {
486
+ const controller = new AbortController();
487
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
488
+ try {
489
+ return (await fetch(`${TELEMETRY_ENDPOINT}/e`, {
490
+ method: "POST",
491
+ headers: { "Content-Type": "application/json" },
492
+ body: JSON.stringify(payload),
493
+ signal: controller.signal
494
+ })).ok;
495
+ } catch {
496
+ return false;
497
+ } finally {
498
+ clearTimeout(timeoutId);
499
+ }
500
+ }
501
+ function delay(ms) {
502
+ return new Promise((resolve) => setTimeout(resolve, ms));
503
+ }
504
+ /**
505
+ * Send a telemetry event. Drops silently if consent is not "granted".
506
+ */
507
+ async function send(event, version, meta) {
508
+ if (readConsentState() !== "granted") return;
509
+ const payload = {
510
+ source: "devtools",
511
+ event,
512
+ anon_id: getOrCreateAnonId(),
513
+ version,
514
+ ts: Date.now(),
515
+ meta: sanitizeMeta(meta)
516
+ };
517
+ if (await doFetch(payload)) return;
518
+ if (process.env.NODE_ENV !== "production") console.debug("[@ait-co/devtools] telemetry: retrying after failure", event);
519
+ await delay(2e3);
520
+ await doFetch(payload);
521
+ }
522
+ /**
523
+ * Send the "session_duration" event via sendBeacon (unload-safe).
524
+ * Falls back to fetch with keepalive if sendBeacon is unavailable.
525
+ * No retry during page unload.
526
+ */
527
+ function sendBeaconEvent(event, version, meta) {
528
+ if (readConsentState() !== "granted") return;
529
+ const payload = {
530
+ source: "devtools",
531
+ event,
532
+ anon_id: getOrCreateAnonId(),
533
+ version,
534
+ ts: Date.now(),
535
+ meta: sanitizeMeta(meta)
536
+ };
537
+ const body = JSON.stringify(payload);
538
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
539
+ navigator.sendBeacon(`${TELEMETRY_ENDPOINT}/e`, new Blob([body], { type: "application/json" }));
540
+ return;
541
+ }
542
+ fetch(`${TELEMETRY_ENDPOINT}/e`, {
543
+ method: "POST",
544
+ headers: { "Content-Type": "application/json" },
545
+ body,
546
+ keepalive: true
547
+ }).catch(() => {});
548
+ }
549
+ //#endregion
550
+ //#region src/telemetry/index.ts
551
+ /**
552
+ * Telemetry client — internal to @ait-co/devtools.
553
+ *
554
+ * NOT exported from src/mock/index.ts — this is panel-internal only.
555
+ *
556
+ * Usage: import { telemetry } from './telemetry/index.js' (from panel code).
557
+ */
558
+ /**
559
+ * Telemetry ingest endpoint.
560
+ * Overridable at build time via define (e.g., for e2e / local dev).
561
+ * Do NOT expose this as a public env-var surface.
562
+ */
563
+ function readGlobalString(key) {
564
+ const val = globalThis[key];
565
+ return typeof val === "string" ? val : void 0;
566
+ }
567
+ const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
568
+ function getVersion() {
569
+ return readGlobalString("__VERSION__") ?? "0.0.0";
570
+ }
571
+ let panelVisibleSince = null;
572
+ let accumulatedMs = 0;
573
+ let pagehideWired = false;
574
+ function onPanelVisible() {
575
+ if (panelVisibleSince === null) panelVisibleSince = Date.now();
576
+ }
577
+ function onPanelHidden() {
578
+ if (panelVisibleSince !== null) {
579
+ accumulatedMs += Date.now() - panelVisibleSince;
580
+ panelVisibleSince = null;
581
+ }
582
+ }
583
+ function wirePagehide() {
584
+ if (pagehideWired) return;
585
+ pagehideWired = true;
586
+ window.addEventListener("pagehide", () => {
587
+ onPanelHidden();
588
+ if (accumulatedMs > 0) sendBeaconEvent("session_duration", getVersion(), { ms: accumulatedMs });
589
+ });
590
+ }
591
+ /**
592
+ * Call once after panel mounts.
593
+ * Handles: consent check, optional toast, panel_mount event, pagehide wiring.
594
+ */
595
+ function init() {
596
+ if (typeof window === "undefined" || typeof document === "undefined") return;
597
+ wirePagehide();
598
+ if (resolveEffectiveConsent() === "granted") {
599
+ getOrCreateAnonId();
600
+ send("panel_mount", getVersion());
601
+ return;
602
+ }
603
+ if (shouldShowToast()) {
604
+ const showToast = () => {
605
+ showConsentToast({
606
+ onAccept: () => {
607
+ acceptConsent();
608
+ getOrCreateAnonId();
609
+ send("panel_mount", getVersion());
610
+ },
611
+ onDeny: () => {
612
+ denyConsent();
613
+ }
614
+ });
615
+ };
616
+ if (typeof requestIdleCallback === "function") requestIdleCallback(showToast, { timeout: 3e3 });
617
+ else setTimeout(showToast, 1500);
618
+ }
619
+ }
620
+ /**
621
+ * Call when the panel is opened/toggled visible.
622
+ */
623
+ function onPanelOpen() {
624
+ send("panel_open", getVersion());
625
+ onPanelVisible();
626
+ }
627
+ /**
628
+ * Call when the panel is closed/hidden.
629
+ */
630
+ function onPanelClose() {
631
+ onPanelHidden();
632
+ }
633
+ /**
634
+ * Call when the user switches tabs.
635
+ */
636
+ function onTabView(tabId) {
637
+ send("tab_view", getVersion(), { tab: tabId });
638
+ }
639
+ const telemetry = {
640
+ init,
641
+ onPanelOpen,
642
+ onPanelClose,
643
+ onTabView
644
+ };
645
+ //#endregion
222
646
  //#region src/panel/helpers.ts
223
647
  /**
224
648
  * 공통 DOM 헬퍼 함수
@@ -1621,9 +2045,54 @@ function renderEnvironmentTab() {
1621
2045
  "OFFLINE",
1622
2046
  "WWAN",
1623
2047
  "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)));
2048
+ ], 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
2049
  return container;
1626
2050
  }
2051
+ function buildTelemetrySection() {
2052
+ const isGranted = readConsentState() === "granted";
2053
+ const statusLabel = h("span", { style: `font-size:12px;font-weight:600;color:${isGranted ? "#4ade80" : "#888"}` }, isGranted ? "On" : "Off");
2054
+ const toggleBtn = h("button", {
2055
+ className: "ait-btn ait-btn-sm",
2056
+ style: "font-size:11px"
2057
+ }, isGranted ? "Turn off" : "Turn on");
2058
+ toggleBtn.addEventListener("click", () => {
2059
+ setConsentViaToggle(!isGranted);
2060
+ window.dispatchEvent(new CustomEvent("__ait:panel-switch-tab", { detail: { tab: "env" } }));
2061
+ });
2062
+ const statusRow = h("div", { className: "ait-row" }, h("label", {}, "Telemetry"), h("span", { style: "display:flex;align-items:center;gap:8px" }, statusLabel, toggleBtn));
2063
+ const anonId = localStorage.getItem("__ait_telemetry:anon_id") ?? "(not yet set)";
2064
+ const anonIdEl = h("span", {
2065
+ style: "font-family:'SF Mono','Menlo',monospace;font-size:11px;color:#95e6cb;cursor:pointer",
2066
+ title: "Click to copy full anon_id"
2067
+ }, `anon_id: ${anonId.length > 8 ? `${anonId.slice(0, 8)}…` : anonId}`);
2068
+ anonIdEl.addEventListener("click", () => {
2069
+ navigator.clipboard.writeText(anonId).catch(() => {});
2070
+ });
2071
+ const deleteBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, "내 데이터 삭제");
2072
+ const deleteStatus = h("span", { style: "font-size:11px;color:#aaa" });
2073
+ deleteBtn.addEventListener("click", () => {
2074
+ deleteBtn.disabled = true;
2075
+ deleteStatus.textContent = "삭제 중…";
2076
+ deleteMyData(TELEMETRY_ENDPOINT).then((ok) => {
2077
+ deleteStatus.textContent = ok ? "삭제 완료" : "삭제 실패 (다시 시도해주세요)";
2078
+ deleteBtn.disabled = false;
2079
+ }).catch(() => {
2080
+ deleteStatus.textContent = "삭제 실패";
2081
+ deleteBtn.disabled = false;
2082
+ });
2083
+ });
2084
+ const privacyLink = h("a", {
2085
+ href: "https://docs.aitc.dev/privacy",
2086
+ target: "_blank",
2087
+ rel: "noopener noreferrer",
2088
+ style: "font-size:11px;color:#666;text-decoration:none"
2089
+ });
2090
+ privacyLink.textContent = "개인정보 처리방침";
2091
+ return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Telemetry"), statusRow, h("div", { style: "margin-bottom:6px" }, anonIdEl), h("div", {
2092
+ className: "ait-btn-row",
2093
+ style: "align-items:center;gap:8px;margin-top:6px"
2094
+ }, deleteBtn, deleteStatus), h("div", { style: "margin-top:8px" }, privacyLink));
2095
+ }
1627
2096
  //#endregion
1628
2097
  //#region src/panel/tabs/events.ts
1629
2098
  function renderEventsTab() {
@@ -1832,6 +2301,43 @@ function renderLocationTab() {
1832
2301
  return container;
1833
2302
  }
1834
2303
  //#endregion
2304
+ //#region src/panel/tabs/notifications.ts
2305
+ const RESULTS = [
2306
+ {
2307
+ value: "newAgreement",
2308
+ label: "newAgreement (first-time agree)"
2309
+ },
2310
+ {
2311
+ value: "alreadyAgreed",
2312
+ label: "alreadyAgreed (already opted-in)"
2313
+ },
2314
+ {
2315
+ value: "agreementRejected",
2316
+ label: "agreementRejected (user declined)"
2317
+ }
2318
+ ];
2319
+ function radioRow(name, current, option, disabled) {
2320
+ const input = h("input", {
2321
+ type: "radio",
2322
+ name,
2323
+ value: option.value
2324
+ });
2325
+ input.checked = current === option.value;
2326
+ if (disabled) input.disabled = true;
2327
+ input.addEventListener("change", () => {
2328
+ if (input.checked) aitState.patch("notification", { nextResult: option.value });
2329
+ });
2330
+ return h("label", { className: "ait-row" }, input, h("span", {}, option.label));
2331
+ }
2332
+ function renderNotificationsTab() {
2333
+ const s = aitState.state;
2334
+ const disabled = !s.panelEditable;
2335
+ const container = h("div");
2336
+ if (disabled) container.appendChild(monitoringNotice());
2337
+ 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))));
2338
+ return container;
2339
+ }
2340
+ //#endregion
1835
2341
  //#region src/panel/tabs/permissions.ts
1836
2342
  function renderPermissionsTab() {
1837
2343
  const s = aitState.state;
@@ -2888,6 +3394,10 @@ const TABS = [
2888
3394
  id: "permissions",
2889
3395
  label: "Permissions"
2890
3396
  },
3397
+ {
3398
+ id: "notifications",
3399
+ label: "Notifications"
3400
+ },
2891
3401
  {
2892
3402
  id: "location",
2893
3403
  label: "Location"
@@ -2922,6 +3432,7 @@ function createTabRenderers(refreshPanel) {
2922
3432
  env: renderEnvironmentTab,
2923
3433
  presets: () => renderPresetsTab(refreshPanel),
2924
3434
  permissions: renderPermissionsTab,
3435
+ notifications: renderNotificationsTab,
2925
3436
  location: renderLocationTab,
2926
3437
  device: renderDeviceTab,
2927
3438
  viewport: renderViewportTab,
@@ -3114,6 +3625,7 @@ function mount() {
3114
3625
  closeBtn.addEventListener("click", () => {
3115
3626
  isOpen = false;
3116
3627
  panelEl.classList.remove("open");
3628
+ telemetry.onPanelClose();
3117
3629
  });
3118
3630
  const mockBadge = h("span", {
3119
3631
  className: `ait-mock-badge ${aitState.state.panelEditable ? "ait-mock-badge-on" : "ait-mock-badge-off"}`,
@@ -3125,7 +3637,7 @@ function mount() {
3125
3637
  mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
3126
3638
  refreshPanel();
3127
3639
  });
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);
3640
+ 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.15`), closeBtn);
3129
3641
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
3130
3642
  tabsEl = h("div", { className: "ait-panel-tabs" });
3131
3643
  for (const tab of TABS) {
@@ -3135,6 +3647,7 @@ function mount() {
3135
3647
  }, tab.label);
3136
3648
  tabEl.addEventListener("click", () => {
3137
3649
  currentTab = tab.id;
3650
+ telemetry.onTabView(tab.id);
3138
3651
  refreshPanel();
3139
3652
  });
3140
3653
  tabsEl.appendChild(tabEl);
@@ -3150,7 +3663,8 @@ function mount() {
3150
3663
  if (isOpen) {
3151
3664
  updatePanelPosition(toggle);
3152
3665
  refreshPanel();
3153
- }
3666
+ telemetry.onPanelOpen();
3667
+ } else telemetry.onPanelClose();
3154
3668
  });
3155
3669
  let resizeRaf = 0;
3156
3670
  resizeHandler = () => {
@@ -3180,6 +3694,7 @@ function mount() {
3180
3694
  };
3181
3695
  window.addEventListener("__ait:panel-switch-tab", panelSwitchTabHandler);
3182
3696
  refreshPanel();
3697
+ telemetry.init();
3183
3698
  }
3184
3699
  /**
3185
3700
  * Pairs with `mount()` (and the existing `disposeViewport()`).