diff --git a/docs/examples/analytics/compose.yaml b/docs/examples/analytics/compose.yaml new file mode 100644 index 000000000..ea521e3f9 --- /dev/null +++ b/docs/examples/analytics/compose.yaml @@ -0,0 +1,40 @@ +# LOCAL DEMO ONLY. Umami starts with admin / umami: change it immediately. +# The DB password and app secret below are public demo values, NOT production secrets. +# SkillHub is an existing upstream, not built or configured by this demo. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: umami + POSTGRES_USER: umami + POSTGRES_PASSWORD: demo-only-change-me + healthcheck: + test: [CMD-SHELL, pg_isready -U umami -d umami] + interval: 5s + timeout: 3s + retries: 20 + umami: + image: ghcr.io/umami-software/umami:3.4.1 + environment: + DATABASE_URL: postgresql://umami:demo-only-change-me@postgres:5432/umami + APP_SECRET: demo-only-change-this-before-use + depends_on: + postgres: + condition: service_healthy + # Direct admin access is LOCAL setup only; never proxy the admin through edge. + ports: ["127.0.0.1:8300:3000"] + edge: + image: nginx:1.30.6-alpine + # Docker Desktop/OrbStack provide this name; native Linux needs this mapping. + extra_hosts: ["host.docker.internal:host-gateway"] + environment: + SKILLHUB_WEB_UPSTREAM: ${SKILLHUB_WEB_UPSTREAM:-host.docker.internal:80} + UMAMI_UPSTREAM: umami:3000 + NGINX_ENVSUBST_FILTER: ^(SKILLHUB_WEB_UPSTREAM|UMAMI_UPSTREAM)$$ + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + - ./observe/v1/observer.mjs:/srv/_skillhub-observe/v1/observer.mjs:ro + - ./observe/v1/skillhub-intents.mjs:/srv/_skillhub-observe/v1/skillhub-intents.mjs:ro + - ./observe/v1/umami-adapter.mjs:/srv/_skillhub-observe/v1/umami-adapter.mjs:ro + ports: ["127.0.0.1:8081:8081"] + depends_on: [umami] diff --git a/docs/examples/analytics/nginx.conf b/docs/examples/analytics/nginx.conf new file mode 100644 index 000000000..e64ce3363 --- /dev/null +++ b/docs/examples/analytics/nginx.conf @@ -0,0 +1,85 @@ +# OPTIONAL deployment-owned edge template. Not included by SkillHub. +# Demo HTTP listener: publish on loopback only, behind your existing HTTPS edge. +# Substitute only SKILLHUB_WEB_UPSTREAM and UMAMI_UPSTREAM (host:port). +# Merge existing TLS, CSP, authentication, host validation and security headers. +upstream skillhub_analytics_web { server ${SKILLHUB_WEB_UPSTREAM}; } +upstream skillhub_analytics_umami { server ${UMAMI_UPSTREAM}; } +server { + listen 8081; + server_name localhost; + # Preserve the standard Web upload limit on every application proxy route. + client_max_body_size 100M; + + # Install ONLY the three .mjs files here, not declarations or other files. + # A single versioned prefix serves the entry AND its relative imports. + location ^~ /_skillhub-observe/v1/ { + root /srv; + try_files $uri =404; + error_page 404 = @module_missing; + autoindex off; + types { application/javascript mjs; } + default_type application/javascript; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + location @module_missing { + default_type application/javascript; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "no-store" always; + return 404 '// module not found\n'; + } + location /_skillhub-observe/ { return 404; } + + # Expose only two collector endpoints, NEVER Umami's dashboard/admin API. + location = /_skillhub-umami/script.js { + limit_except GET { deny all; } + proxy_pass http://skillhub_analytics_umami/script.js; + proxy_set_header Cookie ""; + proxy_set_header Authorization ""; + proxy_set_header Referer ""; + proxy_hide_header Set-Cookie; + add_header X-Content-Type-Options nosniff always; + } + location = /_skillhub-umami/api/send { + limit_except POST { deny all; } + proxy_pass http://skillhub_analytics_umami/api/send; + proxy_set_header Cookie ""; + proxy_set_header Authorization ""; + proxy_set_header Referer ""; + proxy_hide_header Set-Cookie; + proxy_cache off; + add_header Cache-Control "no-store" always; + } + location /_skillhub-umami/ { return 404; } + + # No filtering or cache-policy changes for API, auth, assets, runtime config, + # registry guides/downloads or health. Preserve original URI (no trailing /). + location ~ ^/(api/|oauth2/|login/oauth2/|\.well-known/|assets/|registry/|runtime-config\.js$|nginx-health) { + proxy_pass http://skillhub_analytics_web; + proxy_set_header Host $http_host; + } + + # Only UI HTML is eligible; sub_filter's default MIME is text/html. + location / { + proxy_pass http://skillhub_analytics_web; + proxy_set_header Host $http_host; + proxy_set_header Accept-Encoding ""; + proxy_set_header If-None-Match ""; + proxy_set_header If-Modified-Since ""; + proxy_cache off; + proxy_hide_header ETag; + proxy_hide_header Last-Modified; + proxy_hide_header Content-MD5; + proxy_hide_header Cache-Control; + proxy_hide_header Expires; + etag off; + if_modified_since off; + add_header Cache-Control "no-store" always; + # Also prevent the browser's HTTP Referer from leaking page paths/queries. + proxy_hide_header Referrer-Policy; + add_header Referrer-Policy "no-referrer" always; + sub_filter_once on; + sub_filter_last_modified off; + sub_filter '' ''; + } +} diff --git a/docs/examples/analytics/observe/v1/observer.mjs b/docs/examples/analytics/observe/v1/observer.mjs new file mode 100644 index 000000000..e3141cf10 --- /dev/null +++ b/docs/examples/analytics/observe/v1/observer.mjs @@ -0,0 +1,26 @@ +// Deployment configuration, not an application setting. Review before serving. +import { DEFAULT_RULES, observeSkillhubIntents } from './skillhub-intents.mjs' +import { createUmamiAdapter } from './umami-adapter.mjs' + +const config = Object.freeze({ + enabled: false, + website: '00000000-0000-0000-0000-000000000000', // Replace with your Umami website UUID. + prefix: '/_skillhub-umami', + rules: { ...DEFAULT_RULES, 'skill.example.open': 'click' }, +}) + +// Replace this hook with your consent manager's current approval state. +// It must be false until policy/consent permits collection; do not infer approval. +function hasConsent() { return false } + +let dispose +function start() { + if (!config.enabled || !hasConsent() || dispose) return + const adapter = createUmamiAdapter({ website: config.website, prefix: config.prefix }) + const stopObserver = observeSkillhubIntents({ rules: config.rules, onIntent: adapter.send }) + dispose = () => { stopObserver(); adapter.stop() } +} +function stop() { dispose?.(); dispose = undefined } +window.skillhubAnalytics = Object.freeze({ start, stop }) +// Consent manager: call start() on approval, stop() immediately on withdrawal. +start() diff --git a/docs/examples/analytics/observe/v1/skillhub-intents.d.mts b/docs/examples/analytics/observe/v1/skillhub-intents.d.mts new file mode 100644 index 000000000..c816677ff --- /dev/null +++ b/docs/examples/analytics/observe/v1/skillhub-intents.d.mts @@ -0,0 +1,12 @@ +export interface SkillhubIntent { readonly id: string; readonly event: string } +export type RuleType = 'click' | 'submit' | 'card' +export const DEFAULT_RULES: Readonly> +export function resolveRules(rules: unknown): { + active: Record + skipped: Array<{ id: string | null; reason: string }> +} +export function observeSkillhubIntents(options?: { + root?: Document | Element + rules?: unknown + onIntent?: (intent: SkillhubIntent) => unknown +}): () => void diff --git a/docs/examples/analytics/observe/v1/skillhub-intents.mjs b/docs/examples/analytics/observe/v1/skillhub-intents.mjs new file mode 100644 index 000000000..33fdc0fa8 --- /dev/null +++ b/docs/examples/analytics/observe/v1/skillhub-intents.mjs @@ -0,0 +1,77 @@ +// Optional deployment example, NOT imported or started by SkillHub. +const activeRoots = new WeakMap() +const handledEvents = new WeakSet() +// Same grammar as web/scripts/analytics-manifest.mjs; manifest parity is tested. +const idShape = /^[a-z]+(?:\.[a-z]+){2,}$/ +export const DEFAULT_RULES = Object.freeze({ + 'search.query.submit': 'submit', + 'skill.card.open': 'card', + 'suite.card.open': 'card', + 'skill.download.request': 'click', + 'skill.file.download': 'click', + 'skill.command.copy': 'click', + 'skill.prompt.copy': 'click', + 'skill.publish.request': 'click', + 'skill.publish.confirm': 'click', + 'skill.publish.cancel': 'click', + 'skill.delete.confirm': 'click', + 'skill.delete.cancel': 'click', +}) + +// Pure validation; callers may inspect skipped without installing any listeners. +export function resolveRules(rules) { + const active = Object.create(null) + const skipped = [] + try { + if (!rules || typeof rules !== 'object' || Array.isArray(rules) + || ![Object.prototype, null].includes(Object.getPrototypeOf(rules))) { + return { active, skipped: [{ id: null, reason: 'rules must be a plain object' }] } + } + for (const [id, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(rules))) { + const rule = descriptor.value // Do not run config getters. + const reason = !idShape.test(id) ? 'invalid id shape' + : typeof rule !== 'string' ? 'non-string rule type' + : !['click', 'submit', 'card'].includes(rule) ? 'unknown rule type' : null + if (reason) skipped.push({ id, reason }) + else active[id] = rule + } + } catch { skipped.push({ id: null, reason: 'unreadable rules object' }) } + return { active, skipped } +} + +export function observeSkillhubIntents({ root = document, rules = DEFAULT_RULES, onIntent } = {}) { + if (activeRoots.has(root)) return activeRoots.get(root) + if (typeof onIntent !== 'function') return () => {} + const { active } = resolveRules(rules) + const observe = event => { + if (handledEvents.has(event)) return + const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement + const host = target?.closest('[data-analytics-id]') + if (!host || !root.contains(host)) return + const id = host.getAttribute('data-analytics-id') + const rule = active[id] + if (!rule || host.closest(':disabled, [aria-disabled="true"], [data-disabled]')) return + const interactive = target.closest('button, a, input, select, textarea, [role="button"], [role="link"], [role="option"], [role="menuitem"]') + if (event.type !== 'submit' && interactive && interactive !== host && host.contains(interactive)) return + const clickHost = !host.matches('form, select, textarea, input:not([type="button"]):not([type="submit"]):not([type="reset"])') + const cardHost = host.tagName === 'DIV' && host.getAttribute('role') === 'link' + const keyboardCard = rule === 'card' && cardHost && event.type === 'keydown' + && (event.key === 'Enter' || event.key === ' ') + && !event.repeat && !event.isComposing && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey + const accepted = (rule === 'submit' && event.type === 'submit' && host.tagName === 'FORM') + || (rule === 'click' && clickHost && event.type === 'click') + || (rule === 'card' && cardHost && event.type === 'click') || keyboardCard + if (!accepted) return + handledEvents.add(event) + // Never block app behavior; observe neither callback completion nor delivery success. + try { Promise.resolve(onIntent(Object.freeze({ id, event: event.type }))).catch(() => {}) } catch { /* app stays usable */ } + } + for (const type of ['click', 'submit', 'keydown']) root.addEventListener(type, observe, true) + const stop = () => { + if (activeRoots.get(root) !== stop) return + for (const type of ['click', 'submit', 'keydown']) root.removeEventListener(type, observe, true) + activeRoots.delete(root) + } + activeRoots.set(root, stop) + return stop +} diff --git a/docs/examples/analytics/observe/v1/umami-adapter.mjs b/docs/examples/analytics/observe/v1/umami-adapter.mjs new file mode 100644 index 000000000..15eace3b1 --- /dev/null +++ b/docs/examples/analytics/observe/v1/umami-adapter.mjs @@ -0,0 +1,91 @@ +// Deployment-owned adapter for Umami v3.4.1 (MIT). No SDK in the product. +// Same grammar as web/scripts/analytics-manifest.mjs; manifest parity is tested. +const idShape = /^[a-z]+(?:\.[a-z]+){2,}$/ +const websiteShape = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const events = ['click', 'submit', 'keydown'] + +// Umami's before-send hook returns the inner payload, not the { type, payload } envelope. +export function sanitizeUmamiPayload(type, payload, website) { + let payloadWebsite, name, event + try { + payloadWebsite = payload?.website + name = payload?.name + event = payload?.data?.event + } catch { return null } + if (type !== 'event' || !websiteShape.test(website) || payloadWebsite !== website + || typeof name !== 'string' || !idShape.test(name) || name.length > 50 + || typeof event !== 'string' || !events.includes(event)) return null + return { website: payloadWebsite, name, data: { event } } +} + +export function createUmamiAdapter({ window: win = window, document: doc = document, + website, prefix = '/_skillhub-umami', timeoutMs = 3000 } = {}) { + if (!websiteShape.test(website)) return { send: async () => {}, stop() {} } + let stopped = false, loadState = 'loading', pending = 0, finishLoad + const script = doc.createElement('script') + const ready = new Promise(resolve => { finishLoad = resolve }) + let loadTimer + const loaded = ok => { + if (loadState !== 'loading') return // Failure/timeout cannot be revived by a late load. + loadState = ok ? 'ready' : 'failed' + clearTimeout(loadTimer) + script.onload = script.onerror = null + finishLoad(ok) + } + win.skillhubUmamiBeforeSend = (type, payload) => { + if (stopped) return null + const sanitized = sanitizeUmamiPayload(type, payload, website) + if (!sanitized) return null + const { website: validatedWebsite, name, data: { event } } = sanitized + // Verified against pinned v3.4.1: serialization is synchronous at fetch dispatch, + // inside the SDK's try/catch, after awaiting this hook. Re-verify on SDK upgrades. + Object.defineProperty(sanitized, 'toJSON', { + enumerable: false, + value() { + if (stopped) throw new Error('Analytics stopped before dispatch') + return { website: validatedWebsite, name, data: { event } } + }, + }) + return sanitized + } + script.src = `${prefix}/script.js` + script.async = true + script.setAttribute('data-website-id', website) + script.setAttribute('data-auto-track', 'false') + script.setAttribute('data-host-url', prefix) + script.setAttribute('data-before-send', 'skillhubUmamiBeforeSend') + script.onload = () => loaded(true) + script.onerror = () => loaded(false) + loadTimer = setTimeout(() => loaded(false), timeoutMs) + doc.head.append(script) + + return { + async send({ id, event } = {}) { + if (stopped || loadState === 'failed' + || !sanitizeUmamiPayload('event', { website, name: id, data: { event } }, website)) return + if (loadState === 'loading') { + if (pending >= 20) return // Bounded, memory-only wait for the script; no retry. + pending++ + await ready + pending-- + } + let timer + try { + if (stopped || loadState !== 'ready' || typeof win.umami?.track !== 'function') return + // Each ready intent is independent, even if another SDK fetch never settles. + await Promise.race([ + Promise.resolve().then(() => { if (!stopped) return win.umami.track(id, { event }) }), + new Promise(resolve => { timer = setTimeout(resolve, timeoutMs) }), + ]) + } catch { /* Missing/blocked/offline analytics must never reject into the app. */ } + finally { clearTimeout(timer) } + }, + stop() { + stopped = true + loaded(false) + script.remove() + // Keep the denying hook and dispatch guard: no new request after stop returns. + // Only a request whose fetch already started cannot be recalled. + }, + } +} diff --git a/docs/frontend-analytics-integration.md b/docs/frontend-analytics-integration.md new file mode 100644 index 000000000..ad1c5e942 --- /dev/null +++ b/docs/frontend-analytics-integration.md @@ -0,0 +1,200 @@ +# 前端动作统计接入指南 + +## 1. 先了解边界 + +SkillHub 页面只提供被动的 `data-analytics-id` 标签,默认不采集、不加载统计 SDK、不发送事件。可选 helper 把允许的原生交互转换成 `{ id, event }`,再交给部署方选择的 adapter。点击下载只表示下载意图,不表示下载、安装成功;提交发布也不表示发布成功。部署方负责授权、隐私合规、保留期限和平台安全。 + +本文对应 PR-1(#1010)的手工接入示例,不是产品内置开关。仅通过 Docker/Compose 或 Helm 配置开启、默认关闭和按需加载的能力计划在 PR-2 交付,见 [OpenSpec 变更](../openspec/changes/add-configurable-frontend-analytics/proposal.md)。Issue #989 要等 PR-2 完成并验证后才关闭。精确接口、事件边界和隐私约束以[英文契约](frontend-analytics.md)为准。 + +## 2. 选择接入路径 + +### 路径 A:复用已有统计平台 + +先确认平台接收地址、公开站点标识、授权策略和 CSP。不要把平台管理密钥放到浏览器。平台不受支持时,需要部署方编写自己的 adapter;当前版本不能只填平台名字就接入任意平台。 + +1. 复用 [helper](examples/analytics/observe/v1/skillhub-intents.mjs),不要修改业务页面或引入厂商 SDK。 +2. 参照 [Umami adapter](examples/analytics/observe/v1/umami-adapter.mjs)实现 `send(intent) -> Promise` 和 `stop()`。send 必须消化同步异常和异步拒绝,不向页面抛错;stop 后不能再派发请求,已经开始的 fetch 无法撤回。 +3. 由 adapter 验证平台参数、清洗最终发送字段、管理 SDK 加载和超时。不要只清洗 helper 回调后就信任 SDK 自动附加的 URL、标题或身份字段。 +4. 在部署方入口中连接 helper 与 adapter,只有策略允许时才初始化: + +```js +import { observeSkillhubIntents } from './skillhub-intents.mjs' +// adapter 由部署方创建;必须先确认当前授权。 +const stopObserver = observeSkillhubIntents({ + root: document, + onIntent: intent => adapter.send(intent), +}) +function stop() { stopObserver(); adapter.stop() } +``` + +入口交付、同源代理、授权回调和 CSP 由部署方维护。下面的 edge 仅演示 Umami 两个端点;使用其他平台时,需要自行审核并替换 adapter 和代理端点。不要把平台管理界面代理到公开采集前缀。 + +### 路径 B:本地自托管 Umami 演示 + +前提:已有能正常使用的 SkillHub Web;已安装 Docker 和 Compose;能使用示例指定的 Umami 3.4.1、Postgres 16、Nginx 1.30.6 镜像;本机 8300 和 8081 端口空闲。所有 shell 命令从仓库根目录运行。示例会创建独立演示网络和临时数据库,不用于生产持久化。请在隔离环境运行。 + +先检查配置,再启动: + +```sh +docker compose -f docs/examples/analytics/compose.yaml config +docker compose -f docs/examples/analytics/compose.yaml up -d +``` + +默认上游是 `host.docker.internal:80`,对应 release Compose 的 `WEB_PORT` 默认 80。这里是容器访问宿主机的 Web 入口,不是后端端口,也不是 edge 自己的 8081。若 Web 实际发布在其他端口(比如 18080),先在当前终端导出,再启动。后面所有 compose 命令都要在这个终端里执行,否则重建 edge 时会回到默认的 80: + +```sh +export SKILLHUB_WEB_UPSTREAM=host.docker.internal:18080 +docker compose -f docs/examples/analytics/compose.yaml up -d +``` + +原生 Linux Docker 需要 `host.docker.internal` 映射,示例 edge 已包含 `extra_hosts: ["host.docker.internal:host-gateway"]`。Docker Desktop/OrbStack 已提供这个名字。宿主机 Web 必须监听容器可达的地址;如果防火墙或仅回环监听阻止访问,先调整可达性,或填写容器可以访问的其他 `host:port`。不要填写 `http://` 或 URL 路径。 + +打开 `http://localhost:8300`。首次演示登录是 `admin` / `umami`,立即改密码。示例公开的数据库口令和 APP_SECRET 也只能用于演示;生产需要独立密钥、持久化、备份和访问控制。 + +在 Umami 创建一个演示网站,记录网站 UUID。打开 `docs/examples/analytics/observe/v1/observer.mjs`: + +1. 将全零 `website` 改为刚创建的网站 UUID。 +2. 将 `enabled: false` 改为 `enabled: true`。 +3. 将 `hasConsent()` 接入自己的当前授权判断。仅在隔离演示、明确允许采集时可暂改为返回 true;正式接入不能把它当作用户同意。 +4. 按下一节配置 rules。示例额外的 `skill.example.open` 没有对应产品元素,不会凭空产生事件。 + +然后重建 edge,使单文件挂载更新可靠生效: + +```sh +docker compose -f docs/examples/analytics/compose.yaml up -d --force-recreate edge +docker compose -f docs/examples/analytics/compose.yaml exec edge nginx -t +docker compose -f docs/examples/analytics/compose.yaml exec edge nginx -T +``` + +打开 `http://localhost:8081`,不要从原 Web 入口验收注入。 + +注意缓存:模块按 immutable 长期缓存,浏览器会一直用第一次拿到的 `observer.mjs`。本地演示每次改完入口文件,都要在开发者工具 Network 勾选「Disable cache」后再刷新,或者用一个新的无痕窗口打开。正式交付中不能原地改文件,要发布到新的版本目录(如 `v2`)并同步注入路径,不能覆盖旧版本字节。 + +## 3. 配置规则和增加动作 + +省略 `rules` 使用冻结的 `DEFAULT_RULES`,共 12 条: + +| ID | 规则 | +| --- | --- | +| search.query.submit | submit | +| skill.card.open | card | +| suite.card.open | card | +| skill.download.request | click | +| skill.file.download | click | +| skill.command.copy | click | +| skill.prompt.copy | click | +| skill.publish.request | click | +| skill.publish.confirm | click | +| skill.publish.cancel | click | +| skill.delete.confirm | click | +| skill.delete.cancel | click | + +显式传入 rules 会替换默认值,空对象表示没有动作。要保留默认值再增加已存在的动作,先合并: + +```js +import { DEFAULT_RULES, resolveRules } from './skillhub-intents.mjs' +const rules = { ...DEFAULT_RULES, 'navigation.home.open': 'click' } +const { active, skipped } = resolveRules(rules) +console.table(skipped) // 只在本地检查,不发送这些诊断。 +``` + +把 `active` 传给 helper 的 `rules`。也可在浏览器开发者工具 Console 中直接执行以下代码,无需编辑源码,就能查看无效项: + +```js +const { resolveRules } = await import('/_skillhub-observe/v1/skillhub-intents.mjs') +console.table(resolveRules({ 'skill.example.open': 'drop' }).skipped) +``` + +应看到 `id: skill.example.open`、`reason: unknown rule type`。非法 ID、非字符串规则也会跳过;非法容器返回 `id: null` 的诊断和空 active。该检查只验证配置,不能证明 DOM 元素支持某类事件。 + +仅支持三种规则: + +- `click`:普通按钮、链接;忽略 FORM、SELECT、TEXTAREA、非按钮 INPUT(包括文件选择器)。 +- `submit`:只用于 FORM。点击提交和按 Enter 都归一到原生 submit;原生校验失败可能没有事件。 +- `card`:已有 Enter/Space 处理的 DIV role=link,观察 click 和对应 keydown。helper 不会替应用增加键盘行为。 + +不支持的选择器、拖放、文件和裁剪行为不要硬填 click。未配置的动作不采集。`skill.detail.render` 是结构标签,不是自动页面访问量。 + +新增产品动作时,开发者在正确的现有元素上增加静态字面量 ID,再从仓库根目录运行: + +```sh +node web/scripts/analytics-manifest.mjs --write +node web/scripts/analytics-manifest.mjs +``` + +审核生成的 [manifest](frontend-analytics-manifest.json) 差异和原生交互测试。部署方再加一条规则即可。ID 必须匹配 `^[a-z]+(?:\.[a-z]+){2,}$`:至少三段小写字母,不含数字、下划线或资源信息。已有 ID 含义冻结,不能把用户、技能名、版本、路径拼进去。manifest 是版本清单,不是运行时封闭枚举。 + +## 4. 部署、子路径和 CSP + +edge 仅在 UI HTML 注入一个延迟执行的 module,不改变 API、登录、下载、静态资源或 runtime-config。server 级 `client_max_body_size 100M` 保留标准 Web 的 100 MiB 请求体上限;实际业务校验仍可能拒绝上传。不要在应用代理 location 覆盖成更小值。 + +正式启用前检查 Nginx 含 http_sub_module、`nginx -t` 成功,并审核 `nginx -T`。HTML 输入不能仍被上游 gzip/Brotli 压缩。示例禁用 HTML 缓存和旧校验器;启用或回退都应清理 CDN/浏览器旧 HTML。不要修改 API 或 immutable 资源的缓存规则。 + +根路径演示的 CSP 需要允许同源外部 module 和同源连接。保留原有 CSP;nonce-only 或 strict-dynamic 策略不能只靠同源满足。需要部署方协调每响应 nonce 或已批准的 hash/SRI;做不到就不要启用。不要加 unsafe-inline、unsafe-eval 或通配符。还要保留原安全头,注意 Nginx add_header 的 location 继承规则。 + +`/skillhub/` 等子路径不是只改一个 URL。先按正常部署方式配置 `SKILLHUB_WEB_BASE_PATH=/skillhub/` 和匹配的同源 API base,再同时调整 UI location、无尾斜线重定向、旁路正则、模块目录/注入 src、两个 Umami 精确端点和 adapter prefix。edge 到 Web 的 proxy_pass 不带尾斜线,保留原 URI,不能再次重写。逐项配置见[英文契约的子路径映射](frontend-analytics.md#supported-sub-path-mapping)。OAuth、公网 HTTPS 和资源地址仍遵守原部署要求。 + +## 5. 验收:照这个顺序检查 + +下面命令假定根路径和默认 edge 端口。子路径时为所有采集 URL 加相同前缀;自定义端口时统一替换地址。 + +1. 检查模块确实由 edge 提供,不是 SPA 返回的 HTML: + +```sh +curl -i http://localhost:8081/_skillhub-observe/v1/skillhub-intents.mjs +curl -i http://localhost:8081/_skillhub-observe/v1/missing.mjs +curl -I http://localhost:8081/skills +``` + +第一个应为 200、JavaScript MIME、nosniff 和 immutable。第二个应为非 HTML 的 404。第三个应为 200、Cache-Control: no-store,并保留原 CSP(若原部署配置了 CSP)。 + +2. 打开 edge 的 SkillHub 页面,在开发者工具 Network 勾选「Preserve log」和「Disable cache」,筛选 `/_skillhub-umami/`。刷新后应加载 script.js,但没有自动 pageview 或 send。 +3. 搜索框输入任意本地测试文字,点击搜索。应恰好有一条 `/_skillhub-umami/api/send` POST,响应 200。再按 Enter 搜索一次,应新增一条,不是两条。切换 Network 的 Payload 查看真正出站 JSON,不要只看回调。 +4. JSON 只能包含 `{ type: 'event', payload: { website, name, data: { event } } }`。website 是配置的 UUID;搜索 name 为 search.query.submit,event 为 submit。不得出现搜索词、完整 URL、标题、referrer、身份或额外 data 字段。 +5. 打开 Umami 网站事件页,选择当前时间范围,应看到 search.query.submit 和对应数量。平台处理可能稍有延迟;不能把 HTTP 200 单独当作入库证明。 +6. 执行下面的 Console 命令后再搜索,页面仍能使用,但 Network 不得新增 send: + +```js +window.skillhubAnalytics.stop() +``` + +stop 不能撤回已开始的 fetch。重新获准后才可调用 start();同一个 root 重复初始化保留第一次规则和回调,修改规则需要先 stop 再初始化。 + +7. 将入口恢复 `enabled: false`,重新创建 edge。保持「Disable cache」勾选(或换一个新的无痕窗口),刷新页面后再搜索:不得加载 script.js、产生采集回调或 send。完全不注入时也应零采集。最后在隔离演示中停止 Umami,再打开新页面搜索和打开技能卡片,应仍正常,无统计依赖导致的页面错误: + +```sh +docker compose -f docs/examples/analytics/compose.yaml stop umami +``` + +验收后如需继续演示,使用上面的 up 命令恢复。不要为了测试停止共享平台或生产服务。 + +## 6. 故障排查 + +| 现象 | 常见原因 | 处理 | +| --- | --- | --- | +| module 404 | 未挂载模块,版本目录或子路径不一致 | 对照 nginx -T、挂载路径、注入 src 和相对 import,三份 mjs 都要可访问;不要回退到 index.html | +| MIME 报错或返回 HTML | 模块误入 SPA fallback,缺少 mjs MIME | 使用示例独立前缀、try_files 404 和 JavaScript MIME,保留 nosniff | +| CSP 拦截 | script-src/connect-src 不允许,或 nonce-only 策略 | 按原 CSP 的授权机制交付;不要删除 CSP 或放宽为通配符 | +| 上传 413 | 外层代理仍默认 1 MiB,或请求超过 100 MiB | 用 nginx -T 检查整条代理链;应用代理继承 100M,超过标准上限仍应拒绝 | +| 没有事件 | 走了原 Web 而非 edge,enabled/授权未开,UUID 错,规则未配置或宿主不支持 | 按第 5 节从模块、skipped、Network 到平台事件页逐步确认;检查时间范围;不要把结构标签当 pageview | +| edge 502 | Web 上游地址/端口错误,宿主不可达 | 核对 WEB_PORT、host-gateway、防火墙和监听地址;不要把后端或 edge 端口填成 Web 上游 | +| Umami 不可用 | SDK 404、超时、平台停止或网络故障 | 页面应继续正常;检查平台和代理日志,恢复后重新打开页面;没有重试补发保证 | + +## 7. 升级、关闭和已知限制 + +升级时先比较新旧 manifest。已有 ID 语义不变;含义变化需要新 ID,重命名/删除必须有发布说明。消失的 ID 不再触发,新 ID 默认不自动加入规则。模块/SDK 升级要重新验证真实出站字段、stop 派发边界、并发发送、根/子路径和业务行为,不能只看构建成功。 + +临时关闭当前页用 stop();持久关闭把入口改回 enabled:false,并按版本发布规则更新、清理 HTML 缓存和刷新。彻底关闭则撤掉注入/edge,让访问回到正常 Web 入口,确认新 HTML 不引用模块、Network 无采集。关闭平台网站或封锁采集路由是额外控制,不能代替授权撤回。 + +结束本地演示(会删除演示数据库,先确认无需要保留的数据): + +```sh +docker compose -f docs/examples/analytics/compose.yaml down -v +``` + +限制必须一起评估: + +- 尽力发送,无重试、无持久队列,可能少计。SDK 加载期最多在内存暂存 20 条,默认最多等三秒;超额、失败、超时或 stop 都丢弃。已就绪发送彼此独立,不因一个请求挂起而阻塞后续动作。 +- 示例固定 Umami 3.4.1。stop 的派发保护依赖此版本在 fetch 前同步序列化的顺序,升级必须重跑适配器测试和真实浏览器验收;不要同时加载第二个 Umami tracker。 +- JSON 字段少不等于匿名传输。IP、User-Agent、Origin 仍到平台;SDK 还带网站、实际 hostname 和后续会话 cache 头。edge 去掉采集代理的 Cookie、Authorization、Referer,并使用 no-referrer。Umami 仍可能据传输元数据推导浏览器、系统、设备、地域和会话。 +- SDK 在清洗前仍会在页面内读取标题、URL、referrer 和自身 localStorage 开关;通用 helper 不读取这些内容。平台日志、数据保留和权限需另审计,详见[隐私契约](frontend-analytics.md#privacy-and-integration-modes)。 diff --git a/docs/frontend-analytics-manifest.json b/docs/frontend-analytics-manifest.json new file mode 100644 index 000000000..3b230fb59 --- /dev/null +++ b/docs/frontend-analytics-manifest.json @@ -0,0 +1,951 @@ +{ + "schema": 1, + "description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.", + "counts": {"routes":54,"uniqueIds":804,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":29,"forwardingSites":36}, + "routes": [ + {"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false}, + {"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false}, + {"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false}, + {"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false}, + {"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false}, + {"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false}, + {"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false}, + {"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false}, + {"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false}, + {"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false}, + {"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false}, + {"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false}, + {"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false}, + {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false}, + {"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true}, + {"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true}, + {"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true}, + {"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true}, + {"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true} + ], + "definitions": [ + {"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":90,"ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":91,"ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":92,"ids":["navigation.github.open"]}, + {"file":"web/src/app/layout.tsx","line":93,"ids":["navigation.suites.open"]}, + {"file":"web/src/app/layout.tsx","line":94,"ids":["navigation.dashboard.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":26,"ids":["namespace.skills.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":27,"ids":["namespace.members.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":28,"ids":["namespace.reviews.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":29,"ids":["namespace.settings.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":42,"ids":["account.security.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":43,"ids":["notification.settings.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":50,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":51,"ids":["namespace.list.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]} + ], + "sites": [ + {"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":139,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":159,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]}, + {"file":"web/src/app/layout.tsx","line":175,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/app/layout.tsx","line":193,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":251,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":252,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":253,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, + {"file":"web/src/app/layout.tsx","line":260,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, + {"file":"web/src/app/layout.tsx","line":261,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, + {"file":"web/src/app/layout.tsx","line":262,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, + {"file":"web/src/app/layout.tsx","line":269,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]}, + {"file":"web/src/app/layout.tsx","line":270,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]}, + {"file":"web/src/app/layout.tsx","line":271,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]}, + {"file":"web/src/app/layout.tsx","line":272,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]}, + {"file":"web/src/app/layout.tsx","line":279,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]}, + {"file":"web/src/app/layout.tsx","line":280,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, + {"file":"web/src/app/layout.tsx","line":281,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, + {"file":"web/src/features/auth/auth-shell.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/features/auth/auth-shell.tsx","line":28,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/features/auth/login-button.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.oauth.request"]}, + {"file":"web/src/features/auth/session-bootstrap-entry.tsx","line":54,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.sso.request"]}, + {"file":"web/src/features/auth/session-bootstrap-entry.tsx","line":94,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.sso.request"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.admission.edit"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":61,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.admission.cancel"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":63,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.admission.save"]}, + {"file":"web/src/features/github-sync/github-admission-config.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.admission.cancel"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.create"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":72,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.credential.create.cancel","github.credential.replace.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":74,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.credential.create.submit","github.credential.replace.submit"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.create.cancel","github.credential.replace.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.replace"]}, + {"file":"web/src/features/github-sync/github-credential-config.tsx","line":117,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.credential.disable","github.credential.enable"],"conditional":true}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":65,"host":"input","prop":"data-analytics-id","event":"change","ids":["github.discovery.toggle"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":72,"host":"input","prop":"data-analytics-id","event":"change","ids":["github.discovery.budget.accelerated","github.discovery.budget.balanced","github.discovery.budget.economy"],"conditional":true}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":82,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.exclusion.add"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":83,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.exclusion.kind"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":96,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.exclusion.remove"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":103,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.reload"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":105,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.cancel"]}, + {"file":"web/src/features/github-sync/github-deep-discovery-config.tsx","line":106,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.save"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.owner.create"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":172,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.create"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":176,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.owner.cancel","github.rule.create.cancel","github.rule.edit.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":191,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.owner.add"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":206,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.rule.create.submit","github.rule.edit.submit"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":219,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.kind.keyword"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":219,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.kind.topic"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":224,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.priority.core"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":224,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.selector.priority.extension"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":232,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.selector.remove"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.selector.add"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":239,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.preview"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":240,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.create.cancel","github.rule.edit.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":295,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rule.edit"]}, + {"file":"web/src/features/github-sync/github-discovery-config.tsx","line":296,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.owner.disable","github.owner.enable","github.rule.disable","github.rule.enable"],"conditional":true}, + {"file":"web/src/features/github-sync/github-retry-rejected-skill-button.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.rejected.retry"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.schedule.edit"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":63,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.schedule.cancel"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":65,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.schedule.save"]}, + {"file":"web/src/features/github-sync/github-schedule-config.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.schedule.cancel"]}, + {"file":"web/src/features/github-sync/github-source-ignore-editor.tsx","line":60,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.ignore.save"]}, + {"file":"web/src/features/github-sync/github-source-rescan-button.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.rescan"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":57,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.collection.close"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":75,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.discovery.edit"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":78,"host":"button","prop":"data-analytics-id","event":"click","ids":["github.progress.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":88,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.repository.add"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":98,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.open"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":98,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-collection-drawer.tsx","line":102,"host":"button","prop":"data-analytics-id","event":"click","ids":["github.acquisition.toggle"]}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":49,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.binding.conflict.open","github.binding.pathmove.open"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":53,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.binding.conflict.cancel","github.binding.pathmove.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":60,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.binding.conflict.confirm","github.binding.pathmove.confirm"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-conflict-binder.tsx","line":68,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.binding.conflict.cancel","github.binding.pathmove.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":50,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":51,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.collection.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":59,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":67,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.runs.open"]}, + {"file":"web/src/features/github-sync/github-sync-overview.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":40,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.request","github.platform.resume.request"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":43,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.platform.pause.cancel","github.platform.resume.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.cancel","github.platform.resume.cancel"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-platform-control.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.platform.pause.confirm","github.platform.resume.confirm"],"conditional":true}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":26,"host":"select","prop":"data-analytics-id","event":"change","forward":"analyticsId"}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":54,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.repository.close"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":60,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.repository.external"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":74,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.provenance.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":79,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":80,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.path.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.actions"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":83,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":117,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.repository.search"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":119,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.origin.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":120,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.state.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":121,"host":"SelectFilter","prop":"analyticsId","event":"change","ids":["github.repository.source.status.filter"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":124,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.refresh"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.repository.open"]}, + {"file":"web/src/features/github-sync/github-sync-repositories.tsx","line":137,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.repository.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":25,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.runs.open"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":68,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.path.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-detail.tsx","line":84,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.task.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-run-list.tsx","line":60,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.run.open"]}, + {"file":"web/src/features/github-sync/github-sync-run-list.tsx","line":66,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.run.paginate"]}, + {"file":"web/src/features/github-sync/github-sync-runs-section.tsx","line":16,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.run.kind"]}, + {"file":"web/src/features/github-sync/github-sync-runs-section.tsx","line":19,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.run.status"]}, + {"file":"web/src/features/github-sync/github-sync-settings-drawer.tsx","line":14,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.settings.close"]}, + {"file":"web/src/features/github-sync/github-sync-source-detail.tsx","line":42,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.source.paginate"]}, + {"file":"web/src/features/governance/governance-inbox.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.promotion.open","governance.report.open","governance.review.open","governance.skill.open"],"conditional":true}, + {"file":"web/src/features/governance/governance-notifications.tsx","line":48,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.read.request"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":110,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.member.cancel"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":122,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.member.search"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":139,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.search"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.select"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":196,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.member.add"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":225,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.member.role.interact"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":230,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.admin","namespace.member.role.member"],"conditional":true}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":244,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.cancel"]}, + {"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":247,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.add"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":168,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.import.cancel","namespace.import.close"],"conditional":true}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":178,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.template.download"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":181,"host":"div","prop":"data-analytics-id","event":"click+keydown-enter-space","ids":["namespace.import.choose"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":197,"host":"input","prop":"data-analytics-id","event":"change","ids":["namespace.import.select"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":280,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.cancel"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":286,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.reset"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":289,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.confirm"]}, + {"file":"web/src/features/namespace/batch-import-members-dialog.tsx","line":301,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.close"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":145,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.create.cancel"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":156,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":186,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":238,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.cancel"]}, + {"file":"web/src/features/namespace/create-namespace-dialog.tsx","line":241,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.request"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":70,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.settings.cancel"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.cancel"]}, + {"file":"web/src/features/namespace/edit-namespace-dialog.tsx","line":110,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.save"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":118,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.copy"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":126,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.create"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":129,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.create"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":140,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.retry"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":147,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.replace"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":148,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.revoke"]}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":153,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.invitation.replace.confirm","namespace.invitation.revoke.confirm"],"conditional":true}, + {"file":"web/src/features/namespace/invitation-controls.tsx","line":153,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.invitation.replace.cancel","namespace.invitation.revoke.cancel"],"conditional":true}, + {"file":"web/src/features/namespace/invitation-history.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.refresh"]}, + {"file":"web/src/features/namespace/invitation-history.tsx","line":89,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.invitation.paginate"]}, + {"file":"web/src/features/namespace/namespace-header.tsx","line":68,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.settings.edit"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":11,"host":"Link","prop":"data-analytics-id","event":"click","ids":["namespace.list.open"]}, + {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":35,"host":"Link","prop":"data-analytics-id","event":"click","forward":"tab.analyticsId"}, + {"file":"web/src/features/namespace/namespace-skills.tsx","line":63,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/features/namespace/namespace-skills.tsx","line":72,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.skill.paginate"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":77,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.ownership.cancel"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":94,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.ownership.interact"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":99,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.ownership.select"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.cancel"]}, + {"file":"web/src/features/namespace/transfer-ownership-dialog.tsx","line":135,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.confirm"]}, + {"file":"web/src/features/notification/notification-bell.tsx","line":41,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.menu.toggle"]}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":48,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.all.read"]}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":75,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.fallback.open","notification.promotion.open","notification.report.open","notification.review.detail.open","notification.review.list.open","notification.target.provided.open"],"conditional":true}, + {"file":"web/src/features/notification/notification-dropdown.tsx","line":105,"host":"Link","prop":"data-analytics-id","event":"click","ids":["notification.list.open"]}, + {"file":"web/src/features/notification/notification-preference-form.tsx","line":75,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.preference.promotion.toggle","notification.preference.publish.toggle","notification.preference.report.toggle","notification.preference.review.toggle"],"conditional":true}, + {"file":"web/src/features/publish/upload-zone.tsx","line":71,"host":"div","prop":"data-analytics-id","event":"click+keydown+drop","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":81,"host":"input","prop":"data-analytics-id","event":"change","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":113,"host":"input","prop":"data-analytics-id","event":"change","forward":"folderAnalyticsId"}, + {"file":"web/src/features/publish/upload-zone.tsx","line":123,"host":"button","prop":"data-analytics-id","event":"click","forward":"pickerAnalyticsId"}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":49,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.sync.status"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":62,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.sync.sort.interact"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":64,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.sync.sort"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":65,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.sync.sort"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":93,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/features/review/github-sync-review-queue.tsx","line":103,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.sync.paginate"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":79,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.file.previous"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":81,"host":"select","prop":"data-analytics-id","event":"change","ids":["review.file.select"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":86,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.file.next"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":95,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.diff.changes"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":96,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.diff.full"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":108,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/github-sync-review-source.tsx","line":146,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.commit.external"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":60,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":69,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":81,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":94,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]}, + {"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]}, + {"file":"web/src/features/search/search-bar.tsx","line":55,"host":"form","prop":"data-analytics-id","event":"submit","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/search/search-bar.tsx","line":67,"host":"button","prop":"data-analytics-id","event":"click","ids":["search.query.clear"],"conditional":true}, + {"file":"web/src/features/security-audit/security-audit-section.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.findings.toggle"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.scan.retry"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.scan.open"]}, + {"file":"web/src/features/security-audit/security-audit-summary.tsx","line":77,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["security.scan.close"]}, + {"file":"web/src/features/skill/avatar-review-settings.tsx","line":28,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.avatar.machine.toggle","admin.avatar.manual.toggle"],"conditional":true}, + {"file":"web/src/features/skill/avatar-review-settings.tsx","line":30,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.avatar.settings"]}, + {"file":"web/src/features/skill/compliance-snapshot-panel.tsx","line":51,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.compliance.toggle"]}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":90,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","forward":"closeAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":101,"host":"Button","prop":"data-analytics-id","event":"click","forward":"copyAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":115,"host":"Button","prop":"data-analytics-id","event":"click","forward":"downloadAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":125,"host":"Button","prop":"data-analytics-id","event":"click","forward":"closeAnalyticsId"}, + {"file":"web/src/features/skill/file-preview-dialog.tsx","line":158,"host":"Button","prop":"data-analytics-id","event":"click","forward":"downloadAnalyticsId"}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":59,"host":"div","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":81,"host":"div","prop":"data-analytics-id","event":"click","ids":["skill.directory.toggle"],"conditional":true}, + {"file":"web/src/features/skill/file-tree-node.tsx","line":102,"host":"FileTreeNodeComponent","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/file-tree.tsx","line":41,"host":"FileTreeNodeComponent","prop":"analyticsId","event":"click","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.repository.external"]}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":21,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.commit.external"]}, + {"file":"web/src/features/skill/github-skill-source.tsx","line":37,"host":"a","prop":"data-analytics-id","event":"click","ids":["github.file.external"]}, + {"file":"web/src/features/skill/install-command.tsx","line":83,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":116,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","forward":"skillhubAnalyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":119,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","forward":"clawhubAnalyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":125,"host":"CommandBlock","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-command.tsx","line":129,"host":"CommandBlock","prop":"analyticsId","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/install-for-agent-button.tsx","line":56,"host":"button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/features/skill/markdown-renderer.tsx","line":172,"host":"a","prop":"data-analytics-id","event":"click","ids":["skill.markdown.link"]}, + {"file":"web/src/features/skill/share-button.tsx","line":44,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.share.request"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":124,"host":"div","prop":"data-analytics-id","event":"drop","ids":["skill.avatar.file.drop"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":127,"host":"input","prop":"data-analytics-id","event":"change","ids":["skill.avatar.file.select"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":130,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.change","skill.avatar.choose"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.remove"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.reset"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":139,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.avatar.cancel"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":145,"host":"div","prop":"data-analytics-id","event":"pointerup+keydown-intent","ids":["skill.avatar.crop.adjust"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":168,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.cancel"]}, + {"file":"web/src/features/skill/skill-avatar-editor.tsx","line":169,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.retry","skill.avatar.upload"],"conditional":true}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":28,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.refresh"]}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":49,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.appeal"]}, + {"file":"web/src/features/skill/skill-avatar-manager.tsx","line":50,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.retry"]}, + {"file":"web/src/features/skill/skill-avatar-takedown.tsx","line":21,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.takedown"]}, + {"file":"web/src/features/skill/skill-card.tsx","line":37,"host":"Card","prop":"data-analytics-id","event":"click+keydown-enter-space","ids":[],"conditional":true,"forward":"analyticsId"}, + {"file":"web/src/features/skill/skill-label-panel.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.detach","skill.label.recommended.detach","suite.label.privileged.detach","suite.label.recommended.detach"],"conditional":true}, + {"file":"web/src/features/skill/skill-label-panel.tsx","line":212,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.label.privileged.attach","skill.label.recommended.attach","suite.label.privileged.attach","suite.label.recommended.attach"],"conditional":true}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":51,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.visibility.cancel"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":63,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.visibility.interact"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":64,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.visibility.namespace","skill.visibility.private","skill.visibility.public"],"conditional":true}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.cancel"]}, + {"file":"web/src/features/skill/skill-visibility-dialog.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.visibility.save"]}, + {"file":"web/src/features/social/rating-input.tsx","line":39,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.rating.five.request","skill.rating.four.request","skill.rating.one.request","skill.rating.three.request","skill.rating.two.request"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":60,"host":"input","prop":"data-analytics-id","event":"change","ids":["skill.comment.rating.five","skill.comment.rating.four","skill.comment.rating.one","skill.comment.rating.three","skill.comment.rating.two"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":143,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.delete"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":147,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.cancel"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":150,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.save"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":212,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.hide","skill.comment.restore"],"conditional":true}, + {"file":"web/src/features/social/skill-reviews.tsx","line":269,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.edit"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":273,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.delete"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":318,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.previous"]}, + {"file":"web/src/features/social/skill-reviews.tsx","line":322,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comment.next"]}, + {"file":"web/src/features/social/star-button.tsx","line":39,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.star.add","skill.star.remove"],"conditional":true}, + {"file":"web/src/features/social/subscribe-button.tsx","line":34,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.subscription.add","skill.subscription.remove"],"conditional":true}, + {"file":"web/src/features/suite/resource-card.tsx","line":12,"host":"Card","prop":"data-analytics-id","event":"click+keydown-enter-space","forward":"analyticsId"}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["suite.bundle.select"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["suite.bundle.folder.select"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":241,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["suite.bundle.folder.open"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":317,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.bundle.warning"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":358,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.bundle.removals"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":368,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.bundle.reset"]}, + {"file":"web/src/features/suite/suite-bundle-import.tsx","line":371,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.bundle.confirm"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":112,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.retry"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.cancel"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.restart"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.open"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":137,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.restart"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.operation.refresh"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":280,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":310,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.operation.cancel.confirm"]}, + {"file":"web/src/features/suite/suite-bundle-operation-detail.tsx","line":310,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.operation.cancel.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.reopen"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.create"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":131,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.hide.request","suite.restore.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":139,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.archive.request","suite.unarchive.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.delete.request"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":160,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.archive.confirm","suite.delete.confirm","suite.hide.confirm","suite.restore.confirm","suite.unarchive.confirm"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":160,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.archive.cancel","suite.delete.cancel","suite.hide.cancel","suite.restore.cancel","suite.unarchive.cancel"],"conditional":true}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":173,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["suite.version.yank.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":184,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank.cancel"]}, + {"file":"web/src/features/suite/suite-management-actions.tsx","line":185,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.yank.confirm"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":27,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":71,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-member-table.tsx","line":97,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.version.open"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":95,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.version.expand"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":118,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.version.select"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":191,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit"]}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":197,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/features/suite/suite-version-ledger.tsx","line":221,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/features/suite/suite-workspace-header.tsx","line":26,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.workspace.back"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":144,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["account.token.create.cancel","account.token.created.close"],"conditional":true}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":156,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["account.token.create"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":193,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["account.token.create.expiry.interact"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":197,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.never"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":198,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.week"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":199,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.month"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":200,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.quarter"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":201,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.create.expiry.custom"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":229,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.create.cancel"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":232,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.create"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":265,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.copy"]}, + {"file":"web/src/features/token/create-token-dialog.tsx","line":268,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.created.close"]}, + {"file":"web/src/features/token/token-list.tsx","line":181,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.open"]}, + {"file":"web/src/features/token/token-list.tsx","line":196,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.open"]}, + {"file":"web/src/features/token/token-list.tsx","line":233,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.edit"]}, + {"file":"web/src/features/token/token-list.tsx","line":242,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.revoke"]}, + {"file":"web/src/features/token/token-list.tsx","line":260,"host":"Pagination","prop":"analyticsId","event":"click","ids":["account.token.paginate"]}, + {"file":"web/src/features/token/token-list.tsx","line":263,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["account.token.revoke.confirm"]}, + {"file":"web/src/features/token/token-list.tsx","line":263,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["account.token.revoke.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":274,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["account.token.expiration.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":291,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["account.token.edit.expiry.interact"]}, + {"file":"web/src/features/token/token-list.tsx","line":295,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.never"]}, + {"file":"web/src/features/token/token-list.tsx","line":296,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.week"]}, + {"file":"web/src/features/token/token-list.tsx","line":297,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.month"]}, + {"file":"web/src/features/token/token-list.tsx","line":298,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.quarter"]}, + {"file":"web/src/features/token/token-list.tsx","line":299,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["account.token.edit.expiry.custom"]}, + {"file":"web/src/features/token/token-list.tsx","line":320,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.cancel"]}, + {"file":"web/src/features/token/token-list.tsx","line":323,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.token.expiration.save"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.rebuild.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":110,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.clear"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":123,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.audit.action.interact"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":128,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.audit.action.all","admin.audit.action.archive","admin.audit.action.cli.publish","admin.audit.action.compat.publish","admin.audit.action.hide","admin.audit.action.promotion.approve","admin.audit.action.promotion.reject","admin.audit.action.promotion.submit","admin.audit.action.report","admin.audit.action.report.dismiss","admin.audit.action.report.resolve","admin.audit.action.review.approve","admin.audit.action.review.reject","admin.audit.action.review.submit","admin.audit.action.search.rebuild","admin.audit.action.unarchive","admin.audit.action.unhide","admin.audit.action.version.yank"],"conditional":true}, + {"file":"web/src/pages/admin/audit-log.tsx","line":137,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.user.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":145,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.request.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":153,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.ip.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":161,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.type.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":169,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.resource.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":177,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.start.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":185,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.audit.end.filter"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":242,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.previous"]}, + {"file":"web/src/pages/admin/audit-log.tsx","line":250,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.audit.next"]}, + {"file":"web/src/pages/admin/labels.tsx","line":249,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create"]}, + {"file":"web/src/pages/admin/labels.tsx","line":317,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.up"]}, + {"file":"web/src/pages/admin/labels.tsx","line":320,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.down"]}, + {"file":"web/src/pages/admin/labels.tsx","line":329,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.edit"]}, + {"file":"web/src/pages/admin/labels.tsx","line":332,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete"]}, + {"file":"web/src/pages/admin/labels.tsx","line":353,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.label.create.cancel","admin.label.edit.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":377,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.label.type.interact"]}, + {"file":"web/src/pages/admin/labels.tsx","line":381,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.type.recommended"]}, + {"file":"web/src/pages/admin/labels.tsx","line":382,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.type.privileged"]}, + {"file":"web/src/pages/admin/labels.tsx","line":392,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.label.visibility.interact"]}, + {"file":"web/src/pages/admin/labels.tsx","line":396,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.visibility.visible"]}, + {"file":"web/src/pages/admin/labels.tsx","line":397,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.label.visibility.hidden"]}, + {"file":"web/src/pages/admin/labels.tsx","line":418,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.translation.add"]}, + {"file":"web/src/pages/admin/labels.tsx","line":436,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.translation.remove"]}, + {"file":"web/src/pages/admin/labels.tsx","line":445,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create.cancel","admin.label.edit.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":448,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.create.submit","admin.label.edit.submit"],"conditional":true}, + {"file":"web/src/pages/admin/labels.tsx","line":460,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.label.delete.cancel"]}, + {"file":"web/src/pages/admin/labels.tsx","line":468,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete.cancel"]}, + {"file":"web/src/pages/admin/labels.tsx","line":471,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.label.delete.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":337,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["admin.namespace.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":344,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":345,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.clear"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":356,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.status.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":358,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":359,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":360,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":361,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.status.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":371,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.type.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":373,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":374,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":375,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.type.filter"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":407,"host":"TableRow","prop":"data-analytics-id","event":"click","ids":["admin.namespace.open"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":431,"host":"Pagination","prop":"analyticsId","event":"click","ids":["admin.namespace.paginate"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":456,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":464,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.open"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":484,"host":"Pagination","prop":"analyticsId","event":"click","ids":["admin.namespace.member.paginate"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":493,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.freeze"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":501,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.unfreeze"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":509,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":517,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.restore"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":533,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.member.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":541,"host":"Input","prop":"data-analytics-id","event":"change","ids":["admin.namespace.member.search"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":549,"host":"button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.select"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":573,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.member.role.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":575,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.member"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":576,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.admin"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":582,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":583,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.add"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":589,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.transfer.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":604,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.ownership.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":607,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.ownership.select"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":624,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":625,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.transfer.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":637,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.namespace.member.remove.confirm"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":637,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.namespace.member.remove.cancel"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":658,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.namespace.archive.cancel","admin.namespace.freeze.cancel","admin.namespace.restore.cancel","admin.namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":676,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive.cancel","admin.namespace.freeze.cancel","admin.namespace.restore.cancel","admin.namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":677,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.archive.confirm","admin.namespace.freeze.confirm","admin.namespace.restore.confirm","admin.namespace.unfreeze.confirm"],"conditional":true}, + {"file":"web/src/pages/admin/namespaces.tsx","line":799,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.namespace.member.role.draft.interact"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":801,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.draft.member"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":802,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.draft.admin"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":805,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.role"]}, + {"file":"web/src/pages/admin/namespaces.tsx","line":825,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.remove"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":131,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.auth.password.toggle","admin.auth.registration.toggle"],"conditional":true}, + {"file":"web/src/pages/admin/system-config.tsx","line":145,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.discard"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.save"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.auth.confirm"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.auth.cancel"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":174,"host":"form","prop":"data-analytics-id","event":"submit","ids":["admin.role.rule.create"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":189,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.select"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":218,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.edit"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":226,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.discard"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":227,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.save"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":230,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.disable"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.role.rule.disable.confirm"]}, + {"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.role.rule.disable.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":163,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["admin.user.search"]}, + {"file":"web/src/pages/admin/users.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.search"]}, + {"file":"web/src/pages/admin/users.tsx","line":174,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.clear"]}, + {"file":"web/src/pages/admin/users.tsx","line":186,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.user.status.interact"]}, + {"file":"web/src/pages/admin/users.tsx","line":190,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":191,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":192,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":193,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.status.filter"]}, + {"file":"web/src/pages/admin/users.tsx","line":232,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["admin.user.identifier.copy"]}, + {"file":"web/src/pages/admin/users.tsx","line":253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.edit"]}, + {"file":"web/src/pages/admin/users.tsx","line":261,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.approve"]}, + {"file":"web/src/pages/admin/users.tsx","line":270,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable"]}, + {"file":"web/src/pages/admin/users.tsx","line":278,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.enable"]}, + {"file":"web/src/pages/admin/users.tsx","line":286,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.password.reset"]}, + {"file":"web/src/pages/admin/users.tsx","line":306,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.previous"]}, + {"file":"web/src/pages/admin/users.tsx","line":314,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.next"]}, + {"file":"web/src/pages/admin/users.tsx","line":328,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.user.role.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":339,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["admin.user.role.interact"]}, + {"file":"web/src/pages/admin/users.tsx","line":344,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.user.role.select"]}, + {"file":"web/src/pages/admin/users.tsx","line":353,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.cancel"]}, + {"file":"web/src/pages/admin/users.tsx","line":356,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.role.confirm"]}, + {"file":"web/src/pages/admin/users.tsx","line":372,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["admin.user.disable.cancel","admin.user.enable.cancel","admin.user.password.reset.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/users.tsx","line":384,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable.cancel","admin.user.enable.cancel","admin.user.password.reset.cancel"],"conditional":true}, + {"file":"web/src/pages/admin/users.tsx","line":387,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.disable.confirm","admin.user.enable.confirm","admin.user.password.reset.confirm"],"conditional":true}, + {"file":"web/src/pages/cli-auth.tsx","line":218,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.cli.login"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":46,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.collection.open"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":47,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.settings.open"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":52,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.overview.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":53,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.repositories.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":54,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.runs.tab"]}, + {"file":"web/src/pages/dashboard/github-sync.tsx","line":69,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.source.close"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":102,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.all.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":103,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.review.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":104,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.promotion.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":105,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.report.tab"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":109,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":113,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":117,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":121,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":139,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.notification.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":149,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.activity.paginate"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":161,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.index.rebuild"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["governance.index.rebuild.confirm"]}, + {"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["governance.index.rebuild.cancel"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":92,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.management.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":138,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.open"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":148,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.list.paginate"]}, + {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":157,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":306,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":327,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":331,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":333,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":341,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":354,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":390,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":408,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":451,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":462,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":478,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":494,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":510,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":525,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":554,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":563,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":584,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":584,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":599,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":599,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":612,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":612,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":625,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":625,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":57,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.owned.search"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":63,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.query.clear"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":70,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.owned.interact"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":71,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.owned.filter"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":73,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.attention.filter"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":82,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.owned.refresh"]}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":96,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.management.open","suite.operation.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":115,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.management.open","suite.operation.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-suites.tsx","line":125,"host":"Pagination","prop":"analyticsId","event":"click","ids":["suite.owned.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":80,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.namespace.create"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":86,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.create.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":87,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.create.visibility.public"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":113,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":113,"host":"option","prop":"data-analytics-id","event":"change-selected-option","ids":["github.namespace.visibility.public"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":116,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.disable","github.namespace.enable"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":143,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.source.skills"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":149,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.namespace.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":159,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.skills.close"]}, + {"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":170,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.source.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":184,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.invitation.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":185,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.invitation.close"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":194,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.ownership.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":200,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.import.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":203,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.open"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":261,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["namespace.member.role.draft.interact"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":265,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.draft.member"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":266,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["namespace.member.role.draft.admin"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":289,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.role.save"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":295,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.role.cancel"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":306,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.remove"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":331,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.member.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.member.remove.confirm"]}, + {"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.member.remove.cancel"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":57,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.namespace.paginate"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":97,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":117,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.status.interact"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":119,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":120,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":121,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":130,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.sort.interact"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":134,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":135,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":175,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.user.tab"]}, + {"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":176,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.sync.tab"]}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":45,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.archive.request","namespace.delete.request","namespace.freeze.request","namespace.restore.request","namespace.unfreeze.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.archive.confirm","namespace.delete.confirm","namespace.freeze.confirm","namespace.restore.confirm","namespace.unfreeze.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.archive.cancel","namespace.delete.cancel","namespace.freeze.cancel","namespace.restore.cancel","namespace.unfreeze.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":195,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.previous"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":204,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.next"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":281,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":284,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":342,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.profile.sort.interact"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":346,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.profile.sort"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":347,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.profile.sort"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":363,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.pending.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":369,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.approved.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":375,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.rejected.tab"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":416,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["review.profile.approve.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":428,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":431,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.approve.confirm"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":439,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["review.profile.reject.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":455,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject.cancel"]}, + {"file":"web/src/pages/dashboard/profile-review-table.tsx","line":458,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.profile.reject.confirm"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":82,"host":"Pagination","prop":"analyticsId","event":"click","ids":["promotion.list.paginate"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.review.approve"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":154,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.review.reject"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":250,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.approve"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.reject"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":259,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["promotion.revocation.confirm"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":259,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["promotion.revocation.cancel"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":296,"host":"Pagination","prop":"analyticsId","event":"click","ids":["promotion.revocation.paginate"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":351,"host":"Button","prop":"data-analytics-id","event":"click","ids":["promotion.history.sort"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":437,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.pending.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":438,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":439,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.revocation.history"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":440,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.approved.tab"]}, + {"file":"web/src/pages/dashboard/promotions.tsx","line":441,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["promotion.rejected.tab"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":265,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.namespace.interact"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":269,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.clear"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":271,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.namespace.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":283,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.publish.visibility.interact"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":287,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.public"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":288,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":289,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.publish.visibility.private"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"analyticsId","event":"click+keydown+change+drop","ids":["skill.package.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"folderAnalyticsId","event":"change","ids":["skill.package.folder.select"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":297,"host":"UploadZone","prop":"pickerAnalyticsId","event":"click","ids":["skill.package.folder.open"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":312,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.remove"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":343,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.package.preview.retry"]}, + {"file":"web/src/pages/dashboard/publish.tsx","line":355,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.avatar.save","skill.publish.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.publish.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/publish.tsx","line":365,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.publish.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/reports.tsx","line":131,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.dismiss.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":159,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.resolve.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":167,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.hide.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":175,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.archive.request"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":192,"host":"Pagination","prop":"analyticsId","event":"click","ids":["report.list.paginate"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":208,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.pending.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":209,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.resolved.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":210,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.dismissed.tab"]}, + {"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["report.archive.confirm","report.dismiss.confirm","report.hide.confirm","report.resolve.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["report.archive.cancel","report.dismiss.cancel","report.hide.cancel","report.resolve.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":211,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":233,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":276,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":293,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":317,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":318,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":319,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":320,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":362,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":439,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":453,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":464,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":479,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]}, + {"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":79,"host":"form","prop":"data-analytics-id","event":"submit","ids":["review.progress.search"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":100,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.type.interact"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":104,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":105,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":106,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":118,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.status.interact"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":122,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":123,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":124,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":125,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":138,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.progress.status"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":193,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.progress.paginate"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":228,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.workspace.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":232,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":262,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.resubmit.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":274,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.workspace.open"]}, + {"file":"web/src/pages/dashboard/review-progress.tsx","line":278,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.history.toggle"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":115,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.list.paginate"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":158,"host":"TableRow","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":207,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.list.sort.interact"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":211,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":212,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":221,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.pending.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":227,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.approved.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":233,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.rejected.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":274,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.skill.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":280,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.sync.tab"]}, + {"file":"web/src/pages/dashboard/reviews.tsx","line":286,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.tab"]}, + {"file":"web/src/pages/dashboard/stars.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/dashboard/stars.tsx","line":53,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.starred.paginate"]}, + {"file":"web/src/pages/dashboard/subscriptions.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/dashboard/subscriptions.tsx","line":53,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.subscribed.paginate"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":313,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.authoring.manual"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":322,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.authoring.import"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":355,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.namespace.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":356,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.namespace.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":417,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.visibility.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":419,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.public"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":420,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":421,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.visibility.private"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":519,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.candidate.coordinate.select","suite.candidate.market.select","suite.candidate.namespace.select"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":542,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["suite.candidate.search"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":558,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["suite.candidate.visibility.interact"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":560,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.public"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":561,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.namespace"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":562,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["suite.candidate.visibility.private"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":565,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.candidate.search"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":596,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.member.add","suite.member.version.replace"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":634,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.collapse"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":643,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.entry.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":648,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.up"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":649,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.down"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":650,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.member.remove"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":653,"host":"input","prop":"data-analytics-id","event":"change","ids":["suite.entry.select"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":677,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.member.remove"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":684,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.toggle"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":687,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.cancel"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":688,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.create.save","suite.draft.edit.save","suite.version.draft.save"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":696,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["suite.member.version.confirm"]}, + {"file":"web/src/pages/dashboard/suite-editor.tsx","line":696,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["suite.member.version.cancel"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":92,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit","suite.version.create"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":107,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.market.preview"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":162,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.overview.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":163,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.members.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":164,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.versions.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":165,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.publishing.tab"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":177,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, + {"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, + {"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]}, + {"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, + {"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]}, + {"file":"web/src/pages/github-skills.tsx","line":63,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/github-skills.tsx","line":82,"host":"form","prop":"data-analytics-id","event":"submit","ids":["github.catalog.search"]}, + {"file":"web/src/pages/github-skills.tsx","line":85,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.catalog.sort"]}, + {"file":"web/src/pages/github-skills.tsx","line":91,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.catalog.paginate"]}, + {"file":"web/src/pages/home.tsx","line":52,"host":"SearchBar","prop":"analyticsId","event":"submit","ids":["search.query.submit"]}, + {"file":"web/src/pages/home.tsx","line":56,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/home.tsx","line":62,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/pages/home.tsx","line":81,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.popular.open"]}, + {"file":"web/src/pages/home.tsx","line":94,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/home.tsx","line":113,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.latest.open"]}, + {"file":"web/src/pages/home.tsx","line":126,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/join.tsx","line":58,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.join.retry"]}, + {"file":"web/src/pages/join.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.join.confirm"]}, + {"file":"web/src/pages/landing.tsx","line":92,"host":"input","prop":"data-analytics-id","event":"keydown-enter","ids":["discovery.query.enter"]}, + {"file":"web/src/pages/landing.tsx","line":106,"host":"button","prop":"data-analytics-id","event":"click","ids":["discovery.suggestion.search"]}, + {"file":"web/src/pages/landing.tsx","line":132,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/landing.tsx","line":358,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/pages/landing.tsx","line":361,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/pages/landing.tsx","line":424,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.popular.open"]}, + {"file":"web/src/pages/landing.tsx","line":437,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/landing.tsx","line":453,"host":"Button","prop":"data-analytics-id","event":"click","ids":["discovery.latest.open"]}, + {"file":"web/src/pages/landing.tsx","line":466,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/landing.tsx","line":483,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, + {"file":"web/src/pages/landing.tsx","line":486,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, + {"file":"web/src/pages/login.tsx","line":87,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.personal.select"]}, + {"file":"web/src/pages/login.tsx","line":90,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.enterprise.select"]}, + {"file":"web/src/pages/login.tsx","line":97,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.login.submit"]}, + {"file":"web/src/pages/login.tsx","line":124,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.password.reset.open"]}, + {"file":"web/src/pages/login.tsx","line":145,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.password.visibility"]}, + {"file":"web/src/pages/login.tsx","line":199,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.register.open"]}, + {"file":"web/src/pages/login.tsx","line":209,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, + {"file":"web/src/pages/login.tsx","line":210,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, + {"file":"web/src/pages/notifications.tsx","line":88,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.all.read"]}, + {"file":"web/src/pages/notifications.tsx","line":102,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.category.all","notification.category.promotion","notification.category.publish","notification.category.report","notification.category.review"],"conditional":true}, + {"file":"web/src/pages/notifications.tsx","line":133,"host":"button","prop":"data-analytics-id","event":"click","ids":["notification.fallback.open","notification.promotion.open","notification.report.open","notification.review.detail.open","notification.review.list.open","notification.target.provided.open"],"conditional":true}, + {"file":"web/src/pages/notifications.tsx","line":158,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.delete.request"]}, + {"file":"web/src/pages/notifications.tsx","line":177,"host":"Pagination","prop":"analyticsId","event":"click","ids":["notification.list.paginate"]}, + {"file":"web/src/pages/register.tsx","line":186,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.register.submit"]}, + {"file":"web/src/pages/register.tsx","line":263,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/reset-password.tsx","line":109,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/reset-password.tsx","line":114,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.password.reset.submit"]}, + {"file":"web/src/pages/reset-password.tsx","line":129,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.password.code.request"]}, + {"file":"web/src/pages/search.tsx","line":209,"host":"SearchBar","prop":"analyticsId","event":"submit","ids":["search.query.submit"]}, + {"file":"web/src/pages/search.tsx","line":223,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.relevance"]}, + {"file":"web/src/pages/search.tsx","line":230,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.downloads"]}, + {"file":"web/src/pages/search.tsx","line":237,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.sort.newest"]}, + {"file":"web/src/pages/search.tsx","line":263,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.starred.filter"]}, + {"file":"web/src/pages/search.tsx","line":271,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.label.filter"]}, + {"file":"web/src/pages/search.tsx","line":281,"host":"Button","prop":"data-analytics-id","event":"click","ids":["search.namespace.clear"]}, + {"file":"web/src/pages/search.tsx","line":300,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]}, + {"file":"web/src/pages/search.tsx","line":309,"host":"Pagination","prop":"analyticsId","event":"click","ids":["search.results.paginate"]}, + {"file":"web/src/pages/settings/profile.tsx","line":186,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.security.open"]}, + {"file":"web/src/pages/settings/profile.tsx","line":191,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.profile.edit"]}, + {"file":"web/src/pages/settings/profile.tsx","line":215,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.profile.save"]}, + {"file":"web/src/pages/settings/profile.tsx","line":261,"host":"Button","prop":"data-analytics-id","event":"click","ids":["account.profile.cancel"]}, + {"file":"web/src/pages/settings/security.tsx","line":96,"host":"form","prop":"data-analytics-id","event":"submit","ids":["account.password.save"]}, + {"file":"web/src/pages/skill-detail.tsx","line":831,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":867,"host":"div","prop":"data-analytics-id","event":"structural","ids":["skill.detail.render"]}, + {"file":"web/src/pages/skill-detail.tsx","line":872,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.back"]}, + {"file":"web/src/pages/skill-detail.tsx","line":923,"host":"CopyButton","prop":"analyticsId","event":"click","ids":["skill.coordinate.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":945,"host":"Link","prop":"data-analytics-id","event":"click","ids":["search.label.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":985,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.overview.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":986,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.files.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":987,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["skill.versions.tab"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1015,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.overview.toggle"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1058,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1097,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1104,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.delete"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1123,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1132,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.publish"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1141,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.submit"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1178,"host":"button","prop":"data-analytics-id","event":"click","ids":["skill.browser.toggle"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1200,"host":"FileTree","prop":"analyticsId","event":"click","ids":["skill.file.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1253,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1291,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.detail.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1318,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"analyticsId","event":"click","ids":["skill.command.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"skillhubAnalyticsId","event":"click","ids":["skill.command.skillhub.select"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1373,"host":"InstallCommand","prop":"clawhubAnalyticsId","event":"click","ids":["skill.command.clawhub.select"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1413,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1423,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.download.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1442,"host":"InstallForAgentButton","prop":"analyticsId","event":"click","ids":["skill.prompt.copy"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1514,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1518,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1523,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1555,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.promotion.version.interact"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1560,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.promotion.version"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1569,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1582,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1586,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1621,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.hide.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1625,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.unhide.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1631,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.yank"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1641,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["report.create.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1661,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1664,"host":"Button","prop":"data-analytics-id","event":"click","ids":["report.create.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1671,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1690,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1703,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.cancel","skill.revocation.request.cancel"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1704,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.revocation.direct.submit","skill.revocation.request.submit"],"conditional":true}, + {"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1711,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1720,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.delete.continue"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1729,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.delete.continue.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1748,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1764,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1767,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.delete.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.delete.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1778,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.delete.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1792,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1816,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1836,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1839,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.rerelease.request"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.rerelease.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1846,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.rerelease.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.version.publish.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1867,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.version.publish.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.submit.confirm"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1880,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.submit.cancel"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1902,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["skill.comparison.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1967,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.comparison.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["skill.file.close"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["skill.file.download"]}, + {"file":"web/src/pages/skill-detail.tsx","line":1981,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["skill.file.copy"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":160,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.comparison.base.interact"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":165,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.comparison.base"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":187,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.comparison.head.interact"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":192,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.comparison.head"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":200,"host":"Input","prop":"data-analytics-id","event":"change","ids":["skill.comparison.filter"]}, + {"file":"web/src/pages/skill-version-compare.tsx","line":212,"host":"a","prop":"data-analytics-id","event":"click","ids":["skill.comparison.file"]}, + {"file":"web/src/pages/suite-detail.tsx","line":116,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]}, + {"file":"web/src/pages/suite-detail.tsx","line":122,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.command.copy"]}, + {"file":"web/src/pages/suite-detail.tsx","line":128,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.draft.edit"]}, + {"file":"web/src/pages/suite-detail.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.publish.direct.request","suite.review.submit.request"],"conditional":true}, + {"file":"web/src/pages/suite-detail.tsx","line":155,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.overview.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":156,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.members.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":157,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["suite.versions.tab"]}, + {"file":"web/src/pages/suite-detail.tsx","line":185,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.version.open"]}, + {"file":"web/src/pages/suites.tsx","line":44,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]}, + {"file":"web/src/pages/suites.tsx","line":47,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.query.submit"]}, + {"file":"web/src/pages/suites.tsx","line":61,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.label.clear"]}, + {"file":"web/src/pages/suites.tsx","line":75,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.label.filter"]}, + {"file":"web/src/pages/suites.tsx","line":97,"host":"ResourceCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["suite.card.open"]}, + {"file":"web/src/pages/suites.tsx","line":106,"host":"Pagination","prop":"analyticsId","event":"click","ids":["suite.list.paginate"]}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":68,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","forward":"cancelAnalyticsId"}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","forward":"cancelAnalyticsId"}, + {"file":"web/src/shared/components/confirm-dialog.tsx","line":77,"host":"Button","prop":"data-analytics-id","event":"click","forward":"confirmAnalyticsId"}, + {"file":"web/src/shared/components/copy-button.tsx","line":25,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":51,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.view.select"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":78,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.prompt.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":174,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":193,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":232,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":273,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":321,"host":"a","prop":"data-analytics-id","event":"click","ids":["guide.package.download"]}, + {"file":"web/src/shared/components/landing-quick-start.tsx","line":371,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.mode.select"]}, + {"file":"web/src/shared/components/language-switcher.tsx","line":60,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.language.open"]}, + {"file":"web/src/shared/components/language-switcher.tsx","line":80,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.language.select"]}, + {"file":"web/src/shared/components/pagination.tsx","line":52,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/pagination.tsx","line":74,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/pagination.tsx","line":91,"host":"Button","prop":"data-analytics-id","event":"click","forward":"analyticsId"}, + {"file":"web/src/shared/components/quick-start.tsx","line":31,"host":"button","prop":"data-analytics-id","event":"click","ids":["guide.command.copy"]}, + {"file":"web/src/shared/components/route-error.tsx","line":24,"host":"Button","prop":"data-analytics-id","event":"click","ids":["navigation.route.retry"]}, + {"file":"web/src/shared/components/theme-toggle.tsx","line":18,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.theme.toggle"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":112,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.account.toggle"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":145,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.owned.skills"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":148,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.owned.suites"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":151,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":155,"host":"Link","prop":"data-analytics-id","event":"click","ids":["account.security.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":160,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.queue.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":166,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.users.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":171,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.labels.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":176,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.settings.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":181,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.namespaces.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":186,"host":"Link","prop":"data-analytics-id","event":"click","ids":["admin.audit.open"]}, + {"file":"web/src/shared/components/user-menu.tsx","line":191,"host":"button","prop":"data-analytics-id","event":"click","ids":["auth.logout.request"]}, + {"file":"web/src/shared/ui/dialog.tsx","line":45,"host":"DialogPrimitive.Close","prop":"data-analytics-id","event":"click","forward":"closeAnalyticsId"} + ] +} diff --git a/docs/frontend-analytics.md b/docs/frontend-analytics.md new file mode 100644 index 000000000..621b6d0d6 --- /dev/null +++ b/docs/frontend-analytics.md @@ -0,0 +1,392 @@ +# Passive frontend analytics attributes + +For step-by-step setup, see the [Chinese integration guide](frontend-analytics-integration.md). +This document is the English contract reference. Configuration-only deployment is +planned in [PR-2](../openspec/changes/add-configurable-frontend-analytics/proposal.md); +the current examples remain deployment-owned and opt-in. + +SkillHub exposes semantic HTML attributes for separately maintained, optional +collectors. It does not collect or send analytics by default. There is no SDK, +vendor dependency, vendor token, network reporter, app script loader or +telemetry configuration switch. The application entrypoint and production Nginx +configuration are unchanged. Deployment owners control consent, script delivery, +retention, transport and compliance; this is not an official vendor integration +or a durable business-event feed. + +## Contract and scope + +`data-analytics-id` names an action category (what kind of action), not a unique +event or instance ID, and describes intent, not its result. Many elements and +many events share the same category. Lowercase dotted names are independent of +language, visible text and layout. + +The example callback payload is `{ id, event }`: `id` is the matched category; +`event` is the native DOM event type observed (`click`, `submit`, or `keydown`), +not a business result. Evolution within this module version is additive only: +new optional fields must be allowlisted static values, consumers ignore unknown +keys, and existing field meanings never change. Breaking changes use a new +versioned module path. + +Published IDs are frozen. A meaning change needs a new ID; rename/removal must be +called out in the PR and release notes and visible in the generated manifest diff +(the CI drift test checks source against that manifest). Deployers should diff +the manifest between versions: configured IDs that disappear simply never fire; +new IDs remain inert until explicitly configured. The manifest is not a runtime +closed enum. +Literal conditional branches distinguish finite business actions (for example, +namespace freeze/unfreeze/archive/restore/delete and each confirmation/cancel). +No resource, slug, version, user, provider, role, label instance or backend string +is interpolated into attributes. Fixed navigation metadata is source-owned. +`data-analytics-event`, where present, describes a native observation boundary; +it is a hint for a deployment collector, not an application event dispatcher. + +The source inventory covers these domains: + +- Landing/catalog/search: queries, sorting, filters, results, pagination and links. +- Skills: detail structure, tabs/versions/comparison, documentation and files, + acquisition/copy, social actions, labels, visibility and lifecycle confirmations. +- Publishing: package/folder selection, preview/retry, submission/warnings and + avatar editing, staging, retries and cancellation. +- Suites: catalog/detail, members/versions, editor/import, publishing tasks, + lifecycle and destructive actions. +- Reviews, promotions, reports and governance: queues, detail inspection, + dispositions, layered confirmations, rejection editing, cancellation and retry. +- Namespaces: management, membership/import, invitations, ownership and lifecycle. +- GitHub: collection/settings, discovery, credentials, scheduling/admission, + repositories/sources/runs, conflicts and review inspection. +- Notifications/preferences, account/profile/security/token flows, login, + registration/reset/device/CLI authorization, administration and global navigation. + +[The generated manifest](frontend-analytics-manifest.json) is the authoritative +source index: registered routes and their implementations, literal JSX sites, +conditional values, static navigation definitions and forwarding sites. Counts +are recomputed from the TypeScript AST; they are not quotas. Unique IDs are +semantic categories, JSX sites are source declarations, and forwarding sites +may have no literal ID until a caller opts in. A wrapper prop is not itself a +DOM element. Repeated resources/pages, desktop/mobile navigation and portal +instances can share an ID; neither IDs nor source sites count runtime elements, +unique users, pageviews, successful operations or exhaustive business coverage. + +Development checks (from the repository root with web dependencies installed): + +```sh +node web/scripts/analytics-manifest.mjs +# After reviewing intentional source changes: +node web/scripts/analytics-manifest.mjs --write +pnpm -C web test src/app/analytics-manifest.test.ts +``` + +Do not ship the manifest as a runtime closed enum. A deployment collector should +have its own small, explicit event/ID allowlist and update it deliberately. + +### Original eight compatibility meanings + +| ID | Meaning | +| --- | --- | +| `search.query.submit` | Search form submit; button and Enter converge here. | +| `skill.card.open` | Existing card activation; click or its direct Enter/Space handler. | +| `skill.detail.render` | Structural detail root after loading/error/missing/deleted guards; not a unique pageview or success. | +| `skill.download.request` | Package download attempt; authentication/guards may reject it. | +| `skill.command.copy` | Command-copy attempt for the displayed installation method; not clipboard or installation success. | +| `skill.prompt.copy` | Agent-prompt-copy attempt; not execution or installation. | +| `skill.publish.request` | Publish/update/resubmit attempt, never avatar-only save. | +| `skill.publish.confirm` | Explicit publish warning continuation, never all generic dialogs or avatar-only save. | + +Installation method tabs distinguish SkillHub and ClawHub; the original command +copy ID remains compatible. Public/namespace/private choices and business modes +use static categories, not data fetched from the selected object. + +## Native observation policy and limits + +Attributes alone never create events. Choose the native event per host, and +count one semantic target per event rather than every marked ancestor: + +- Forms: observe `submit`, not both submit-button click and submit. Enter and + native validation follow existing app behavior. Rejected validation may produce + no submit. Search typing/clear is not a search submission. +- Buttons/links: observe click, including browser-generated keyboard click. Ignore + disabled, aria-disabled and data-disabled controls. An enabled click can still + fail app guards; busy/authorization semantics remain the application's. +- Custom DIV cards with existing Enter/Space handlers need that keydown in addition + to click. Native buttons must not count both. Tabs generate click from navigation + keys; do not count Arrow/Home/End keydown again. Do not invent keyboard handlers + for existing mouse-only rows or directories. +- Native select: observe change. Where the static marker is on an option, read only + `selectedOptions[0].getAttribute('data-analytics-id')`, never option value/text. + A select marker describes its context, not the raw choice. Avoid click/change + double counting. Checkbox/radio change is an intent, not saved server state. +- Radix Select items are actual option DOM, sometimes in a portal. Pointerup or + Enter/Space on an item is selection intent, not proof that onValueChange committed + a new value. Opening the trigger and navigation/highlight are not committed + selections. A click-only collector is insufficient. Validate event order with + the deployed component/browser version rather than manufacturing app hooks. + Trigger `*.interact` markers cover observable activation/keydown intent, including + closed-trigger typeahead; never capture the typed key, infer the chosen value, + or call that interaction a committed selection. Typeahead can change selection + without opening an item. Keep trigger interaction and item selection-intent + metrics separate instead of counting both as the same selection. +- File inputs: change means a selection was offered, not upload success. Drop is + offered files, not accepted/validated files. Picker-opening click differs from + change; ignore programmatic input clicks as additional user intent. Never read + filenames, paths or file metadata. Picker cancellation is not observed. +- Crop surface pointer/keyboard interaction is adjustment intent, not proof the + crop changed or an image upload succeeded; no coordinates or image data may leave. +- Explicit cancel/close buttons, including X, preserve workflow-specific meaning. + Escape, overlay dismissal, route teardown, browser Back, automatic close and + native picker cancel are not reliably observable through these static attrs. + Closing an editor is not proof an in-flight request was aborted. + +Use document-level delegation for portals; React ancestry is not DOM ancestry. +Wrapper forwarding is caller-scoped, including confirmation/cancel and close X; +generic UI primitives do not guess business IDs. Keep the nearest semantic target +and reject nested unmarked interactive controls rather than attributing them to +an ancestor card/form. Synthetic clicks (including temporary download anchors) +are not new successful user actions. Do not block or change default behavior, +propagation, focus, route history, permissions or app handlers. + +`skill.detail.render` is not an automatic pageview event. SPA route classification, +render retries, consent transitions, duplicate initialization and view deduplication +are separate collector policies. Never derive these from full URLs/query strings. + +### Privacy and integration modes + +The recommended output allowlist is only `{ id, event }`, each validated against +operator-owned static categories. Do not collect textContent/innerHTML, input or +option values, clipboard contents, href/full URLs/query/fragment, cookies, storage, +identity/account IDs, device codes, tokens, invitation links, private coordinates, +filenames/paths, review/report reasons or crop data. Do not scrape arbitrary DOM +attributes or reuse test IDs (some intentionally contain resource identifiers). + +Disabled integration means no injected script and zero analytics collection. +Enabled delivery does not itself authorize collection: initialize only after +applicable consent/policy approval, stop on withdrawal, and separately review any +outbound adapter and CSP connect destinations. External JS has page privileges; +serve reviewed versioned code, restrict access and treat compromise as a security +incident. Vendor credentials and ingestion endpoints belong outside core. + +## Configure rules and add tags + +[The vendor-neutral helper](examples/analytics/observe/v1/skillhub-intents.mjs) +exports `observeSkillhubIntents({ root, rules, onIntent })`, frozen `DEFAULT_RULES` +(the original 12 categories), and pure `resolveRules(rules)`: + +```js +import { DEFAULT_RULES, resolveRules, observeSkillhubIntents } from './skillhub-intents.mjs' +const rules = { ...DEFAULT_RULES, 'skill.example.open': 'click' } +const { active, skipped } = resolveRules(rules) // Inspect locally; do not send config diagnostics. +const stop = observeSkillhubIntents({ root: document, rules: active, onIntent: sendIntent }) +``` + +Omitting `rules` uses the defaults; passing it REPLACES them. Merge explicitly as +above. The example accepts the same grammar the product manifest enforces: +`^[a-z]+(?:\.[a-z]+){2,}$` (at least three dot-separated segments, lowercase letters +only; no digits or underscores). For example, `skill.command.skillhub.select` +can reuse `click`. Only finite rule types are supported: + +- `click`: ordinary buttons/links. FORM, SELECT, TEXTAREA and non-button INPUT + hosts are ignored (including file inputs). +- `submit`: FORM only; button click and Enter converge on native submit. +- `card`: DIV role=link with its existing Enter/Space handler, plus click. The + observer does not implement keyboard activation or synthesize clicks. + +Select/file/drop/crop and other special interactions need a future rule type; +never configure them as `click`. Unknown/unconfigured actions, invalid IDs, +non-string rules and unsupported rule types/hosts are skipped without affecting +the page. `resolveRules(...).skipped` gives `{ id, reason }` for invalid entries; +an invalid rules container yields an `id: null` diagnostic and an empty active map. +It validates configuration, not the live DOM host. + +Importing the helper does nothing; without a callback it installs no listeners. +The same root returns the existing stop function and keeps the FIRST config and +callback. To change config, call `stop()` and initialize again. Nested observers +from this module instance share event deduplication; only the nearest marked host +is considered, never an ancestor behind an unmarked nested interactive control. +Native-button keydown, repeated/IME/modifier card keydown are ignored. Sync throws +and returned rejected promises from the callback are swallowed, with no retry or +queue. No network, cookies, storage, identity or route patching occurs in the +helper, and it never prevents defaults or stops propagation. + +To add a normal action: product owners add a literal `data-analytics-id` on the +appropriate existing host, run `node web/scripts/analytics-manifest.mjs --write`, +review the manifest diff and test the host/native behavior. Deployers then add one +rule line. No product listener or SDK is needed. Download click is NOT download or +installation success; publish submission is NOT publish success. + +The repository-integration test lives at `web/analytics-example.test.ts`, alongside +the existing root-level web tests. Vitest discovers it with `pnpm -C web test`; +run it alone with `pnpm -C web test analytics-example.test.ts`. It stays outside +the production TypeScript project's `src`/`test` inputs because the docs-only +example is not present in the `web/` Docker build context. + +## Connect a platform: local Umami v3.4.1 demo + +[The Compose example](examples/analytics/compose.yaml) runs Umami v3.4.1 (MIT), +Postgres 16 and an optional edge; SkillHub remains your existing web upstream. +From the repository root: + +```sh +docker compose -f docs/examples/analytics/compose.yaml config +docker compose -f docs/examples/analytics/compose.yaml up -d +``` + +The default Web upstream is `host.docker.internal:80`, matching the release +Compose `WEB_PORT` default of 80. Override `SKILLHUB_WEB_UPSTREAM` with the actual +reachable host:port if different. The edge's `extra_hosts` maps this hostname to +`host-gateway` on native Linux; Docker Desktop/OrbStack already provide it. The +host Web listener must be reachable from containers. The edge inherits the standard +server-level `client_max_body_size 100M` for application proxying (100 MiB); it does +not reduce uploads to Nginx's default 1 MiB. + +This creates a demo network and ephemeral database, not production storage. Use +an isolated local Docker environment with available network pools. Open +`http://localhost:8300` for local setup only. Umami's default `admin` / `umami` +credentials, the public DB password and app secret are DEMO ONLY: change them. +Create a synthetic website in Umami, copy its UUID into +[`observer.mjs`](examples/analytics/observe/v1/observer.mjs), then explicitly change +`enabled` and the consent hook for your local policy. Open SkillHub through +`http://localhost:8081` and exercise an enabled action; inspect the request to +`/_skillhub-umami/api/send` and Umami's event view. Tear down the local demo with +`docker compose -f docs/examples/analytics/compose.yaml down -v` (demo data is removed). + +[`umami-adapter.mjs`](examples/analytics/observe/v1/umami-adapter.mjs) is the only +vendor-specific code. It loads `/script.js` with `data-auto-track="false"`, so +v3.4.1 installs no pageview, history hook, click listener or performance observer. +`data-host-url` is the same-origin `/_skillhub-umami` prefix, and +`data-before-send` names its sanitizer. Do not load another Umami tracker on the +same page (the SDK/global hook are singletons). It calls `umami.track(id, { event })`; +at most 20 valid intents wait in memory for up to three seconds while the script +loads (excess intents are dropped). They are released when ready, or dropped on +load failure, timeout or `stop()`. Once ready, every valid intent calls the tracker +independently: concurrent or hung requests do not block later intents. Each call's +wait is time-bounded; missing, blocked, throwing or rejecting trackers resolve +silently. There is no retry, durable queue or post-load backlog; best-effort counts +can undercount. After `stop()` returns, no new request is dispatched; only a +request whose fetch already started cannot be recalled (nor cancelled by a timeout). +The before-send hook keeps its immediate stop check and adds a non-enumerable +`toJSON` guard that re-checks consent when the SDK serializes at dispatch. In pinned +v3.4.1 this runs synchronously in the fetch expression inside the SDK's try/catch: +throwing on stop prevents fetch and is swallowed by the SDK. This relies on that +ordering; a different Umami version must re-verify it with the dispatch-boundary +regression tests in `web/analytics-umami-example.test.ts`. + +The sanitizer allowlists the FINAL SDK payload, not just the callback. Only +`{ type: 'event', payload: { website, name, data: { event } } }` survives; website +must match configuration, name must match the ID shape (and Umami's 50-character +limit), event must be click/submit/keydown. It returns null for pageviews (including +unnamed `event` payloads), identify, performance and everything else. Hostname, URL, +title, referrer, screen, language, tag, identity and extra data keys are removed. +Values are read once, and only validated primitives are forwarded. +The SDK still reads title/URL/referrer in-page before sanitizing; it also reads +localStorage for its own `umami.disabled` switch. Those are SDK behaviors, not +behaviors of the vendor-neutral helper. + +This is NOT anonymous transport or zero server processing: IP, User-Agent and +Origin still leave the browser. Umami sends `x-umami-website-id`, +`x-umami-hostname` (the actual hostname) and, after a response, `x-umami-cache` +(a session cache token). They are NOT sanitized JSON. The edge tightens UI +Referrer-Policy to `no-referrer` and strips Referer, Cookie and Authorization on +collector proxying; otherwise same-origin Referer can expose paths/queries. +Umami v3.4.1 hashes website/IP/User-Agent/salt into a server-side session and derives +browser/OS/device/geo from transport metadata. Removing JSON screen/language does +not disable these derivations; review access logs, retention, consent and local +law. Its missing-URL fallback stores empty url_path and fallback hostname +`localhost`, not the current page; url_query is an empty string (not SQL NULL in +v3.4.1), while title/referrer/screen/language stay null. + +To replace the platform, replace the adapter and edge ingestion endpoints, keeping +the helper and static payload contract. No admin configuration page, SkillHub +analytics DB, persistent queue or multi-vendor SDK is introduced. + +## Turn collection off + +No injection means zero collection. `enabled: false` in the deployer's entry is +also inert: no helper initialization or tracker load. On consent withdrawal call +`window.skillhubAnalytics.stop()`; it removes listeners and denies SDK payloads, +including those awaiting dispatch. After it returns, no new request is dispatched; +only a request whose fetch already started cannot be recalled. The dispatch guard +relies on pinned v3.4.1 serializing at dispatch; re-run the adapter regression tests +to re-verify this ordering for a different Umami version. Resume only after approval +via `start()`. Platform-side disabling/deleting the website, Umami's local +`umami.disabled` switch, or blocking the two ingestion routes are additional +operator controls, not substitutes for consent. Removing injection and purging +HTML is the deployment rollback; default SkillHub never loads any of this. + +## Optional deployment-owned Nginx injection + +Prefer a separately maintained same-origin HTTPS module, optionally inserted by +Nginx `sub_filter`, not an app entrypoint modification. Start with +[the opt-in edge template](examples/analytics/nginx.conf). It is not loaded by the +product. Compose substitutes only the two upstream host:port variables. Publish +its HTTP listener on loopback behind your HTTPS terminator. Serve the supplied +`observer.mjs`, helper and adapter outside the app bundle; only enable the entry +after configuring website/rules/consent. + +The sample filters only the UI fallback's default `text/html` type, once per +response, inserting a module before ``. Module loading is deferred and no +application code depends on it; missing/failed JS must not stop SkillHub. The +versioned prefix location overrides SPA fallback, sets JavaScript MIME and nosniff, +and uses `try_files $uri =404` with directory listing off. The served directory +contains only the three JS modules, never `.d.mts` or config. Missing modules are +404, never index.html. Code is immutable: deploy changed bytes/config under a new +version directory and update the injection path (breaking contracts need a new +module version). The two exact Umami routes expose tracker and send only; all +other paths under that prefix return 404. Same-origin script-src/connect-src are +sufficient for this demo, subject to the existing policy below. + +Deployment checklist: + +1. Check `nginx -V` for http_sub_module (built with `--with-http_sub_module` or an + equivalent loaded module), then `nginx -t`. Module absence is a blocker, not an + invitation to weaken security. Review actual final config with `nginx -T`. +2. Upstream `Accept-Encoding ""` requests uncompressed HTML. Ensure upstream + gzip_static/Brotli/CDN compression cannot still send encoded bytes. Filtering + compressed input does not work; downstream compression may run after filtering. + Leave API, auth, download, asset and runtime-config locations unmodified. +3. Filtered HTML must not reuse unfiltered validators or cached 304 responses. The + example removes upstream ETag/Last-Modified/Content-MD5 and cache headers, + suppresses conditional requests and disables proxy cache, serving no-store HTML. + Purge old edge/CDN HTML on enable/rollback; test GET/HEAD, conditional headers, + existing browser caches and 200/304 behavior. Do not cache injected HTML under + the old unfiltered entity validator. API and immutable assets retain their policy. +4. Preserve existing CSP and all security headers. The script must satisfy the + current script-src policy (same-origin alone does not satisfy nonce-only or + strict-dynamic policies). A per-response nonce or approved external hash/SRI + policy needs deployment-specific coordination; otherwise do not enable injection. + Never add unsafe-inline/unsafe-eval or broad wildcards. Any reporter needs an + explicitly permitted connect-src endpoint; this example uses same-origin only. + Nginx add_header inheritance is location-sensitive: merge/repeat existing edge + security headers as necessary; do not drop or hide upstream CSP to make it work. +5. Test actual built SkillHub HTML, not just a synthetic HTML fixture: exactly one + script for UI routes, none for APIs/assets/downloads/auth endpoints; script MIME, + missing-script 404, root/deep links, compression, CSP, disabled delivery and + collector failure. Then browser-check native events, portals, guards and consent. + A fixture filter probe is not final-config, SkillHub or production acceptance. + +### Supported sub-path mapping + +The sample is root deployment. For a supported `/skillhub/` deployment, keep that +prefix on the edge-to-app proxy URI (proxy_pass without a trailing slash), and +configure the app normally with SKILLHUB_WEB_BASE_PATH=/skillhub/ and matching +same-origin API base. Its existing container entrypoint owns internal prefix +rewriting; do not rewrite it a second time at the edge. + +Adapt the sample's UI `location /` to `location /skillhub/`; add an exact +`/skillhub` redirect to `/skillhub/`; scope the bypass regex to +`^/skillhub/(api/|oauth2/|login/oauth2/|\.well-known/|assets/|registry/|runtime-config\.js$|nginx-health)`. +Change the versioned module prefix location and injected src to +`/skillhub/_skillhub-observe/v1/` and +`/skillhub/_skillhub-observe/v1/observer.mjs`, plus the matching deny prefix. +With `root /srv`, install the three JS files under +`/srv/skillhub/_skillhub-observe/v1/`; relative imports follow automatically. +Also scope BOTH exact Umami endpoints and the deny prefix to +`/skillhub/_skillhub-umami/`, and change the entry's adapter `prefix` accordingly; +upstream `/script.js` and `/api/send` stay unchanged. Keep other site locations +unchanged; do not put sub_filter at http/server scope. Use any supported validated +base path consistently, preserving original request URIs. HTTPS/public registry, +OAuth callbacks and generated asset URLs still need the normal deployment setup. + +Rollback: remove the sub_filter directives/injection location customization, test +configuration, reload through the normal deployment procedure, and purge cached +HTML. Existing cached versioned JS can remain temporarily but must not be referenced +or initialized. Verify new HTML has no script, no callback/reporting occurs and +SkillHub remains usable. This document does not authorize production changes. diff --git a/openspec/changes/add-configurable-frontend-analytics/design.md b/openspec/changes/add-configurable-frontend-analytics/design.md new file mode 100644 index 000000000..2592fd0ba --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/design.md @@ -0,0 +1,58 @@ +## Context and decisions + +本变更在 PR #1010 上保留原贡献历史并合入最新 main,保留 footer 翻译及 Trans 登录协议。使用已有运行时配置比 Nginx HTML 注入更易自动交付;完整标签管理平台增加额外服务与权限体系,本期不采用。 + +## 分阶段交付与接口对齐 + +PR-1(#1010)仅完成被动标签、厂商中立 helper 与示例 adapter、edge 示例问题修正和中文接入指南。它不修改产品入口或部署开关,手工接入方式继续明确标为可选示例。PR-2 独立交付配置化加载、产品模块、runtime-config、同源代理和 Compose/Helm。两者可分别合并,Issue #989 仅在 PR-2 完成并验证后关闭。 + +PR-2 必须采用 PR-1 实际交付的接口,不能另起一套不兼容协议: + +| PR-1 接口 | PR-2 采用方式 | +| --- | --- | +| `data-analytics-id` 匹配 `^[a-z]+(?:\.[a-z]+){2,}$`,由 manifest 索引 | 保持已发布 ID 含义冻结;manifest 不是运行时封闭枚举,不插值资源/身份信息 | +| `observeSkillhubIntents({ root, rules, onIntent }) -> stop` | controller 按授权安装委托观察;不改变页面原生事件和业务处理 | +| 冻结的 `DEFAULT_RULES`(12 条) | rules 省略用默认值,显式提供为替换,空对象不采集;增加默认规则需显式合并 | +| `resolveRules(rules) -> { active, skipped: [{ id, reason }] }` | 使用相同纯校验;非法容器的 id 为 null;诊断只留本地,不发送原配置 | +| 有限类型 `click`/`submit`/`card` | 保持宿主限制和原生事件边界;未知类型/ID 格式/不支持宿主跳过,不猜测选择、文件、拖放或裁剪事件 | +| intent 仅 `{ id, event }` | event 为原生 click/submit/keydown,不表示业务成功;同版本只允许增加可选的静态白名单字段,消费者忽略未知字段;破坏性变更另版本 | +| adapter `send(intent) -> Promise`、`stop()` | send 消化异常、永不 reject;平台参数只归 adapter;停止后不得派发新请求,已开始 fetch 不可撤回 | +| 同 root 重复初始化保留第一次 config/callback 并返回相同 stop | 改配置先 stop 再初始化;旧 stop 不得撤掉新安装,stop 可重复调用;嵌套观察共享事件去重 | + +配置映射:enabled=false 不安装观察、不加载 SDK;consent=denied 同样禁止初始化。enabled 与 consent 的联合判断由通用 controller 管理,不放进业务组件。rules 原样通过 resolveRules,保留 active/skipped 语义。adapter 仅选择静态注册 factory;adapterConfig 只交给所选适配层校验,不把 websiteId 等厂商字段提升到通用配置。PR-1 示例的 website/prefix 对应 PR-2 Umami 的 adapterConfig.websiteId/部署生成的同源前缀;PR-1 的 hasConsent hook 对应 PR-2 consent 和动态 start/stop。PR-2 新增默认关闭的模块加载边界,不能声称 PR-1 示例已具备产品配置化加载。 + +PR-2 将 helper 迁入 `web/src/features/analytics` 的单一产品源码,迁移而非复制,保留 PR-1 测试的全部断言,包括 grammar、有限规则、生命周期、隐私和 dispatch 边界。示例 adapter 仍不是任意远程 JS 插件机制。 + +## PR-2 产品设计(下列能力尚未在 PR-1 实现) + +### 1. Stable configuration contract + +- `SKILLHUB_WEB_ANALYTICS_CONFIG`:可选 JSON 对象,空值等同关闭。字段为 enabled(boolean,默认 false)、adapter(注册适配器名)、consent(`denied`/`granted`,默认 denied)、rules(可选动作→有限规则对象,省略沿用默认规则,显式空对象不采集)、adapterConfig(平台自有公开参数对象)。 +- `SKILLHUB_WEB_ANALYTICS_UPSTREAM`:仅部署可见的代理 origin,不能含凭证、路径、查询、fragment、空白或 Nginx 注入字符;不进入浏览器 runtime-config。 +- 内置 Umami 适配器在 adapterConfig 中读取 websiteId(公开 UUID),不让通用 controller 认识该字段。平台密钥不允许写入浏览器配置。 +- `consent=granted` 表示部署方显式确认其部署策略允许本次采集,不等同每个用户单独点击同意。需动态用户授权的部署可使用公开 start/stop 接口接入既有同意组件;本期不新增同意 UI。 +- 不合法配置只关闭采集,并给出不包含原值的字段级诊断;不可破坏 API/auth 等原 runtime-config 或阻止 Web 启动。 + +### 2. Module boundary and single source + +业务 TSX 仅有被动标签。采集源码在 web/src/features/analytics 下单一维护,包含通用类型、controller、规则 helper、静态注册表和独立 adapters/umami。迁移既有示例测试到正式源码,保留既有断言;不维护 docs 和产品两份 helper。 + +bootstrap 完成原应用启动后,以不等待 SDK 的独立异步路径读取 analytics 配置并按需导入 controller。enabled=false 或 consent=denied 时不得导入采集模块、加载 SDK 或请求统计平台。适配器接口只要求 send(intent)、stop();factory 注册与配置校验由适配层完成,不开放任意远程 JS URL。 + +controller 负责一次初始化、规则校验、代际隔离和停止。异步初始化期间被关闭后,迟到的模块或适配器不能重新启动。stop 后不得新派发请求,已开始的 fetch 无法撤回。保留既有队列上限、超时及 best-effort 不重试行为。异常、拒绝或统计服务不可用不得影响业务。 + +### 3. Same-origin optional proxy + +新增保留前缀 `/_skillhub-analytics/`,同步现有 base-path 的 shell、TS、配置校验及 Helm 保留规则。浏览器侧自动使用 BASE_PATH;既有 Nginx 子路径 rewrite 只处理一次。 + +Nginx 通过独立 include 片段选择已注册适配器的精确端点:Umami 仅 script.js 和 api/send;其余路径拒绝。使用变量 proxy_pass 和已有 resolver,使统计域名解析不成为启动依赖。HTTPS 上游必须验证证书并带正确 SNI。采集路由移除 Cookie、Authorization、Referer,不扩大原 API/auth 路由、不修改原 100M 上传限制、TLS、业务缓存或权限。禁止为接入放宽 CSP。公开 runtime 配置只含适配器必须的同源前缀和公开参数。 + +### 4. Deployment and lifecycle + +现有平台接入:填写 JSON 和内部 upstream,更新 Web。可选自托管组合:在 release Compose 上叠加统计服务与其持久化数据库,使用显式凭证,不新增 Nginx;创建网站/取得 UUID 等一次性平台步骤必须诚实说明。入口和 Helm 均默认关闭。 + +部署配置变更经重建/重启 Web 使新页面生效;旧标签页不自动接收新环境参数。动态停止通过当前页面接口生效;紧急全局停止可关闭独立采集路由。文档不得宣称仅修改 env 即刻撤销已在发送的请求。模块使用正常发布构建产物与缓存策略,不把可变配置放入 immutable JS。 + +## Risks and verification + +重点验证默认零 SDK/采集请求、错误配置降级、页面启动不等统计 SDK、DNS失败不阻断Web、stop/init竞争、适配器字段清洗、根/子路径、100M原业务上传、登录/下载及配置回滚。固定 SDK 升级须验证真实发送顺序;不能将静态配置或模拟API测试描述为真实业务E2E。正式测试矩阵在实现和独立Review收敛后生成。 diff --git a/openspec/changes/add-configurable-frontend-analytics/proposal.md b/openspec/changes/add-configurable-frontend-analytics/proposal.md new file mode 100644 index 000000000..922a57161 --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/proposal.md @@ -0,0 +1,36 @@ +## Why + +Issue #989 / PR #1010 已提供被动动作标签、通用 helper 和独立平台适配示例,但部署方仍需修改 JS、提供文件和手工接入 Nginx。维护者确认新的交付目标:部署只填配置、尽量一次启动、文档可独立复现,同时控制对原业务的影响。此目标是原评审之后新增的正式产品能力,不将其描述为原贡献者返修遗漏。 + +## 分两次交付 + +- PR-1(#1010):完成被动标签、厂商中立 helper、示例 adapter、edge 上传上限与默认 Web 上游修正,以及可独立照做的中文接入指南。示例仍由部署方手工接入,不增加产品运行时开关。 +- PR-2(后续 PR):交付下述配置化产品能力,包括 `web/src/features/analytics` 单一产品源码、runtime-config 可选加载、同源代理路由及 Docker/Compose/Helm 配置。Umami 是首个注册 adapter,通用配置和加载入口不绑定平台。 +- 两个 PR 分别合并。Issue #989 保留到 PR-2 完成并验证后才关闭;PR-1 不自动关闭该 Issue。 + +## What Changes + +以下产品加载和部署变更由 PR-2 交付;PR-1 的稳定接口与验收边界见 design 和 specs。 + +- 保留原作者的被动标签及协议,增加默认关闭的通用可选加载入口。 +- 复用现有 runtime-config;采集参数通过部署 JSON 配置提供,平台参数放在 adapterConfig,不编辑源码。 +- 模块随 Web 发布,平台适配器按需加载;第一版以 Umami 作为一个注册实现,不把厂商 SDK 或字段耦合进业务页面。 +- 现有 Web Nginx 只增加独立的精确采集路由,不再依赖 HTML 注入或额外 Nginx。统计上游地址不公开给浏览器,DNS 仅请求时解析。 +- Compose 与 Helm 透传配置,提供可选自托管 Compose 组合;必要平台信息仍明确由部署方提供。 +- 增补关闭、故障、子路径、上传及登录回归与详细部署文档。 + +## Capabilities + +### New Capabilities + +- `configurable-frontend-analytics`: 配置化、厂商中立的可选采集加载与部署接入。 + +### Modified Capabilities + +无数据库/API能力变更。原被动动作类别及原业务语义不变。 + +## Impact + +影响按阶段区分:PR-1 仅涉及被动标签契约、示例、测试和接入文档;以下产品部署影响属于 PR-2。影响 Web 可选模块、运行时配置、容器启动、Compose/Helm、部署测试和文档。新增小型 jq 镜像依赖用于标准 JSON 校验与安全转义,避免用 envsubst 拼接任意配置。默认部署不启动统计服务、不加载采集模块、不外发事件。 + +范围外:后台采集配置页、SkillHub 分析数据库、通用远程代码插件平台、用户级同意弹窗、可靠消息/重试队列、服务端业务成功事件、生产环境部署和自动合并。统计平台自己的数据库属于可选外部服务,不修改 SkillHub schema。 diff --git a/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md new file mode 100644 index 000000000..db8da76f7 --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md @@ -0,0 +1,152 @@ +## ADDED Requirements + +### Requirement: Preserve passive business semantics +Delivered by: PR-1 (passive markers and examples); PR-2 (product integration regression). + +The system SHALL preserve existing static action categories and business interactions without introducing analytics dependencies into business components. + +#### Scenario: Existing business operation +Delivered by: PR-1 (marker/helper assertions); PR-2 (full business regression). + +- **WHEN** a user logs in, publishes, uploads, downloads or navigates with analytics disabled or unavailable +- **THEN** existing permissions, requests, keyboard behavior and business outcomes remain unchanged + +### Requirement: Deploy using configuration only +Delivered by: PR-2. + +The system SHALL deliver optional analytics modules with the Web artifact and accept deployment configuration without source edits or hand-written Nginx rules. + +#### Scenario: Connect an existing supported platform +Delivered by: PR-2. + +- **WHEN** the operator supplies valid analytics JSON, a supported adapter configuration and internal upstream origin +- **THEN** the Web startup generates safe runtime configuration and the required isolated proxy routes without an additional Nginx service + +### Requirement: Default closed and fail isolated +Delivered by: PR-2 (product loader); PR-1 examples SHALL remain opt-in and inert by default. + +The system SHALL default to analytics disabled and consent denied, and SHALL isolate invalid configuration and runtime analytics failures from Web and business availability. + +#### Scenario: Disabled or denied collection +Delivered by: PR-2. + +- **WHEN** configuration is omitted, enabled is false, or consent is denied +- **THEN** the browser does not load analytics modules or SDKs and does not send analytics requests + +#### Scenario: Disabled example +Delivered by: PR-1. + +- **WHEN** the deployment-owned entry remains enabled:false or consent is not approved, or no entry is injected +- **THEN** no observer is installed, no tracker is loaded and no intent is sent; importing the helper alone has no collection side effects + +#### Scenario: Invalid configuration +Delivered by: PR-2. + +- **WHEN** analytics JSON, adapter or upstream is invalid +- **THEN** only analytics is disabled with a diagnostic that does not echo raw values, while existing Web configuration remains usable + +#### Scenario: Statistics service unavailable +Delivered by: PR-2 (including request-time DNS); PR-1 covers an unavailable example tracker without making a product DNS-startup guarantee. + +- **WHEN** the upstream is unreachable or its hostname cannot resolve +- **THEN** the Web still starts and business remains usable without waiting for the statistics service + +### Requirement: Vendor-neutral module contract +Delivered by: PR-1 (helper and example adapter); PR-2 (registered product factories and configuration). + +The system SHALL separate generic configuration, rules and lifecycle from adapter-specific parameters and SDK calls. PR-2 SHALL migrate the helper to one product source under web/src/features/analytics and retain every PR-1 test assertion rather than maintaining duplicate implementations. + +#### Scenario: Registered adapter +Delivered by: PR-2. + +- **WHEN** a supported adapter is selected +- **THEN** its factory validates its own adapterConfig and exposes send and stop while generic code does not depend on vendor fields + +#### Scenario: Stable helper and adapter boundary +Delivered by: PR-1; adopted unchanged by PR-2. + +- **WHEN** a deployment connects a collector to passive markers +- **THEN** IDs follow `^[a-z]+(?:\.[a-z]+){2,}$` and the manifest lifecycle; observeSkillhubIntents({ root, rules, onIntent }) returns stop; frozen DEFAULT_RULES contains the original 12 rules; resolveRules(rules) returns active and skipped entries with id and reason +- **AND** only click, submit and card rule types are supported with their documented host restrictions; invalid or unknown entries are skipped without affecting the page +- **AND** the intent is { id, event }, describes native intent rather than business success, and evolves additive-only within its version +- **AND** adapter send(intent) returns Promise and never rejects, stop prevents new dispatch, and vendor parameters belong only to the adapter + +#### Scenario: Configuration maps to the existing contract +Delivered by: PR-2. + +- **WHEN** enabled, adapter, consent, rules and adapterConfig are supplied +- **THEN** enabled and consent gate optional loading and observation, adapter selects a static registered factory, rules use the existing resolver (omitted means defaults, explicit object replaces them), and adapterConfig is validated only by the selected adapter + +### Requirement: Safe event lifecycle +Delivered by: PR-1 (example lifecycle); PR-2 (product controller and asynchronous module loading). + +The system SHALL preserve finite configured rules, field allowlists, duplicate prevention, bounded loading waits and failure isolation. + +#### Scenario: Stop races with asynchronous initialization +Delivered by: PR-1 (SDK wait/dispatch); PR-2 (module/controller races as well). + +- **WHEN** collection is stopped while a module or SDK is loading +- **THEN** late initialization does not restart collection and no new request dispatches after stop returns, excluding requests already started + +#### Scenario: Repeated initialization and rule changes +Delivered by: PR-1; preserved by PR-2. + +- **WHEN** the same root is initialized repeatedly before stop +- **THEN** it returns the same stop and retains the first configuration and callback without duplicate events +- **AND** stop is idempotent, configuration changes require stop then reinitialization, and an old stop does not remove a new installation + +### Requirement: Compatible deployment paths +Delivered by: PR-2 (normal deployment); PR-1 (opt-in edge example and mapping guide). + +The system SHALL support root and supported sub-path deployments while preserving existing API, auth, upload, cache and security behavior. + +#### Scenario: Large valid upload through normal Web +Delivered by: PR-2. + +- **WHEN** a user uploads a business-valid package larger than 1 MiB through the standard Web entry with analytics enabled +- **THEN** optional analytics routes do not reduce the existing upload limit or alter the upload request + +#### Scenario: Example edge preserves upload boundaries +Delivered by: PR-1. + +- **WHEN** an operator uses the opt-in edge in front of the Web +- **THEN** client_max_body_size 100M applies at server level and is not lowered on application proxy locations; a 5 MiB API/upload probe reaches the upstream rather than returning edge 413, while a body larger than 100 MiB is rejected with 413 +- **AND** any tighter collector-only limit does not change application uploads + +#### Scenario: Example defaults match release Web +Delivered by: PR-1. + +- **WHEN** SKILLHUB_WEB_UPSTREAM is omitted from the example Compose configuration +- **THEN** the edge targets host.docker.internal:80, matching the release WEB_PORT default of 80, and its extra_hosts provides host.docker.internal:host-gateway on native Linux +- **AND** the guide explains overrides and container-to-host reachability, while Docker Desktop/OrbStack already provide the hostname + +#### Scenario: Optional endpoint boundary +Delivered by: PR-1 (example routes); PR-2 (generated product routes). + +- **WHEN** a browser requests an unregistered analytics route +- **THEN** the route is rejected and does not expose the statistics platform administration or arbitrary upstream paths + +### Requirement: Reproducible deployment documentation +Delivered by: PR-1 (manual example guide); PR-2 (configuration-only production guide). + +The system SHALL document configuration fields, existing-platform and optional self-hosted deployment, required initialization, verification, troubleshooting, upgrade, disable and rollback. + +#### Scenario: Operator follows the guide +Delivered by: PR-2. + +- **WHEN** an operator deploys the supported integration using the published guide +- **THEN** no JS or Nginx source file edits are required, all manual platform prerequisites are explicit, and enabled, disabled and failure behavior can be independently verified + +#### Scenario: Operator follows the PR-1 Chinese example guide +Delivered by: PR-1. + +- **WHEN** an operator follows the Chinese integration guide without reading implementation source +- **THEN** the guide explicitly identifies the required entry configuration edits and platform initialization, explains both a custom send/stop adapter and self-hosted Umami, and provides copyable deployment and acceptance commands +- **AND** it explains the 12 defaults, finite rules and skipped diagnostics, upstream/host-gateway, supported sub-path mapping, CSP, actual Network payload and platform event checks, zero collection when disabled, troubleshooting, manifest/ID upgrades and shutdown +- **AND** best-effort/no retry, the 20-intent loading cap, pinned Umami version and remaining transport metadata are disclosed, with configuration-only deployment clearly deferred to PR-2 + +#### Scenario: Separate merges keep the issue open +Delivered by: PR-1 (scope declaration); PR-2 (completion gate). + +- **WHEN** PR-1 merges independently +- **THEN** Issue #989 remains open until PR-2 configuration-only deployment is complete and verified diff --git a/openspec/changes/add-configurable-frontend-analytics/tasks.md b/openspec/changes/add-configurable-frontend-analytics/tasks.md new file mode 100644 index 000000000..79fcab128 --- /dev/null +++ b/openspec/changes/add-configurable-frontend-analytics/tasks.md @@ -0,0 +1,36 @@ +## 1. Scope and integration +- [x] 1.1 Confirm maintainer authorization, preserve contributor history and resolve latest-main translation conflicts. +- [x] 1.2 Define runtime configuration, adapter boundary, failure behavior and production deployment scope. + +## 2. PR-1 (#1010) +- [x] 2.1 Verify passive markers, frozen published IDs and manifest drift against the current base. +- [x] 2.2 Verify the vendor-neutral helper, frozen defaults, finite rules, skipped diagnostics, repeat-init/stop and example adapter assertions. +- [x] 2.3 Fix and regression-test the example edge's server-level 100M application upload limit, release Web upstream default and native Linux host-gateway mapping. +- [x] 2.4 Provide an independently followable Chinese guide for existing-platform adapters and the local Umami demo, including configuration, deployment, acceptance, troubleshooting, upgrade and shutdown; link the English contract. +- [x] 2.5 Align the two-PR interfaces and manually check OpenSpec structure against add-skill-suites (CLI unavailable; not CLI validation). +- [x] 2.6 Run focused/full tests, typecheck, lint, build, manifest, Compose rendering and diff checks; repeat built-frontend/real-collector browser acceptance and the guide walkthrough, including upload boundaries. +- [ ] 2.7 Independent parent review and publication; keep Issue #989 open. + +## 3. PR-2 (follow-up): optional frontend capability +- [ ] 3.1 Move helper/adapter to a single product source under web/src/features/analytics and preserve all PR-1 assertions. +- [ ] 3.2 Add default-closed runtime loader and vendor-neutral controller/registry; register Umami as the first adapter. +- [ ] 3.3 Cover consent, validation, duplicate init, async stop races and adapter failures. +- [ ] 3.4 Validate this OpenSpec change with the CLI when available and bind the follow-up PR/task. + +## 4. PR-2 (follow-up): deployment +- [ ] 4.1 Safely serialize and validate configuration without breaking existing runtime config. +- [ ] 4.2 Add isolated proxy snippets with request-time DNS, certificate validation and exact endpoints. +- [ ] 4.3 Wire Docker/Compose/Helm defaults and reserved sub-path validation. +- [ ] 4.4 Provide optional persistent self-hosted Compose combination without another Nginx. + +## 5. PR-2 (follow-up): documentation +- [ ] 5.1 Replace source-editing quickstart with configuration-only production paths for supported platforms; distinguish custom adapter development. +- [ ] 5.2 Document fields, platform prerequisites, initialization, verification, troubleshooting, upgrade and shutdown. +- [ ] 5.3 Link deployment entry points and document adapter extension and actual privacy boundaries. + +## 6. PR-2 (follow-up): quality gates +- [ ] 6.1 Consolidated independent implementation review, fixes and final base checkpoint. +- [ ] 6.2 Reviewed requirement/scenario/test matrix and targeted source/deployment checks. +- [ ] 6.3 Exact-SHA release Compose S3/auth smoke and Chromium normal/changed/failure scenarios, including default zero module/SDK/request and root/sub-path upload/login/download/CSP/cache compatibility. +- [ ] 6.4 CI/privacy/readiness, one validation report and retained preview for human acceptance. +- [ ] 6.5 Close Issue #989 only after configuration-only deployment is complete and verified, not when PR-1 merges. diff --git a/web/analytics-example.test.ts b/web/analytics-example.test.ts new file mode 100644 index 000000000..0f3ec9651 --- /dev/null +++ b/web/analytics-example.test.ts @@ -0,0 +1,241 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/react' +import { observeSkillhubIntents, DEFAULT_RULES, resolveRules } from '../docs/examples/analytics/observe/v1/skillhub-intents.mjs' + +const stops: Array<() => void> = [] +afterEach(() => { stops.splice(0).forEach(stop => stop()); document.body.replaceChildren(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) +function start(onIntent = vi.fn<(intent: { id: string; event: string }) => void>()) { + const stop = observeSkillhubIntents({ root: document, onIntent }) + stops.push(stop) + return { onIntent, stop } +} +function fixture(html: string) { document.body.innerHTML = html; return document.body.firstElementChild as HTMLElement } + +describe('configurable finite intent rules', () => { + it('exports frozen defaults and resolves new categories without mutating config', () => { + expect(Object.isFrozen(DEFAULT_RULES)).toBe(true) + expect(Object.keys(DEFAULT_RULES)).toHaveLength(12) + const rules = Object.freeze({ 'skill.example.open': 'click' }) + expect(resolveRules(rules)).toEqual({ active: rules, skipped: [] }) + }) + + it('reports invalid IDs, unknown rules, and non-string rules without throwing', () => { + expect(resolveRules({ 'bad-id': 'click', 'skill.example.open': 'drop', 'skill.other.open': 42 })).toEqual({ + active: {}, skipped: [ + { id: 'bad-id', reason: 'invalid id shape' }, + { id: 'skill.example.open', reason: 'unknown rule type' }, + { id: 'skill.other.open', reason: 'non-string rule type' }, + ], + }) + for (const invalid of [null, [], 42, 'click', undefined]) expect(() => resolveRules(invalid)).not.toThrow() + expect(resolveRules(null).active).toEqual({}) + }) + + it('invalid config is inert at runtime and does not run throwing config getters', () => { + const button = fixture('') + const getter = vi.fn(() => { throw Error('invalid config getter') }) + const rules = { get 'skill.example.open'() { return getter() }, 'not-an-id': 'click', 'skill.other.open': 'drop' } + const onIntent = vi.fn(), app = vi.fn() + button.addEventListener('click', app) + stops.push(observeSkillhubIntents({ root: document, rules, onIntent })) + fireEvent.click(button) + expect(getter).not.toHaveBeenCalled() + expect(onIntent).not.toHaveBeenCalled() + expect(app).toHaveBeenCalledTimes(1) + }) + + it('does not fall back to a marked ancestor when the nearest category is unconfigured', () => { + const card = fixture('
new
') + const { onIntent } = start() + fireEvent.click(card.firstElementChild!) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('replaces defaults, and keeps the first config until stop and re-init', () => { + const host = fixture('
') + const first = vi.fn(), second = vi.fn() + const stop = observeSkillhubIntents({ root: document, rules: { 'skill.example.open': 'click' }, onIntent: first }) + stops.push(stop) + expect(observeSkillhubIntents({ root: document, rules: DEFAULT_RULES, onIntent: second })).toBe(stop) + fireEvent.click(host.children[0]); fireEvent.click(host.children[1]) + expect(first).toHaveBeenCalledExactlyOnceWith({ id: 'skill.example.open', event: 'click' }) + expect(second).not.toHaveBeenCalled() + stop() + stops.push(observeSkillhubIntents({ root: document, rules: { ...DEFAULT_RULES, 'skill.example.open': 'click' }, onIntent: second })) + stop() // an old disposer must not remove the new installation + fireEvent.click(host.children[0]); fireEvent.click(host.children[1]) + expect(second).toHaveBeenCalledTimes(2) + }) + + it.each(['form', 'select', 'textarea', 'input', 'input type="file"', 'input type="search"', 'input type="number"'])('does not use click for unsupported host %s', tag => { + const host = fixture(`<${tag} data-analytics-id="skill.example.open">`) + const onIntent = vi.fn() + stops.push(observeSkillhubIntents({ root: document, rules: { 'skill.example.open': 'click' }, onIntent })) + fireEvent.click(host) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('skips submit on non-FORM and card keydown on non-DIV role=link', () => { + const host = fixture('
no role
') + const { onIntent } = start() + fireEvent.submit(host.children[0]); fireEvent.keyDown(host.children[1], { key: 'Enter' }); fireEvent.keyDown(host.children[2], { key: ' ' }) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('counts at most once across nested roots, using nearest marked host only', () => { + const host = fixture('
') + const onIntent = vi.fn() + stops.push(observeSkillhubIntents({ root: document, onIntent })) + stops.push(observeSkillhubIntents({ root: host, onIntent })) + fireEvent.click(host.firstElementChild!) + expect(onIntent).toHaveBeenCalledExactlyOnceWith({ id: 'skill.download.request', event: 'click' }) + }) + + it.each(['repeat', 'isComposing', 'altKey', 'ctrlKey', 'metaKey', 'shiftKey'])('ignores card keydown with %s', flag => { + const card = fixture('
') + const { onIntent } = start() + fireEvent.keyDown(card, { key: 'Enter', [flag]: true }) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('swallows rejected callbacks without unhandled rejection or changing app behavior', async () => { + const button = fixture('') + const unhandled = vi.fn(), app = vi.fn() + window.addEventListener('unhandledrejection', unhandled) + button.addEventListener('click', app) + start(vi.fn(() => Promise.reject(new Error('synthetic send failure')))) + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + button.dispatchEvent(event) + await new Promise(resolve => window.setTimeout(resolve, 0)) + window.removeEventListener('unhandledrejection', unhandled) + expect(unhandled).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(app).toHaveBeenCalledTimes(1) + }) +}) + +describe('optional deployment example (never automatically loaded)', () => { + it('has no listener or network defaults without an explicit callback', () => { + const listener = vi.spyOn(document, 'addEventListener') + observeSkillhubIntents({ root: document })() + expect(listener).not.toHaveBeenCalled() + }) + + it('reads a nested click as one allowlisted intent, not content or identity', () => { + const host = fixture('') + const { onIntent } = start() + fireEvent.click(host.firstElementChild!) + expect(onIntent).toHaveBeenCalledExactlyOnceWith({ id: 'skill.command.copy', event: 'click' }) + }) + + it('ignores unknown IDs, structural anchors and nested unmarked controls', () => { + const host = fixture('
') + const { onIntent } = start() + fireEvent.click(host.firstElementChild!) + host.setAttribute('data-analytics-id', 'not.allowlisted.action') + fireEvent.click(host) + host.setAttribute('data-analytics-id', 'skill.detail.render') + fireEvent.click(host) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('counts submit once, not typing, clearing, Enter keydown or submit-button click', () => { + const form = fixture('
') + // Model the existing application submit handler without allowing jsdom navigation. + const submit = vi.fn((event: Event) => event.preventDefault()) + form.addEventListener('submit', submit) + const { onIntent } = start() + fireEvent.change(form.querySelector('input')!, { target: { value: 'another-private-query' } }) + fireEvent.keyDown(form.querySelector('input')!, { key: 'Enter' }) + fireEvent.click(form.querySelector('button')!) + expect(onIntent).toHaveBeenCalledExactlyOnceWith({ id: 'search.query.submit', event: 'submit' }) + expect(submit).toHaveBeenCalledTimes(1) + fireEvent.submit(form) + expect(onIntent).toHaveBeenCalledTimes(2) + }) + + it('covers the existing custom-card Enter/Space behavior without synthesizing events', () => { + const card = fixture('
open
') + const navigate = vi.fn() + card.addEventListener('click', navigate) + card.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); navigate() } + }) + const { onIntent } = start() + fireEvent.click(card) + fireEvent.keyDown(card, { key: 'Enter' }) + fireEvent.keyDown(card, { key: ' ' }) + expect(navigate).toHaveBeenCalledTimes(3) + expect(onIntent.mock.calls.map(([event]) => event)).toEqual([ + { id: 'skill.card.open', event: 'click' }, + { id: 'skill.card.open', event: 'keydown' }, + { id: 'skill.card.open', event: 'keydown' }, + ]) + fireEvent.keyDown(card, { key: 'Enter', repeat: true }) + fireEvent.keyDown(card, { key: 'Enter', ctrlKey: true }) + fireEvent.keyDown(card, { key: 'Enter', isComposing: true }) + fireEvent.keyDown(card, { key: 'Escape' }) + expect(onIntent).toHaveBeenCalledTimes(3) + }) + + it('does not count native-button keydown in addition to its generated click', () => { + const button = fixture('') + const { onIntent } = start() + fireEvent.keyDown(button, { key: 'Enter' }) + fireEvent.click(button) + expect(onIntent).toHaveBeenCalledExactlyOnceWith({ id: 'skill.download.request', event: 'click' }) + }) + + it.each(['disabled', 'aria-disabled="true"', 'data-disabled'])('ignores disabled/busy activation (%s)', disabled => { + const button = fixture(``) + const { onIntent } = start() + fireEvent.click(button) + expect(onIntent).not.toHaveBeenCalled() + }) + + it('is idempotent, removable, and safe to initialize again after disposal', () => { + const button = fixture('') + const { onIntent, stop } = start() + expect(observeSkillhubIntents({ root: document, onIntent })).toBe(stop) + fireEvent.click(button) + expect(onIntent).toHaveBeenCalledTimes(1) + stop() + fireEvent.click(button) + expect(onIntent).toHaveBeenCalledTimes(1) + const second = start() + stop() + fireEvent.click(button) + expect(second.onIntent).toHaveBeenCalledTimes(1) + }) + + it('does not modify application propagation/default behavior even if the callback throws', () => { + const button = fixture('') + const handler = vi.fn() + button.addEventListener('click', handler) + start(vi.fn(() => { throw new Error('synthetic operator failure') })) + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + button.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('never reads cookies/storage/location or sends network requests', () => { + const button = fixture('') + const fetch = vi.fn() + const xhr = vi.fn() + const beacon = vi.fn() + vi.stubGlobal('fetch', fetch) + vi.stubGlobal('XMLHttpRequest', xhr) + vi.stubGlobal('navigator', { sendBeacon: beacon }) + const cookie = vi.spyOn(document, 'cookie', 'get') + const cookieWrite = vi.spyOn(document, 'cookie', 'set') + const local = vi.spyOn(window, 'localStorage', 'get') + const session = vi.spyOn(window, 'sessionStorage', 'get') + const storage = (['getItem', 'setItem', 'removeItem', 'clear', 'key'] as const).map(method => vi.spyOn(Storage.prototype, method)) + const { onIntent } = start() + fireEvent.click(button) + expect(onIntent).toHaveBeenCalledExactlyOnceWith({ id: 'skill.prompt.copy', event: 'click' }) + for (const spy of [fetch, xhr, beacon, cookie, cookieWrite, local, session, ...storage]) expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/web/analytics-umami-example.test.ts b/web/analytics-umami-example.test.ts new file mode 100644 index 000000000..28882c4e5 --- /dev/null +++ b/web/analytics-umami-example.test.ts @@ -0,0 +1,369 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUmamiAdapter, sanitizeUmamiPayload } from '../docs/examples/analytics/observe/v1/umami-adapter.mjs' +import { resolveRules } from '../docs/examples/analytics/observe/v1/skillhub-intents.mjs' +import { readFileSync } from 'node:fs' + +const manifest = JSON.parse(readFileSync('../docs/frontend-analytics-manifest.json', 'utf8')) as { + counts: { uniqueIds: number } + sites: Array<{ ids?: string[] }> + definitions: Array<{ ids: string[] }> +} +const publishedIds = [...new Set([...manifest.sites.flatMap(site => site.ids ?? []), ...manifest.definitions.flatMap(site => site.ids)])] + +afterEach(() => { document.head.replaceChildren(); vi.useRealTimers(); vi.restoreAllMocks() }) +const website = '4398de8e-40df-4483-9eea-ed213643525f' +const intent = { id: 'skill.example.open', event: 'click' } + +describe('optional edge deployment defaults', () => { + const nginx = readFileSync('../docs/examples/analytics/nginx.conf', 'utf8') + const compose = readFileSync('../docs/examples/analytics/compose.yaml', 'utf8') + + it('inherits the standard 100 MiB limit on all proxied application routes', () => { + // Tokenize quoted strings as units so braces/semicolons in responses do not + // affect scope. Track blocks instead of accepting a location-only limit. + const tokens = nginx.replace(/#.*$/gm, '').match(/"[^"\n]*"|'[^'\n]*'|[{};]|[^\s{};]+/g) ?? [] + const blocks: string[] = [], statement: string[] = [] + const limits: Array<{ scope: string[]; value: string }> = [] + for (const token of tokens) { + if (token === '{') { blocks.push(statement.join(' ')); statement.length = 0 } + else if (token === '}') { blocks.pop(); statement.length = 0 } + else if (token === ';') { + if (statement[0] === 'client_max_body_size') limits.push({ scope: [...blocks], value: statement[1] }) + statement.length = 0 + } else statement.push(token) + } + expect(limits).toContainEqual({ scope: ['server'], value: '100M' }) + // A collector-only restriction is allowed; application locations must inherit. + expect(limits.filter(limit => limit.scope.some(scope => scope.startsWith('location') && !scope.includes('/_skillhub-umami/')))).toEqual([]) + expect(limits.filter(limit => limit.scope.length === 1)).toEqual([{ scope: ['server'], value: '100M' }]) + }) + + it('defaults to the release Web entry, not the development port', () => { + const release = readFileSync('../compose.release.yml', 'utf8') + expect(release).toContain('${WEB_PORT:-80}:80') + expect(compose).toContain('SKILLHUB_WEB_UPSTREAM: ${SKILLHUB_WEB_UPSTREAM:-host.docker.internal:80}') + expect(compose).not.toContain('host.docker.internal:8080') + }) + + it('maps the host gateway on native Linux for the edge service', () => { + const edge = compose.split('\n edge:\n')[1] + expect(edge).toBeDefined() + expect(edge).toContain('extra_hosts: ["host.docker.internal:host-gateway"]') + }) +}) + +describe('product ID grammar parity', () => { + it.each([4, 5, 6])('accepts a published %i-segment ID in rules and the final payload', segments => { + const id = publishedIds.find(id => id.split('.').length === segments)! + expect(id).toBeTypeOf('string') + expect(resolveRules({ [id]: 'click' })).toEqual({ active: { [id]: 'click' }, skipped: [] }) + expect(sanitizeUmamiPayload('event', { website, name: id, data: { event: 'click' } }, website)) + .toEqual({ website, name: id, data: { event: 'click' } }) + }) + it.each(['skill2.example.open', 'skill.example_open.click', 'Skill.example.open', 'skill.open', '.skill.example.open', 'skill.example.open.'])('skips non-product grammar %s', id => { + expect(resolveRules({ [id]: 'click' })).toEqual({ active: {}, skipped: [{ id, reason: 'invalid id shape' }] }) + expect(sanitizeUmamiPayload('event', { website, name: id, data: { event: 'click' } }, website)).toBeNull() + }) + it('accepts EVERY published ID through both boundaries (not a closed runtime enum)', () => { + expect(publishedIds).toHaveLength(manifest.counts.uniqueIds) + const rules = Object.fromEntries(publishedIds.map(id => [id, 'click'])) + expect(resolveRules(rules)).toEqual({ active: rules, skipped: [] }) + for (const id of publishedIds) { + expect(sanitizeUmamiPayload('event', { website, name: id, data: { event: 'click' } }, website), id) + .toEqual({ website, name: id, data: { event: 'click' } }) + } + }) + it('keeps the Umami 50-character limit separate from the product grammar', () => { + const id = `skill.example.${'a'.repeat(37)}` + expect(id).toHaveLength(51) + expect(resolveRules({ [id]: 'click' }).skipped).toEqual([]) + expect(sanitizeUmamiPayload('event', { website, name: id, data: { event: 'click' } }, website)).toBeNull() + expect(sanitizeUmamiPayload('event', { website, name: id.slice(0, 50), data: { event: 'click' } }, website)).not.toBeNull() + }) +}) + +describe('Umami v3.4.1 dispatch-time consent boundary', () => { + function platform(fetch = vi.fn().mockResolvedValue(undefined)) { + let approved: unknown + const win = { + skillhubUmamiBeforeSend: undefined as unknown, + umami: { track: async (name: string | object | (() => object), data?: { event: string }) => { + const type = 'event', endpoint = '/_skillhub-umami/api/send' + // Pinned track(object) shallow-copies; track(function) preserves its result's accessors. + let payload: unknown = typeof name === 'string' ? { website, name, data, url: '/private', title: 'private' } + : typeof name === 'function' ? name() : { ...name } + const callback = (type: string, payload: unknown) => { + approved = (win.skillhubUmamiBeforeSend as (type: string, payload: unknown) => unknown)(type, payload) + return approved + } + // Mirror pinned v3.4.1 send(): await before-send, null check, then + // serialize synchronously in the fetch expression inside its try/catch. + payload = await Promise.resolve(callback(type, payload)) + if (!payload) return + try { + await fetch(endpoint, { body: JSON.stringify({ type, payload }) }) + } catch { /* SDK swallows serialization and transport failures. */ } + } }, + } + const adapter = createUmamiAdapter({ window: win, document, website }) + document.querySelector('script')!.dispatchEvent(new Event('load')) + return { adapter, fetch, approved: () => approved, track: win.umami.track } + } + + it.each([ + ['event', 'string'], ['event', 'toJSON'], ['name', 'string'], ['name', 'toJSON'], + ])('snapshots a changing %s getter before its second %s value can leak or stop then dispatch', async (field, mode) => { + let stopReturned = false, dispatchedAfterStop = false, reads = 0 + const fetch = vi.fn(async () => { if (stopReturned) dispatchedAfterStop = true }) + const { adapter, track } = platform(fetch) + const lateValue = mode === 'string' ? 'PRIVATE VALUE' : { + toJSON() { adapter.stop(); stopReturned = true; return { secret: 'PRIVATE VALUE' } }, + } + const payload = { website, name: intent.id, data: { event: 'click' } } + Object.defineProperty(field === 'name' ? payload : payload.data, field, { + enumerable: true, + get() { return ++reads === 1 ? (field === 'name' ? intent.id : 'click') : lateValue }, + }) + try { + // Object path retains nested event accessors; function path retains name accessors. + await expect(track(field === 'name' ? () => payload : payload)).resolves.toBeUndefined() + expect(dispatchedAfterStop).toBe(false) + expect(fetch.mock.calls.length).toBeLessThanOrEqual(1) + for (const call of fetch.mock.calls as unknown as Array<[string, { body: string }]>) { + expect(call[1].body).toBe(JSON.stringify({ type: 'event', payload: { website, name: intent.id, data: { event: 'click' } } })) + expect(call[1].body).not.toContain('PRIVATE') + } + expect(reads).toBe(1) + adapter.stop(); stopReturned = true + await track(intent.id, { event: 'click' }) + expect(dispatchedAfterStop).toBe(false) + } finally { adapter.stop() } + }) + + it.each(['website', 'name', 'data', 'event'])('drops a throwing %s getter without sending or unhandled rejection', async field => { + const { adapter, fetch, track } = platform() + const unhandled = vi.fn() + window.addEventListener('unhandledrejection', unhandled) + const payload = { website, name: intent.id, data: { event: 'click' } } + Object.defineProperty(field === 'event' ? payload.data : payload, field, { + get() { throw Error('unreadable payload') }, + }) + try { + expect(sanitizeUmamiPayload('event', payload, website)).toBeNull() + await expect(track(() => payload)).resolves.toBeUndefined() + await new Promise(resolve => window.setTimeout(resolve, 0)) + expect(fetch).not.toHaveBeenCalled() + expect(unhandled).not.toHaveBeenCalled() + } finally { + adapter.stop() + window.removeEventListener('unhandledrejection', unhandled) + } + }) + + it('reads website/name/data/event once and serializes only captured primitives despite returned-object mutation', async () => { + const { adapter, fetch, track, approved } = platform() + const getters = { + website: vi.fn(() => website), name: vi.fn(() => intent.id), event: vi.fn(() => 'click'), + data: vi.fn(() => Object.defineProperty({}, 'event', { get: getters.event })), + } + const payload = Object.defineProperties({}, { + website: { get: getters.website }, name: { get: getters.name }, data: { get: getters.data }, + }) + try { + const pending = track(() => payload) + const guarded = approved() as object + for (const field of ['website', 'name', 'data']) { + Object.defineProperty(guarded, field, { get() { throw Error('must not re-read returned object') } }) + } + await expect(pending).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledExactlyOnceWith('/_skillhub-umami/api/send', { + body: JSON.stringify({ type: 'event', payload: { website, name: intent.id, data: { event: 'click' } } }), + }) + for (const getter of Object.values(getters)) expect(getter).toHaveBeenCalledTimes(1) + } finally { adapter.stop() } + }) + + it('stop in the before-send/fetch microtask gap dispatches nothing and send resolves without unhandled rejection', async () => { + const { adapter, fetch, approved } = platform() + const unhandled = vi.fn() + window.addEventListener('unhandledrejection', unhandled) + try { + const pending = adapter.send(intent) + await Promise.resolve() + expect(approved()).toEqual({ website, name: intent.id, data: { event: 'click' } }) + expect(fetch).not.toHaveBeenCalled() + adapter.stop() + await expect(pending).resolves.toBeUndefined() + await new Promise(resolve => window.setTimeout(resolve, 0)) + expect(fetch).not.toHaveBeenCalled() + expect(unhandled).not.toHaveBeenCalled() + } finally { + adapter.stop() + window.removeEventListener('unhandledrejection', unhandled) + } + }) + + it('stop in a later macrotask lets an already-started fetch complete but dispatches no more', async () => { + let complete!: () => void + const fetch = vi.fn(() => new Promise(resolve => { complete = resolve })) + const { adapter } = platform(fetch) + try { + const pending = adapter.send(intent) + await new Promise(resolve => window.setTimeout(resolve, 0)) + expect(fetch).toHaveBeenCalledTimes(1) + adapter.stop() + complete() + await expect(pending).resolves.toBeUndefined() + await expect(adapter.send(intent)).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledTimes(1) + } finally { adapter.stop() } + }) + + it('serializes the exact allowlisted envelope and keeps toJSON non-enumerable', async () => { + const { adapter, fetch, approved } = platform() + try { + await expect(adapter.send(intent)).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledExactlyOnceWith('/_skillhub-umami/api/send', { + body: `{"type":"event","payload":{"website":"${website}","name":"${intent.id}","data":{"event":"click"}}}`, + }) + expect(Object.keys(approved() as object)).toEqual(['website', 'name', 'data']) + expect(Object.getOwnPropertyDescriptor(approved(), 'toJSON')).toMatchObject({ enumerable: false, value: expect.any(Function) }) + const guarded = approved() as { toJSON: () => { data: object } } + const first = guarded.toJSON(), second = guarded.toJSON() + expect(first).toEqual({ website, name: intent.id, data: { event: 'click' } }) + expect(Object.getPrototypeOf(first)).toBe(Object.prototype) + expect(first).not.toBe(second) + expect(first.data).not.toBe(second.data) + expect(Object.getOwnPropertyNames(first)).toEqual(['website', 'name', 'data']) + } finally { adapter.stop() } + }) +}) + +describe('Umami v3.4.1 adapter', () => { + it('allowlists the FINAL payload, never SDK context or extra data', () => { + expect(sanitizeUmamiPayload('event', { + website, name: intent.id, data: { event: 'click', token: 'private', value: 'private' }, + hostname: 'private', url: '/private?q=secret', title: 'private', referrer: 'private', + screen: 'private', language: 'private', tag: 'private', id: 'private', extra: 'private', + }, website)).toEqual({ website, name: intent.id, data: { event: 'click' } }) + }) + it.each(['pageview', 'identify', 'performance', 'unknown'])('drops %s', type => { + expect(sanitizeUmamiPayload(type, { website, name: intent.id, data: { event: 'click' } }, website)).toBeNull() + }) + it.each(['bad', 'skill.bad-value.open', '', null, 42])('drops bad name %s', name => { + expect(sanitizeUmamiPayload('event', { website, name, data: { event: 'click' } }, website)).toBeNull() + }) + it('drops pageviews encoded as events, wrong websites and unknown native event kinds', () => { + for (const payload of [null, {}, { website }, { website: 'other', name: intent.id, data: { event: 'click' } }, { website, name: intent.id, data: { event: 'input' } }]) { + expect(sanitizeUmamiPayload('event', payload, website)).toBeNull() + } + }) + it('loads only the manual tracker and passes static intent fields', async () => { + const win = { umami: undefined as unknown, skillhubUmamiBeforeSend: undefined as unknown } + const adapter = createUmamiAdapter({ window: win, document, website }) + const script = document.querySelector('script')! + expect(script.src).toContain('/_skillhub-umami/script.js') + expect(script.getAttribute('data-auto-track')).toBe('false') + expect(script.getAttribute('data-host-url')).toBe('/_skillhub-umami') + expect(script.getAttribute('data-before-send')).toBe('skillhubUmamiBeforeSend') + const track = vi.fn().mockResolvedValue(undefined) + const sent = adapter.send({ ...intent, token: 'private' }) + win.umami = { track } + script.dispatchEvent(new Event('load')) + await expect(sent).resolves.toBeUndefined() + expect(track).toHaveBeenCalledExactlyOnceWith(intent.id, { event: 'click' }) + adapter.stop() + }) + it('resolves when load fails, and never sends after stop', async () => { + const win = { umami: undefined as unknown } + const adapter = createUmamiAdapter({ window: win, document, website }) + const pending = adapter.send(intent) + document.querySelector('script')!.dispatchEvent(new Event('error')) + await expect(pending).resolves.toBeUndefined() + win.umami = { track: vi.fn() } + adapter.stop() + await adapter.send(intent) + expect((win.umami as { track: ReturnType }).track).not.toHaveBeenCalled() + }) + it('sends two rapid ready intents before either track settles', async () => { + const finish: Array<() => void> = [] + const track = vi.fn<(id: string, data: { event: string }) => Promise>(() => new Promise(resolve => finish.push(resolve))) + const adapter = createUmamiAdapter({ window: { umami: { track } }, document, website }) + document.querySelector('script')!.dispatchEvent(new Event('load')) + const first = adapter.send(intent), second = adapter.send({ ...intent, id: 'skill.command.copy' }) + await vi.waitFor(() => expect(track).toHaveBeenCalledTimes(2)) + expect(track.mock.calls.map(args => args[0])).toEqual([intent.id, 'skill.command.copy']) + finish.forEach(resolve => resolve()) + await Promise.all([first, second]) + adapter.stop() + }) + it('does not let a hung track block a later send', async () => { + vi.useFakeTimers() + const track = vi.fn().mockImplementationOnce(() => new Promise(() => {})).mockResolvedValue(undefined) + const adapter = createUmamiAdapter({ window: { umami: { track } }, document, website, timeoutMs: 20 }) + document.querySelector('script')!.dispatchEvent(new Event('load')) + const first = adapter.send(intent) + await vi.advanceTimersByTimeAsync(0) + await adapter.send({ ...intent, id: 'skill.command.copy' }) + expect(track).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(20) + await expect(first).resolves.toBeUndefined() + adapter.stop() + }) + it('holds at most 20 intents only during loading and releases them in order', async () => { + const track = vi.fn().mockResolvedValue(undefined) + const adapter = createUmamiAdapter({ window: { umami: { track } }, document, website }) + // Invalid payloads must not consume pending capacity. + await adapter.send({ id: 'bad', event: 'click' }) + const ids = Array.from({ length: 20 }, (_, i) => `skill.example.${String.fromCharCode(97 + i)}`) + const pending = ids.map(id => adapter.send({ id, event: 'click' })) + await expect(adapter.send(intent)).resolves.toBeUndefined() + expect(track).not.toHaveBeenCalled() + document.querySelector('script')!.dispatchEvent(new Event('load')) + await Promise.all(pending) + expect(track.mock.calls).toEqual(ids.map(id => [id, { event: 'click' }])) + // The loading cap must not become a lifetime cap. + await adapter.send(intent) + expect(track).toHaveBeenCalledTimes(21) + adapter.stop() + }) + it.each(['error', 'timeout', 'stop'])('drops all pending intents on %s, including a late load', async mode => { + vi.useFakeTimers() + const track = vi.fn().mockResolvedValue(undefined) + const adapter = createUmamiAdapter({ window: { umami: { track } }, document, website, timeoutMs: 20 }) + const script = document.querySelector('script')! + const pending = Array.from({ length: 20 }, () => adapter.send(intent)) + if (mode === 'stop') adapter.stop() + else if (mode === 'error') script.dispatchEvent(new Event('error')) + else await vi.advanceTimersByTimeAsync(20) + await Promise.all(pending) + script.dispatchEvent(new Event('load')) + await adapter.send(intent) + expect(track).not.toHaveBeenCalled() + adapter.stop() + }) + it('stop denies payloads even with a previously ready tracker', async () => { + const win = { umami: { track: vi.fn().mockResolvedValue(undefined) }, skillhubUmamiBeforeSend: undefined as unknown } + const adapter = createUmamiAdapter({ window: win, document, website }) + document.querySelector('script')!.dispatchEvent(new Event('load')) + adapter.stop() + await adapter.send(intent) + expect(win.umami.track).not.toHaveBeenCalled() + const hook = win.skillhubUmamiBeforeSend as (type: string, payload: unknown) => unknown + expect(hook('event', { website, name: intent.id, data: { event: 'click' } })).toBeNull() + }) + it('resolves when tracker is absent after load, throws, rejects, or never settles', async () => { + vi.useFakeTimers() + for (const track of [undefined, vi.fn(() => { throw Error('blocked') }), vi.fn().mockRejectedValue(Error('offline')), vi.fn(() => new Promise(() => {}))]) { + const win = { umami: track ? { track } : undefined } + const adapter = createUmamiAdapter({ window: win, document, website, timeoutMs: 20 }) + document.querySelector('script')!.dispatchEvent(new Event('load')) + const pending = adapter.send(intent) + await vi.advanceTimersByTimeAsync(20) + await expect(pending).resolves.toBeUndefined() + adapter.stop() + document.head.replaceChildren() + } + }) +}) diff --git a/web/scripts/analytics-manifest.d.mts b/web/scripts/analytics-manifest.d.mts new file mode 100644 index 000000000..e93001cc7 --- /dev/null +++ b/web/scripts/analytics-manifest.d.mts @@ -0,0 +1,2 @@ +// Development tooling only; keeps Node tooling out of application TS globals. +export function buildManifest(): unknown diff --git a/web/scripts/analytics-manifest.mjs b/web/scripts/analytics-manifest.mjs new file mode 100644 index 000000000..aca26cabc --- /dev/null +++ b/web/scripts/analytics-manifest.mjs @@ -0,0 +1,142 @@ +// Development-only source inventory. Never imported by the application bundle. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const destination = path.join(root, 'docs/frontend-analytics-manifest.json') +const sourceRoot = path.join(root, 'web/src') +const markerProp = /^(data-analytics-id|analyticsId|[a-z]+AnalyticsId)$/ +const idPattern = /^[a-z]+(?:\.[a-z]+){2,}$/ +const passthrough = new Set(['analyticsId', 'confirmAnalyticsId', 'cancelAnalyticsId', 'closeAnalyticsId', 'copyAnalyticsId', 'downloadAnalyticsId', 'skillhubAnalyticsId', 'clawhubAnalyticsId', 'folderAnalyticsId', 'pickerAnalyticsId']) +const navigationFiles = new Set(['web/src/app/layout.tsx', 'web/src/pages/dashboard.tsx', 'web/src/features/namespace/namespace-management-nav.tsx']) + +function walk(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en')).flatMap(entry => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) +} +function parse(file) { + return ts.createSourceFile(file, fs.readFileSync(path.join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +} +function visit(node, fn) { + fn(node) + ts.forEachChild(node, child => visit(child, fn)) +} +function line(sf, node) { + return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 +} +function properties(node, sf) { + return Object.fromEntries(node.properties.filter(ts.isPropertyAssignment).map(prop => [prop.name.getText(sf), prop.initializer])) +} +function values(node, sf, file) { + if (!node) throw new Error(`Missing marker value in ${file}`) + if (ts.isStringLiteral(node)) { + if (!idPattern.test(node.text)) throw new Error(`Nonsemantic marker in ${file}:${line(sf, node)}`) + return { ids: [node.text] } + } + if (ts.isJsxExpression(node) || ts.isParenthesizedExpression(node)) return values(node.expression, sf, file) + if (ts.isConditionalExpression(node)) { + const yes = values(node.whenTrue, sf, file) + const no = values(node.whenFalse, sf, file) + return { ids: [...new Set([...(yes.ids ?? []), ...(no.ids ?? [])])].sort(), conditional: true, ...(yes.forward || no.forward ? { forward: yes.forward ?? no.forward } : {}) } + } + if (ts.isIdentifier(node) && node.text === 'undefined') return { ids: [] } + if (ts.isIdentifier(node) && passthrough.has(node.text)) return { forward: node.text } + if (ts.isPropertyAccessExpression(node) && node.name.text === 'analyticsId' && navigationFiles.has(file) && ['item', 'card', 'tab'].includes(node.expression.getText(sf))) return { forward: node.getText(sf) } + throw new Error(`Dynamic analytics value rejected in ${file}:${line(sf, node)}: ${node.getText(sf)}`) +} +function eventFor(tag, prop, attrs, ids) { + if (ids.includes('skill.detail.render')) return 'structural' + if (tag === 'SelectItem') return 'radix-item-intent' + if (tag === 'SelectTrigger') return 'click+keydown-intent' + if (tag === 'form' || tag === 'SearchBar') return 'submit' + if (tag === 'UploadZone') return prop === 'pickerAnalyticsId' ? 'click' : prop === 'folderAnalyticsId' ? 'change' : 'click+keydown+change+drop' + if (tag === 'SelectFilter') return 'change' + if (tag === 'FileTree' || tag === 'FileTreeNodeComponent') return 'click' + if (tag === 'SkillCard' || tag === 'ResourceCard' || (attrs.has('onKeyDown') && attrs.has('onClick') && tag !== 'TabsTrigger')) return 'click+keydown-enter-space' + if (tag === 'input' || tag === 'Input') return attrs.has('onKeyDown') ? 'keydown-enter' : 'change' + if (tag === 'select') return 'change' + if (tag === 'option') return 'change-selected-option' + if (ids.includes('skill.avatar.crop.adjust')) return 'pointerup+keydown-intent' + if (tag === 'div' && attrs.has('data-analytics-event')) return ids.includes('skill.avatar.file.drop') ? 'drop' : 'click+keydown+drop' + return 'click' +} + +export function buildManifest() { + const sites = [] + const definitions = [] + for (const full of walk(sourceRoot).filter(file => file.endsWith('.tsx') && !/\.(test|spec)\.tsx$/.test(file))) { + const file = path.relative(root, full).split(path.sep).join('/') + const sf = parse(file) + visit(sf, node => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const attrs = node.attributes.properties.filter(ts.isJsxAttribute) + const names = new Set(attrs.map(attr => attr.name.getText(sf))) + for (const attr of attrs.filter(attr => markerProp.test(attr.name.getText(sf)))) { + const result = values(attr.initializer, sf, file) + const tag = node.tagName.getText(sf) + sites.push({ file, line: line(sf, node), host: tag, prop: attr.name.getText(sf), event: eventFor(tag, attr.name.getText(sf), names, result.ids ?? []), ...result }) + } + } + if (ts.isPropertyAssignment(node) && node.name.getText(sf) === 'analyticsId') { + if (!navigationFiles.has(file)) throw new Error(`Unreviewed navigation metadata in ${file}`) + definitions.push({ file, line: line(sf, node), ...values(node.initializer, sf, file) }) + } + }) + } + const routeFile = 'web/src/app/router.tsx' + const sf = parse(routeFile) + const bindings = new Map() + const routes = [] + let registered = [] + visit(sf, node => { + if (!ts.isVariableDeclaration(node) || node.parent?.parent?.parent !== sf || !node.initializer || !ts.isCallExpression(node.initializer)) return + const call = node.initializer + const callee = call.expression.getText(sf) + if (['createLazyRouteComponent', 'createRoleProtectedRouteComponent'].includes(callee)) { + let modulePath + visit(call.arguments[0], child => { + if (ts.isCallExpression(child) && child.expression.kind === ts.SyntaxKind.ImportKeyword && ts.isStringLiteral(child.arguments[0])) modulePath = child.arguments[0].text + }) + const exported = call.arguments[1] + if (!modulePath?.startsWith('@/') || !exported || !ts.isStringLiteral(exported)) throw new Error('Unresolved lazy route') + const file = 'web/src/' + modulePath.slice(2) + '.tsx' + const page = parse(file) + let implementation + visit(page, child => { + if ((ts.isFunctionDeclaration(child) || ts.isVariableDeclaration(child)) && child.name?.getText(page) === exported.text) implementation = { file, line: line(page, child), export: exported.text } + }) + if (!implementation) throw new Error(`Missing route implementation: ${file} ${exported.text}`) + bindings.set(node.name.getText(sf), { implementation, roleProtected: callee === 'createRoleProtectedRouteComponent' }) + } + if (callee === 'createRoute') { + const props = properties(call.arguments[0], sf) + if (!ts.isStringLiteral(props.path)) throw new Error('Nonliteral route path') + const component = props.component?.getText(sf) + const binding = bindings.get(component) + routes.push({ name: node.name.getText(sf), path: props.path.text, declaration: { file: routeFile, line: line(sf, node) }, implementation: binding?.implementation ?? null, guarded: !!props.beforeLoad, roleProtected: binding?.roleProtected ?? false }) + } + if (callee === 'rootRoute.addChildren') registered = call.arguments[0].elements.map(element => element.getText(sf)) + }) + if (routes.length !== registered.length || routes.some(route => !registered.includes(route.name)) || new Set(registered).size !== registered.length) throw new Error('Route declaration/registration drift') + const ids = new Set([...sites, ...definitions].flatMap(site => site.ids ?? [])) + return { + schema: 1, + description: 'Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.', + counts: { routes: routes.length, uniqueIds: ids.size, literalJsxSites: sites.filter(site => site.ids?.length).length, literalJsxValues: sites.reduce((count, site) => count + (site.ids?.length ?? 0), 0), navigationDefinitions: definitions.length, forwardingSites: sites.filter(site => site.forward).length }, + routes, + definitions, + sites, + } +} +function serialize(manifest) { + // Compact one-record-per-line projection; source and event stay reviewable together. + return '{\n "schema": 1,\n "description": ' + JSON.stringify(manifest.description) + ',\n "counts": ' + JSON.stringify(manifest.counts) + ',\n' + ['routes', 'definitions', 'sites'].map(key => ' "' + key + '": [\n' + manifest[key].map(row => ' ' + JSON.stringify(row)).join(',\n') + '\n ]').join(',\n') + '\n}\n' +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const manifest = buildManifest() + const text = serialize(manifest) + if (process.argv.includes('--write')) fs.writeFileSync(destination, text) + else if (fs.readFileSync(destination, 'utf8') !== text) throw new Error('Analytics manifest drift. Review source, then run node web/scripts/analytics-manifest.mjs --write from the repository root.') + console.log(JSON.stringify(manifest.counts)) +} diff --git a/web/src/app/analytics-branches.test.tsx b/web/src/app/analytics-branches.test.tsx new file mode 100644 index 000000000..720c75303 --- /dev/null +++ b/web/src/app/analytics-branches.test.tsx @@ -0,0 +1,147 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedNamespace, SkillSuite } from '@/api/types' +import { NamespaceSettingsPage } from '@/pages/dashboard/namespace-settings' +import { ReportsPage } from '@/pages/dashboard/reports' +import { SuiteManagementActions } from '@/features/suite/suite-management-actions' +import { GithubSyncPlatformControl } from '@/features/github-sync/github-sync-platform-control' +import { GithubCredentialConfig } from '@/features/github-sync/github-credential-config' + +const state = vi.hoisted(() => ({ + namespace: null as ManagedNamespace | null, + namespaceMutate: vi.fn(), resolve: vi.fn(), dismiss: vi.fn(), + hidden: vi.fn(), archived: vi.fn(), remove: vi.fn(), + paused: false, platform: vi.fn(), + credential: { id: 'synthetic-private-id', label: 'synthetic-private-label', status: 'ACTIVE', keyAvailable: true }, + create: vi.fn(), replace: vi.fn(), enable: vi.fn(), +})) +vi.mock('react-i18next', async () => ({ ...await vi.importActual('react-i18next'), useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })) +vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'synthetic-private-team' }), useNavigate: () => vi.fn() })) +vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () => null })) +vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => null, NamespaceManagementNav: () => null })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useNamespaceDetail: () => ({ data: state.namespace, isLoading: false }), + useMyNamespaces: () => ({ data: state.namespace ? [state.namespace] : [] }), + useFreezeNamespace: () => ({ mutateAsync: state.namespaceMutate }), + useUnfreezeNamespace: () => ({ mutateAsync: state.namespaceMutate }), + useArchiveNamespace: () => ({ mutateAsync: state.namespaceMutate }), + useRestoreNamespace: () => ({ mutateAsync: state.namespaceMutate }), + useDeleteNamespace: () => ({ mutateAsync: state.namespaceMutate }), +})) +vi.mock('@/shared/hooks/use-suite-queries', () => ({ + useReopenSuiteVersion: () => ({ mutateAsync: vi.fn() }), + useYankSuiteVersion: () => ({ mutateAsync: vi.fn() }), + useSetSuiteHidden: () => ({ mutateAsync: state.hidden }), + useSetSuiteArchived: () => ({ mutateAsync: state.archived }), + useDeleteSuite: () => ({ mutateAsync: state.remove }), +})) +vi.mock('@/features/report/use-skill-reports', () => ({ + useSkillReports: (status: string) => ({ data: { items: status === 'PENDING' ? [{ id: 1, skillId: 2, namespace: 'private-team', skillSlug: 'private-skill', reason: 'private-reason', createdAt: '2026-01-01' }] : [], total: status === 'PENDING' ? 1 : 0, size: 10 }, isLoading: false }), + useResolveSkillReport: () => ({ mutateAsync: state.resolve, isPending: false }), + useDismissSkillReport: () => ({ mutateAsync: state.dismiss, isPending: false }), +})) +vi.mock('@/features/github-sync/use-github-sync-admin', () => ({ + useGithubPlatformControl: () => ({ data: { desiredPaused: state.paused, version: 1, state: state.paused ? 'PAUSED' : 'RUNNING' } }), + useUpdateGithubPlatformControl: () => ({ mutateAsync: state.platform }), + useGithubCredentials: () => ({ data: [state.credential] }), + useGithubCredentialMutations: () => ({ create: { mutateAsync: state.create }, replace: { mutateAsync: state.replace }, setEnabled: { mutateAsync: state.enable } }), +})) +const id = (node: Element) => node.getAttribute('data-analytics-id') +function assertNoSensitiveAttributes() { + for (const node of document.querySelectorAll('[data-analytics-id]')) expect(id(node)).not.toMatch(/synthetic|private|reason|token-value/) +} +beforeEach(() => { + vi.clearAllMocks() + state.namespace = { id: 2, slug: 'synthetic-private-team', displayName: 'Private name', type: 'TEAM', status: 'ACTIVE', createdAt: '', immutable: false, currentUserRole: 'OWNER', canFreeze: true, canUnfreeze: true, canArchive: true, canRestore: true, canDelete: true } + state.paused = false + state.credential.status = 'ACTIVE' +}) +afterEach(cleanup) + +describe('finite business branch markers on real confirmation DOM', () => { + it.each(['freeze', 'unfreeze', 'archive', 'restore', 'delete'] as const)('namespace %s keeps request, cancel, X and confirm distinct', async action => { + render() + const request = screen.getByTestId(`${action}-namespace-synthetic-private-team`) + expect(id(request)).toBe(`namespace.${action}.request`) + fireEvent.click(request) + const confirm = screen.getByTestId(`namespace-action-confirm-${action}`) + expect(id(confirm)).toBe(`namespace.${action}.confirm`) + expect(id(screen.getByRole('button', { name: 'Close' }))).toBe(`namespace.${action}.cancel`) + fireEvent.click(screen.getByRole('button', { name: 'dialog.cancel' })) + expect(state.namespaceMutate).not.toHaveBeenCalled() + fireEvent.click(request) + fireEvent.click(screen.getByTestId(`namespace-action-confirm-${action}`)) + await waitFor(() => expect(state.namespaceMutate).toHaveBeenCalledTimes(1)) + assertNoSensitiveAttributes() + }) + + it('does not expose namespace actions for global or missing data', () => { + state.namespace!.type = 'GLOBAL' + const view = render() + expect(document.querySelector('[data-analytics-id]')).toBeNull() + state.namespace = null + view.rerender() + expect(document.querySelector('[data-analytics-id]')).toBeNull() + }) + + it.each(['hide', 'restore', 'archive', 'unarchive', 'delete'] as const)('suite %s confirmation and cancel preserve the handler', async action => { + const suite = { id: 7, versionId: 70, namespace: 'private-team', slug: 'private-suite', displayName: 'Private name', version: '1', status: 'PUBLISHED', suiteStatus: action === 'unarchive' ? 'ARCHIVED' : 'ACTIVE', hidden: action === 'restore', allowedActions: [action.toUpperCase()] } as SkillSuite + render() + const request = screen.getByRole('button', { name: `suite.${action}` }) + expect(id(request)).toBe(`suite.${action}.request`) + fireEvent.click(request) + expect(id(screen.getByRole('button', { name: 'dialog.cancel' }))).toBe(`suite.${action}.cancel`) + expect(id(screen.getByRole('button', { name: 'Close' }))).toBe(`suite.${action}.cancel`) + const confirm = screen.getAllByRole('button', { name: `suite.${action}` }).find(node => id(node) === `suite.${action}.confirm`)! + expect(confirm).toBeDefined() + fireEvent.click(confirm) + if (action === 'hide' || action === 'restore') await waitFor(() => expect(state.hidden).toHaveBeenCalledWith({ suiteId: 7, hidden: action === 'hide' })) + else if (action === 'archive' || action === 'unarchive') await waitFor(() => expect(state.archived).toHaveBeenCalledWith({ suiteId: 7, archived: action === 'archive' })) + else await waitFor(() => expect(state.remove).toHaveBeenCalledWith({ suiteId: 7 })) + assertNoSensitiveAttributes() + }) + + it.each([ + ['dismiss', 'dismiss', undefined], ['resolve', 'resolve', 'RESOLVE_ONLY'], + ['resolveAndHide', 'hide', 'RESOLVE_AND_HIDE'], ['resolveAndArchive', 'archive', 'RESOLVE_AND_ARCHIVE'], + ] as const)('report %s keeps its disposition and abandonment', async (label, action, disposition) => { + render() + const request = screen.getByRole('button', { name: `reports.${label}` }) + expect(id(request)).toBe(`report.${action}.request`) + fireEvent.click(request) + expect(id(screen.getByRole('button', { name: 'dialog.cancel' }))).toBe(`report.${action}.cancel`) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(state.resolve).not.toHaveBeenCalled() + expect(state.dismiss).not.toHaveBeenCalled() + fireEvent.click(request) + const confirm = document.querySelector(`[data-analytics-id="report.${action}.confirm"]`)! + fireEvent.click(confirm) + if (disposition) await waitFor(() => expect(state.resolve).toHaveBeenCalledWith({ id: 1, disposition })) + else await waitFor(() => expect(state.dismiss).toHaveBeenCalledWith({ id: 1 })) + assertNoSensitiveAttributes() + }) + + it.each([false, true])('platform paused=%s uses finite pause/resume values', async paused => { + state.paused = paused + render() + const action = paused ? 'resume' : 'pause' + fireEvent.click(document.querySelector(`[data-analytics-id="github.platform.${action}.request"]`)!) + expect(id(screen.getByRole('button', { name: 'Close' }))).toBe(`github.platform.${action}.cancel`) + fireEvent.click(document.querySelector(`[data-analytics-id="github.platform.${action}.confirm"]`)!) + await waitFor(() => expect(state.platform).toHaveBeenCalledWith({ expectedVersion: 1, paused: !paused })) + }) + + it.each(['create', 'replace'] as const)('credential %s marks submit only, never the secret fields', action => { + render() + fireEvent.click(document.querySelector(`[data-analytics-id="github.credential.${action}"]`)!) + const form = document.querySelector('form')! + expect(id(form)).toBe(`github.credential.${action}.submit`) + expect(id(screen.getByRole('button', { name: 'Close' }))).toBe(`github.credential.${action}.cancel`) + expect(form.querySelector('button[type="submit"]')?.hasAttribute('data-analytics-id')).toBe(false) + for (const field of form.querySelectorAll('input')) expect(id(field)).toBeNull() + expect(document.querySelector('[data-analytics-id="github.credential.disable"]')).not.toBeNull() + assertNoSensitiveAttributes() + }) +}) diff --git a/web/src/app/analytics-manifest.test.ts b/web/src/app/analytics-manifest.test.ts new file mode 100644 index 000000000..56ce9aad2 --- /dev/null +++ b/web/src/app/analytics-manifest.test.ts @@ -0,0 +1,71 @@ +import { buildManifest } from '../../scripts/analytics-manifest.mjs' +import manifestText from '../../../docs/frontend-analytics-manifest.json?raw' +import { describe, expect, it } from 'vitest' + + +interface Site { file: string; host: string; prop: string; event: string; ids?: string[]; forward?: string } +const manifest = JSON.parse(manifestText) as { + counts: { routes: number; uniqueIds: number; literalJsxSites: number; forwardingSites: number } + routes: Array<{ name: string; declaration: { file: string; line: number }; implementation: { file: string; line: number } | null }> + definitions: Array<{ ids: string[] }> + sites: Site[] +} +const ids = new Set([...manifest.sites.flatMap(site => site.ids ?? []), ...manifest.definitions.flatMap(site => site.ids)]) + +describe('frontend semantic marker source contract', () => { + it('detects marker/host/forwarding and registered-route drift using the actual TypeScript AST', () => { + expect(buildManifest()).toEqual(manifest) + }) + + it('keeps static lowercase categories and precise, separate route/page source anchors', () => { + expect(ids.size).toBe(manifest.counts.uniqueIds) + expect(manifest.routes).toHaveLength(manifest.counts.routes) + expect(new Set(manifest.routes.map(route => route.name)).size).toBe(manifest.routes.length) + for (const id of ids) expect(id).toMatch(/^[a-z]+(?:\.[a-z]+){2,}$/) + for (const route of manifest.routes) { + expect(route.declaration.file).toBe('web/src/app/router.tsx') + if (route.implementation) { + expect(route.implementation.file).toMatch(/^web\/src\/pages\//) + expect(route.implementation.line).toBeGreaterThan(0) + expect(route.implementation.file).not.toBe(route.declaration.file) + } + } + expect(ids.has('navigation.primary.open')).toBe(false) + expect(['navigation.skills.open', 'navigation.suites.open', 'navigation.github.open'].every(id => ids.has(id))).toBe(true) + }) + + it('retains the original eight meanings and explicit meaningful cancellation categories', () => { + for (const id of ['search.query.submit', 'skill.card.open', 'skill.detail.render', 'skill.download.request', 'skill.command.copy', 'skill.prompt.copy', 'skill.publish.request', 'skill.publish.confirm', 'skill.publish.cancel', 'skill.delete.cancel', 'review.reject.cancel', 'github.admission.cancel', 'suite.draft.cancel']) expect(ids.has(id)).toBe(true) + expect(manifest.sites.filter(site => site.ids?.includes('skill.detail.render')).map(site => [site.file, site.host, site.event])).toEqual([['web/src/pages/skill-detail.tsx', 'div', 'structural']]) + expect(manifest.sites.some(site => site.host === 'ConfirmDialog' && site.prop === 'cancelAnalyticsId' && site.ids?.includes('skill.publish.cancel'))).toBe(true) + }) + + it('has no guessed business defaults in generic UI and forwards only caller-scoped props', () => { + const primitives = manifest.sites.filter(site => site.file.startsWith('web/src/shared/ui/')) + expect(primitives.length).toBeGreaterThan(0) + expect(primitives.every(site => site.forward && !site.ids?.length)).toBe(true) + for (const [file, prop] of [ + ['features/search/search-bar.tsx', 'analyticsId'], + ['features/skill/skill-card.tsx', 'analyticsId'], + ['shared/components/confirm-dialog.tsx', 'confirmAnalyticsId'], + ['shared/components/confirm-dialog.tsx', 'cancelAnalyticsId'], + ['features/skill/file-preview-dialog.tsx', 'downloadAnalyticsId'], + ['features/skill/file-preview-dialog.tsx', 'copyAnalyticsId'], + ]) expect(manifest.sites.some(site => site.file === 'web/src/' + file && site.forward === prop)).toBe(true) + expect(manifest.sites.some(site => /pages\/(dashboard\/github-deep-discovery|settings\/accounts)\.tsx$/.test(site.file))).toBe(false) + }) + + it('keeps destructive and finite business branches separate without generic lifecycle fallbacks', () => { + for (const domain of ['namespace', 'suite']) { + const actions = domain === 'namespace' ? ['freeze', 'unfreeze', 'archive', 'restore', 'delete'] : ['hide', 'restore', 'archive', 'unarchive', 'delete'] + for (const action of actions) for (const phase of ['request', 'confirm', 'cancel']) expect(ids.has(`${domain}.${action}.${phase}`)).toBe(true) + } + for (const action of ['dismiss', 'resolve', 'hide', 'archive']) for (const phase of ['request', 'confirm', 'cancel']) expect(ids.has(`report.${action}.${phase}`)).toBe(true) + for (const action of ['pause', 'resume']) for (const phase of ['request', 'confirm', 'cancel']) expect(ids.has(`github.platform.${action}.${phase}`)).toBe(true) + for (const action of ['create', 'update']) for (const phase of ['request', 'confirm', 'cancel']) expect(ids.has(`skill.promotion.${action}.${phase}`)).toBe(true) + for (const action of ['direct', 'request']) for (const phase of ['submit', 'cancel']) expect(ids.has(`skill.revocation.${action}.${phase}`)).toBe(true) + for (const generic of ['namespace.lifecycle.confirm', 'suite.lifecycle.confirm', 'report.action.confirm', 'github.platform.control', 'github.credential.toggle', 'skill.promotion.confirm', 'skill.revocation.submit']) expect(ids.has(generic)).toBe(false) + expect(manifest.sites.some(site => site.file.endsWith('/github-deep-discovery-repository-drawer.tsx'))).toBe(false) + expect(manifest.sites.some(site => site.file.endsWith('/github-deep-discovery-pagination.tsx'))).toBe(false) + }) +}) diff --git a/web/src/app/analytics-markers.test.tsx b/web/src/app/analytics-markers.test.tsx new file mode 100644 index 000000000..bb79bfdcb --- /dev/null +++ b/web/src/app/analytics-markers.test.tsx @@ -0,0 +1,174 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SearchBar } from '@/features/search/search-bar' +import { SkillCard } from '@/features/skill/skill-card' +import { InstallCommand } from '@/features/skill/install-command' +import { InstallForAgentButton } from '@/features/skill/install-for-agent-button' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { Pagination } from '@/shared/components/pagination' +import { UploadZone } from '@/features/publish/upload-zone' +import type { SkillSummary } from '@/api/types' + +const mocks = vi.hoisted(() => ({ copy: vi.fn(), language: 'en' })) +vi.mock('react-i18next', async () => ({ ...await vi.importActual('react-i18next'), useTranslation: () => ({ t: (key: string) => key, i18n: { language: mocks.language } }) })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ isAuthenticated: false }) })) +vi.mock('@/features/social/use-star', () => ({ useStarredIdSet: () => ({ starredIds: new Set() }) })) +vi.mock('@/shared/lib/clipboard', () => ({ useCopyToClipboard: () => [false, mocks.copy] })) + +const skill = { id: 1, namespace: 'fixture', slug: 'private-fixture', displayName: 'Private fixture', starCount: 0, downloadCount: 0, ratingCount: 0 } as SkillSummary +const attr = (element: Element) => element.getAttribute('data-analytics-id') +afterEach(() => { cleanup(); vi.clearAllMocks(); mocks.language = 'en' }) + +describe('passive, caller-scoped analytics attributes', () => { + it('omits disabled dropzone intents without changing dropzone or native folder guards', () => { + const select = vi.fn() + const view = render() + const drop = view.container.querySelector('.upload-zone')! + expect(attr(drop)).toBe('skill.package.select') + view.rerender() + expect(attr(drop)).toBeNull() + const file = view.container.querySelector('.upload-zone input')! + expect(attr(file)).toBeNull() + expect(view.container.querySelector('[data-analytics-id="skill.package.folder.select"]')?.hasAttribute('disabled')).toBe(true) + fireEvent.change(file, { target: { files: [new File(['synthetic'], 'synthetic.zip', { type: 'application/zip' })] } }) + fireEvent.click(drop) + expect(select).not.toHaveBeenCalled() + view.rerender() + expect(view.container.querySelector('[data-analytics-id]')).toBeNull() + }) + + it('marks only opted-in search forms and keeps typing/clear separate from submit', () => { + const search = vi.fn() + const view = render() + const form = screen.getByRole('textbox').closest('form')! + expect(attr(form)).toBe('search.query.submit') + expect(attr(screen.getByRole('button', { name: 'searchBar.button' }))).toBeNull() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'not-an-analytics-field' } }) + fireEvent.click(screen.getByRole('button', { name: 'searchBar.clear' })) + expect(search).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'searchBar.button' })) + expect(search).toHaveBeenCalledTimes(1) + fireEvent.submit(form) + expect(search).toHaveBeenCalledTimes(2) + view.rerender() + expect(attr(form)).toBeNull() + view.rerender() + expect(attr(form)).toBeNull() + }) + + it('retains card click/Enter/Space handlers and omits inactive/unopted markers', () => { + const open = vi.fn() + const view = render() + const card = screen.getByRole('link') + expect(attr(card)).toBe('skill.card.open') + fireEvent.click(card) + fireEvent.keyDown(card, { key: 'Enter' }) + fireEvent.keyDown(card, { key: ' ' }) + fireEvent.keyDown(card, { key: 'Escape' }) + expect(open).toHaveBeenCalledTimes(3) + view.rerender() + expect(view.container.querySelector('[data-analytics-id]')).toBeNull() + view.rerender() + expect(view.container.querySelector('[data-analytics-id]')).toBeNull() + }) + + it.each(['en', 'zh'])('marks copy intents in %s without resource/command/prompt values', async language => { + mocks.language = language + mocks.copy.mockResolvedValue(undefined) + render() + expect(attr(screen.getByRole('tab', { name: 'skillDetail.installMethodSkillhub' }))).toBe('skill.command.skillhub.select') + expect(attr(screen.getByRole('tab', { name: 'skillDetail.installMethodClawhub' }))).toBe('skill.command.clawhub.select') + const copy = screen.getByRole('button', { name: 'copyButton.copy' }) + expect(attr(copy)).toBe('skill.command.copy') + fireEvent.click(copy) + await waitFor(() => expect(mocks.copy).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('tab', { name: 'skillDetail.installMethodClawhub' })) + expect(attr(screen.getByRole('button', { name: 'copyButton.copy' }))).toBe('skill.command.copy') + fireEvent.click(screen.getByRole('button', { name: 'copyButton.copy' })) + await waitFor(() => expect(mocks.copy).toHaveBeenCalledTimes(2)) + cleanup() + render() + const prompt = screen.getByTestId('install-for-agent-button') + expect(attr(prompt)).toBe('skill.prompt.copy') + fireEvent.click(prompt) + await waitFor(() => expect(mocks.copy).toHaveBeenCalledTimes(3)) + for (const node of document.querySelectorAll('[data-analytics-id]')) { + expect(attr(node)).not.toMatch(/fixture|1\.0|https|npx/) + } + }) + + it('keeps disabled and unsafe-version prompt copy disabled', () => { + const view = render() + const prompt = screen.getByTestId('install-for-agent-button') + expect(prompt.hasAttribute('disabled')).toBe(true) + fireEvent.click(prompt) + expect(mocks.copy).not.toHaveBeenCalled() + view.rerender() + fireEvent.click(prompt) + expect(mocks.copy).not.toHaveBeenCalled() + view.rerender() + expect(attr(prompt)).toBeNull() + }) + + it('does not turn clipboard rejection into success or another activation', async () => { + mocks.copy.mockRejectedValue(new Error('synthetic clipboard failure')) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + render() + fireEvent.click(screen.getByRole('button', { name: 'copyButton.copy' })) + await waitFor(() => expect(error).toHaveBeenCalledTimes(1)) + expect(mocks.copy).toHaveBeenCalledTimes(1) + expect(attr(screen.getByRole('button', { name: 'copyButton.copy' }))).toBe('skill.command.copy') + error.mockRestore() + }) + + it('forwards confirmation only to the requested confirm button and retains pending guard', async () => { + let finish!: () => void + const confirm = vi.fn(() => new Promise(resolve => { finish = resolve })) + const close = vi.fn() + const view = render() + const button = screen.getByRole('button', { name: 'continue' }) + expect(attr(button)).toBe('skill.publish.confirm') + expect(attr(screen.getByRole('button', { name: 'cancel' }))).toBeNull() + fireEvent.click(button) + fireEvent.click(button) + expect(confirm).toHaveBeenCalledTimes(1) + expect(button.hasAttribute('disabled')).toBe(true) + finish() + await waitFor(() => expect(close).toHaveBeenCalledWith(false)) + view.rerender() + expect(attr(screen.getByRole('button', { name: 'continue' }))).toBeNull() + }) + + it('pagination forwards intent to native controls, not its structural nav or disabled activation', () => { + const change = vi.fn() + const view = render() + expect(attr(screen.getByRole('navigation'))).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'pagination.prev' })) + expect(change).not.toHaveBeenCalled() + const next = screen.getByRole('button', { name: 'pagination.next' }) + expect(attr(next)).toBe('search.results.paginate') + fireEvent.click(next) + expect(change).toHaveBeenCalledExactlyOnceWith(1) + view.rerender() + expect(view.container.querySelector('[data-analytics-id]')).toBeNull() + }) + + it('opts destructive/configuration cancellation in per caller, including the close button', () => { + const close = vi.fn() + const confirm = vi.fn() + const view = render() + expect(attr(screen.getByRole('button', { name: 'delete' }))).toBe('skill.delete.confirm') + expect(attr(screen.getByRole('button', { name: 'cancel' }))).toBe('skill.delete.cancel') + const dismiss = screen.getByRole('button', { name: 'Close' }) + expect(attr(dismiss)).toBe('skill.delete.cancel') + fireEvent.click(dismiss) + expect(close).toHaveBeenCalledExactlyOnceWith(false) + expect(confirm).not.toHaveBeenCalled() + view.rerender() + expect(attr(screen.getByRole('button', { name: 'cancel' }))).toBe('github.admission.cancel') + expect(attr(screen.getByRole('button', { name: 'save' }))).toBeNull() + view.rerender() + expect(document.querySelector('[data-analytics-id]')).toBeNull() + }) +}) diff --git a/web/src/app/analytics-radix.test.tsx b/web/src/app/analytics-radix.test.tsx new file mode 100644 index 000000000..d69ecb52c --- /dev/null +++ b/web/src/app/analytics-radix.test.tsx @@ -0,0 +1,76 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + Element.prototype.scrollIntoView ??= () => {} +}) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +function fixture(onValueChange: (value: string) => void, open = true) { + return render() +} + +describe('actual Radix item activation with passive caller attributes', () => { + it.each(['Enter', ' '])('retains %s keyboard selection and the item marker', async key => { + const change = vi.fn() + fixture(change) + const option = await screen.findByRole('option', { name: 'Beta' }) + expect(option.getAttribute('data-analytics-id')).toBe('search.fixture.filter') + fireEvent.keyDown(option, { key }) + await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith('beta')) + expect(screen.queryByRole('option', { name: 'Beta' })).toBeNull() + }) + + it('selects mouse items on pointerup, not pointerdown, without requiring a click', async () => { + const change = vi.fn() + fixture(change) + const option = await screen.findByRole('option', { name: 'Beta' }) + fireEvent.pointerDown(option, { pointerType: 'mouse', button: 0 }) + expect(change).not.toHaveBeenCalled() + fireEvent.pointerUp(option, { pointerType: 'mouse', button: 0 }) + await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith('beta')) + }) + + it.each(['touch', 'pen'])('selects %s items on click, not pointerup', async pointerType => { + const change = vi.fn() + fixture(change) + const option = await screen.findByRole('option', { name: 'Beta' }) + fireEvent.pointerDown(option, { pointerType }) + fireEvent.pointerUp(option, { pointerType }) + expect(change).not.toHaveBeenCalled() + fireEvent.click(option) + await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith('beta')) + }) + + it('retains disabled selection guards', async () => { + const change = vi.fn() + fixture(change) + const option = await screen.findByRole('option', { name: 'Gamma' }) + expect(option.getAttribute('data-disabled')).toBe('') + fireEvent.keyDown(option, { key: 'Enter' }) + fireEvent.pointerDown(option, { pointerType: 'mouse' }) + fireEvent.pointerUp(option, { pointerType: 'mouse' }) + fireEvent.click(option) + expect(change).not.toHaveBeenCalled() + }) + + it('documents closed-trigger typeahead as a real change with no item-activation event', async () => { + const change = vi.fn() + fixture(change, false) + const trigger = screen.getByRole('combobox') + expect(trigger.getAttribute('data-analytics-id')).toBe('search.fixture.interact') + expect(screen.queryByRole('option')).toBeNull() + fireEvent.keyDown(trigger, { key: 'b' }) + await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith('beta')) + expect(screen.queryByRole('option')).toBeNull() + }) +}) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 9f0190bc6..287a78f27 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -82,15 +82,16 @@ export function Layout() { const navItems: Array<{ label: string to: string + analyticsId?: string exact?: boolean auth?: boolean }> = [ - { label: t('nav.landing'), to: '/', exact: true }, - { label: t('nav.publish'), to: '/dashboard/publish', auth: true }, - { label: t('nav.marketplace'), to: '/search' }, - { label: t('nav.githubSkills'), to: '/github' }, - { label: t('nav.suites'), to: '/suites' }, - { label: t('nav.dashboard'), to: '/dashboard', auth: true }, + { label: t('nav.landing'), to: '/', analyticsId: 'navigation.home.open', exact: true }, + { label: t('nav.publish'), to: '/dashboard/publish', analyticsId: 'skill.publish.open', auth: true }, + { label: t('nav.marketplace'), to: '/search', analyticsId: 'navigation.skills.open' }, + { label: t('nav.githubSkills'), to: '/github', analyticsId: 'navigation.github.open' }, + { label: t('nav.suites'), to: '/suites', analyticsId: 'navigation.suites.open' }, + { label: t('nav.dashboard'), to: '/dashboard', analyticsId: 'navigation.dashboard.open', auth: true }, ] const isActive = (to: string, exact?: boolean) => { @@ -122,7 +123,7 @@ export function Layout() { style={isSplitAuthRoute ? undefined : { borderColor: 'hsl(var(--border))' }} > {!isSplitAuthRoute ? ( - + SkillHub ) : null} @@ -135,7 +136,7 @@ export function Layout() { const active = isActive(item.to, item.exact) return ( - {/* Hamburger — visible below lg */} {!isAuthEntryRoute ? ( - +
{t('githubSync.platformWatermark')}

