diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 83baa5e..6ae955b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,18 @@ import './preview.css'; -import type { Preview } from '@storybook/react-vite'; +import type { Decorator, Preview } from '@storybook/react-vite'; + +type Theme = 'system' | 'light' | 'dark'; + +// Force a color scheme by toggling the .theme-* classes styles.css reacts to. +// A story can pin one via `parameters.theme`; otherwise the toolbar global wins. +const withTheme: Decorator = (Story, ctx) => { + const theme = (ctx.parameters.theme as Theme | undefined) ?? (ctx.globals.theme as Theme) ?? 'system'; + const root = document.documentElement; + root.classList.remove('theme-light', 'theme-dark'); + if (theme === 'light') root.classList.add('theme-light'); + if (theme === 'dark') root.classList.add('theme-dark'); + return ; +}; const preview: Preview = { parameters: { @@ -12,10 +25,27 @@ const preview: Preview = { backgrounds: { disable: true }, options: { storySort: { - method: 'alphabetical', + order: ['Report', ['App', ['Light', 'Dark']]], + }, + }, + }, + globalTypes: { + theme: { + description: 'Color scheme', + defaultValue: 'system', + toolbar: { + title: 'Theme', + icon: 'mirror', + items: [ + { value: 'system', title: 'System' }, + { value: 'light', title: 'Light' }, + { value: 'dark', title: 'Dark' }, + ], + dynamicTitle: true, }, }, }, + decorators: [withTheme], }; export default preview; diff --git a/AGENTS.md b/AGENTS.md index 774261e..4ef68e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,9 @@ ## Project: patchwave-analysis -A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It runs in the user's environment, crawls `api.github.com`, and writes a self-contained HTML report plus a raw-data zip to a temporary directory. No data leaves the user's network unless they choose to share the generated artifacts. +A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It runs in the user's environment, crawls `api.github.com`, and writes a self-contained HTML report to a temporary directory. No data leaves the user's network unless they choose to share the generated report. -The single entrypoint is `patchwave-analysis []` — an interactive session that prompts for the target if omitted, then for what to share when the scan finishes. There are no other flags; the time window (90 days) is fixed. +The single entrypoint is `patchwave-analysis []` — an interactive session that prompts for the target if omitted, then whether to share the report when the scan finishes. There are no other flags; the time window (90 days) is fixed. ## Stack @@ -68,6 +68,7 @@ Beyond the rules: - **In Zod string schemas, prefer `.nonempty()` over `.min(1)`** (`.trim().nonempty()` when surrounding whitespace should not count). - **Destructured defaults over `??` fallbacks.** Apply defaults in a single destructuring assignment — `const { version = '0.0.1' } = input;`, not per-field `??`. - **Helpers at the bottom of files.** Primary exports come first; module-local helpers and factories sit below them. In test files they live after all `describe()` blocks. +- **The product is always "PatchWave" in prose and UI copy** — capital P and W, one word. Never "patchwave", "Patchwave", or "patch wave". The only lowercase forms are literal identifiers that must match their real-world spelling: the `patchwave-analysis` CLI/binary name, the `patchwave-report.html` artifact, and the `patchwave.ai` domain. ### File naming diff --git a/README.md b/README.md index 00c0d71..cf2fa0a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # patchwave-analysis -A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It writes a self-contained HTML report plus a raw-data zip. +A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It writes a self-contained HTML report. ## What it tells you @@ -63,34 +63,15 @@ The CLI takes a single optional argument — the org or user to scan. There are ## Output -Each run writes two files into a fresh temporary directory and prints the full paths when the scan finishes: +Each run writes a single file into a fresh temporary directory and prints the full path when the scan finishes: -- **`patchwave-report.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file. -- **`patchwave-report.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge. +- **`patchwave-report.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up data that drives every metric. This is the artifact to send back when you want a deeper look from contextbridge. -The zip contains: - -```text -patchwave-report.html — interactive HTML report -README.txt — what's in the bundle -data/meta.json — CLI version, target, window, run options, top-level counts -data/aggregated.json — rolled-up metrics that drive the report -data/repos.json — repo metadata -data/languages.json — per-repo language byte counts -data/dependabot-config.json — per-repo Dependabot config and ecosystems -data/dependabot-prs.json — Dependabot PRs in the window (state, checks, reviewers) -data/cve.json — Dependabot security alert slices -data/reverts.json — revert commits detected in the window -data/branch-protection.json — default-branch protection slices -data/contributors.json — active human committers per repo -data/warnings.json — per-collector warnings suppressed during the crawl -``` - -The report and bundle are not uploaded unless you choose to share them. The archive does not include tokens, secrets, or repository file contents. +The report is not uploaded unless you choose to share it. It does not include tokens, secrets, or repository file contents. ## What it does not do -- It does not upload the report or any GitHub data unless you choose to share the generated artifacts. It reads from `api.github.com`. Filesystem writes are limited to the `patchwave-report.html` / `patchwave-report.zip` pair in a temporary directory and a one-time anonymous-id file (see Telemetry & privacy). +- It does not upload the report or any GitHub data unless you choose to share it. It reads from `api.github.com`. Filesystem writes are limited to the `patchwave-report.html` file in a temporary directory and a one-time anonymous-id file (see Telemetry & privacy). - It does not keep a Markdown compatibility report. - It does not auto-update. diff --git a/bun.lock b/bun.lock index 613e695..ddc8f61 100644 --- a/bun.lock +++ b/bun.lock @@ -12,16 +12,20 @@ "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", "@sentry/bun": "^10.50.0", - "fflate": "^0.8.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.16.0", "neverthrow": "^8.2.0", "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.370.0", "posthog-node": "^5.35.1", + "radix-ui": "^1.4.3", "react": "^19", "react-dom": "^19", "semver": "^7.8.1", + "tailwind-merge": "^3.3.1", "zod": "^4.4.3", }, "devDependencies": { @@ -60,45 +64,45 @@ "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], @@ -186,6 +190,14 @@ "@fastify/otel": ["@fastify/otel@0.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], @@ -452,6 +464,126 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -718,7 +850,9 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -756,13 +890,17 @@ "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], + "comment-parser": ["comment-parser@1.4.7", "", {}, "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ=="], "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -794,6 +932,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -872,7 +1012,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -894,6 +1034,8 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -1000,6 +1142,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1122,6 +1266,8 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], @@ -1134,6 +1280,12 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1206,6 +1358,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -1216,7 +1370,7 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -1252,6 +1406,10 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], @@ -1330,6 +1488,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1352,8 +1512,6 @@ "path-scurry/lru-cache": ["lru-cache@11.5.0", "", {}, "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA=="], - "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], - "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/package.json b/package.json index dcf6d07..1c26964 100644 --- a/package.json +++ b/package.json @@ -75,21 +75,25 @@ "dependencies": { "@clack/prompts": "^1.4.0", "@js-temporal/polyfill": "^0.5.1", - "@sentry/bun": "^10.50.0", "@octokit/graphql": "^9.0.3", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", - "fflate": "^0.8.3", + "@sentry/bun": "^10.50.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.16.0", "neverthrow": "^8.2.0", "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.370.0", "posthog-node": "^5.35.1", + "radix-ui": "^1.4.3", "react": "^19", "react-dom": "^19", "semver": "^7.8.1", + "tailwind-merge": "^3.3.1", "zod": "^4.4.3" } } diff --git a/src/FileSystem.ts b/src/FileSystem.ts index e801f9f..885ec0d 100644 --- a/src/FileSystem.ts +++ b/src/FileSystem.ts @@ -10,7 +10,6 @@ export type FsError = export interface FileSystem { writeTextFile(path: string, contents: string): ResultAsync; - writeBinaryFile(path: string, contents: Uint8Array): ResultAsync; /** Create a fresh, uniquely named directory under the OS temp dir and return its path. */ makeTempDir(prefix: string): ResultAsync; } @@ -20,10 +19,6 @@ export class FileSystemImpl implements FileSystem { return this.write(path, contents); } - writeBinaryFile(path: string, contents: Uint8Array): ResultAsync { - return this.write(path, contents); - } - makeTempDir(prefix: string): ResultAsync { return ResultAsync.fromPromise( mkdtemp(join(tmpdir(), prefix)), @@ -31,7 +26,7 @@ export class FileSystemImpl implements FileSystem { ); } - private write(path: string, contents: string | Uint8Array): ResultAsync { + private write(path: string, contents: string): ResultAsync { return ResultAsync.fromPromise( Bun.write(path, contents).then(() => undefined), (e): FsError => ({ diff --git a/src/cli.test.ts b/src/cli.test.ts index 38f51fd..98464eb 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,4 @@ import { expect, test } from 'bun:test'; -import { strFromU8, unzipSync } from 'fflate'; import { main } from './cli.ts'; import { createFakeContext } from './testHelpers/index.ts'; @@ -89,7 +88,6 @@ test('writes a report when the GitHub calls succeed', async () => { // Output lands in a temp dir, not the CWD; locate it via the returned paths. expect(result.run.target).toBe('acme'); expect(result.run.paths.html.endsWith('patchwave-report.html')).toBe(true); - expect(result.run.paths.zip.endsWith('patchwave-report.zip')).toBe(true); const written = fs.read(result.run.paths.html); expect(written).toBeDefined(); @@ -99,38 +97,8 @@ test('writes a report when the GitHub calls succeed', async () => { const embedded = JSON.parse(match?.[1] ?? '') as { meta: { org: string } }; expect(embedded.meta.org).toBe('acme'); - const zipBytes = fs.readBinary(result.run.paths.zip); - expect(zipBytes).toBeInstanceOf(Uint8Array); - const entries = unzipSync(zipBytes as Uint8Array); - expect(Object.keys(entries).sort()).toEqual( - [ - 'README.txt', - 'data/aggregated.json', - 'data/branch-protection.json', - 'data/contributors.json', - 'data/cve.json', - 'data/dependabot-config.json', - 'data/dependabot-prs.json', - 'data/languages.json', - 'data/meta.json', - 'data/repos.json', - 'data/warnings.json', - 'patchwave-report.html', - ].sort(), - ); - const meta = JSON.parse(strFromU8(entries['data/meta.json'] as Uint8Array)) as Record; - expect(meta).toMatchObject({ - target: 'acme', - windowDays: 90, - counts: { reposTotal: 1, reposIncluded: 1, dependabotPrs: 0, warnings: 0 }, - }); - const repos = JSON.parse(strFromU8(entries['data/repos.json'] as Uint8Array)) as Array<{ name: string }>; - expect(repos).toHaveLength(1); - expect(repos[0]?.name).toBe('widgets'); - - // The completed run hands the bytes back so the caller (index.ts) can drive + // The completed run hands the html back so the caller (index.ts) can drive // the share prompt without re-reading the filesystem. - expect(result.run.zipBytes).toBeInstanceOf(Uint8Array); expect(result.run.html).toContain(' ({ report, - collected: data, - aggregated: bundle, stats: { reposTotal: repos.length, reposIncluded: filtered.length, diff --git a/src/github/GithubClient.ts b/src/github/GithubClient.ts index 8bd8a7f..a98ad88 100644 --- a/src/github/GithubClient.ts +++ b/src/github/GithubClient.ts @@ -6,7 +6,7 @@ import { ResultAsync } from 'neverthrow'; import type { Logger } from '../logger.ts'; import { type GithubError, toGithubError } from './errors.ts'; -const PatchwaveOctokit = Octokit.plugin(retry, throttling); +const PatchWaveOctokit = Octokit.plugin(retry, throttling); /** * Narrow GitHub API surface the collectors depend on. Each method returns a @@ -26,7 +26,7 @@ export interface GithubClientImplOptions { } export class GithubClientImpl implements GithubClient { - private readonly rest: InstanceType; + private readonly rest: InstanceType; private readonly graphqlClient: typeof graphqlBase; constructor(options: GithubClientImplOptions) { @@ -36,7 +36,7 @@ export class GithubClientImpl implements GithubClient { // our logger as `request.log` on each client too, so that noise lands in pino // (silent by default) instead of the user's terminal. A child tags the source. const log = logger.child({ source: 'octokit' }); - this.rest = new PatchwaveOctokit({ + this.rest = new PatchWaveOctokit({ auth: token, userAgent, log, diff --git a/src/index.ts b/src/index.ts index 5e43fd6..919f6c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,9 +95,7 @@ if (result.kind === 'completed') { context: ctx, target: result.run.target, htmlPath: result.run.paths.html, - zipPath: result.run.paths.zip, htmlContent: result.run.html, - zipBytes: result.run.zipBytes, identifier, }); } diff --git a/src/interactive/sharePrompt.test.ts b/src/interactive/sharePrompt.test.ts index 2453fb6..9a8a2fc 100644 --- a/src/interactive/sharePrompt.test.ts +++ b/src/interactive/sharePrompt.test.ts @@ -4,7 +4,7 @@ import { runSharePrompt } from './sharePrompt.ts'; import { sharePromptInputsFor } from './testFactories.ts'; describe('runSharePrompt', () => { - test('shows file paths and a clear share question, defaulting to HTML only', async () => { + test('shows the report path and a clear share question, defaulting to sharing', async () => { const handle = fakeContextHandle.build(); handle.prompter.scriptSelect('declined'); @@ -13,9 +13,8 @@ describe('runSharePrompt', () => { const reportReadyNote = handle.prompter.notes.find((n) => n.title === 'Report ready'); expect(reportReadyNote?.message).toContain('acme'); expect(reportReadyNote?.message).toContain('/tmp/report.html'); - expect(reportReadyNote?.message).toContain('/tmp/report.zip'); expect(handle.prompter.selects[0]?.message).toContain('share this with us'); - expect(handle.prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['full', 'html', 'declined']); + expect(handle.prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['html', 'declined']); expect(handle.prompter.selects[0]?.initialValue).toBe('html'); }); @@ -39,32 +38,20 @@ describe('runSharePrompt', () => { const outcome = await runSharePrompt(sharePromptInputsFor(handle)); - expect(outcome).toMatchObject({ kind: 'shared', choice: 'html', identifier: 'anon-uuid' }); + expect(outcome).toMatchObject({ kind: 'shared', identifier: 'anon-uuid' }); expect(handle.uploader.calls).toHaveLength(1); expect(handle.uploader.calls[0]).toMatchObject({ - kind: 'html', identifier: 'anon-uuid', appVersion: '0.0.1', timestamp: '2026-05-22T12:00:00Z', }); expect(new TextDecoder().decode(handle.uploader.calls[0]?.bytes)).toBe(''); - expect(handle.analytics.capturedEvents('upload_succeeded')[0]?.properties).toMatchObject({ mode: 'html' }); - }); - - test('full: uploads the original zip bytes unchanged with kind:zip', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('full').scriptText(''); - const zipBytes = new Uint8Array([1, 2, 3, 4, 5]); - - await runSharePrompt(sharePromptInputsFor(handle, { zipBytes })); - - expect(handle.uploader.calls[0]?.kind).toBe('zip'); - expect(handle.uploader.calls[0]?.bytes).toEqual(zipBytes); + expect(handle.analytics.capturedEvents('upload_succeeded')).toHaveLength(1); }); test('uses a volunteered email as the identifier', async () => { const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('full').scriptText('ben@example.com'); + handle.prompter.scriptSelect('html').scriptText('ben@example.com'); const outcome = await runSharePrompt(sharePromptInputsFor(handle)); @@ -72,25 +59,22 @@ describe('runSharePrompt', () => { expect(handle.uploader.calls[0]?.identifier).toBe('ben@example.com'); }); - test('upload failure surfaces the error and leaves files in place', async () => { + test('upload failure surfaces the error and leaves the report in place', async () => { const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('full').scriptText(''); + handle.prompter.scriptSelect('html').scriptText(''); handle.uploader.fails({ kind: 'presign-bad-status', status: 500, body: 'boom' }); const outcome = await runSharePrompt(sharePromptInputsFor(handle)); expect(outcome.kind).toBe('upload-failed'); if (outcome.kind === 'upload-failed') { - expect(outcome.choice).toBe('full'); expect(outcome.message).toContain('500'); } expect(handle.analytics.capturedEvents('upload_failed')[0]?.properties).toMatchObject({ - mode: 'full', error_kind: 'presign-bad-status', }); const failureNote = handle.prompter.notes.find((n) => n.title === "We couldn't upload"); expect(failureNote?.message).toContain('/tmp/report.html'); - expect(failureNote?.message).toContain('/tmp/report.zip'); expect(failureNote?.message).toContain('founders@contextbridge.ai'); }); diff --git a/src/interactive/sharePrompt.ts b/src/interactive/sharePrompt.ts index 85afdf4..8504550 100644 --- a/src/interactive/sharePrompt.ts +++ b/src/interactive/sharePrompt.ts @@ -1,37 +1,34 @@ import type { Context } from '../context.ts'; import { type Prompter, formatPromptError } from '../prompt/Prompter.ts'; -import { type BundleKind, formatUploadError } from '../upload/Uploader.ts'; +import { formatUploadError } from '../upload/Uploader.ts'; const SUPPORT_LINE = 'Reach us at founders@contextbridge.ai — or learn more at https://patchwave.ai'; -export type ShareChoice = 'html' | 'full' | 'declined'; +export type ShareChoice = 'html' | 'declined'; export interface SharePromptInputs { readonly context: Context; readonly target: string; readonly htmlPath: string; - readonly zipPath: string; readonly htmlContent: string; - readonly zipBytes: Uint8Array; readonly identifier: string; } export type ShareOutcome = - | { kind: 'shared'; choice: 'html' | 'full'; uploadId: string; identifier: string } + | { kind: 'shared'; uploadId: string; identifier: string } | { kind: 'declined' } | { kind: 'cancelled' } - | { kind: 'upload-failed'; choice: 'html' | 'full'; message: string }; + | { kind: 'upload-failed'; message: string }; export async function runSharePrompt(inputs: SharePromptInputs): Promise { const { prompter, analytics, uploader } = inputs.context; prompter.note( [ - `Scanned: ${inputs.target}`, - `HTML report: ${inputs.htmlPath}`, - `Raw data bundle: ${inputs.zipPath}`, + `Scanned: ${inputs.target}`, + `HTML report: ${inputs.htmlPath}`, '', - "Open either file to see what would be sent — we'll upload exactly what's on disk.", + "Open it to see what would be sent — we'll upload exactly what's on disk.", ].join('\n'), 'Report ready', ); @@ -42,8 +39,7 @@ export async function runSharePrompt(inputs: SharePromptInputs): Promise(() => { context: handle.ctx, target: 'acme', htmlPath: '/tmp/report.html', - zipPath: '/tmp/report.zip', htmlContent: '', - zipBytes: new Uint8Array([0x50, 0x4b]), identifier: 'anon-uuid', }; }); diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts index c39e8c4..91bb18a 100644 --- a/src/report/aggregate.test.ts +++ b/src/report/aggregate.test.ts @@ -94,6 +94,31 @@ test('counts CVE alerts by severity and surfaces oldest critical days', () => { } }); +test('keeps all CVE repos by severity for frontend truncation', () => { + const data = collectedData.build({ + cve: Array.from({ length: 6 }, (_, i) => + cveSliceOk.build({ + owner: 'acme', + name: `repo-${i + 1}`, + alerts: [ + cveAlert.build({ + owner: 'acme', + name: `repo-${i + 1}`, + severity: i === 0 ? 'critical' : 'high', + }), + ], + }), + ), + }); + + const bundle = aggregate(data); + expect(bundle.cve.status).toBe('ok'); + if (bundle.cve.status === 'ok') { + expect(bundle.cve.topReposBySeverity).toHaveLength(6); + expect(bundle.cve.topReposBySeverity.map((r) => r.repo)).toContain('acme/repo-6'); + } +}); + test('aggregates failing check names across open PRs, sorted by frequency', () => { const prA = dependabotPr.build({ state: 'open', @@ -162,7 +187,7 @@ test('builds a cost estimate matrix with rate columns and time-per-PR rows', () ); }); -test('topMergers excludes bot logins and surfaces per-person weekly cost', () => { +test('mergers excludes bot logins and surfaces per-person window cost', () => { const data = collectedData.build({ dependabotPrs: [ dependabotPr.build({ number: 1, merged: true, mergedAt: '2026-04-01T00:00:00Z', mergedBy: 'alice' }), @@ -174,8 +199,8 @@ test('topMergers excludes bot logins and surfaces per-person weekly cost', () => ], }); const bundle = aggregate(data); - expect(bundle.people.topMergers.map((m) => m.login)).toEqual(['alice', 'bob']); - const alice = bundle.people.topMergers.find((m) => m.login === 'alice'); + expect(bundle.people.mergers.map((m) => m.login)).toEqual(['alice', 'bob']); + const alice = bundle.people.mergers.find((m) => m.login === 'alice'); expect(alice?.count).toBe(2); expect(alice?.windowCostUsd).toBeGreaterThan(0); expect(alice?.annualCostUsd).toBeGreaterThan(alice?.windowCostUsd ?? 0); diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts index 8f7ed08..6cbd477 100644 --- a/src/report/aggregate.ts +++ b/src/report/aggregate.ts @@ -80,9 +80,9 @@ export interface StalledSignals { } export interface People { - topMergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; - topReviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; - topCommenters: Array<{ login: string; count: number }>; + mergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + reviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + commenters: Array<{ login: string; count: number }>; } export interface CostEstimate { @@ -98,7 +98,7 @@ export interface CostEstimate { } export interface CveExposure { - status: 'ok' | 'scope-missing' | 'no-data'; + status: 'ok' | 'scope-missing'; requiredScope?: string; totalOpenAlerts: number; bySeverity: Record; @@ -361,24 +361,26 @@ function buildPeople(data: CollectedData, windowDays: number): People { for (const r of pr.reviewers) if (!isBotLogin(r)) reviewerCounts.set(r, (reviewerCounts.get(r) ?? 0) + 1); for (const c of pr.commenters) if (!isBotLogin(c)) commenterCounts.set(c, (commenterCounts.get(c) ?? 0) + 1); } - const top = (m: Map) => - [...m.entries()] - .map(([login, count]) => ({ login, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); - - const topMergers = derivePersonCosts(top(mergerCounts), windowDays, ASSUMED_MIN_PER_PR, ASSUMED_HOURLY_RATE_USD); - const topReviewers = derivePersonCosts( - top(reviewerCounts), + const rankedCounts = (m: Map) => + [...m.entries()].map(([login, count]) => ({ login, count })).sort((a, b) => b.count - a.count); + + const mergers = derivePersonCosts( + rankedCounts(mergerCounts), + windowDays, + ASSUMED_MIN_PER_PR, + ASSUMED_HOURLY_RATE_USD, + ); + const reviewers = derivePersonCosts( + rankedCounts(reviewerCounts), windowDays, ASSUMED_MIN_PER_REVIEW, ASSUMED_HOURLY_RATE_USD, ); return { - topMergers, - topReviewers, - topCommenters: top(commenterCounts), + mergers, + reviewers, + commenters: rankedCounts(commenterCounts), }; } @@ -437,8 +439,7 @@ function buildCveExposure(data: CollectedData, now: Instant): CveExposure { } const topReposBySeverity = [...byRepo.entries()] .map(([repo, counts]) => ({ repo, ...counts })) - .sort((a, b) => severityScore(b) - severityScore(a)) - .slice(0, 5); + .sort((a, b) => severityScore(b) - severityScore(a)); const oldest = (sev: CveSeverity): number | null => { const filtered = allAlerts.filter((a) => a.severity === sev); diff --git a/src/report/bundle.test.ts b/src/report/bundle.test.ts deleted file mode 100644 index 2964de9..0000000 --- a/src/report/bundle.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from 'bun:test'; -import { strFromU8, unzipSync } from 'fflate'; -import { collectedData, dependabotPr } from '../testFactories.ts'; -import { aggregate } from './aggregate.ts'; -import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './bundle.ts'; - -const meta: BundleMeta = { - cliVersion: '0.0.1', - generatedAt: '2026-05-22T00:00:00Z', - target: 'acme', - windowDays: 90, - windowStart: '2026-02-21T00:00:00Z', - options: { include: null, exclude: [] }, - counts: { reposTotal: 1, reposIncluded: 1, dependabotPrs: 1, warnings: 0 }, -}; - -test('buildBundleFiles emits one JSON file per slice plus the html report and README', () => { - const collected = collectedData.build({ dependabotPrs: [dependabotPr.build()] }); - const aggregated = aggregate(collected); - const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: 'hi' }); - - expect(Object.keys(files).sort()).toEqual( - [ - 'README.txt', - 'data/aggregated.json', - 'data/branch-protection.json', - 'data/contributors.json', - 'data/cve.json', - 'data/dependabot-config.json', - 'data/dependabot-prs.json', - 'data/languages.json', - 'data/meta.json', - 'data/repos.json', - 'data/warnings.json', - 'patchwave-report.html', - ].sort(), - ); - expect(files['patchwave-report.html']).toBe('hi'); - expect(files['README.txt']).toContain('patchwave-analysis bundle'); -}); - -test('data JSON files are pretty-printed and slice-shaped', () => { - const collected = collectedData.build({ dependabotPrs: [dependabotPr.build({ number: 42 })] }); - const aggregated = aggregate(collected); - const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: '' }); - - const repos = JSON.parse(files['data/repos.json'] as string) as unknown; - expect(Array.isArray(repos)).toBe(true); - - const prs = JSON.parse(files['data/dependabot-prs.json'] as string) as Array<{ number: number }>; - expect(prs[0]?.number).toBe(42); - - expect(files['data/aggregated.json']).toContain(' '); // indented -}); - -test('zipBundleFiles round-trips through unzipSync', () => { - const zip = zipBundleFiles({ 'patchwave-report.html': '', 'data/meta.json': '{}\n' }); - const entries = unzipSync(zip); - expect(strFromU8(entries['patchwave-report.html'] as Uint8Array)).toBe(''); - expect(strFromU8(entries['data/meta.json'] as Uint8Array)).toBe('{}\n'); -}); diff --git a/src/report/bundle.ts b/src/report/bundle.ts deleted file mode 100644 index b64cdd7..0000000 --- a/src/report/bundle.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { strToU8, zipSync } from 'fflate'; -import type { CollectedData } from '../types.ts'; -import type { ReportBundle } from './aggregate.ts'; - -export interface BundleMeta { - cliVersion: string; - generatedAt: string; - target: string; - windowDays: number; - windowStart: string; - options: { - include: string[] | null; - exclude: string[]; - }; - counts: { - reposTotal: number; - reposIncluded: number; - dependabotPrs: number; - warnings: number; - }; -} - -export interface BundleInputs { - meta: BundleMeta; - collected: CollectedData; - aggregated: ReportBundle; - reportHtml: string; -} - -export function buildBundleFiles(inputs: BundleInputs): Record { - const { meta, collected, aggregated, reportHtml } = inputs; - return { - 'patchwave-report.html': reportHtml, - 'README.txt': sharebackReadme(meta), - 'data/meta.json': stringify(meta), - 'data/repos.json': stringify(collected.repos), - 'data/languages.json': stringify(collected.languages), - 'data/dependabot-config.json': stringify(collected.dependabotConfig), - 'data/dependabot-prs.json': stringify(collected.dependabotPrs), - 'data/cve.json': stringify(collected.cve), - 'data/branch-protection.json': stringify(collected.branchProtection), - 'data/contributors.json': stringify(collected.contributors), - 'data/warnings.json': stringify(collected.errors), - 'data/aggregated.json': stringify(aggregated), - }; -} - -export function zipBundleFiles(files: Record): Uint8Array { - const entries: Record = {}; - for (const [path, contents] of Object.entries(files)) { - entries[path] = strToU8(contents); - } - return zipSync(entries); -} - -function stringify(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n`; -} - -function sharebackReadme(meta: BundleMeta): string { - return [ - `patchwave-analysis bundle`, - ``, - `Generated: ${meta.generatedAt}`, - `Target: ${meta.target}`, - `Window: ${meta.windowDays} days (since ${meta.windowStart})`, - `CLI: v${meta.cliVersion}`, - ``, - `Contents:`, - ` patchwave-report.html — interactive report (open in any browser)`, - ` data/meta.json — run metadata`, - ` data/aggregated.json — rolled-up metrics that drive the report`, - ` data/repos.json — repo metadata`, - ` data/languages.json — per-repo language byte counts`, - ` data/dependabot-config.json — per-repo Dependabot config + ecosystems`, - ` data/dependabot-prs.json — Dependabot PRs in the window (state, checks, reviewers)`, - ` data/cve.json — Dependabot security alert slices`, - ` data/branch-protection.json — default-branch protection slices`, - ` data/contributors.json — active human committers per repo`, - ` data/warnings.json — per-collector warnings suppressed during the crawl`, - ``, - `Sharing this zip with contextbridge gives us the same view the report does plus the raw`, - `numbers behind every metric. No tokens, secrets, or file contents leave your machine`, - `unless you choose to share this archive.`, - ``, - ].join('\n'); -} diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts index 18d550e..3b9c33d 100644 --- a/src/report/testFactories.ts +++ b/src/report/testFactories.ts @@ -78,12 +78,12 @@ export const stalledSignals = Factory.define(() => ({ })); export const people = Factory.define(() => ({ - topMergers: [ + mergers: [ { login: 'alice', count: 90, windowCostUsd: 1125, annualCostUsd: 4563 }, { login: 'bob', count: 60, windowCostUsd: 750, annualCostUsd: 3042 }, ], - topReviewers: [{ login: 'alice', count: 12, windowCostUsd: 90, annualCostUsd: 365 }], - topCommenters: [], + reviewers: [{ login: 'alice', count: 12, windowCostUsd: 90, annualCostUsd: 365 }], + commenters: [], })); export const costEstimate = Factory.define(() => ({ diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index 98540ef..8b84945 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -1,14 +1,19 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { Analytics } from '../../Analytics.ts'; -import { embeddedReportData } from '../testFactories.ts'; +import { cveExposureOk, embeddedReportData } from '../testFactories.ts'; +import { automatedStoryTestIds } from './acts/AutomatedStory.tsx'; import { callToActionCopy, callToActionTestIds } from './acts/CallToAction.tsx'; import { costStoryCopy, costStoryTestIds } from './acts/CostStory.tsx'; +import { methodologyAppendixTestIds } from './acts/MethodologyAppendix.tsx'; +import { openPrAgeStoryCopy, openPrAgeStoryTestIds } from './acts/OpenPrAgeStory.tsx'; import { riskStoryCopy, riskStoryTestIds } from './acts/RiskStory.tsx'; import { verdictCopy, verdictTestIds } from './acts/Verdict.tsx'; import { AnalyticsProvider } from './analytics/AnalyticsContext.tsx'; import { App, appTestIds } from './App.tsx'; import { assumptionInputTestIds } from './primitives/AssumptionInput.tsx'; +import { assumptionsFootnoteTestId } from './primitives/AssumptionsFootnote.tsx'; +import { footnoteReferenceTestId } from './primitives/FootnoteReference.tsx'; import type { EmbeddedReportData } from './types.ts'; describe('App report shell', () => { @@ -20,33 +25,148 @@ describe('App report shell', () => { renderReport(); expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$13,836/year'); - expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costDescription); + expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costLeadIn); + expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costTrailer); }); - it('recalculates costs and savings when assumptions change', () => { + it('recalculates the headline cost and comparison cards when assumptions change', () => { renderReport(); - const firstAssumptions = screen.getAllByTestId(assumptionInputTestIds.container)[0]; - if (!firstAssumptions) throw new Error('missing assumptions form'); + const assumptions = screen.getByTestId(assumptionInputTestIds.container); - fireEvent.change(within(firstAssumptions).getByTestId(assumptionInputTestIds.hourlyRate), { + fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate), { target: { value: '300' }, }); expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$27,684/year'); expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$27,684/yr'); - expect(screen.getByText('$22,152')).toBeInTheDocument(); + // "Today" mirrors the headline; "PatchWave savings" is the recovered cost at the default 65% share. + expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$27,684/yr'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,995/yr'); + }); + + it('recalculates the PatchWave savings card when the auto-merge share changes', () => { + renderReport(); + + // Default 65% share starts in the middle of the modeled range. + expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$8,993/yr'); + + fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } }); + + expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%'); + expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$6,918/yr'); + }); + + it('reveals the methodology assumptions panel when an estimate footnote is clicked', () => { + renderReport(); + const details = screen.getByTestId(assumptionInputTestIds.container).closest('details'); + expect(details).toBeTruthy(); + expect(details).not.toHaveAttribute('open'); + + const footnote = screen.getAllByTestId(assumptionsFootnoteTestId)[0]; + if (!footnote) throw new Error('missing assumptions footnote'); + const restore = suppressNavigation(); + fireEvent.click(footnote); + restore(); + + expect(details).toHaveAttribute('open'); + }); + + it('switches back to the calculation tab when an assumptions footnote is clicked from raw data', () => { + renderReport(); + fireEvent.click(screen.getByText('How this report was calculated')); + fireEvent.click(screen.getByRole('tab', { name: 'Raw data' })); + expect(screen.queryByTestId(assumptionInputTestIds.container)).not.toBeInTheDocument(); + + const restore = suppressNavigation(); + fireEvent.click(screen.getAllByTestId(assumptionsFootnoteTestId)[0] as HTMLElement); + restore(); + + expect(screen.getByRole('tab', { name: 'Calculation' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument(); + }); + + it('lists footnotes in ascending first-appearance order', () => { + renderReport(); + + fireEvent.click(screen.getByText('How this report was calculated')); + + const sources = screen.getByTestId(methodologyAppendixTestIds.sources); + expect(sources).toHaveTextContent('1. Adjustable cost assumptions.'); + expect(sources).toHaveTextContent('2. Mend Renovate Enterprise ROI calculator.'); + expect(sources).toHaveTextContent('3. VulnCheck, May 2026'); + expect(sources).toHaveTextContent('4. Pixee: Why Developers Ignore 85% of Security PRs.'); + expect(sources).toHaveTextContent('5. Rombaut et al. 2024'); + expect(sources).toHaveTextContent('6. Cortex State of Developer Productivity Report 2024.'); + expect(sources).toHaveTextContent('7. Atlassian State of Developer Experience Report 2025.'); + }); + + it('opens the appendix source note instead of navigating directly when a citation is clicked', () => { + renderReport(); + const details = screen.getByTestId(assumptionInputTestIds.container).closest('details'); + expect(details).toBeTruthy(); + expect(details).not.toHaveAttribute('open'); + + const restore = suppressNavigation(); + fireEvent.click(screen.getAllByTestId(footnoteReferenceTestId)[0] as HTMLElement); + restore(); + + expect(details).toHaveAttribute('open'); + expect(screen.getByTestId(methodologyAppendixTestIds.sources)).toHaveTextContent( + 'Mend Renovate Enterprise ROI calculator', + ); + expect(screen.getByRole('link', { name: 'https://www.mend.io/renovate-enterprise/' })).toBeInTheDocument(); }); it('renders the ok CVE state with severity counts', () => { renderReport(); expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent('7 open security alerts'); - expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent( - "7 open alerts, and how long they've been sitting", - ); + expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent('7 open security alerts'); expect(screen.getByTestId(riskStoryTestIds.severityBar)).toBeInTheDocument(); }); + it('summarizes repos with security alerts disabled and lists them in the source note', () => { + renderReport({ cve: cveExposureOk.build({ reposWithSecurityAlertsDisabled: ['acme/a', 'acme/b'] }) }); + + const warning = screen.getByTestId(riskStoryTestIds.disabledAlertsWarning); + expect(warning).toHaveTextContent( + 'Did you know: 2 of your 24 repos do not have Dependabot security alerts enabled', + ); + expect(warning).not.toHaveTextContent('*'); + + const restore = suppressNavigation(); + fireEvent.click(within(warning).getByTestId(footnoteReferenceTestId)); + restore(); + + const sources = screen.getByTestId(methodologyAppendixTestIds.sources); + expect(sources).toHaveTextContent('Repos without security alerts enabled'); + expect(sources).toHaveTextContent('acme/a, acme/b'); + }); + + it('limits the top repos by severity table to the top five with an optional expansion', () => { + renderReport({ + cve: cveExposureOk.build({ + topReposBySeverity: Array.from({ length: 6 }, (_, i) => ({ + repo: `acme/repo-${i + 1}`, + critical: i === 0 ? 1 : 0, + high: Math.max(0, 6 - i), + medium: i, + low: 0, + })), + }), + }); + + const table = screen.getByTestId(riskStoryTestIds.topReposTable); + expect(within(table).getByText('acme/repo-5')).toBeInTheDocument(); + expect(within(table).queryByText('acme/repo-6')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId(riskStoryTestIds.topReposToggle)); + + expect(within(table).getByText('acme/repo-6')).toBeInTheDocument(); + expect(screen.getByTestId(riskStoryTestIds.topReposToggle)).toHaveTextContent('Show top 5'); + }); + it('renders the scope-missing CVE state', () => { renderReport({ cve: { @@ -68,32 +188,105 @@ describe('App report shell', () => { ); }); - it('renders the no-data CVE state', () => { - renderReport({ - cve: { - status: 'no-data', - totalOpenAlerts: 0, - bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, - topReposBySeverity: [], - oldestCriticalDays: null, - oldestHighDays: null, - reposWithSecurityAlertsDisabled: [], - }, - }); - - expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent(verdictCopy.cveNoData); - expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent(riskStoryCopy.noAlertsHeading); - }); - it('renders the primary sections and CTAs', () => { renderReport(); expect(screen.getByTestId(appTestIds.header)).toBeInTheDocument(); + expect(screen.getByTestId(appTestIds.headerContext)).toHaveTextContent('Analysis for acme'); + expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('PatchWave Analysis'); expect(screen.getByTestId(costStoryTestIds.section)).toHaveTextContent(costStoryCopy.heading); + expect(screen.getByTestId(openPrAgeStoryTestIds.section)).toHaveTextContent(openPrAgeStoryCopy.heading); expect(screen.getByTestId(riskStoryTestIds.section)).toHaveTextContent(riskStoryCopy.eyebrow); expect(screen.getByTestId(callToActionTestIds.section)).toHaveTextContent(callToActionCopy.heading); + expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('based on adjustable1 assumptions'); + expect(screen.getByTestId(automatedStoryTestIds.todayCost).parentElement).toHaveTextContent('Adjustable1 estimate'); expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta); - expect(screen.getByTestId(callToActionTestIds.link)).toHaveTextContent(callToActionCopy.linkLabel); + expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default'); + expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel); + + fireEvent.click(screen.getByText('How this report was calculated')); + expect(screen.getByTestId(methodologyAppendixTestIds.tabList)).toBeInTheDocument(); + expect(screen.getByTestId(methodologyAppendixTestIds.sources)).toHaveTextContent('Sources and notes'); + expect(screen.getByRole('link', { name: 'patchwave-analysis' })).toHaveAttribute( + 'href', + 'https://github.com/contextbridge/patchwave-analysis', + ); + expect(screen.getByRole('link', { name: 'ContextBridge' })).toHaveAttribute('href', 'https://contextbridge.ai'); + expect(screen.getByTestId(methodologyAppendixTestIds.section)).not.toHaveTextContent('patchwave.ai'); + }); + + it('places every assumptions footnote immediately after adjustable', () => { + renderReport(); + fireEvent.click(screen.getByText('How this report was calculated')); + + for (const footnote of screen.getAllByTestId(assumptionsFootnoteTestId)) { + const previousText = footnote.previousSibling?.textContent ?? ''; + expect(previousText.trimEnd().toLowerCase().endsWith('adjustable')).toBe(true); + } + }); + + it('combines person merge and review rows and labels the cost window', () => { + renderReport(); + + const table = screen.getByTestId(costStoryTestIds.peopleTable); + expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument(); + expect(within(table).getAllByText('alice')).toHaveLength(1); + expect(within(table).getByText('90')).toBeInTheDocument(); + expect(within(table).getAllByText('merged').length).toBeGreaterThan(0); + expect(within(table).getByText('12')).toBeInTheDocument(); + expect(within(table).getByText('reviewed')).toBeInTheDocument(); + }); + + it('limits the people table to the top five with an optional expansion', () => { + renderReport({ + people: { + mergers: Array.from({ length: 6 }, (_, i) => ({ + login: `person-${i + 1}`, + count: 10 - i, + windowCostUsd: 100 - i, + annualCostUsd: 1000 - i, + })), + reviewers: [], + commenters: [], + }, + }); + + const table = screen.getByTestId(costStoryTestIds.peopleTable); + expect(within(table).getByText('person-5')).toBeInTheDocument(); + expect(within(table).queryByText('person-6')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId(costStoryTestIds.peopleToggle)); + + expect(within(table).getByText('person-6')).toBeInTheDocument(); + }); + + it('renders the open PR age buckets as a separate section with count-only rows', () => { + renderReport(); + + const section = screen.getByTestId(openPrAgeStoryTestIds.section); + const breakdown = screen.getByTestId(openPrAgeStoryTestIds.breakdown); + expect(section).toHaveTextContent(openPrAgeStoryCopy.heading); + expect(breakdown).toHaveTextContent('0–30 days'); + expect(breakdown).toHaveTextContent('40'); + expect(breakdown).toHaveTextContent('Time-to-merge in your data: p50 2d, p90 14d'); + expect(breakdown).not.toHaveTextContent('38 open Dependabot PRs are more than 90 days old'); + expect(breakdown).not.toHaveTextContent('39%'); + }); + + it('uses neutral open PR copy when there is no backlog', () => { + renderReport({ + prBacklog: { + ...embeddedReportData.build().prBacklog, + openCount: 0, + oldestOpenDays: null, + openAgeBuckets: [], + }, + }); + + const section = screen.getByTestId(openPrAgeStoryTestIds.section); + expect(section).toHaveTextContent(openPrAgeStoryCopy.emptyHeading); + expect(section).not.toHaveTextContent(openPrAgeStoryCopy.heading); + expect(section).not.toHaveTextContent('Volume is trending up, not down'); }); }); @@ -152,15 +345,15 @@ describe('App analytics', () => { expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'verdict_primary' } }); }); - it('captures cta_clicked when the call-to-action link is clicked', () => { + it('captures cta_clicked when the call-to-action CTA is clicked', () => { const { analytics, events } = createFakeAnalytics(); renderWithAnalytics(analytics); const restore = suppressNavigation(); - fireEvent.click(screen.getByTestId(callToActionTestIds.link)); + fireEvent.click(screen.getByTestId(callToActionTestIds.cta)); restore(); - expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'call_to_action' } }); + expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'call_to_action_primary' } }); }); it('captures assumption_changed when an assumption input is committed', () => { diff --git a/src/report/web/App.stories.tsx b/src/report/web/App.stories.tsx index da9af10..19b0d83 100644 --- a/src/report/web/App.stories.tsx +++ b/src/report/web/App.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { toEmbeddedShape } from '../embeddedShape.ts'; -import { cveExposureOk, cveExposureScopeMissing, orgOverview, reportBundle } from '../testFactories.ts'; +import { cveExposureOk, cveExposureScopeMissing, orgOverview, people, reportBundle } from '../testFactories.ts'; import { App } from './App.tsx'; // A representative report, built from the same fishery factories the tests use. @@ -22,9 +22,33 @@ const sampleReport = toEmbeddedShape( { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 }, { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 }, { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 }, + { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 }, + { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 }, + { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 }, + { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 }, + { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 }, ], reposWithSecurityAlertsDisabled: ['acme/legacy-cron'], }), + people: people.build({ + mergers: [ + { login: 'alex', count: 96, windowCostUsd: 1200, annualCostUsd: 4867 }, + { login: 'blair', count: 82, windowCostUsd: 1025, annualCostUsd: 4157 }, + { login: 'casey', count: 70, windowCostUsd: 875, annualCostUsd: 3549 }, + { login: 'devon', count: 64, windowCostUsd: 800, annualCostUsd: 3244 }, + { login: 'ellis', count: 58, windowCostUsd: 725, annualCostUsd: 2940 }, + { login: 'finley', count: 44, windowCostUsd: 550, annualCostUsd: 2231 }, + { login: 'gray', count: 36, windowCostUsd: 450, annualCostUsd: 1825 }, + { login: 'harper', count: 28, windowCostUsd: 350, annualCostUsd: 1420 }, + { login: 'indigo', count: 20, windowCostUsd: 250, annualCostUsd: 1014 }, + { login: 'jules', count: 12, windowCostUsd: 150, annualCostUsd: 608 }, + ], + reviewers: [ + { login: 'alex', count: 24, windowCostUsd: 180, annualCostUsd: 730 }, + { login: 'casey', count: 18, windowCostUsd: 135, annualCostUsd: 548 }, + { login: 'harper', count: 14, windowCostUsd: 105, annualCostUsd: 426 }, + ], + }), }), ); @@ -39,10 +63,22 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = { +export const Light: Story = { + args: { + data: sampleReport, + }, + parameters: { + theme: 'light', + }, +}; + +export const Dark: Story = { args: { data: sampleReport, }, + parameters: { + theme: 'dark', + }, }; export const CveScopeMissing: Story = { diff --git a/src/report/web/App.tsx b/src/report/web/App.tsx index fff4fb9..c418b56 100644 --- a/src/report/web/App.tsx +++ b/src/report/web/App.tsx @@ -2,49 +2,71 @@ import { AutomatedStory } from './acts/AutomatedStory.tsx'; import { CallToAction } from './acts/CallToAction.tsx'; import { CostStory } from './acts/CostStory.tsx'; import { MethodologyAppendix } from './acts/MethodologyAppendix.tsx'; +import { OpenPrAgeStory } from './acts/OpenPrAgeStory.tsx'; import { RiskStory } from './acts/RiskStory.tsx'; import { Verdict } from './acts/Verdict.tsx'; import { EmbeddedDataProvider } from './data/EmbeddedDataContext.tsx'; import { AssumptionsProvider } from './hooks/useAssumptions.tsx'; +import { AssumptionsDisclosureProvider } from './hooks/useAssumptionsDisclosure.tsx'; +import { FootnoteProvider } from './hooks/useFootnotes.tsx'; import { BrandMark } from './primitives/BrandMark.tsx'; import type { EmbeddedReportData } from './types.ts'; export const appTestIds = { main: 'report-main', header: 'report-header', + headerContext: 'report-header-context', +} as const; + +const appCopy = { + analysisFor: 'Analysis for', } as const; export function App({ data }: { data: EmbeddedReportData }) { return ( - -
- - - - - - -
+ + + +
+ + + + + + + +
+
+
); } -function ReportHeader() { +function ReportHeader({ org }: { org: string }) { return (
- - - patchwave - +
+ + + PatchWave + + +

+ {appCopy.analysisFor} {org} +

+
patchwave.ai diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx index a48180a..394687e 100644 --- a/src/report/web/acts/AutomatedStory.tsx +++ b/src/report/web/acts/AutomatedStory.tsx @@ -1,78 +1,146 @@ -import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; +import { useState } from 'react'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; +import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; import { Citation } from '../primitives/Citation.tsx'; -import { SavingsBars } from '../primitives/SavingsBars.tsx'; + +export const automatedStoryTestIds = { + section: 'automated-story-section', + todayCost: 'automated-story-today-cost', + patchwaveCost: 'automated-story-patchwave-cost', + delta: 'automated-story-delta', + shareSlider: 'automated-story-share-slider', +} as const; + +const SHARE_MIN = 50; +const SHARE_MAX = 80; +const SHARE_DEFAULT = 65; export function AutomatedStory() { - const data = useEmbeddedData(); - const { derived } = useAssumptions(); + const { assumptions, derived } = useAssumptions(); + const [sharePct, setSharePct] = useState(SHARE_DEFAULT); + + const todayCost = derived.annualCostUsd; + const patchwaveSavings = Math.round(todayCost * (sharePct / 100)); return ( -
+
If this were automated

- Today vs. with code-aware auto-merge + Today vs. with PatchWave auto-merge

-
-
-
Today
-
-
- {data.costEstimate.mergedInWindow.toLocaleString()}{' '} - PRs handled by humans over {data.meta.windowDays} days -
-
- {fmtUsd(derived.annualCostUsd)}/yr in engineer time -
+

+ PatchWave reviews each update, merges the ones it can clear safely, and sends the rest to a human. Here's how + much engineering time your team would get back. +

+ +
+ +
+
+ {sharePct}%
-
-
-
With auto-merge
-
-
Safe updates merge themselves, gated by your existing CI
-
Humans only look at PRs the analysis flags as risky
-
Time and cost return to the team
+
+ cost recovered
+
-

- Annual savings by auto-merge share -

-

Recalculates as you change the assumptions above.

-
- +
+ + setSharePct(Number(e.target.value))} + className="accent-primary mt-3 w-full" + /> +
+ {SHARE_MIN}% + {SHARE_MAX}% +

- Auto-merge is only safe if the analysis understands what changed. Research found that 67% of Dependabot PRs lack - any compatibility scoring beyond the version bump itself - — which is why most teams default to "review every one." + Most Dependabot PRs arrive with no signal about whether the upgrade is safe. Research found 67% lack any + compatibility scoring beyond the version bump + .

- -
); } -function MidReportCta() { +function CompareCard({ + label, + value, + sub, + testId, + accent = false, + footnote = false, +}: { + label: string; + value: string; + sub: string; + testId: string; + accent?: boolean; + footnote?: boolean; +}) { return ( -
-

PatchWave automates this triage

-

- A GitHub App that reads each Dependabot PR's actual changes, auto-merges the safe majority, and surfaces the - rest with reasoned context so this report stops being a quarterly fire drill. -

- +
{label}
+
+
+ {footnote && ( + <> + Adjustable + estimate + · + + )} + {sub} +
); } + +function quarterHoursLabel(costUsd: number, hourlyRateUsd: number): string { + const hours = Math.round(costUsd / hourlyRateUsd / 4); + if (hours <= 0) return 'under an hour of engineer time per quarter'; + return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time per quarter`; +} + +function quarterHoursSavedLabel(savingsUsd: number, hourlyRateUsd: number): string { + const hours = Math.round(savingsUsd / hourlyRateUsd / 4); + if (hours <= 0) return 'under an hour of engineer time saved per quarter'; + return `~${hours.toLocaleString()} ${hours === 1 ? 'hour' : 'hours'} of engineer time saved per quarter`; +} diff --git a/src/report/web/acts/CallToAction.tsx b/src/report/web/acts/CallToAction.tsx index c882396..adc2f82 100644 --- a/src/report/web/acts/CallToAction.tsx +++ b/src/report/web/acts/CallToAction.tsx @@ -1,13 +1,17 @@ import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; +import { Button } from '../components/ui/button.tsx'; export const callToActionTestIds = { section: 'call-to-action-section', - link: 'call-to-action-link', + cta: 'call-to-action-cta', } as const; export const callToActionCopy = { heading: 'Get on the waitlist', - linkLabel: 'patchwave.ai →', + pitch: + 'PatchWave reviews each Dependabot PR, auto-merges the safe updates, and gives engineers context for the few that need judgment.', + earlyAccess: "Early access, plus a heads-up when the public beta opens. That's all we'll email you about.", + ctaLabel: 'Join the waitlist →', } as const; export function CallToAction() { @@ -17,18 +21,17 @@ export function CallToAction() {

{callToActionCopy.heading}

-

- Early access notifications, plus a heads-up when we open the public beta. No newsletter, no upsell email - sequence. -

- analytics.capture('cta_clicked', { which: 'call_to_action' })} - className="bg-foreground text-background mt-6 inline-flex items-center rounded-md px-5 py-2.5 text-sm font-medium no-underline hover:opacity-90" - > - {callToActionCopy.linkLabel} - +

{callToActionCopy.pitch}

+

{callToActionCopy.earlyAccess}

+
); } diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx index ccd5b0e..99c88cc 100644 --- a/src/report/web/acts/CostStory.tsx +++ b/src/report/web/acts/CostStory.tsx @@ -1,9 +1,8 @@ +import { useState } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; -import { fmtDaysShort } from '../format/days.ts'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; -import { AssumptionInput } from '../primitives/AssumptionInput.tsx'; -import { BarChart } from '../primitives/BarChart.tsx'; +import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; import { Citation } from '../primitives/Citation.tsx'; import { PersonRow } from '../primitives/PersonRow.tsx'; @@ -13,12 +12,14 @@ export const costStoryTestIds = { monthlyCost: 'cost-story-monthly-cost', annualCost: 'cost-story-annual-cost', peopleTable: 'cost-story-people-table', + peopleToggle: 'cost-story-people-toggle', } as const; +const INITIAL_PEOPLE_COUNT = 5; + export const costStoryCopy = { eyebrow: 'Cost of dependency toil', heading: 'How that number is built', - ageHeading: 'Open PRs by age', } as const; export function CostStory() { @@ -26,7 +27,6 @@ export function CostStory() { const { assumptions, derived } = useAssumptions(); const merged = data.costEstimate.mergedInWindow; - const ttm = data.prBacklog; return (
@@ -39,10 +39,11 @@ export function CostStory() {

Your team merged {merged.toLocaleString()} Dependabot PRs in - the last {data.meta.windowDays} days. At{' '} - {assumptions.minutesPerPr} minutes per PR for triage and - review and a ${assumptions.hourlyRateUsd}/hr loaded engineer - rate, that comes out to: + the last {data.meta.windowDays} days. Using adjustable + defaults of{' '} + {assumptions.minutesPerPr} minutes per PR and{' '} + ${assumptions.hourlyRateUsd}/hr engineer cost, that comes + out to:

@@ -64,33 +65,15 @@ export function CostStory() { />
-
- -
+ -

- The 5 min/PR default sits at the low end of Mend's published 15–60 min/week per developer range for - Dependabot triage - . Time-to-merge in your data: p50{' '} - {fmtDaysShort(ttm.timeToMergeP50Days)}, p90{' '} - {fmtDaysShort(ttm.timeToMergeP90Days)}. -

- - - -

- {costStoryCopy.ageHeading} -

-
- ({ label: b.label, value: b.count }))} - emptyLabel="No open Dependabot PRs." - /> -
- -

- Volume is trending up, not down. GitHub-published CVEs rose 476% year-to-date in 2026 - , and every new CVE in your stack eventually becomes a Dependabot PR. +

+ The adjustable + 5 min/PR default sits at the low end of Mend's published + 15–60 min/week per developer range for Dependabot triage + + . +

); @@ -117,9 +100,13 @@ function CostCell({ ); } -function PeopleTable() { +function PeopleTable({ windowDays }: { windowDays: number }) { const { derived } = useAssumptions(); - const hasAny = derived.topMergers.length + derived.topReviewers.length > 0; + const [expanded, setExpanded] = useState(false); + const people = combinedPeopleRows(derived.mergers, derived.reviewers); + const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT); + const hiddenCount = people.length - visiblePeople.length; + const hasAny = people.length > 0; if (!hasAny) { return (
@@ -130,9 +117,6 @@ function PeopleTable() { return (

Who's bearing this cost

-

- Bot accounts excluded. Annualized = cost projected to a full year. -

Person Count - Cost in window + Cost over last {windowDays} days Annualized - {derived.topMergers.map((m) => ( + {visiblePeople.map((r) => ( - ))} - {derived.topReviewers.map((r) => ( - ))} + {hiddenCount > 0 || expanded ? ( + + + + + + ) : null}
); } + +interface ActivityCost { + login: string; + count: number; + windowCostUsd: number; + annualCostUsd: number; +} + +interface CombinedPersonRow { + login: string; + mergedCount: number; + reviewedCount: number; + windowCostUsd: number; + annualCostUsd: number; +} + +function combinedPeopleRows(mergers: readonly ActivityCost[], reviewers: readonly ActivityCost[]): CombinedPersonRow[] { + const rows = new Map(); + for (const m of mergers) { + rows.set(m.login, { + login: m.login, + mergedCount: m.count, + reviewedCount: 0, + windowCostUsd: m.windowCostUsd, + annualCostUsd: m.annualCostUsd, + }); + } + for (const r of reviewers) { + const row = + rows.get(r.login) ?? + ({ + login: r.login, + mergedCount: 0, + reviewedCount: 0, + windowCostUsd: 0, + annualCostUsd: 0, + } satisfies CombinedPersonRow); + row.reviewedCount += r.count; + row.windowCostUsd += r.windowCostUsd; + row.annualCostUsd += r.annualCostUsd; + rows.set(r.login, row); + } + return [...rows.values()].sort( + (a, b) => b.annualCostUsd - a.annualCostUsd || b.windowCostUsd - a.windowCostUsd || a.login.localeCompare(b.login), + ); +} diff --git a/src/report/web/acts/MethodologyAppendix.tsx b/src/report/web/acts/MethodologyAppendix.tsx index 4a19a71..a7df842 100644 --- a/src/report/web/acts/MethodologyAppendix.tsx +++ b/src/report/web/acts/MethodologyAppendix.tsx @@ -1,106 +1,487 @@ +import type { ReactNode } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { fmtBytes } from '../format/bytes.ts'; +import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; +import { type MethodologyTab, useAssumptionsDisclosure } from '../hooks/useAssumptionsDisclosure.tsx'; +import { useRegisteredFootnotes } from '../hooks/useFootnotes.tsx'; +import { AssumptionInput } from '../primitives/AssumptionInput.tsx'; +import { AssumptionsFootnote, assumptionsPanelId } from '../primitives/AssumptionsFootnote.tsx'; import { Citation } from '../primitives/Citation.tsx'; +import { reposWithoutSecurityAlertsId } from './RiskStory.tsx'; + +export const methodologyAppendixTestIds = { + section: 'methodology-appendix-section', + tabList: 'methodology-tab-list', + tabPanel: 'methodology-tab-panel', + sources: 'methodology-sources-and-notes', + rawData: 'methodology-raw-data', + disabledAlertsRepos: 'methodology-disabled-alerts-repos', +} as const; + +const appendixTabs: Array<{ id: MethodologyTab; label: string }> = [ + { id: 'calculation', label: 'Calculation' }, + { id: 'data', label: 'Raw data' }, +]; export function MethodologyAppendix() { const data = useEmbeddedData(); const { assumptions } = useAssumptions(); + const { open, setOpen, activeTab, setActiveTab } = useAssumptionsDisclosure(); const org = data.orgOverview; const cov = data.dependabotCoverage; + const pr = data.prBacklog; + const stalled = data.stalledSignals; + const cve = data.cve; return ( -
-
+
+
setOpen(e.currentTarget.open)}> How this report was calculated -
-
-

Cost math

-
    -
  • - Cost in window = merged PRs × minutes per PR ÷ 60 × hourly rate. Currently:{' '} - {data.costEstimate.mergedInWindow} PRs × {assumptions.minutesPerPr} min ÷ 60 × $ - {assumptions.hourlyRateUsd}/hr. -
  • -
  • - Monthly run rate scales the window cost by (365 / 12) / windowDays. Annualized = monthly × 12. -
  • -
  • - The 5 min/PR default sits at the low end of Mend's published 15–60 min/week per developer - . The $150/hr default reflects a $300k fully-loaded engineer cost - divided by 2,000 working hours. -
  • -
  • Savings scenarios multiply the monthly cost by the auto-merge share (50%, 60%, 70%, 80%).
  • -
  • Bot accounts are excluded from merger and reviewer counts.
  • -
+
+
+ {appendixTabs.map((tab) => ( + + ))}
-
-

Data window and scope

-
    -
  • - Window: {data.meta.windowDays} days ending {data.meta.generatedAt.slice(0, 10)}. -
  • -
  • - Repos scanned: {org.repoCount} active ({org.publicCount} public, {org.privateCount} private,{' '} - {org.internalCount} internal) - {org.archivedExcluded > 0 ? `; ${org.archivedExcluded} archived repos excluded` : ''}. -
  • -
  • - Dependabot coverage: {cov.reposWithConfig} repos ({cov.reposWithConfigPercentage}%) have a config;{' '} - {cov.reposWithSecurityUpdates} ({cov.reposWithSecurityUpdatesPercentage}%) have security updates - enabled. -
  • -
  • {org.activeHumanCommitters} distinct human committers active in window.
  • -
-
+
+ {activeTab === 'calculation' && ( +
+
+ Cost assumptions +

+ Every cost and time figure in this report is modeled from these two inputs. Change them and the + numbers throughout recalculate. The defaults are intentionally conservative. +

+
+ +
+
- {org.topLanguages.length > 0 && ( -
-

Language mix

-
    - {org.topLanguages.slice(0, 5).map((l) => ( -
  • - {l.language}: {fmtBytes(l.bytes)} ({l.percentage}%) -
  • - ))} -
-
- )} - -
-

Caveats

-

- Estimates only; real savings vary by team. The defaults are intentionally conservative - - . The CVE numbers count what Dependabot reports, not what is - actually exploitable in your stack. -

-
+
+ Calculation model +
+ + + + Merged PRs × minutes per PR ÷ 60 × hourly rate. Currently using + adjustable + assumptions:{' '} + {data.costEstimate.mergedInWindow} PRs × {assumptions.minutesPerPr} min ÷ 60 + × ${assumptions.hourlyRateUsd} + /hr. + + } + /> + + + + + +
+
+

+ The adjustable + 5 min/PR default sits at the low end of + Mend's published 15–60 min/week per developer range + . The adjustable + $150/hr default reflects a $300k engineer + cost divided by 2,000 working hours. +

+

+ Estimates only; real savings vary by team. The defaults are intentionally conservative + + . +

+
+
+ )} + + {activeTab === 'data' && ( +
+ Raw investigation data +
+ + + + + + + + + + + + + + + + -
-

Raw data

-

- The accompanying{' '} - patchwave-report.zip ships the - full per-repo dataset as JSON. Open{' '} - data/aggregated.json for the - rolled-up metrics this page renders. -

+ + `${r.repo} (${r.openPrs} open)`)} + /> + + + + + + + + + + + + + + + + {org.topLanguages.length > 0 && ( + +
    + {org.topLanguages.map((l) => ( +
  • + {l.language} + + {fmtBytes(l.bytes)} ({l.percentage}%) + +
  • + ))} +
+
+ )} +
+
+ )}
+ +
+ Sources and notes + +
- Generated by patchwave-analysis. PatchWave is a project from{' '} + Generated by{' '} + + patchwave-analysis + + . PatchWave is a project from{' '} - contextbridge + ContextBridge .
); } + +function SectionHeading({ children }: { children: string }) { + return

{children}

; +} + +function FormulaRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + {value} + + ); +} + +function DataPanel({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function MetricList({ rows }: { rows: Array<[string, string]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +function InlineBreakdown({ + label, + rows, + nameKey, + valueKey, +}: { + label: string; + rows: readonly T[]; + nameKey: keyof T; + valueKey: keyof T; +}) { + if (rows.length === 0) return null; + return ( +
+
{label}
+
    + {rows.map((row) => ( +
  • + {String(row[nameKey])} + {String(row[valueKey])} +
  • + ))} +
+
+ ); +} + +function SourcesAndNotes() { + const notes = useRegisteredFootnotes(); + return ( +
+ {notes.map((note) => ( +
+ {note.number}. + {note.title}. + {note.body} +
+ ))} +
+ ); +} + +function RepoList({ + id, + testId, + label, + empty, + repos, +}: { + id?: string; + testId?: string; + label: string; + empty: string; + repos: readonly string[]; +}) { + return ( +
+
{label}
+ {repos.length === 0 ? ( +

{empty}

+ ) : ( +
+ {repos.map((repo) => ( + + {repo} + + ))} +
+ )} +
+ ); +} + +function SeverityTable({ + repos, +}: { + repos: ReadonlyArray<{ repo: string; critical: number; high: number; medium: number; low: number }>; +}) { + if (repos.length === 0) return null; + return ( +
+
+ Top repos by severity +
+
+ + + + + + + + + + + + {repos.map((repo) => ( + + + + + + + + ))} + +
RepoCHML
{repo.repo}{repo.critical || ''}{repo.high || ''}{repo.medium || ''}{repo.low || ''}
+
+
+ ); +} + +function PeopleList({ + label, + rows, + countLabel, +}: { + label: string; + rows: ReadonlyArray<{ login: string; count: number; annualCostUsd?: number }>; + countLabel: string; +}) { + return ( +
+
{label}
+ {rows.length === 0 ? ( +

No {countLabel} activity in the window.

+ ) : ( +
    + {rows.map((row) => ( +
  • + {row.login} + + {row.count} {countLabel} + {row.annualCostUsd !== undefined ? `, ${fmtUsd(row.annualCostUsd)}/yr` : ''} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/report/web/acts/OpenPrAgeStory.tsx b/src/report/web/acts/OpenPrAgeStory.tsx new file mode 100644 index 0000000..1a4de77 --- /dev/null +++ b/src/report/web/acts/OpenPrAgeStory.tsx @@ -0,0 +1,77 @@ +import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; +import { fmtDaysShort } from '../format/days.ts'; +import { Citation } from '../primitives/Citation.tsx'; + +export const openPrAgeStoryTestIds = { + section: 'open-pr-age-story-section', + breakdown: 'open-pr-age-story-breakdown', +} as const; + +export const openPrAgeStoryCopy = { + eyebrow: 'Open PR age', + heading: 'The backlog is getting stale', + emptyHeading: 'No open Dependabot PR backlog', +} as const; + +export function OpenPrAgeStory() { + const { prBacklog: pr } = useEmbeddedData(); + const hasOpenPrs = pr.openCount > 0; + + return ( +
+
+ {openPrAgeStoryCopy.eyebrow} +
+

+ {hasOpenPrs ? openPrAgeStoryCopy.heading : openPrAgeStoryCopy.emptyHeading} +

+ +

+ {hasOpenPrs ? ( + <> + Volume is trending up, not down. GitHub-published CVEs rose 476% year-to-date in 2026 + + , + {' '} + and every new CVE in your stack eventually becomes a Dependabot PR. + + ) : ( + 'You have no open Dependabot PRs right now. Keep security updates enabled so new CVE-driven updates keep flowing into this queue.' + )} +

+ +
+ {!hasOpenPrs ? ( +
+ You have no open Dependabot PRs right now. +
+ ) : ( +
+ {pr.openAgeBuckets.map((bucket) => ( + + ))} +

+ Time-to-merge in your data: p50{' '} + {fmtDaysShort(pr.timeToMergeP50Days)}, + p90{' '} + {fmtDaysShort(pr.timeToMergeP90Days)}. +

+
+ )} +
+
+ ); +} + +function AgeBucketRow({ label, count, total }: { label: string; count: number; total: number }) { + const pct = total === 0 ? 0 : Math.round((count / total) * 100); + return ( +
+
{label}
+
+
+
+
{count.toLocaleString()}
+
+ ); +} diff --git a/src/report/web/acts/RiskStory.tsx b/src/report/web/acts/RiskStory.tsx index e9df9b1..b7b79c1 100644 --- a/src/report/web/acts/RiskStory.tsx +++ b/src/report/web/acts/RiskStory.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { Citation } from '../primitives/Citation.tsx'; +import { FootnoteReference } from '../primitives/FootnoteReference.tsx'; import { StackedBar } from '../primitives/StackedBar.tsx'; export const riskStoryTestIds = { @@ -7,6 +9,9 @@ export const riskStoryTestIds = { heading: 'risk-story-heading', scopeRefreshCommand: 'risk-story-scope-refresh-command', severityBar: 'risk-story-severity-bar', + topReposTable: 'risk-story-top-repos-table', + topReposToggle: 'risk-story-top-repos-toggle', + disabledAlertsWarning: 'risk-story-disabled-alerts-warning', } as const; export const riskStoryCopy = { @@ -15,8 +20,15 @@ export const riskStoryCopy = { noAlertsHeading: 'No open security alerts', } as const; +const INITIAL_REPO_COUNT = 5; +export const reposWithoutSecurityAlertsId = 'repos-without-security-alerts'; + export function RiskStory() { - const cve = useEmbeddedData().cve; + const { cve, orgOverview } = useEmbeddedData(); + const disabledAlertsNote = + cve.reposWithSecurityAlertsDisabled.length > 0 + ? `Repos without security alerts enabled: ${cve.reposWithSecurityAlertsDisabled.join(', ')}. New CVEs in these repos will not appear in this report.` + : 'These repos returned a not-enabled response for Dependabot security alerts, so new CVEs in them will not appear in this report.'; if (cve.status === 'scope-missing') { return ( @@ -45,7 +57,7 @@ export function RiskStory() { ); } - if (cve.status === 'no-data' || cve.totalOpenAlerts === 0) { + if (cve.totalOpenAlerts === 0) { return (
@@ -65,7 +77,6 @@ export function RiskStory() { ); } - const topRepos = cve.topReposBySeverity.slice(0, 3); const hasOldest = cve.oldestCriticalDays !== null || cve.oldestHighDays !== null; return ( @@ -77,9 +88,14 @@ export function RiskStory() { data-testid={riskStoryTestIds.heading} className="text-foreground mt-2 text-3xl leading-tight font-medium tracking-tight sm:text-4xl" > - {cve.totalOpenAlerts.toLocaleString()} open alerts, and how long they've been sitting + {cve.totalOpenAlerts.toLocaleString()} open security alerts +

+ Not every CVE is exploitable, so treat this as exposure, not breach risk. For triage, age is what matters: how + long each alert has sat open. +

+
)} - {topRepos.length > 0 && ( + {cve.topReposBySeverity.length > 0 && ( <>

Top repos by severity

-
- - - - - - - - - - - - {topRepos.map((r) => ( - - - - - - - - ))} - -
RepoCriticalHighMediumLow
{r.repo}{r.critical || ''}{r.high || ''}{r.medium || ''}{r.low || ''}
- {cve.topReposBySeverity.length > topRepos.length && ( -
- {cve.topReposBySeverity.length - topRepos.length} more repos in the appendix data. -
- )} -
+ )} {cve.reposWithSecurityAlertsDisabled.length > 0 && ( -

- {cve.reposWithSecurityAlertsDisabled.length} repos have Dependabot security alerts turned off (or the feature - isn't available there). New CVEs in those repos won't appear here. +

+ Did you know:{' '} + + {cve.reposWithSecurityAlertsDisabled.length} + {' '} + of your {orgOverview.repoCount} repos{' '} + {cve.reposWithSecurityAlertsDisabled.length === 1 ? 'does' : 'do'} not have Dependabot security alerts enabled + + . New CVEs in {cve.reposWithSecurityAlertsDisabled.length === 1 ? 'that repo' : 'those repos'} will not appear + in this report.

)}

- Not every CVE is exploitable. This counts exposure, not breach risk. The number that matters for prioritization - is age: industry data shows Dependabot security PRs hit just 13% adoption in JavaScript open-source projects + Just 13% of Dependabot security PRs get merged in open-source JavaScript projects , which is why backlogs grow.

); } +interface RepoSeverityRow { + repo: string; + critical: number; + high: number; + medium: number; + low: number; +} + +function TopReposBySeverityTable({ repos }: { repos: readonly RepoSeverityRow[] }) { + const [expanded, setExpanded] = useState(false); + const visibleRepos = expanded ? repos : repos.slice(0, INITIAL_REPO_COUNT); + const hiddenCount = repos.length - visibleRepos.length; + + return ( +
+ + + + + + + + + + + + {visibleRepos.map((r) => ( + + + + + + + + ))} + {hiddenCount > 0 || expanded ? ( + + + + ) : null} + +
RepoCriticalHighMediumLow
{r.repo}{r.critical || ''}{r.high || ''}{r.medium || ''}{r.low || ''}
+ +
+
+ ); +} + function AgeCell({ label, days, tone }: { label: string; days: number; tone: 'critical' | 'high' }) { const color = tone === 'critical' ? 'text-destructive' : 'text-tangerine'; return ( diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx index 2a1bbed..169ca83 100644 --- a/src/report/web/acts/Verdict.tsx +++ b/src/report/web/acts/Verdict.tsx @@ -1,114 +1,101 @@ import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; +import { Button } from '../components/ui/button.tsx'; import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx'; import { fmtUsd } from '../format/money.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; -import { AssumptionInput } from '../primitives/AssumptionInput.tsx'; +import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx'; export const verdictTestIds = { section: 'verdict-section', annualCost: 'verdict-annual-cost', - assumptionsDetails: 'verdict-assumptions-details', cveLine: 'verdict-cve-line', primaryCta: 'verdict-primary-cta', } as const; export const verdictCopy = { - eyebrow: 'Dependabot diagnostic', - costDescription: 'Engineer time your team spends to triage, review, and merge Dependabot PRs at the current pace.', - primaryCta: 'Looking to automate this? See the PatchWave waitlist →', + costLeadIn: 'Your engineering team spends', + costTrailer: 'triaging, reviewing, and merging Dependabot PRs', + primaryCta: 'See how PatchWave helps', cveScopeMissing: 'CVE exposure not measured (missing GitHub scope)', - cveNoData: 'CVE exposure not available', } as const; export function Verdict() { - const data = useEmbeddedData(); const { derived } = useAssumptions(); const analytics = useAnalytics(); - const generatedDate = data.meta.generatedAt.slice(0, 10); - const pr = data.prBacklog; - const openLabel = pr.openCount === 1 ? 'open Dependabot PR' : 'open Dependabot PRs'; - const oldestSuffix = pr.oldestOpenDays !== null ? `, oldest ${pr.oldestOpenDays} days` : ''; - return (
-
{verdictCopy.eyebrow}
-
{data.meta.org}
- +

{verdictCopy.costLeadIn}

- {fmtUsd(derived.annualCostUsd)} + ~{fmtUsd(derived.annualCostUsd)} /year

-

{verdictCopy.costDescription}

+

+ {verdictCopy.costTrailer}, based on adjustable + assumptions +

-
    -
  • - {pr.mergedInWindowCount.toLocaleString()} Dependabot PRs - merged in the last {data.meta.windowDays} days -
  • -
  • - {pr.openCount.toLocaleString()} {openLabel} - {oldestSuffix} -
  • - -
+ - analytics.capture('cta_clicked', { which: 'verdict_primary' })} - className="text-primary mt-7 inline-block text-sm font-medium underline underline-offset-4 hover:opacity-80" - > - {verdictCopy.primaryCta} - + +
+ ); +} -
- - Adjust the assumptions - -
- -

- Numbers throughout the report recalculate as you change these. Defaults are conservative. -

-
-
+// One dense line under the headline: volume and time first, the alert count as a +// tail signal. The CVE fragment also carries the not-measured state so the +// report never silently drops the security signal. +function SupportingFacts() { + const { prBacklog: pr } = useEmbeddedData(); + const oldestSuffix = pr.oldestOpenDays !== null ? ` (oldest ${pr.oldestOpenDays} days)` : ''; -
- Generated {generatedDate}. {data.meta.windowDays}-day rolling window. {data.meta.totalReposScanned} repos - scanned. -
-
+ return ( +

+ + {pr.mergedInWindowCount.toLocaleString()} Dependabot PRs + merged + + + + {pr.openCount.toLocaleString()} still open{oldestSuffix} + + + +

); } -function CveLineItem() { +function CveFact() { const cve = useEmbeddedData().cve; if (cve.status === 'scope-missing') { return ( -
  • + {verdictCopy.cveScopeMissing} -
  • - ); - } - if (cve.status === 'no-data') { - return ( -
  • - {verdictCopy.cveNoData} -
  • + ); } return ( -
  • - {cve.totalOpenAlerts.toLocaleString()} open security alerts:{' '} - {cve.bySeverity.critical} critical,{' '} - {cve.bySeverity.high} high -
  • + + {cve.totalOpenAlerts.toLocaleString()} open security alerts + + ); +} + +function Dot() { + return ( + + · + ); } diff --git a/src/report/web/assumptionFields.ts b/src/report/web/assumptionFields.ts new file mode 100644 index 0000000..fd4f34e --- /dev/null +++ b/src/report/web/assumptionFields.ts @@ -0,0 +1,18 @@ +// Single source of truth for the user-adjustable cost assumptions: the labels, +// affixes, and editable bounds the UI exposes. The runtime defaults live with the +// shared math in `../costFormulas.ts` and reach the report via the embedded data; +// this module owns only what the input control and its clamp need. + +export interface AssumptionField { + label: string; + prefix?: string; + suffix?: string; + min: number; + max: number; + step: number; +} + +export const assumptionFields = { + hourlyRateUsd: { label: 'Loaded hourly rate', prefix: '$', suffix: '/hr', min: 1, max: 1000, step: 5 }, + minutesPerPr: { label: 'Minutes per PR', min: 1, max: 240, step: 1 }, +} as const satisfies Record; diff --git a/src/report/web/components/ui/button.tsx b/src/report/web/components/ui/button.tsx new file mode 100644 index 0000000..2cf2697 --- /dev/null +++ b/src/report/web/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; +import * as React from 'react'; +import { cn } from '../../lib/utils.ts'; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot.Root : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/src/report/web/components/ui/input.tsx b/src/report/web/components/ui/input.tsx new file mode 100644 index 0000000..036c024 --- /dev/null +++ b/src/report/web/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils.ts'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/src/report/web/data/citations.ts b/src/report/web/data/citations.ts index 9a0a4d7..6f509a7 100644 --- a/src/report/web/data/citations.ts +++ b/src/report/web/data/citations.ts @@ -11,8 +11,9 @@ export const CITATIONS = { url: 'https://www.mend.io/renovate-enterprise/', }, 'rombaut-2024': { - label: 'Rombaut et al. 2024, "Studying the Practices of Deploying Machine Learning Projects on Docker"', - url: 'https://arxiv.org/abs/2206.00699', + label: + 'Rombaut et al. 2024, "Leveraging the Crowd for Dependency Management: An Empirical Study on the Dependabot Compatibility Score"', + url: 'https://arxiv.org/abs/2403.09012', }, 'atlassian-dx-2025': { label: 'Atlassian State of Developer Experience Report 2025', diff --git a/src/report/web/hooks/useAssumptions.tsx b/src/report/web/hooks/useAssumptions.tsx index c4f92f8..aa8e64e 100644 --- a/src/report/web/hooks/useAssumptions.tsx +++ b/src/report/web/hooks/useAssumptions.tsx @@ -1,5 +1,6 @@ import { type ReactNode, createContext, useCallback, useContext, useMemo, useState } from 'react'; import { ASSUMED_MIN_PER_REVIEW, deriveCostEstimate, derivePersonCosts } from '../../costFormulas.ts'; +import { assumptionFields } from '../assumptionFields.ts'; import type { EmbeddedReportData } from '../types.ts'; export interface Assumptions { @@ -12,14 +13,16 @@ export interface DerivedCost { monthlyCostUsd: number; annualCostUsd: number; savingsScenarios: Array<{ autoMergeRate: number; monthlySavingsUsd: number; annualSavingsUsd: number }>; - topMergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; - topReviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + mergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + reviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; } +type ValueUpdate = number | ((prev: number) => number); + interface ContextValue { assumptions: Assumptions; - setHourlyRate: (n: number) => void; - setMinutesPerPr: (n: number) => void; + setHourlyRate: (next: ValueUpdate) => void; + setMinutesPerPr: (next: ValueUpdate) => void; reset: () => void; derived: DerivedCost; } @@ -33,11 +36,27 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa }; const [assumptions, setAssumptions] = useState(defaults); const setHourlyRate = useCallback( - (n: number) => setAssumptions((prev) => ({ ...prev, hourlyRateUsd: clamp(n, 1, 1000) })), + (next: ValueUpdate) => + setAssumptions((prev) => ({ + ...prev, + hourlyRateUsd: clamp( + resolve(next, prev.hourlyRateUsd), + assumptionFields.hourlyRateUsd.min, + assumptionFields.hourlyRateUsd.max, + ), + })), [], ); const setMinutesPerPr = useCallback( - (n: number) => setAssumptions((prev) => ({ ...prev, minutesPerPr: clamp(n, 1, 240) })), + (next: ValueUpdate) => + setAssumptions((prev) => ({ + ...prev, + minutesPerPr: clamp( + resolve(next, prev.minutesPerPr), + assumptionFields.minutesPerPr.min, + assumptionFields.minutesPerPr.max, + ), + })), [], ); const reset = useCallback(() => setAssumptions(defaults), [defaults.hourlyRateUsd, defaults.minutesPerPr]); @@ -49,8 +68,8 @@ export function AssumptionsProvider({ data, children }: { data: EmbeddedReportDa const cost = deriveCostEstimate(merged, windowDays, assumptions); return { ...cost, - topMergers: derivePersonCosts(data.people.topMergers, windowDays, minutesPerPr, hourlyRateUsd), - topReviewers: derivePersonCosts(data.people.topReviewers, windowDays, ASSUMED_MIN_PER_REVIEW, hourlyRateUsd), + mergers: derivePersonCosts(data.people.mergers, windowDays, minutesPerPr, hourlyRateUsd), + reviewers: derivePersonCosts(data.people.reviewers, windowDays, ASSUMED_MIN_PER_REVIEW, hourlyRateUsd), }; }, [assumptions, data]); @@ -66,6 +85,10 @@ export function useAssumptions(): ContextValue { return v; } +function resolve(next: ValueUpdate, prev: number): number { + return typeof next === 'function' ? next(prev) : next; +} + function clamp(n: number, min: number, max: number): number { if (!Number.isFinite(n)) return min; return Math.max(min, Math.min(max, Math.round(n))); diff --git a/src/report/web/hooks/useAssumptionsDisclosure.tsx b/src/report/web/hooks/useAssumptionsDisclosure.tsx new file mode 100644 index 0000000..9bb3e40 --- /dev/null +++ b/src/report/web/hooks/useAssumptionsDisclosure.tsx @@ -0,0 +1,52 @@ +import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react'; + +export type MethodologyTab = 'calculation' | 'data'; + +// The cost assumptions panel lives in the methodology appendix, behind a collapsed +//
    . Footnote links use normal hash anchors; this context only opens the +// disclosure and selects the tab containing the target. +interface ContextValue { + open: boolean; + setOpen: (open: boolean) => void; + activeTab: MethodologyTab; + setActiveTab: (tab: MethodologyTab) => void; + reveal: (tab?: MethodologyTab) => void; +} + +const Ctx = createContext(null); + +export function AssumptionsDisclosureProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState('calculation'); + + const reveal = useCallback((tab: MethodologyTab = 'calculation') => { + setActiveTab(tab); + setOpen(true); + }, []); + + useEffect(() => { + const revealHashTarget = () => { + if (window.location.hash.length <= 1) return; + const target = decodeURIComponent(window.location.hash.slice(1)); + if (target === 'appendix-assumptions') { + reveal('calculation'); + } + if (target === 'appendix-sources' || target.startsWith('footnote-')) { + setOpen(true); + } + }; + revealHashTarget(); + window.addEventListener('hashchange', revealHashTarget); + return () => window.removeEventListener('hashchange', revealHashTarget); + }, []); + + return {children}; +} + +export function useAssumptionsDisclosure(): ContextValue { + const v = useContext(Ctx); + if (!v) { + throw new Error('useAssumptionsDisclosure called outside AssumptionsDisclosureProvider'); + } + return v; +} diff --git a/src/report/web/hooks/useFootnotes.tsx b/src/report/web/hooks/useFootnotes.tsx new file mode 100644 index 0000000..c7e4837 --- /dev/null +++ b/src/report/web/hooks/useFootnotes.tsx @@ -0,0 +1,53 @@ +import { type ReactNode, createContext, useContext, useMemo, useRef } from 'react'; + +export interface FootnoteRegistration { + id: string; + title: string; + body: ReactNode; +} + +export interface RegisteredFootnote extends FootnoteRegistration { + number: number; +} + +interface FootnoteContextValue { + register: (footnote: FootnoteRegistration) => number; + list: () => RegisteredFootnote[]; +} + +const FootnoteContext = createContext(null); + +export function FootnoteProvider({ children }: { children: ReactNode }) { + const footnotes = useRef(new Map()); + const value = useMemo( + () => ({ + register: (footnote) => { + const existing = footnotes.current.get(footnote.id); + if (existing !== undefined) return existing.number; + const registered = { ...footnote, number: footnotes.current.size + 1 }; + footnotes.current.set(footnote.id, registered); + return registered.number; + }, + list: () => [...footnotes.current.values()].sort((a, b) => a.number - b.number), + }), + [], + ); + + return {children}; +} + +export function useFootnote(footnote: FootnoteRegistration): number { + const registry = useContext(FootnoteContext); + if (registry === null) { + throw new Error('useFootnote must be used within FootnoteProvider'); + } + return registry.register(footnote); +} + +export function useRegisteredFootnotes(): RegisteredFootnote[] { + const registry = useContext(FootnoteContext); + if (registry === null) { + throw new Error('useRegisteredFootnotes must be used within FootnoteProvider'); + } + return registry.list(); +} diff --git a/src/report/web/index.dev.html b/src/report/web/index.dev.html index cdaedc3..7de2677 100644 --- a/src/report/web/index.dev.html +++ b/src/report/web/index.dev.html @@ -3,7 +3,7 @@ - Patchwave diagnostic report dev + PatchWave diagnostic report dev diff --git a/src/report/web/index.html b/src/report/web/index.html index 7d437e7..1be6a0e 100644 --- a/src/report/web/index.html +++ b/src/report/web/index.html @@ -3,7 +3,7 @@ - Patchwave diagnostic report + PatchWave diagnostic report @@ -21,8 +21,7 @@ >

    This report needs JavaScript

    - The Patchwave report renders in your browser. Turn JavaScript on and reload, or open the accompanying - patchwave-report.zip for the raw JSON data. + The PatchWave report renders in your browser. Turn JavaScript on and reload to view the embedded report data.

    diff --git a/src/report/web/lib/utils.ts b/src/report/web/lib/utils.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/src/report/web/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/report/web/primitives/AssumptionInput.tsx b/src/report/web/primitives/AssumptionInput.tsx index d10c7fb..e78f105 100644 --- a/src/report/web/primitives/AssumptionInput.tsx +++ b/src/report/web/primitives/AssumptionInput.tsx @@ -1,9 +1,8 @@ import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; +import { assumptionFields } from '../assumptionFields.ts'; +import { Button } from '../components/ui/button.tsx'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; - -interface Props { - variant?: 'inline' | 'panel'; -} +import { NumberStepper } from './NumberStepper.tsx'; export const assumptionInputTestIds = { container: 'assumption-input-container', @@ -12,88 +11,40 @@ export const assumptionInputTestIds = { reset: 'assumption-input-reset', } as const; -export function AssumptionInput({ variant = 'inline' }: Props) { +export function AssumptionInput() { const { assumptions, setHourlyRate, setMinutesPerPr, reset } = useAssumptions(); const analytics = useAnalytics(); - const isPanel = variant === 'panel'; return (
    - analytics.capture('assumption_changed', { field: 'hourly_rate', value })} - min={1} - max={1000} - step={5} + {...assumptionFields.hourlyRateUsd} /> - analytics.capture('assumption_changed', { field: 'minutes_per_pr', value })} - min={1} - max={240} - step={1} + {...assumptionFields.minutesPerPr} /> - +
    ); } - -interface FieldProps { - label: string; - value: number; - onChange: (n: number) => void; - onCommit?: (n: number) => void; - prefix?: string; - suffix?: string; - min: number; - max: number; - step: number; - testId: string; -} - -function NumberField({ label, value, onChange, onCommit, prefix, suffix, min, max, step, testId }: FieldProps) { - return ( - - ); -} diff --git a/src/report/web/primitives/AssumptionsFootnote.tsx b/src/report/web/primitives/AssumptionsFootnote.tsx new file mode 100644 index 0000000..7576e5a --- /dev/null +++ b/src/report/web/primitives/AssumptionsFootnote.tsx @@ -0,0 +1,22 @@ +import { useAnalytics } from '../analytics/AnalyticsContext.tsx'; +import { FootnoteReference } from './FootnoteReference.tsx'; + +export const assumptionsFootnoteTestId = 'assumptions-footnote'; +export const assumptionsFootnoteId = 'assumptions'; +export const assumptionsPanelId = 'appendix-assumptions'; + +export function AssumptionsFootnote({ from, className }: { from: string; className?: string }) { + const analytics = useAnalytics(); + return ( + analytics.capture('assumptions_revealed', { from })} + /> + ); +} diff --git a/src/report/web/primitives/BarChart.tsx b/src/report/web/primitives/BarChart.tsx deleted file mode 100644 index 540a76b..0000000 --- a/src/report/web/primitives/BarChart.tsx +++ /dev/null @@ -1,36 +0,0 @@ -interface Bar { - label: string; - value: number; -} - -interface Props { - data: Bar[]; - emptyLabel?: string; -} - -export function BarChart({ data, emptyLabel = 'No data.' }: Props) { - const max = data.reduce((m, d) => Math.max(m, d.value), 0); - if (max === 0) { - return
    {emptyLabel}
    ; - } - return ( -
    - {data.map((d) => { - const pct = (d.value / max) * 100; - return ( -
    - {d.label} - - - - {d.value} -
    - ); - })} -
    - ); -} diff --git a/src/report/web/primitives/Citation.tsx b/src/report/web/primitives/Citation.tsx index cd8d415..135152b 100644 --- a/src/report/web/primitives/Citation.tsx +++ b/src/report/web/primitives/Citation.tsx @@ -1,4 +1,5 @@ import { CITATIONS, type CitationKey } from '../data/citations.ts'; +import { FootnoteBodyLink, FootnoteReference } from './FootnoteReference.tsx'; interface Props { source: CitationKey; @@ -7,14 +8,11 @@ interface Props { export function Citation({ source }: Props) { const c = CITATIONS[source]; return ( - - [src] - + body={{c.url}} + titleText={`${c.label} — view source note`} + /> ); } diff --git a/src/report/web/primitives/FootnoteMarker.tsx b/src/report/web/primitives/FootnoteMarker.tsx new file mode 100644 index 0000000..b71ec44 --- /dev/null +++ b/src/report/web/primitives/FootnoteMarker.tsx @@ -0,0 +1,2 @@ +export const footnoteMarkerClass = + 'text-muted-foreground hover:text-foreground ml-0.5 inline-block align-super text-[0.6em] leading-none font-medium underline-offset-2 hover:underline'; diff --git a/src/report/web/primitives/FootnoteReference.tsx b/src/report/web/primitives/FootnoteReference.tsx new file mode 100644 index 0000000..33b4aef --- /dev/null +++ b/src/report/web/primitives/FootnoteReference.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; +import { useAssumptionsDisclosure } from '../hooks/useAssumptionsDisclosure.tsx'; +import { type FootnoteRegistration, useFootnote } from '../hooks/useFootnotes.tsx'; +import { cn } from '../lib/utils.ts'; +import { footnoteMarkerClass } from './FootnoteMarker.tsx'; + +export const footnoteReferenceTestId = 'footnote-reference'; + +interface Props extends FootnoteRegistration { + kind?: 'note' | 'assumptions'; + className?: string; + testId?: string; + titleText?: string; + onClick?: () => void; +} + +export function FootnoteReference({ + id, + title, + body, + kind = 'note', + className, + testId = footnoteReferenceTestId, + titleText, + onClick, +}: Props) { + const number = useFootnote({ id, title, body }); + const { reveal } = useAssumptionsDisclosure(); + const href = kind === 'assumptions' ? '#appendix-assumptions' : '#appendix-sources'; + + return ( + { + onClick?.(); + reveal(); + }} + className={cn(footnoteMarkerClass, className)} + > + {number} + + ); +} + +export function FootnoteBodyLink({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/report/web/primitives/NumberStepper.tsx b/src/report/web/primitives/NumberStepper.tsx new file mode 100644 index 0000000..a8aa609 --- /dev/null +++ b/src/report/web/primitives/NumberStepper.tsx @@ -0,0 +1,98 @@ +import { Minus, Plus } from 'lucide-react'; +import { Button } from '../components/ui/button.tsx'; +import { Input } from '../components/ui/input.tsx'; +import { cn } from '../lib/utils.ts'; + +type ValueUpdate = number | ((prev: number) => number); + +interface Props { + label: string; + value: number; + onChange: (next: ValueUpdate) => void; + min: number; + max: number; + step: number; + prefix?: string; + suffix?: string; + testId: string; + decrementTestId?: string; + incrementTestId?: string; + onCommit?: (n: number) => void; +} + +// shadcn Button + Input composed into a stepper (neither shadcn nor the shared +// @contextbridge/ui ship one). Steps go through a functional update so back-to-back +// clicks accumulate; the caller clamps, so out-of-range input is safe. +export function NumberStepper({ + label, + value, + onChange, + min, + max, + step, + prefix, + suffix, + testId, + decrementTestId, + incrementTestId, + onCommit, +}: Props) { + const lower = label.toLowerCase(); + const commit = (n: number) => onCommit?.(Math.max(min, Math.min(max, n))); + return ( +
    + {label} +
    + +
    + {prefix && ( + + {prefix} + + )} + onChange(Number(e.target.value.replace(/[^0-9]/g, '')))} + onBlur={(e) => commit(Number(e.target.value.replace(/[^0-9]/g, '')))} + className={cn('w-28 text-center tabular-nums', prefix && 'pl-7', suffix && 'pr-9')} + /> + {suffix && ( + + {suffix} + + )} +
    + +
    +
    + ); +} diff --git a/src/report/web/primitives/PersonRow.tsx b/src/report/web/primitives/PersonRow.tsx index 56be931..9699e4e 100644 --- a/src/report/web/primitives/PersonRow.tsx +++ b/src/report/web/primitives/PersonRow.tsx @@ -2,19 +2,20 @@ import { fmtUsd } from '../format/money.ts'; interface Props { login: string; - count: number; - countLabel: string; + mergedCount: number; + reviewedCount: number; windowCostUsd: number; annualCostUsd: number; } -export function PersonRow({ login, count, countLabel, windowCostUsd, annualCostUsd }: Props) { +export function PersonRow({ login, mergedCount, reviewedCount, windowCostUsd, annualCostUsd }: Props) { return ( {login} - {count}{' '} - {countLabel} + + {mergedCount > 0 && reviewedCount > 0 ? , : null} + {fmtUsd(windowCostUsd)} @@ -23,3 +24,13 @@ export function PersonRow({ login, count, countLabel, windowCostUsd, annualCostU ); } + +function ActivityCount({ count, label }: { count: number; label: string }) { + if (count === 0) return null; + return ( + <> + {count.toLocaleString()}{' '} + {label} + + ); +} diff --git a/src/report/web/primitives/SavingsBars.tsx b/src/report/web/primitives/SavingsBars.tsx deleted file mode 100644 index 22bc3d3..0000000 --- a/src/report/web/primitives/SavingsBars.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { fmtUsd } from '../format/money.ts'; - -interface Scenario { - autoMergeRate: number; - annualSavingsUsd: number; -} - -interface Props { - scenarios: Scenario[]; -} - -export function SavingsBars({ scenarios }: Props) { - const max = scenarios.reduce((m, s) => Math.max(m, s.annualSavingsUsd), 0); - if (max === 0) { - return
    No baseline cost to project from.
    ; - } - return ( -
    - {scenarios.map((s) => { - const pct = (s.annualSavingsUsd / max) * 100; - const label = `${Math.round(s.autoMergeRate * 100)}% auto-merged`; - return ( -
    - {label} - - - - {fmtUsd(s.annualSavingsUsd)} -
    - ); - })} -
    - ); -} diff --git a/src/report/web/styles.css b/src/report/web/styles.css index e395da4..dbf187c 100644 --- a/src/report/web/styles.css +++ b/src/report/web/styles.css @@ -1,4 +1,4 @@ -/* Patchwave report — uses the ContextBridge design system (planbridge ui package). +/* PatchWave report — uses the ContextBridge design system (planbridge ui package). OKLCH brand palette, IBM Plex Sans (chrome + prose), IBM Plex Mono (code). */ @import 'tailwindcss'; @@ -96,8 +96,12 @@ --ring: var(--color-neutral-400); } +/* Dark palette. Applies when the OS asks for it (unless a Storybook story forces + light with .theme-light) and when .theme-dark is set explicitly. The .theme-* + classes exist only so Storybook can force a scheme; the shipped report never + sets them. Keep the two blocks below in sync. */ @media (prefers-color-scheme: dark) { - :root { + :root:not(.theme-light) { --background: var(--color-neutral-990); --foreground: var(--color-neutral-50); --muted-foreground: var(--color-neutral-400); @@ -118,6 +122,26 @@ } } +:root.theme-dark { + --background: var(--color-neutral-990); + --foreground: var(--color-neutral-50); + --muted-foreground: var(--color-neutral-400); + --border: var(--color-neutral-800); + + --card: var(--color-neutral-975); + --card-foreground: var(--foreground); + --primary: var(--royal-light); + --primary-foreground: var(--color-neutral-50); + --secondary: var(--color-neutral-900); + --secondary-foreground: var(--foreground); + --accent: var(--color-neutral-900); + --accent-foreground: var(--foreground); + --muted: var(--color-neutral-900); + + --input: var(--color-neutral-800); + --ring: var(--color-neutral-600); +} + @theme inline { --font-sans: var(--cb-font-sans); --font-mono: var(--cb-font-mono); diff --git a/src/testHelpers/FakeFileSystem.ts b/src/testHelpers/FakeFileSystem.ts index f8e1946..3479fa3 100644 --- a/src/testHelpers/FakeFileSystem.ts +++ b/src/testHelpers/FakeFileSystem.ts @@ -3,7 +3,7 @@ import type { FileSystem, FsError } from '../FileSystem.ts'; export interface FakeWrite { readonly path: string; - readonly contents: string | Uint8Array; + readonly contents: string; } export class FakeFileSystem implements FileSystem { @@ -25,10 +25,6 @@ export class FakeFileSystem implements FileSystem { return this.recordWrite(path, contents); } - writeBinaryFile(path: string, contents: Uint8Array): ResultAsync { - return this.recordWrite(path, contents); - } - makeTempDir(prefix: string): ResultAsync { if (this.tempDirFailure) { const err = this.tempDirFailure; @@ -41,16 +37,10 @@ export class FakeFileSystem implements FileSystem { } read(path: string): string | undefined { - const contents = this.writes.find((w) => w.path === path)?.contents; - return typeof contents === 'string' ? contents : undefined; - } - - readBinary(path: string): Uint8Array | undefined { - const contents = this.writes.find((w) => w.path === path)?.contents; - return typeof contents === 'string' ? undefined : contents; + return this.writes.find((w) => w.path === path)?.contents; } - private recordWrite(path: string, contents: string | Uint8Array): ResultAsync { + private recordWrite(path: string, contents: string): ResultAsync { if (this.failure) { const err = this.failure; this.failure = null; diff --git a/src/upload/Uploader.test.ts b/src/upload/Uploader.test.ts index 606d6ce..109f216 100644 --- a/src/upload/Uploader.test.ts +++ b/src/upload/Uploader.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { htmlBytes, presignResponseBody, uploadInput, zipBytes } from './testFactories.ts'; +import { htmlBytes, presignResponseBody, uploadInput } from './testFactories.ts'; import { type FetchFn, UploaderImpl } from './Uploader.ts'; const ENDPOINT = 'https://api.test/v1/uploads/analysis-bundle'; @@ -26,9 +26,9 @@ function presignResponse() { } describe('UploaderImpl', () => { - test("zip kind: posts kind:'zip' and PUTs with application/zip", async () => { + test("html kind: posts kind:'html' and PUTs raw bytes with text/html", async () => { const { fetch, calls } = recordFetch([presignResponse(), new Response('', { status: 200 })]); - const bytes = zipBytes.build(); + const bytes = htmlBytes.build(); const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build({ bytes })); @@ -42,7 +42,7 @@ describe('UploaderImpl', () => { identifier: 'ben@example.com', appVersion: '0.0.1', timestamp: '2026-05-22T12:00:00Z', - kind: 'zip', + kind: 'html', sizeBytes: bytes.byteLength, }); expect(postBody).not.toHaveProperty('contentType'); @@ -50,21 +50,6 @@ describe('UploaderImpl', () => { expect(calls[1]?.url).toBe(presignResponseBody.build().presignedUrl); expect(calls[1]?.init?.method).toBe('PUT'); expect(calls[1]?.init?.body).toBe(bytes as BodyInit); - expect((calls[1]?.init?.headers as Record)['content-type']).toBe('application/zip'); - }); - - test("html kind: posts kind:'html' and PUTs raw bytes with text/html", async () => { - const { fetch, calls } = recordFetch([presignResponse(), new Response('', { status: 200 })]); - const bytes = htmlBytes.build(); - - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload( - uploadInput.build({ bytes, kind: 'html' }), - ); - - expect(result.isOk()).toBe(true); - const postBody = JSON.parse(calls[0]?.init?.body as string) as Record; - expect(postBody).toMatchObject({ kind: 'html', sizeBytes: bytes.byteLength }); - expect(calls[1]?.init?.body).toBe(bytes as BodyInit); expect((calls[1]?.init?.headers as Record)['content-type']).toBe('text/html'); }); diff --git a/src/upload/Uploader.ts b/src/upload/Uploader.ts index d32bf77..85611fa 100644 --- a/src/upload/Uploader.ts +++ b/src/upload/Uploader.ts @@ -3,16 +3,6 @@ import { toError } from '../errors.ts'; export const DEFAULT_UPLOAD_ENDPOINT = 'https://api.patchwave.ai/v1/uploads/analysis-bundle'; -export type BundleKind = 'zip' | 'html'; - -// The server keys off `kind` and rejects requests whose PUT content-type doesn't -// match — `content-type` is part of the signed headers — so the canonical -// mapping lives here, not on the client's choice of MIME string. -const BUNDLE_CONTENT_TYPES: Record = { - zip: 'application/zip', - html: 'text/html', -}; - export type UploadError = | { kind: 'presign-request-failed'; message: string } | { kind: 'presign-bad-status'; status: number; body: string } @@ -22,7 +12,6 @@ export type UploadError = export interface UploadInput { readonly bytes: Uint8Array; - readonly kind: BundleKind; readonly identifier: string; readonly appVersion: string; readonly timestamp: string; @@ -60,7 +49,7 @@ export class UploaderImpl implements Uploader { upload(input: UploadInput): ResultAsync { return this.#requestPresign(input).andThen((presign) => - this.#putToS3(presign.presignedUrl, input.kind, input.bytes).map(() => ({ uploadId: presign.uploadId })), + this.#putToS3(presign.presignedUrl, input.bytes).map(() => ({ uploadId: presign.uploadId })), ); } @@ -69,7 +58,7 @@ export class UploaderImpl implements Uploader { identifier: input.identifier, appVersion: input.appVersion, timestamp: input.timestamp, - kind: input.kind, + kind: 'html', sizeBytes: input.bytes.byteLength, }); return ResultAsync.fromPromise( @@ -92,11 +81,11 @@ export class UploaderImpl implements Uploader { }); } - #putToS3(url: string, kind: BundleKind, bytes: Uint8Array): ResultAsync { + #putToS3(url: string, bytes: Uint8Array): ResultAsync { return ResultAsync.fromPromise( this.#fetch(url, { method: 'PUT', - headers: { 'content-type': BUNDLE_CONTENT_TYPES[kind] }, + headers: { 'content-type': 'text/html' }, // The DOM lib's `BodyInit` narrows `BufferSource` to `Uint8Array`, // but our bytes are `Uint8Array`. fetch accepts them at runtime. body: bytes as BodyInit, diff --git a/src/upload/testFactories.ts b/src/upload/testFactories.ts index 31aa995..4ec0058 100644 --- a/src/upload/testFactories.ts +++ b/src/upload/testFactories.ts @@ -1,13 +1,10 @@ import { Factory } from 'fishery'; import type { UploadInput } from './Uploader.ts'; -export const zipBytes = Factory.define(() => new Uint8Array([0x50, 0x4b, 0x03, 0x04])); - export const htmlBytes = Factory.define(() => new TextEncoder().encode('')); export const uploadInput = Factory.define(() => ({ - bytes: zipBytes.build(), - kind: 'zip', + bytes: htmlBytes.build(), identifier: 'ben@example.com', appVersion: '0.0.1', timestamp: '2026-05-22T12:00:00Z',