{policy.globalLow ?? '—'} / {policy.globalHigh ?? '—'}

@@ -58,16 +58,16 @@ export function GithubAdmissionConfig({ policy }: { policy: GithubSyncAdmissionP
{t('githubSync.platformPendingCandidates')}

{policy.platformPendingCandidateCount ?? '—'} / {policy.candidatePendingLimit}

- + {t('githubSync.capacityTitle')}{t('githubSync.capacityConfigHelp')} -
+
{fields.map((field) => )}
-
+
diff --git a/web/src/features/github-sync/github-credential-config.tsx b/web/src/features/github-sync/github-credential-config.tsx index 784602fbd..8f3b5463e 100644 --- a/web/src/features/github-sync/github-credential-config.tsx +++ b/web/src/features/github-sync/github-credential-config.tsx @@ -66,12 +66,12 @@ export function GithubCredentialConfig() {

{t('githubSync.credentials')}

{t('githubSync.credentialHelp')}

- +
{ setOpen(nextOpen); if (!nextOpen) reset() }}> - + {editingId ? t('githubSync.replaceCredential') : t('githubSync.addCredential')}{t('githubSync.credentialHelp')} -
+ @@ -79,7 +79,7 @@ export function GithubCredentialConfig() { setToken(event.target.value)} required /> -
+
@@ -111,10 +111,10 @@ export function GithubCredentialConfig() { )) : '—'} {formatLocalDateTime(credential.lastUsedAt, i18n.language)} - - diff --git a/web/src/features/github-sync/github-deep-discovery-config.tsx b/web/src/features/github-sync/github-deep-discovery-config.tsx index 57eaa4616..210dac8b2 100644 --- a/web/src/features/github-sync/github-deep-discovery-config.tsx +++ b/web/src/features/github-sync/github-deep-discovery-config.tsx @@ -62,14 +62,14 @@ export function GithubDeepDiscoveryConfig({ configuration, onClose, embedded = f
{t('deepDiscovery.budget')}
{(['ECONOMY', 'BALANCED', 'ACCELERATED'] as const).map((profile) => )} @@ -79,8 +79,8 @@ export function GithubDeepDiscoveryConfig({ configuration, onClose, embedded = f

{t('deepDiscovery.exclusions')}

{t('deepDiscovery.exclusionsHelp')}

-
- setKind(event.target.value as Exclusion['kind'])} className="h-9 rounded border bg-background px-2 text-sm"> @@ -93,17 +93,17 @@ export function GithubDeepDiscoveryConfig({ configuration, onClose, embedded = f
{exclusions.map((item, index) =>
{t(item.kind === 'OWNER' ? 'deepDiscovery.exclusionOwner' : 'deepDiscovery.exclusionRepository')}: {item.value} -
)} {exclusions.length === 0 ?

{t('deepDiscovery.noExclusions')}

: null}
- {conflict ?
{t('deepDiscovery.configConflict')}
: null} + {conflict ?
{t('deepDiscovery.configConflict')}
: null} - - + + diff --git a/web/src/features/github-sync/github-discovery-config.tsx b/web/src/features/github-sync/github-discovery-config.tsx index aa67daca4..80dcb4925 100644 --- a/web/src/features/github-sync/github-discovery-config.tsx +++ b/web/src/features/github-sync/github-discovery-config.tsx @@ -168,12 +168,12 @@ export function GithubDiscoveryConfig({ layout = 'default' }: { layout?: 'defaul
{layout === 'default' ? : null} - - + +
{ if (!open) closeForm() }}> - + {t(mode === 'repository' ? 'githubSync.addRepository' : mode === 'owner' ? 'githubSync.addOwner' : editingRuleId ? 'githubSync.editRule' : 'githubSync.addRule')} {t(mode === 'repository' ? 'githubSync.repositoryHelp' : mode === 'owner' ? 'githubSync.ownerHelp' : 'githubSync.ruleHelp')} @@ -188,7 +188,7 @@ export function GithubDiscoveryConfig({ layout = 'default' }: { layout?: 'defaul ) : null} {mode === 'owner' ? (
- + setOwner(event.target.value)} required />
))} - {selectors.length < 20 ? : null} + {selectors.length < 20 ? : null}
- - + +
@@ -292,8 +292,8 @@ export function GithubDiscoveryConfig({ layout = 'default' }: { layout?: 'defaul {formatLocalDateTime(entry.nextDueAt, i18n.language)} {entry.enabled ? t('githubSync.active') : t('githubSync.paused')} - {entry.entryType === 'SEARCH_RULE' ? : null} - + {entry.entryType === 'SEARCH_RULE' ? : null} + ))} diff --git a/web/src/features/github-sync/github-retry-rejected-skill-button.tsx b/web/src/features/github-sync/github-retry-rejected-skill-button.tsx index 7dee16abf..cdaed165b 100644 --- a/web/src/features/github-sync/github-retry-rejected-skill-button.tsx +++ b/web/src/features/github-sync/github-retry-rejected-skill-button.tsx @@ -27,7 +27,7 @@ export function GithubRetryRejectedSkillButton({ sourceId, path, namespaceSlug } if (!skillPath || (path.latestBindingIngestionStatus !== 'REJECTED' && !path.retryRequested)) return null return
- diff --git a/web/src/features/github-sync/github-schedule-config.tsx b/web/src/features/github-sync/github-schedule-config.tsx index da3e8a85b..6d66537ef 100644 --- a/web/src/features/github-sync/github-schedule-config.tsx +++ b/web/src/features/github-sync/github-schedule-config.tsx @@ -53,16 +53,16 @@ export function GithubScheduleConfig({ policy }: { policy: GithubSyncSchedulePol return

{t('githubSync.scheduleTitle')}

{t('githubSync.scheduleHelp')}

- +
{fields.map((field) =>
{t(`githubSync.${field}`)}

{policy[field] == null ? '—' : `${policy[field]} ${t('githubSync.hoursUnit')}`}

)}
{policy.repositoryHours == null ?

{t('githubSync.scheduleLegacy', { interval: policy.effectiveRepositoryInterval })}

: null} - + {t('githubSync.scheduleTitle')}{t('githubSync.scheduleHelp')} -
+
{fields.map((field) => )}
-
+
diff --git a/web/src/features/github-sync/github-source-ignore-editor.tsx b/web/src/features/github-sync/github-source-ignore-editor.tsx index 717a9fbf2..cc83a0823 100644 --- a/web/src/features/github-sync/github-source-ignore-editor.tsx +++ b/web/src/features/github-sync/github-source-ignore-editor.tsx @@ -57,7 +57,7 @@ function IgnoreForm({ initial, saving, onSave }: { void onSave(value.split('\n').map((line) => line.trim()).filter(Boolean)) } return ( -
+