astralyx-ui 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/package.json +10 -10
- package/registry/index.json +12 -1
- package/registry/items/ab-test-results.json +1 -1
- package/registry/items/accordion.json +1 -1
- package/registry/items/address-input.json +1 -1
- package/registry/items/agent-memory.json +1 -1
- package/registry/items/agent-steps.json +1 -1
- package/registry/items/alert-triage.json +1 -1
- package/registry/items/anomaly-chart.json +1 -1
- package/registry/items/api-keys.json +1 -1
- package/registry/items/asset-grid.json +1 -1
- package/registry/items/attribution.json +1 -1
- package/registry/items/audio-player.json +1 -1
- package/registry/items/audit-log.json +1 -1
- package/registry/items/avatar.json +1 -1
- package/registry/items/back-to-top.json +1 -1
- package/registry/items/banner.json +1 -1
- package/registry/items/benchmark-table.json +1 -1
- package/registry/items/blame-view.json +1 -1
- package/registry/items/box-plot.json +1 -1
- package/registry/items/branch-select.json +1 -1
- package/registry/items/build-log.json +1 -1
- package/registry/items/bundle-treemap.json +1 -1
- package/registry/items/calendar.json +1 -1
- package/registry/items/candlestick-chart.json +1 -1
- package/registry/items/card-input.json +1 -1
- package/registry/items/card.json +1 -1
- package/registry/items/carousel.json +1 -1
- package/registry/items/cascader.json +1 -1
- package/registry/items/chain-select.json +1 -1
- package/registry/items/chart.json +1 -1
- package/registry/items/checkbox.json +1 -1
- package/registry/items/code-block.json +1 -1
- package/registry/items/code-search.json +1 -1
- package/registry/items/cohort-table.json +1 -1
- package/registry/items/collapsible.json +1 -1
- package/registry/items/color-picker.json +1 -1
- package/registry/items/combobox.json +1 -1
- package/registry/items/command.json +1 -1
- package/registry/items/commit-graph.json +1 -1
- package/registry/items/compare-slider.json +1 -1
- package/registry/items/composer.json +1 -1
- package/registry/items/connection-string.json +1 -1
- package/registry/items/context-menu.json +1 -1
- package/registry/items/context-picker.json +1 -1
- package/registry/items/cookie-consent.json +1 -1
- package/registry/items/coordinate-input.json +1 -1
- package/registry/items/cost-breakdown.json +1 -1
- package/registry/items/countdown.json +1 -1
- package/registry/items/coverage-report.json +1 -1
- package/registry/items/csv-preview.json +1 -1
- package/registry/items/curl-command.json +1 -1
- package/registry/items/currency-input.json +1 -1
- package/registry/items/data-grid.json +1 -1
- package/registry/items/date-picker.json +1 -1
- package/registry/items/date-range-compare.json +1 -1
- package/registry/items/dependency-list.json +1 -1
- package/registry/items/diff-proposal.json +1 -1
- package/registry/items/diff-view.json +1 -1
- package/registry/items/donut.json +1 -1
- package/registry/items/drawer.json +1 -1
- package/registry/items/dropdown-menu.json +1 -1
- package/registry/items/dropzone.json +1 -1
- package/registry/items/emoji-picker.json +1 -1
- package/registry/items/endpoint-list.json +1 -1
- package/registry/items/env-diff.json +1 -1
- package/registry/items/env-vars.json +1 -1
- package/registry/items/eval-board.json +1 -1
- package/registry/items/eval-results.json +1 -1
- package/registry/items/event-stream.json +1 -1
- package/registry/items/feedback.json +1 -1
- package/registry/items/few-shot-editor.json +1 -1
- package/registry/items/field.json +1 -1
- package/registry/items/file-preview.json +1 -1
- package/registry/items/file-tree.json +1 -1
- package/registry/items/fingerprint-diff.json +1 -1
- package/registry/items/flame-graph.json +1 -1
- package/registry/items/form.json +1 -1
- package/registry/items/gantt.json +1 -1
- package/registry/items/gauge.json +1 -1
- package/registry/items/heatmap-grid.json +1 -1
- package/registry/items/hover-card.json +1 -1
- package/registry/items/image-cropper.json +1 -1
- package/registry/items/image.json +1 -1
- package/registry/items/input-file.json +1 -1
- package/registry/items/input-otp.json +1 -1
- package/registry/items/input.json +1 -1
- package/registry/items/inspector.json +1 -1
- package/registry/items/ip-cluster.json +1 -1
- package/registry/items/json-viewer.json +1 -1
- package/registry/items/jwt-inspector.json +1 -1
- package/registry/items/kanban.json +1 -1
- package/registry/items/knowledge-graph.json +1 -1
- package/registry/items/label-picker.json +1 -1
- package/registry/items/ledger-table.json +1 -1
- package/registry/items/lib-motion.json +1 -1
- package/registry/items/lib-use-clipboard.json +1 -1
- package/registry/items/lib-use-force-graph.json +1 -1
- package/registry/items/lib-use-uploads.json +1 -1
- package/registry/items/live-announcer.json +1 -1
- package/registry/items/location-picker.json +1 -1
- package/registry/items/log-viewer.json +1 -1
- package/registry/items/login-form.json +1 -1
- package/registry/items/logo.json +18 -0
- package/registry/items/map-embed.json +1 -1
- package/registry/items/markdown-editor.json +1 -1
- package/registry/items/markdown.json +1 -1
- package/registry/items/market-table.json +1 -1
- package/registry/items/mask-input.json +1 -1
- package/registry/items/mcp-config-editor.json +1 -1
- package/registry/items/mcp-elicitation.json +1 -1
- package/registry/items/mcp-prompt-list.json +1 -1
- package/registry/items/mcp-resource-list.json +1 -1
- package/registry/items/mcp-server-picker.json +1 -1
- package/registry/items/media-gallery.json +1 -1
- package/registry/items/mention-input.json +1 -1
- package/registry/items/menubar.json +1 -1
- package/registry/items/merge-conflict.json +1 -1
- package/registry/items/mint-panel.json +1 -1
- package/registry/items/model-comparison.json +1 -1
- package/registry/items/moderation-queue.json +1 -1
- package/registry/items/money-input.json +1 -1
- package/registry/items/multi-select.json +1 -1
- package/registry/items/multiplier-chart.json +1 -1
- package/registry/items/navigation-menu.json +1 -1
- package/registry/items/node-canvas.json +1 -1
- package/registry/items/note-graph.json +1 -1
- package/registry/items/notification-inbox.json +1 -1
- package/registry/items/number-input.json +1 -1
- package/registry/items/object-list.json +1 -1
- package/registry/items/on-call-schedule.json +1 -1
- package/registry/items/order-book.json +1 -1
- package/registry/items/org-chart.json +1 -1
- package/registry/items/password-input.json +1 -1
- package/registry/items/pdf-viewer.json +1 -1
- package/registry/items/permission-matrix.json +1 -1
- package/registry/items/phone-input.json +1 -1
- package/registry/items/popconfirm.json +1 -1
- package/registry/items/popover.json +1 -1
- package/registry/items/portfolio-balance.json +1 -1
- package/registry/items/postback-config.json +1 -1
- package/registry/items/price-ticker.json +1 -1
- package/registry/items/primitive-collapsible.json +1 -1
- package/registry/items/primitive-dialog.json +1 -1
- package/registry/items/primitive-dismissable.json +1 -1
- package/registry/items/primitive-field.json +1 -1
- package/registry/items/primitive-focus-trap.json +1 -1
- package/registry/items/primitive-media-query.json +1 -1
- package/registry/items/primitive-popper.json +1 -1
- package/registry/items/primitive-portal.json +1 -1
- package/registry/items/primitive-radio-group.json +1 -1
- package/registry/items/primitive-router.json +1 -1
- package/registry/items/primitive-tabs.json +1 -1
- package/registry/items/prompt-diff.json +1 -1
- package/registry/items/prompt-input.json +1 -1
- package/registry/items/prompt-template.json +1 -1
- package/registry/items/prompt-variables.json +1 -1
- package/registry/items/qr-code.json +1 -1
- package/registry/items/query-constructor.json +1 -1
- package/registry/items/query-editor.json +1 -1
- package/registry/items/query-plan.json +1 -1
- package/registry/items/radar-chart.json +1 -1
- package/registry/items/range-slider.json +1 -1
- package/registry/items/rating.json +1 -1
- package/registry/items/reasoning-block.json +1 -1
- package/registry/items/regex-tester.json +1 -1
- package/registry/items/resizable.json +1 -1
- package/registry/items/retention-curve.json +1 -1
- package/registry/items/retrieval-results.json +1 -1
- package/registry/items/retry-policy.json +1 -1
- package/registry/items/review-thread.json +1 -1
- package/registry/items/rpc-console.json +1 -1
- package/registry/items/runbook-steps.json +1 -1
- package/registry/items/sankey.json +1 -1
- package/registry/items/scatter-plot.json +1 -1
- package/registry/items/scheduler.json +1 -1
- package/registry/items/schema-form.json +1 -1
- package/registry/items/schema-viewer.json +1 -1
- package/registry/items/seed-phrase.json +1 -1
- package/registry/items/segment-builder.json +1 -1
- package/registry/items/segmented-control.json +1 -1
- package/registry/items/select.json +1 -1
- package/registry/items/session-list.json +1 -1
- package/registry/items/sidebar.json +1 -1
- package/registry/items/signature-pad.json +1 -1
- package/registry/items/slider.json +1 -1
- package/registry/items/slow-query-log.json +1 -1
- package/registry/items/stack-trace.json +1 -1
- package/registry/items/status-checks.json +1 -1
- package/registry/items/story.json +1 -1
- package/registry/items/stream-inspector.json +1 -1
- package/registry/items/streaming-text.json +1 -1
- package/registry/items/symbol-outline.json +1 -1
- package/registry/items/table-of-contents.json +1 -1
- package/registry/items/tabs.json +1 -1
- package/registry/items/tag-input.json +1 -1
- package/registry/items/terminal.json +1 -1
- package/registry/items/test-results.json +1 -1
- package/registry/items/textarea.json +1 -1
- package/registry/items/theme-toggle.json +1 -1
- package/registry/items/theme.json +1 -1
- package/registry/items/time-picker.json +1 -1
- package/registry/items/timezone-select.json +1 -1
- package/registry/items/toast.json +1 -1
- package/registry/items/toggle.json +1 -1
- package/registry/items/token-approvals.json +1 -1
- package/registry/items/token-inspector.json +1 -1
- package/registry/items/token-select.json +1 -1
- package/registry/items/tool-approval.json +1 -1
- package/registry/items/tool-diff.json +1 -1
- package/registry/items/tool-latency.json +1 -1
- package/registry/items/tool-picker.json +1 -1
- package/registry/items/tool-result.json +1 -1
- package/registry/items/tool-schema.json +1 -1
- package/registry/items/toolbar.json +1 -1
- package/registry/items/tooltip.json +1 -1
- package/registry/items/topology-map.json +1 -1
- package/registry/items/tour.json +1 -1
- package/registry/items/trace-waterfall.json +1 -1
- package/registry/items/transaction-list.json +1 -1
- package/registry/items/transfer.json +1 -1
- package/registry/items/tree-select.json +1 -1
- package/registry/items/tree.json +1 -1
- package/registry/items/treemap.json +1 -1
- package/registry/items/two-factor-setup.json +1 -1
- package/registry/items/utm-builder.json +1 -1
- package/registry/items/validator-list.json +1 -1
- package/registry/items/video-player.json +1 -1
- package/registry/items/virtual-list.json +1 -1
- package/registry/items/webhook-inspector.json +1 -1
- package/registry/items/websocket-frames.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# astralyx-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/astralyx-ui)
|
|
4
|
+
[](https://ui.astralyx.dev)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
**344 accessible React components you copy into your repo** — with a CLI and a
|
|
4
8
|
registry that work out what each one needs and bring that too.
|
|
5
9
|
|
|
10
|
+
**[Browse every component, running, at ui.astralyx.dev →](https://ui.astralyx.dev)**
|
|
11
|
+
|
|
6
12
|
Not a dependency. There is no package to upgrade and nothing to fight when a
|
|
7
13
|
design changes: the code lands in your tree and it is yours.
|
|
8
14
|
|
|
@@ -14,7 +20,7 @@ npx astralyx-ui add button
|
|
|
14
20
|
|
|
15
21
|
## What you get
|
|
16
22
|
|
|
17
|
-
- **
|
|
23
|
+
- **344 components** across 36 categories — forms, data, overlays, commerce,
|
|
18
24
|
auth, AI, crypto, observability, developer tooling.
|
|
19
25
|
- **12 primitives** underneath them. Slot, Popper, FocusTrap, Dismissable and
|
|
20
26
|
friends — no headless-UI dependency.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astralyx-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "344 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Astralyx",
|
|
8
8
|
"homepage": "https://ui.astralyx.dev",
|
|
@@ -50,32 +50,32 @@
|
|
|
50
50
|
},
|
|
51
51
|
"sideEffects": false,
|
|
52
52
|
"scripts": {
|
|
53
|
-
"dev": "
|
|
54
|
-
"build": "node scripts/build-props.mjs && node scripts/
|
|
53
|
+
"dev": "next dev",
|
|
54
|
+
"build": "node scripts/build-props.mjs && node scripts/build-docs.mjs && node scripts/check-api.mjs && next build && node scripts/build-llms.mjs",
|
|
55
55
|
"lint": "oxlint",
|
|
56
|
-
"preview": "vite preview",
|
|
57
56
|
"build:props": "node scripts/build-props.mjs",
|
|
58
57
|
"check:api": "node scripts/check-api.mjs",
|
|
59
58
|
"build:registry": "node scripts/build-registry.mjs",
|
|
60
59
|
"build:cli": "tsc -p tsconfig.cli.json",
|
|
61
|
-
"build:package": "npm run build:props && npm run build:registry && npm run build:cli",
|
|
60
|
+
"build:package": "npm run build:props && npm run build:docs && npm run build:registry && npm run build:cli",
|
|
62
61
|
"prepublishOnly": "npm run build:package",
|
|
63
|
-
"cli": "node bin/index.js"
|
|
62
|
+
"cli": "node bin/index.js",
|
|
63
|
+
"start": "npx serve out",
|
|
64
|
+
"build:docs": "node scripts/build-docs.mjs"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
67
|
"@babel/core": "^7.29.7",
|
|
67
68
|
"@hookform/resolvers": "^5.9.1",
|
|
68
|
-
"@
|
|
69
|
-
"@tailwindcss/vite": "^4.3.3",
|
|
69
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
70
70
|
"@types/babel__core": "^7.20.5",
|
|
71
71
|
"@types/node": "^24.13.3",
|
|
72
72
|
"@types/react": "^19.2.18",
|
|
73
73
|
"@types/react-dom": "^19.2.4",
|
|
74
|
-
"@vitejs/plugin-react": "^6.1.0",
|
|
75
74
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
76
75
|
"class-variance-authority": "^0.7.1",
|
|
77
76
|
"cn": "^0.2.4",
|
|
78
77
|
"lucide-react": "^1.39.0",
|
|
78
|
+
"next": "^16.3.4",
|
|
79
79
|
"oxlint": "^1.79.0",
|
|
80
80
|
"react": "^19.2.8",
|
|
81
81
|
"react-dom": "^19.2.8",
|
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astralyx-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"homepage": "https://ui.astralyx.dev",
|
|
5
5
|
"items": [
|
|
6
6
|
{
|
|
@@ -2420,6 +2420,17 @@
|
|
|
2420
2420
|
"separator"
|
|
2421
2421
|
]
|
|
2422
2422
|
},
|
|
2423
|
+
{
|
|
2424
|
+
"name": "logo",
|
|
2425
|
+
"type": "registry:ui",
|
|
2426
|
+
"title": "Logo",
|
|
2427
|
+
"description": "A brand mark that follows the room it has: the full artwork while the rail is open, the square mark once it collapses. Falls back to the product name, and to its initials when only 52px are left.",
|
|
2428
|
+
"category": "Utility",
|
|
2429
|
+
"dependencies": [],
|
|
2430
|
+
"registryDependencies": [
|
|
2431
|
+
"lib-utils"
|
|
2432
|
+
]
|
|
2433
|
+
},
|
|
2423
2434
|
{
|
|
2424
2435
|
"name": "map-embed",
|
|
2425
2436
|
"type": "registry:ui",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"path": "components/ui/ab-test-results.tsx",
|
|
19
19
|
"type": "registry:ui",
|
|
20
|
-
"content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle } from 'lucide-react'\nimport {\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Experiment variants with lift, a confidence interval, and an honest verdict.\n *\n * **It shows the interval, not just the number.** \"+12% lift\" is not a result;\n * \"+12%, 95% CI [−3%, +27%]\" is, and it says the opposite — that the experiment\n * has not concluded. A dashboard that prints the point estimate alone\n * manufactures certainty, and people ship on it.\n *\n * **The maths.** A two-proportion z-test with a pooled standard error for the\n * p-value, and an unpooled standard error for the interval — the standard pair,\n * and the reason the interval can straddle zero while p sits just under 0.05.\n * The interval is then divided by the control rate so it is on the same scale\n * as the lift beside it; an absolute-difference interval printed next to a\n * relative lift is read as bounding that lift, and it does not.\n * The normal approximation needs roughly ten conversions in each arm, so below\n * that the verdict is withheld rather than computed on data too thin to carry\n * it.\n *\n * **It warns about peeking, because that is the real-world failure.** Checking\n * an experiment repeatedly and stopping at the first significant reading\n * inflates the false-positive rate far above the nominal 5% — a fixed-horizon\n * test read continuously is wrong roughly a third of the time. If a sample-size\n * target is given and has not been reached, this says so plainly.\n *\n * It reports a *statistical* result about one metric. Whether that metric is\n * the one that matters, whether the assignment was actually random, and whether\n * the effect is worth the change are not things any component can tell you.\n */\nexport type Variant = {\n id: string\n name: ReactNode\n /** Users in this arm. */\n visitors: number\n conversions: number\n /** Exactly one variant should be the baseline. */\n control?: boolean\n}\n\ntype AbTestResultsProps = Omit<ComponentProps<'div'>, 'title'> & {\n variants: Variant[]\n title?: ReactNode\n /** Two-sided significance threshold. */\n alpha?: number\n /** Per-arm sample size the test was planned for. Enables the peeking warning. */\n targetSample?: number\n metricLabel?: string\n emptyLabel?: string\n label?: string\n}\n\n/** Normal CDF via Abramowitz & Stegun 7.1.26 — accurate to ~1e-7. */\nfunction normalCdf(z: number): number {\n const sign = z < 0 ? -1 : 1\n const x = Math.abs(z) / Math.SQRT2\n const t = 1 / (1 + 0.3275911 * x)\n const y =\n 1 -\n ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *\n t *\n Math.exp(-x * x)\n return 0.5 * (1 + sign * y)\n}\n\n/** 95% ≈ 1.96. Inverse normal by bisection: exact enough, and tiny. */\nfunction zFor(confidence: number): number {\n let lo = 0\n let hi = 6\n const target = 1 - (1 - confidence) / 2\n for (let i = 0; i < 60; i++) {\n const mid = (lo + hi) / 2\n if (normalCdf(mid) < target) lo = mid\n else hi = mid\n }\n return (lo + hi) / 2\n}\n\nfunction AbTestResults({\n variants,\n title,\n alpha = 0.05,\n targetSample,\n metricLabel = 'Conversion',\n emptyLabel = 'No variants.',\n label = 'Experiment results',\n className,\n ...props\n}: AbTestResultsProps) {\n const titleId = useId()\n\n const rows = useMemo(() => {\n const control = variants.find((variant) => variant.control) ?? variants[0]\n if (!control) return []\n\n const z = zFor(1 - alpha)\n const controlRate = control.visitors > 0 ? control.conversions / control.visitors : 0\n\n return variants.map((variant) => {\n const rate = variant.visitors > 0 ? variant.conversions / variant.visitors : 0\n if (variant.id === control.id) {\n return { variant, rate, isControl: true, lift: null, ci: null, p: null, thin: false }\n }\n\n // The normal approximation needs enough successes and failures in both\n // arms; under that, no verdict is honest.\n const thin =\n Math.min(\n variant.conversions,\n variant.visitors - variant.conversions,\n control.conversions,\n control.visitors - control.conversions,\n ) < 10\n\n const pooled =\n (variant.conversions + control.conversions) / (variant.visitors + control.visitors)\n const pooledSe = Math.sqrt(\n pooled * (1 - pooled) * (1 / variant.visitors + 1 / control.visitors),\n )\n const statistic = pooledSe > 0 ? (rate - controlRate) / pooledSe : 0\n const p = 2 * (1 - normalCdf(Math.abs(statistic)))\n\n // Unpooled for the interval — pooling assumes the null it is testing.\n const se = Math.sqrt(\n (rate * (1 - rate)) / variant.visitors + (controlRate * (1 - controlRate)) / control.visitors,\n )\n const diff = rate - controlRate\n\n /**\n * The interval is put on the same scale as the lift beside it.\n *\n * The z-test gives an interval for the *absolute* difference in rates —\n * 0.75 percentage points. Printing that next to a *relative* lift of\n * +12.5% invites the reader to take it as bounding the lift, which it\n * does not: the two numbers are in different units and disagree by a\n * factor of the control rate. Dividing by the control rate is the\n * first-order (delta method) interval for relative lift, which is what\n * every experiment dashboard means by \"95% CI\" on a lift column.\n */\n const ci: [number, number] =\n controlRate > 0\n ? [(diff - z * se) / controlRate, (diff + z * se) / controlRate]\n : [diff - z * se, diff + z * se]\n\n return {\n variant,\n rate,\n isControl: false,\n lift: controlRate > 0 ? diff / controlRate : null,\n ci,\n p,\n thin,\n }\n })\n }, [variants, alpha])\n\n if (rows.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'p-4', className)} {...props}>\n <p className=\"text-muted-foreground text-xs\">{emptyLabel}</p>\n </div>\n )\n }\n\n const underpowered =\n targetSample !== undefined && variants.some((variant) => variant.visitors < targetSample)\n\n const percent = (value: number) => `${(value * 100).toFixed(2)}%`\n const signed = (value: number) => `${value >= 0 ? '+' : ''}${(value * 100).toFixed(1)}%`\n\n return (\n <div\n data-slot=\"ab-test-results\"\n className={cn(enterFade, surface, radius.surface, 'overflow-hidden', className)}\n aria-labelledby={titleId}\n {...props}\n >\n {title && (\n <div className=\"border-border border-b px-4 py-3\">\n <p id={titleId} className=\"text-sm font-medium\">\n {title}\n </p>\n </div>\n )}\n {!title && (\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n )}\n\n <div className=\"overflow-x-auto\">\n {/* The kit's table parts: the six column headings were six copies of\n the same recipe, and the rows now pick up the shared rule and hover\n for free. */}\n <table className=\"w-full text-start text-sm\">\n <TableHeader>\n <TableRow>\n <TableHead>Variant</TableHead>\n <TableHead className=\"text-end\">Visitors</TableHead>\n <TableHead className=\"text-end\">{metricLabel}</TableHead>\n <TableHead className=\"text-end\">Lift</TableHead>\n <TableHead className=\"text-end\">\n {Math.round((1 - alpha) * 100)}% CI on lift\n </TableHead>\n <TableHead className=\"text-end\">p</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => {\n const significant = row.p !== null && !row.thin && row.p < alpha\n return (\n <TableRow key={row.variant.id}>\n <TableCell>\n <span className=\"flex items-center gap-2\">\n {row.variant.name}\n {row.isControl && (\n <span className=\"text-muted-foreground text-[11px]\">control</span>\n )}\n </span>\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {row.variant.visitors.toLocaleString()}\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {percent(row.rate)}\n <span className=\"text-muted-foreground ms-1 text-[11px]\">\n ({row.variant.conversions})\n </span>\n </TableCell>\n <TableCell\n className={cn(\n 'text-end tabular-nums',\n significant && (row.lift ?? 0) > 0 && 'text-[var(--green-soft-foreground)]',\n significant && (row.lift ?? 0) < 0 && 'text-[var(--destructive)]',\n )}\n >\n {row.lift === null ? '—' : signed(row.lift)}\n </TableCell>\n <TableCell className=\"text-muted-foreground text-end text-xs tabular-nums\">\n {row.ci ? `[${signed(row.ci[0])}, ${signed(row.ci[1])}]` : '—'}\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {row.p === null ? (\n '—'\n ) : row.thin ? (\n <span className=\"text-muted-foreground text-xs\">too few</span>\n ) : (\n <span className={cn(significant && 'font-medium')}>\n {row.p < 0.001 ? '<0.001' : row.p.toFixed(3)}\n </span>\n )}\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </table>\n </div>\n\n {/* The warning that matters more than the p-value. */}\n {underpowered && (\n <p\n role=\"status\"\n className=\"border-border text-muted-foreground flex items-start gap-2 border-t px-4 py-2 text-xs\"\n >\n <AlertTriangle aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n <span>\n Below the planned {targetSample?.toLocaleString()} per arm. Stopping at the first\n significant reading inflates the false-positive rate well past {Math.round(alpha * 100)}%\n — treat anything here as provisional.\n </span>\n </p>\n )}\n </div>\n )\n}\n\nexport { AbTestResults }\nexport type { AbTestResultsProps }\n"
|
|
20
|
+
"content": "'use client'\n\nimport { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle } from 'lucide-react'\nimport {\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Experiment variants with lift, a confidence interval, and an honest verdict.\n *\n * **It shows the interval, not just the number.** \"+12% lift\" is not a result;\n * \"+12%, 95% CI [−3%, +27%]\" is, and it says the opposite — that the experiment\n * has not concluded. A dashboard that prints the point estimate alone\n * manufactures certainty, and people ship on it.\n *\n * **The maths.** A two-proportion z-test with a pooled standard error for the\n * p-value, and an unpooled standard error for the interval — the standard pair,\n * and the reason the interval can straddle zero while p sits just under 0.05.\n * The interval is then divided by the control rate so it is on the same scale\n * as the lift beside it; an absolute-difference interval printed next to a\n * relative lift is read as bounding that lift, and it does not.\n * The normal approximation needs roughly ten conversions in each arm, so below\n * that the verdict is withheld rather than computed on data too thin to carry\n * it.\n *\n * **It warns about peeking, because that is the real-world failure.** Checking\n * an experiment repeatedly and stopping at the first significant reading\n * inflates the false-positive rate far above the nominal 5% — a fixed-horizon\n * test read continuously is wrong roughly a third of the time. If a sample-size\n * target is given and has not been reached, this says so plainly.\n *\n * It reports a *statistical* result about one metric. Whether that metric is\n * the one that matters, whether the assignment was actually random, and whether\n * the effect is worth the change are not things any component can tell you.\n */\nexport type Variant = {\n id: string\n name: ReactNode\n /** Users in this arm. */\n visitors: number\n conversions: number\n /** Exactly one variant should be the baseline. */\n control?: boolean\n}\n\ntype AbTestResultsProps = Omit<ComponentProps<'div'>, 'title'> & {\n variants: Variant[]\n title?: ReactNode\n /** Two-sided significance threshold. */\n alpha?: number\n /** Per-arm sample size the test was planned for. Enables the peeking warning. */\n targetSample?: number\n metricLabel?: string\n emptyLabel?: string\n label?: string\n}\n\n/** Normal CDF via Abramowitz & Stegun 7.1.26 — accurate to ~1e-7. */\nfunction normalCdf(z: number): number {\n const sign = z < 0 ? -1 : 1\n const x = Math.abs(z) / Math.SQRT2\n const t = 1 / (1 + 0.3275911 * x)\n const y =\n 1 -\n ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *\n t *\n Math.exp(-x * x)\n return 0.5 * (1 + sign * y)\n}\n\n/** 95% ≈ 1.96. Inverse normal by bisection: exact enough, and tiny. */\nfunction zFor(confidence: number): number {\n let lo = 0\n let hi = 6\n const target = 1 - (1 - confidence) / 2\n for (let i = 0; i < 60; i++) {\n const mid = (lo + hi) / 2\n if (normalCdf(mid) < target) lo = mid\n else hi = mid\n }\n return (lo + hi) / 2\n}\n\nfunction AbTestResults({\n variants,\n title,\n alpha = 0.05,\n targetSample,\n metricLabel = 'Conversion',\n emptyLabel = 'No variants.',\n label = 'Experiment results',\n className,\n ...props\n}: AbTestResultsProps) {\n const titleId = useId()\n\n const rows = useMemo(() => {\n const control = variants.find((variant) => variant.control) ?? variants[0]\n if (!control) return []\n\n const z = zFor(1 - alpha)\n const controlRate = control.visitors > 0 ? control.conversions / control.visitors : 0\n\n return variants.map((variant) => {\n const rate = variant.visitors > 0 ? variant.conversions / variant.visitors : 0\n if (variant.id === control.id) {\n return { variant, rate, isControl: true, lift: null, ci: null, p: null, thin: false }\n }\n\n // The normal approximation needs enough successes and failures in both\n // arms; under that, no verdict is honest.\n const thin =\n Math.min(\n variant.conversions,\n variant.visitors - variant.conversions,\n control.conversions,\n control.visitors - control.conversions,\n ) < 10\n\n const pooled =\n (variant.conversions + control.conversions) / (variant.visitors + control.visitors)\n const pooledSe = Math.sqrt(\n pooled * (1 - pooled) * (1 / variant.visitors + 1 / control.visitors),\n )\n const statistic = pooledSe > 0 ? (rate - controlRate) / pooledSe : 0\n const p = 2 * (1 - normalCdf(Math.abs(statistic)))\n\n // Unpooled for the interval — pooling assumes the null it is testing.\n const se = Math.sqrt(\n (rate * (1 - rate)) / variant.visitors + (controlRate * (1 - controlRate)) / control.visitors,\n )\n const diff = rate - controlRate\n\n /**\n * The interval is put on the same scale as the lift beside it.\n *\n * The z-test gives an interval for the *absolute* difference in rates —\n * 0.75 percentage points. Printing that next to a *relative* lift of\n * +12.5% invites the reader to take it as bounding the lift, which it\n * does not: the two numbers are in different units and disagree by a\n * factor of the control rate. Dividing by the control rate is the\n * first-order (delta method) interval for relative lift, which is what\n * every experiment dashboard means by \"95% CI\" on a lift column.\n */\n const ci: [number, number] =\n controlRate > 0\n ? [(diff - z * se) / controlRate, (diff + z * se) / controlRate]\n : [diff - z * se, diff + z * se]\n\n return {\n variant,\n rate,\n isControl: false,\n lift: controlRate > 0 ? diff / controlRate : null,\n ci,\n p,\n thin,\n }\n })\n }, [variants, alpha])\n\n if (rows.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'p-4', className)} {...props}>\n <p className=\"text-muted-foreground text-xs\">{emptyLabel}</p>\n </div>\n )\n }\n\n const underpowered =\n targetSample !== undefined && variants.some((variant) => variant.visitors < targetSample)\n\n const percent = (value: number) => `${(value * 100).toFixed(2)}%`\n const signed = (value: number) => `${value >= 0 ? '+' : ''}${(value * 100).toFixed(1)}%`\n\n return (\n <div\n data-slot=\"ab-test-results\"\n className={cn(enterFade, surface, radius.surface, 'overflow-hidden', className)}\n aria-labelledby={titleId}\n {...props}\n >\n {title && (\n <div className=\"border-border border-b px-4 py-3\">\n <p id={titleId} className=\"text-sm font-medium\">\n {title}\n </p>\n </div>\n )}\n {!title && (\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n )}\n\n <div className=\"overflow-x-auto\">\n {/* The kit's table parts: the six column headings were six copies of\n the same recipe, and the rows now pick up the shared rule and hover\n for free. */}\n <table className=\"w-full text-start text-sm\">\n <TableHeader>\n <TableRow>\n <TableHead>Variant</TableHead>\n <TableHead className=\"text-end\">Visitors</TableHead>\n <TableHead className=\"text-end\">{metricLabel}</TableHead>\n <TableHead className=\"text-end\">Lift</TableHead>\n <TableHead className=\"text-end\">\n {Math.round((1 - alpha) * 100)}% CI on lift\n </TableHead>\n <TableHead className=\"text-end\">p</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => {\n const significant = row.p !== null && !row.thin && row.p < alpha\n return (\n <TableRow key={row.variant.id}>\n <TableCell>\n <span className=\"flex items-center gap-2\">\n {row.variant.name}\n {row.isControl && (\n <span className=\"text-muted-foreground text-[11px]\">control</span>\n )}\n </span>\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {row.variant.visitors.toLocaleString()}\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {percent(row.rate)}\n <span className=\"text-muted-foreground ms-1 text-[11px]\">\n ({row.variant.conversions})\n </span>\n </TableCell>\n <TableCell\n className={cn(\n 'text-end tabular-nums',\n significant && (row.lift ?? 0) > 0 && 'text-[var(--green-soft-foreground)]',\n significant && (row.lift ?? 0) < 0 && 'text-[var(--destructive)]',\n )}\n >\n {row.lift === null ? '—' : signed(row.lift)}\n </TableCell>\n <TableCell className=\"text-muted-foreground text-end text-xs tabular-nums\">\n {row.ci ? `[${signed(row.ci[0])}, ${signed(row.ci[1])}]` : '—'}\n </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {row.p === null ? (\n '—'\n ) : row.thin ? (\n <span className=\"text-muted-foreground text-xs\">too few</span>\n ) : (\n <span className={cn(significant && 'font-medium')}>\n {row.p < 0.001 ? '<0.001' : row.p.toFixed(3)}\n </span>\n )}\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </table>\n </div>\n\n {/* The warning that matters more than the p-value. */}\n {underpowered && (\n <p\n role=\"status\"\n className=\"border-border text-muted-foreground flex items-start gap-2 border-t px-4 py-2 text-xs\"\n >\n <AlertTriangle aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n <span>\n Below the planned {targetSample?.toLocaleString()} per arm. Stopping at the first\n significant reading inflates the false-positive rate well past {Math.round(alpha * 100)}%\n — treat anything here as provisional.\n </span>\n </p>\n )}\n </div>\n )\n}\n\nexport { AbTestResults }\nexport type { AbTestResultsProps }\n"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"path": "components/ui/accordion.tsx",
|
|
18
18
|
"type": "registry:ui",
|
|
19
|
-
"content": "
|
|
19
|
+
"content": "'use client'\n\nimport {\n createContext,\n use,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n} from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { useCollapsibleHeight } from '@/components/primitives/collapsible'\nimport { focusRing } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A stack of collapsible sections.\n *\n * `type=\"single\"` closes the previous section when another opens; `\"multiple\"`\n * lets any number stand open. Each header is a real `<button>` inside a heading,\n * which is what makes the set navigable by heading in a screen reader.\n */\ntype AccordionContextValue = {\n open: string[]\n toggle: (value: string) => void\n collapsible: boolean\n}\n\nconst AccordionContext = createContext<AccordionContextValue | null>(null)\n\nconst ItemContext = createContext<{\n value: string\n open: boolean\n ids: { trigger: string; content: string }\n} | null>(null)\n\nfunction useAccordion() {\n const context = use(AccordionContext)\n if (!context) throw new Error('Must be used inside <Accordion>')\n return context\n}\n\nfunction useItem() {\n const context = use(ItemContext)\n if (!context) throw new Error('Must be used inside <AccordionItem>')\n return context\n}\n\ntype AccordionProps = Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n type?: 'single' | 'multiple'\n value?: string[]\n defaultValue?: string[]\n onValueChange?: (value: string[]) => void\n /** With `type=\"single\"`, allow closing the open section. */\n collapsible?: boolean\n}\n\nfunction Accordion({\n className,\n type = 'single',\n value: valueProp,\n defaultValue = [],\n onValueChange,\n collapsible = true,\n ...props\n}: AccordionProps) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const open = controlled ? valueProp : uncontrolled\n\n const context = useMemo<AccordionContextValue>(\n () => ({\n open,\n collapsible,\n toggle: (item) => {\n const isOpen = open.includes(item)\n const next =\n type === 'single'\n ? isOpen\n ? collapsible\n ? []\n : open\n : [item]\n : isOpen\n ? open.filter((v) => v !== item)\n : [...open, item]\n\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n },\n }),\n [open, type, collapsible, controlled, onValueChange],\n )\n\n return (\n <AccordionContext value={context}>\n <div\n data-slot=\"accordion\"\n className={cn('border-border divide-border divide-y border-y', className)}\n {...props}\n />\n </AccordionContext>\n )\n}\n\nfunction AccordionItem({\n className,\n value,\n ...props\n}: ComponentProps<'div'> & { value: string }) {\n const { open } = useAccordion()\n const id = useId()\n\n const context = useMemo(\n () => ({\n value,\n open: open.includes(value),\n ids: { trigger: `${id}-trigger`, content: `${id}-content` },\n }),\n [value, open, id],\n )\n\n return (\n <ItemContext value={context}>\n <div\n data-slot=\"accordion-item\"\n data-state={context.open ? 'open' : 'closed'}\n className={className}\n {...props}\n />\n </ItemContext>\n )\n}\n\nfunction AccordionTrigger({\n className,\n children,\n ...props\n}: ComponentProps<'button'>) {\n const { toggle } = useAccordion()\n const { value, open, ids } = useItem()\n\n return (\n // The heading wrapper is what lets screen-reader users jump between\n // sections by heading rather than tabbing through every trigger.\n <h3 className=\"flex\">\n <button\n type=\"button\"\n id={ids.trigger}\n data-slot=\"accordion-trigger\"\n aria-expanded={open}\n aria-controls={ids.content}\n onClick={() => toggle(value)}\n className={cn(\n 'flex flex-1 cursor-pointer items-center justify-between gap-4 py-4 text-start text-sm font-medium',\n 'hover:text-muted-foreground transition-colors duration-150 ease-out motion-reduce:transition-none',\n focusRing,\n className,\n )}\n {...props}\n >\n {children}\n <ChevronDown\n className={cn(\n 'text-muted-foreground size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n />\n </button>\n </h3>\n )\n}\n\nfunction AccordionContent({\n className,\n children,\n ...props\n}: ComponentProps<'div'>) {\n const { open, ids } = useItem()\n const innerRef = useRef<HTMLDivElement>(null)\n const height = useCollapsibleHeight(innerRef, open)\n\n return (\n <div\n id={ids.content}\n role=\"region\"\n aria-labelledby={ids.trigger}\n data-slot=\"accordion-content\"\n style={{ height }}\n className={cn(\n 'overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none',\n className,\n )}\n {...props}\n >\n <div ref={innerRef} className=\"text-muted-foreground pb-4 text-sm\">\n {children}\n </div>\n </div>\n )\n}\n\nexport { Accordion, AccordionContent, AccordionItem, AccordionTrigger }\n"
|
|
20
20
|
}
|
|
21
21
|
]
|
|
22
22
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"path": "components/ui/address-input.tsx",
|
|
18
18
|
"type": "registry:ui",
|
|
19
|
-
"content": "
|
|
19
|
+
"content": "'use client'\n\nimport { useId, type ComponentProps, type ReactNode } from 'react'\nimport { Combobox } from '@/components/ui/combobox'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { enterFade } from '@/lib/motion'\nimport { cn } from '@/lib/utils'\n\n/**\n * A structured postal address whose shape follows the selected country.\n *\n * The country comes first and everything below it re-labels. A US form asks for\n * a State and a ZIP code; a UK one asks for a County and a Postcode and treats\n * the region as optional; Japan puts the postal code above the prefecture.\n * Shipping the US layout to everyone is how forms end up rejecting valid\n * addresses.\n *\n * Nothing is validated against a format table beyond requiredness. Postal code\n * regexes are famously wrong — Irish Eircodes, BFPO addresses, new UK districts\n * — and a form that refuses a real address is worse than one that accepts a\n * typo the courier will query.\n *\n * `autoComplete` tokens are set on every field. The browser can fill a whole\n * address from one tap, and it only does so when the tokens are right.\n */\nexport type AddressValue = {\n country: string\n line1: string\n line2: string\n city: string\n region: string\n postalCode: string\n}\n\ntype CountryRules = {\n regionLabel: string\n postalLabel: string\n regionRequired: boolean\n postalRequired: boolean\n /** Postal code sits above city and region. */\n postalFirst?: boolean\n}\n\nconst DEFAULT_RULES: CountryRules = {\n regionLabel: 'Region',\n postalLabel: 'Postal code',\n regionRequired: false,\n postalRequired: true,\n}\n\nconst RULES: Record<string, CountryRules> = {\n US: { regionLabel: 'State', postalLabel: 'ZIP code', regionRequired: true, postalRequired: true },\n CA: { regionLabel: 'Province', postalLabel: 'Postal code', regionRequired: true, postalRequired: true },\n GB: { regionLabel: 'County', postalLabel: 'Postcode', regionRequired: false, postalRequired: true },\n IE: { regionLabel: 'County', postalLabel: 'Eircode', regionRequired: false, postalRequired: false },\n AU: { regionLabel: 'State', postalLabel: 'Postcode', regionRequired: true, postalRequired: true },\n DE: { regionLabel: 'State', postalLabel: 'Postleitzahl', regionRequired: false, postalRequired: true },\n FR: { regionLabel: 'Region', postalLabel: 'Code postal', regionRequired: false, postalRequired: true },\n NL: { regionLabel: 'Province', postalLabel: 'Postcode', regionRequired: false, postalRequired: true },\n JP: { regionLabel: 'Prefecture', postalLabel: '郵便番号', regionRequired: true, postalRequired: true, postalFirst: true },\n // Hong Kong, the UAE and a handful of others have no postal codes at all.\n HK: { regionLabel: 'District', postalLabel: 'Postal code', regionRequired: false, postalRequired: false },\n AE: { regionLabel: 'Emirate', postalLabel: 'Postal code', regionRequired: true, postalRequired: false },\n}\n\nconst COUNTRIES = [\n 'US', 'CA', 'GB', 'IE', 'AU', 'NZ', 'DE', 'FR', 'ES', 'IT', 'NL', 'BE', 'SE',\n 'NO', 'DK', 'FI', 'PL', 'PT', 'CH', 'AT', 'JP', 'SG', 'HK', 'AE', 'IN', 'BR',\n 'MX', 'ZA',\n]\n\nfunction countryName(code: string) {\n try {\n return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code\n } catch {\n return code\n }\n}\n\nconst EMPTY: AddressValue = {\n country: '',\n line1: '',\n line2: '',\n city: '',\n region: '',\n postalCode: '',\n}\n\nfunction AddressInput({\n value = EMPTY,\n onValueChange,\n countries = COUNTRIES,\n size = 'default',\n disabled,\n countryLabel = 'Country',\n countryPlaceholder = 'Select country…',\n countrySearchPlaceholder = 'Search countries…',\n addressLabel = 'Address',\n addressLine2Label = 'Apartment, suite, etc.',\n cityLabel = 'City',\n optionalLabel = '(optional)',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange' | 'value'> & {\n value?: AddressValue\n onValueChange?: (value: AddressValue) => void\n countries?: string[]\n size?: 'sm' | 'default'\n disabled?: boolean\n countryLabel?: ReactNode\n countryPlaceholder?: string\n countrySearchPlaceholder?: string\n addressLabel?: string\n addressLine2Label?: string\n cityLabel?: string\n /** Suffix marking a non-required field. */\n optionalLabel?: ReactNode\n}) {\n const scope = useId()\n const rules = RULES[value.country] ?? DEFAULT_RULES\n\n const set = (patch: Partial<AddressValue>) => onValueChange?.({ ...value, ...patch })\n\n const field = (\n key: keyof AddressValue,\n label: string,\n autoComplete: string,\n required = false,\n extra?: string,\n ) => (\n <div className={cn('flex flex-col gap-1.5', extra)}>\n <Label htmlFor={`${scope}-${key}`} className=\"text-xs\">\n {label}\n {!required && key !== 'line1' && key !== 'city' && (\n <span className=\"text-muted-foreground font-normal\"> {optionalLabel}</span>\n )}\n </Label>\n <Input\n id={`${scope}-${key}`}\n size={size}\n value={value[key]}\n disabled={disabled}\n required={required}\n // One tap fills the lot — but only when these tokens are right.\n autoComplete={autoComplete}\n onChange={(event) => set({ [key]: event.target.value } as Partial<AddressValue>)}\n />\n </div>\n )\n\n const postal = rules.postalRequired || value.postalCode\n ? field('postalCode', rules.postalLabel, 'postal-code', rules.postalRequired)\n : null\n\n return (\n <div\n data-slot=\"address-input\"\n className={cn(enterFade, 'grid gap-3 sm:grid-cols-2', className)}\n {...props}\n >\n {/* First, because everything below it re-labels. */}\n <div className=\"flex flex-col gap-1.5 sm:col-span-2\">\n <Label htmlFor={`${scope}-country`} className=\"text-xs\">\n {countryLabel}\n </Label>\n <Combobox\n size={size === 'sm' ? 'sm' : 'md'}\n value={value.country}\n disabled={disabled}\n placeholder={countryPlaceholder}\n searchPlaceholder={countrySearchPlaceholder}\n options={countries.map((code) => ({ value: code, label: countryName(code) }))}\n onValueChange={(country) => set({ country })}\n />\n </div>\n\n {field('line1', addressLabel, 'address-line1', true, 'sm:col-span-2')}\n {field('line2', addressLine2Label, 'address-line2', false, 'sm:col-span-2')}\n\n {rules.postalFirst && postal}\n {field('city', cityLabel, 'address-level2', true)}\n {field('region', rules.regionLabel, 'address-level1', rules.regionRequired)}\n {!rules.postalFirst && postal}\n </div>\n )\n}\n\nexport { AddressInput, RULES as addressRules, EMPTY as emptyAddress }\n"
|
|
20
20
|
}
|
|
21
21
|
]
|
|
22
22
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "components/ui/agent-memory.tsx",
|
|
22
22
|
"type": "registry:ui",
|
|
23
|
-
"content": "
|
|
23
|
+
"content": "'use client'\n\nimport { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Pin, Search, Trash2 } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Empty } from '@/components/ui/empty'\nimport { Input } from '@/components/ui/input'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * What an agent has remembered, and where each piece came from.\n *\n * Distinct from `RetrievalResults`, which shows what one query pulled back.\n * This is the durable store — the things that will be injected into *every*\n * future run whether they are relevant or not, which is why it needs to be\n * inspectable and editable by a person.\n *\n * **Provenance is mandatory in the type, not optional.** A memory with no\n * source cannot be audited, and an agent that has silently learned something\n * wrong is exactly the case this screen exists for. `source` is required.\n *\n * Pinned entries sort first and are exempt from eviction — they are the ones a\n * person asserted, as opposed to the ones the agent inferred.\n */\nexport type MemoryEntry = {\n id: string\n content: ReactNode\n /** Where it came from. Required: an unattributable memory cannot be audited. */\n source: string\n /** Already formatted — this component does not own your locale. */\n at?: string\n /** 0–1. How strongly it is weighted at retrieval time. */\n importance?: number\n /** Held permanently, exempt from eviction. */\n pinned?: boolean\n /** Free-form grouping — 'preference', 'fact', 'correction'. */\n kind?: string\n}\n\ntype AgentMemoryProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n entries: MemoryEntry[]\n onPin?: (id: string, pinned: boolean) => void\n onForget?: (id: string) => void\n searchable?: boolean\n searchPlaceholder?: string\n searchLabel?: string\n pinLabel?: string\n unpinLabel?: string\n forgetLabel?: string\n emptyLabel?: string\n emptyHint?: string\n sourceLabel?: (source: string) => ReactNode\n}\n\nfunction AgentMemory({\n entries,\n onPin,\n onForget,\n searchable = true,\n searchPlaceholder = 'Search memory…',\n searchLabel = 'Search memory',\n pinLabel = 'Pin',\n unpinLabel = 'Unpin',\n forgetLabel = 'Forget',\n emptyLabel = 'Nothing remembered yet',\n emptyHint = 'Memories appear here as the agent learns them.',\n sourceLabel = (source) => source,\n className,\n ...props\n}: AgentMemoryProps) {\n const [query, setQuery] = useState('')\n\n const rows = useMemo(() => {\n const needle = query.trim().toLowerCase()\n const matched = needle\n ? entries.filter((entry) =>\n `${typeof entry.content === 'string' ? entry.content : ''} ${entry.source} ${entry.kind ?? ''}`\n .toLowerCase()\n .includes(needle),\n )\n : entries\n // Pinned first: those were asserted by a person, not inferred.\n return [...matched].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)))\n }, [entries, query])\n\n return (\n <div data-slot=\"agent-memory\" className={cn(enterFade, 'flex flex-col gap-3', className)} {...props}>\n {searchable && (\n <Input\n size=\"sm\"\n value={query}\n aria-label={searchLabel}\n placeholder={searchPlaceholder}\n icon={<Search />}\n clearable\n onChange={(event) => setQuery(event.target.value)}\n />\n )}\n\n {rows.length === 0 ? (\n <Empty title={emptyLabel} description={emptyHint} />\n ) : (\n <ul className={cn(surface, radius.surface, 'divide-border list-none divide-y overflow-hidden')}>\n {rows.map((entry) => (\n <li key={entry.id} className=\"flex items-start gap-3 px-4 py-3\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm leading-relaxed\">{entry.content}</p>\n\n <div className=\"text-muted-foreground/70 mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]\">\n <span className=\"font-mono\">{sourceLabel(entry.source)}</span>\n {entry.at && <span className=\"tabular-nums\">{entry.at}</span>}\n {entry.importance !== undefined && (\n <span className=\"tabular-nums\">\n weight {entry.importance.toFixed(2)}\n </span>\n )}\n {entry.kind && (\n <Badge size=\"sm\" variant=\"outline\">\n {entry.kind}\n </Badge>\n )}\n {entry.pinned && (\n <Badge size=\"sm\" color=\"amber\">\n <Pin className=\"size-3\" aria-hidden=\"true\" />\n pinned\n </Badge>\n )}\n </div>\n </div>\n\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {onPin && (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${entry.pinned ? unpinLabel : pinLabel}: ${entry.source}`}\n onClick={() => onPin(entry.id, !entry.pinned)}\n >\n <Pin className={cn(entry.pinned && 'text-[var(--amber-soft-foreground)]')} />\n </Button>\n )}\n {onForget && (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${forgetLabel}: ${entry.source}`}\n onClick={() => onForget(entry.id)}\n >\n <Trash2 />\n </Button>\n )}\n </div>\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { AgentMemory }\nexport type { AgentMemoryProps }\n"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
{
|
|
19
19
|
"path": "components/ui/agent-steps.tsx",
|
|
20
20
|
"type": "registry:ui",
|
|
21
|
-
"content": "
|
|
21
|
+
"content": "'use client'\n\nimport { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, ChevronDown, ChevronRight, Circle, CircleSlash, X } from 'lucide-react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Spinner } from '@/components/ui/spinner'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An agent's plan, with each step's state as it runs.\n *\n * The plan is shown whole from the start, including steps not yet reached.\n * Revealing them one at a time hides how much is left, which is the single\n * thing someone watching an agent most wants to know.\n *\n * Related to `ToolCall`, which shows one invocation with its arguments and\n * result. This is the level above: the sequence those calls belong to.\n */\nexport type AgentStepStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'\n\nexport type AgentStep = {\n id: string\n label: ReactNode\n status: AgentStepStatus\n /** Seconds. */\n duration?: number\n /** Tool calls, output, an error — anything expandable. */\n detail?: ReactNode\n}\n\nconst STATUS = {\n pending: { Icon: Circle, tone: 'text-muted-foreground/40' },\n running: { Icon: null, tone: 'text-[var(--blue-soft-foreground)]' },\n done: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]' },\n failed: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]' },\n skipped: { Icon: CircleSlash, tone: 'text-muted-foreground/60' },\n} as const\n\nfunction AgentSteps({\n steps,\n defaultExpanded = [],\n title,\n className,\n ...props\n}: ComponentProps<'div'> & {\n steps: AgentStep[]\n defaultExpanded?: string[]\n title?: ReactNode\n}) {\n const [expanded, setExpanded] = useState<string[]>(defaultExpanded)\n\n const done = steps.filter((step) => step.status === 'done').length\n const running = steps.some((step) => step.status === 'running')\n\n return (\n <div\n data-slot=\"agent-steps\"\n className={cn(enterFade, 'border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex items-center gap-2 border-b p-3\">\n <span className=\"min-w-0 flex-1 truncate text-xs font-medium\">\n {title ?? 'Plan'}\n </span>\n <span\n className=\"text-muted-foreground shrink-0 text-xs tabular-nums\"\n aria-live=\"polite\"\n >\n {done} / {steps.length}\n {running && ' · running'}\n </span>\n </div>\n\n <ol className=\"divide-border/60 flex list-none flex-col divide-y\">\n {steps.map((step) => {\n const { Icon, tone } = STATUS[step.status]\n const open = expanded.includes(step.id)\n\n return (\n <li key={step.id} data-status={step.status}>\n <div className=\"flex items-center gap-2.5 px-3 py-2\">\n {step.detail ? (\n <button\n type=\"button\"\n aria-expanded={open}\n aria-label={`${open ? 'Hide' : 'Show'} detail`}\n onClick={() =>\n setExpanded((current) =>\n open\n ? current.filter((id) => id !== step.id)\n : [...current, step.id],\n )\n }\n className={cn(\n 'text-muted-foreground hover:text-foreground -m-1 shrink-0 p-1',\n radius.xs,\n interactive,\n focusRing,\n )}\n >\n {open ? (\n <ChevronDown className=\"size-3.5\" />\n ) : (\n <ChevronRight className=\"size-3.5\" />\n )}\n </button>\n ) : (\n <span className=\"w-3.5 shrink-0\" />\n )}\n\n {Icon ? (\n <Icon className={cn('size-4 shrink-0', tone)} aria-hidden=\"true\" />\n ) : (\n <Spinner size=\"xs\" className={cn('shrink-0', tone)} label=\"Running\" />\n )}\n\n <span\n className={cn(\n 'min-w-0 flex-1 text-sm',\n step.status === 'pending' && 'text-muted-foreground',\n step.status === 'skipped' &&\n 'text-muted-foreground/60 line-through',\n )}\n >\n {step.label}\n </span>\n\n {step.duration !== undefined && (\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n <Fmt type=\"duration\" value={step.duration} />\n </span>\n )}\n </div>\n\n {open && step.detail && (\n <div className=\"bg-muted/30 border-border/60 border-t p-3 text-sm\">\n {step.detail}\n </div>\n )}\n </li>\n )\n })}\n </ol>\n </div>\n )\n}\n\nexport { AgentSteps }\n"
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "components/ui/alert-triage.tsx",
|
|
22
22
|
"type": "registry:ui",
|
|
23
|
-
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle, BellOff, Check, ChevronDown, ChevronRight } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Fmt } from '@/components/ui/fmt'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An alert queue, grouped by fingerprint.\n *\n * Grouping is the whole job. A flapping check fires two hundred times and the\n * useful view is \"this one alert, 200 occurrences\" — an ungrouped list buries\n * every other alert underneath it, which is how real pages get missed.\n *\n * Bulk actions report ids and never mutate the list. Acknowledging is a\n * server-side decision, and a queue that optimistically hid rows would show two\n * responders different pictures of the same incident.\n */\nexport type Alert = {\n id: string\n title: ReactNode\n severity: 'critical' | 'warning' | 'info'\n /** Alerts sharing a fingerprint collapse into one row. */\n fingerprint?: string\n count?: number\n source?: string\n firstSeen?: Date\n lastSeen?: Date\n acknowledged?: boolean\n detail?: ReactNode\n}\n\nconst SEVERITY = {\n critical: { label: 'Critical', color: 'destructive', edge: 'bg-[var(--destructive)]' },\n warning: { label: 'Warning', color: 'amber', edge: 'bg-[var(--amber)]' },\n info: { label: 'Info', color: 'blue', edge: 'bg-[var(--blue)]' },\n} as const\n\nconst RANK = { critical: 0, warning: 1, info: 2 } as const\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_SELECT_ALERT_LABEL: (fingerprint: string) => string = (key) => `Select alert ${key}`\nconst DEFAULT_TOGGLE_DETAIL_LABEL: (open: boolean) => string = (open) => `${open ? 'Hide' : 'Show'} alert detail`\nconst DEFAULT_SELECTED_LABEL: (count: number) => ReactNode = (count) => `${count} selected`\nconst DEFAULT_SUMMARY_LABEL: (total: number, critical: number) => ReactNode = (total, critical) => `${total} alerts · ${critical} critical`\n\nfunction AlertTriage({\n alerts,\n onAcknowledge,\n onSilence,\n now,\n selectAllLabel = 'Select all alerts',\n clearSelectionLabel = 'Clear selection',\n selectAlertLabel = DEFAULT_SELECT_ALERT_LABEL,\n toggleDetailLabel = DEFAULT_TOGGLE_DETAIL_LABEL,\n selectedLabel = DEFAULT_SELECTED_LABEL,\n summaryLabel = DEFAULT_SUMMARY_LABEL,\n acknowledgeLabel = 'Acknowledge',\n silenceLabel = 'Silence',\n acknowledgedLabel = 'Acked',\n lastSeenLabel = 'last',\n firstSeenLabel = 'first',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n alerts: Alert[]\n onAcknowledge?: (ids: string[]) => void\n onSilence?: (ids: string[]) => void\n now?: Date\n selectAllLabel?: string\n clearSelectionLabel?: string\n selectAlertLabel?: (fingerprint: string) => string\n toggleDetailLabel?: (open: boolean) => string\n selectedLabel?: (count: number) => ReactNode\n /** Roll-up shown when nothing is selected. */\n summaryLabel?: (total: number, critical: number) => ReactNode\n acknowledgeLabel?: ReactNode\n silenceLabel?: ReactNode\n acknowledgedLabel?: ReactNode\n /** Precedes the last-seen time. */\n lastSeenLabel?: ReactNode\n /** Precedes the first-seen time. */\n firstSeenLabel?: ReactNode\n}) {\n const [selected, setSelected] = useState<string[]>([])\n const [expanded, setExpanded] = useState<string[]>([])\n\n // Group by fingerprint, then sort by severity. Order within a group is the\n // caller's; only the roll-up is computed.\n const groups = useMemo(() => {\n const map = new Map<string, Alert[]>()\n for (const alert of alerts) {\n const key = alert.fingerprint ?? alert.id\n map.set(key, [...(map.get(key) ?? []), alert])\n }\n return [...map.entries()]\n .map(([key, members]) => ({\n key,\n lead: members[0],\n count: members.reduce((sum, alert) => sum + (alert.count ?? 1), 0),\n members,\n }))\n .sort((a, b) => RANK[a.lead.severity] - RANK[b.lead.severity])\n }, [alerts])\n\n const allKeys = groups.map((group) => group.key)\n const allSelected = allKeys.length > 0 && allKeys.every((key) => selected.includes(key))\n\n const idsFor = (keys: string[]) =>\n groups.filter((group) => keys.includes(group.key)).flatMap((group) => group.members.map((m) => m.id))\n\n return (\n <div\n data-slot=\"alert-triage\"\n className={cn(enterFade, surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border-b p-2\">\n <Checkbox\n aria-label={allSelected ? clearSelectionLabel : selectAllLabel}\n checked={allSelected}\n indeterminate={selected.length > 0 && !allSelected}\n onChange={() => setSelected(allSelected ? [] : allKeys)}\n />\n <span className=\"text-muted-foreground text-xs\">\n {selected.length > 0\n ? selectedLabel(selected.length)\n : summaryLabel(\n groups.length,\n groups.filter((g) => g.lead.severity === 'critical').length,\n )}\n </span>\n\n {selected.length > 0 && (\n <div className=\"ms-auto flex flex-wrap gap-1.5\">\n {onAcknowledge && (\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => {\n onAcknowledge(idsFor(selected))\n setSelected([])\n }}\n >\n <Check />\n {acknowledgeLabel}\n </Button>\n )}\n {onSilence && (\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => {\n onSilence(idsFor(selected))\n setSelected([])\n }}\n >\n <BellOff />\n {silenceLabel}\n </Button>\n )}\n </div>\n )}\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {groups.map((group) => {\n const alert = group.lead\n const level = SEVERITY[alert.severity]\n const open = expanded.includes(group.key)\n const isSelected = selected.includes(group.key)\n\n return (\n <li\n key={group.key}\n data-severity={alert.severity}\n className={cn('relative ps-1.5', alert.acknowledged && 'opacity-60')}\n >\n <span\n aria-hidden=\"true\"\n className={cn('absolute inset-y-0 start-0 w-1', level.edge)}\n />\n\n <div className=\"flex items-start gap-2.5 p-3\">\n <Checkbox\n aria-label={selectAlertLabel(group.key)}\n checked={isSelected}\n className=\"mt-0.5\"\n onChange={() =>\n setSelected((current) =>\n isSelected\n ? current.filter((key) => key !== group.key)\n : [...current, group.key],\n )\n }\n />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\" color={level.color}>\n <AlertTriangle />\n {level.label}\n </Badge>\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {alert.title}\n </span>\n {group.count > 1 && (\n <Badge size=\"sm\">×{group.count}</Badge>\n )}\n {alert.acknowledged && (\n <Badge size=\"sm\" color=\"green\">\n <Check />\n {acknowledgedLabel}\n </Badge>\n )}\n </div>\n\n <p className=\"text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 text-xs\">\n {alert.source && <span>{alert.source}</span>}\n {alert.lastSeen && (\n <span>\n {lastSeenLabel} <Fmt type=\"relative\" value={alert.lastSeen} now={now} />\n </span>\n )}\n {alert.firstSeen && (\n <span>\n {firstSeenLabel} <Fmt type=\"relative\" value={alert.firstSeen} now={now} />\n </span>\n )}\n </p>\n </div>\n\n {alert.detail && (\n <button\n type=\"button\"\n aria-expanded={open}\n aria-label={toggleDetailLabel(open)}\n onClick={() =>\n setExpanded((current) =>\n open\n ? current.filter((key) => key !== group.key)\n : [...current, group.key],\n )\n }\n className={cn(\n 'text-muted-foreground hover:text-foreground shrink-0 p-1',\n radius.xs,\n interactive,\n focusRing,\n )}\n >\n {open ? (\n <ChevronDown className=\"size-4\" />\n ) : (\n <ChevronRight className=\"size-4\" />\n )}\n </button>\n )}\n </div>\n\n {open && alert.detail && (\n <div className=\"bg-muted/30 border-border/60 border-t p-3 text-sm\">\n {alert.detail}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { AlertTriage }\n"
|
|
23
|
+
"content": "'use client'\n\nimport { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle, BellOff, Check, ChevronDown, ChevronRight } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Fmt } from '@/components/ui/fmt'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An alert queue, grouped by fingerprint.\n *\n * Grouping is the whole job. A flapping check fires two hundred times and the\n * useful view is \"this one alert, 200 occurrences\" — an ungrouped list buries\n * every other alert underneath it, which is how real pages get missed.\n *\n * Bulk actions report ids and never mutate the list. Acknowledging is a\n * server-side decision, and a queue that optimistically hid rows would show two\n * responders different pictures of the same incident.\n */\nexport type Alert = {\n id: string\n title: ReactNode\n severity: 'critical' | 'warning' | 'info'\n /** Alerts sharing a fingerprint collapse into one row. */\n fingerprint?: string\n count?: number\n source?: string\n firstSeen?: Date\n lastSeen?: Date\n acknowledged?: boolean\n detail?: ReactNode\n}\n\nconst SEVERITY = {\n critical: { label: 'Critical', color: 'destructive', edge: 'bg-[var(--destructive)]' },\n warning: { label: 'Warning', color: 'amber', edge: 'bg-[var(--amber)]' },\n info: { label: 'Info', color: 'blue', edge: 'bg-[var(--blue)]' },\n} as const\n\nconst RANK = { critical: 0, warning: 1, info: 2 } as const\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_SELECT_ALERT_LABEL: (fingerprint: string) => string = (key) => `Select alert ${key}`\nconst DEFAULT_TOGGLE_DETAIL_LABEL: (open: boolean) => string = (open) => `${open ? 'Hide' : 'Show'} alert detail`\nconst DEFAULT_SELECTED_LABEL: (count: number) => ReactNode = (count) => `${count} selected`\nconst DEFAULT_SUMMARY_LABEL: (total: number, critical: number) => ReactNode = (total, critical) => `${total} alerts · ${critical} critical`\n\nfunction AlertTriage({\n alerts,\n onAcknowledge,\n onSilence,\n now,\n selectAllLabel = 'Select all alerts',\n clearSelectionLabel = 'Clear selection',\n selectAlertLabel = DEFAULT_SELECT_ALERT_LABEL,\n toggleDetailLabel = DEFAULT_TOGGLE_DETAIL_LABEL,\n selectedLabel = DEFAULT_SELECTED_LABEL,\n summaryLabel = DEFAULT_SUMMARY_LABEL,\n acknowledgeLabel = 'Acknowledge',\n silenceLabel = 'Silence',\n acknowledgedLabel = 'Acked',\n lastSeenLabel = 'last',\n firstSeenLabel = 'first',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n alerts: Alert[]\n onAcknowledge?: (ids: string[]) => void\n onSilence?: (ids: string[]) => void\n now?: Date\n selectAllLabel?: string\n clearSelectionLabel?: string\n selectAlertLabel?: (fingerprint: string) => string\n toggleDetailLabel?: (open: boolean) => string\n selectedLabel?: (count: number) => ReactNode\n /** Roll-up shown when nothing is selected. */\n summaryLabel?: (total: number, critical: number) => ReactNode\n acknowledgeLabel?: ReactNode\n silenceLabel?: ReactNode\n acknowledgedLabel?: ReactNode\n /** Precedes the last-seen time. */\n lastSeenLabel?: ReactNode\n /** Precedes the first-seen time. */\n firstSeenLabel?: ReactNode\n}) {\n const [selected, setSelected] = useState<string[]>([])\n const [expanded, setExpanded] = useState<string[]>([])\n\n // Group by fingerprint, then sort by severity. Order within a group is the\n // caller's; only the roll-up is computed.\n const groups = useMemo(() => {\n const map = new Map<string, Alert[]>()\n for (const alert of alerts) {\n const key = alert.fingerprint ?? alert.id\n map.set(key, [...(map.get(key) ?? []), alert])\n }\n return [...map.entries()]\n .map(([key, members]) => ({\n key,\n lead: members[0],\n count: members.reduce((sum, alert) => sum + (alert.count ?? 1), 0),\n members,\n }))\n .sort((a, b) => RANK[a.lead.severity] - RANK[b.lead.severity])\n }, [alerts])\n\n const allKeys = groups.map((group) => group.key)\n const allSelected = allKeys.length > 0 && allKeys.every((key) => selected.includes(key))\n\n const idsFor = (keys: string[]) =>\n groups.filter((group) => keys.includes(group.key)).flatMap((group) => group.members.map((m) => m.id))\n\n return (\n <div\n data-slot=\"alert-triage\"\n className={cn(enterFade, surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border-b p-2\">\n <Checkbox\n aria-label={allSelected ? clearSelectionLabel : selectAllLabel}\n checked={allSelected}\n indeterminate={selected.length > 0 && !allSelected}\n onChange={() => setSelected(allSelected ? [] : allKeys)}\n />\n <span className=\"text-muted-foreground text-xs\">\n {selected.length > 0\n ? selectedLabel(selected.length)\n : summaryLabel(\n groups.length,\n groups.filter((g) => g.lead.severity === 'critical').length,\n )}\n </span>\n\n {selected.length > 0 && (\n <div className=\"ms-auto flex flex-wrap gap-1.5\">\n {onAcknowledge && (\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => {\n onAcknowledge(idsFor(selected))\n setSelected([])\n }}\n >\n <Check />\n {acknowledgeLabel}\n </Button>\n )}\n {onSilence && (\n <Button\n size=\"xs\"\n variant=\"secondary\"\n onClick={() => {\n onSilence(idsFor(selected))\n setSelected([])\n }}\n >\n <BellOff />\n {silenceLabel}\n </Button>\n )}\n </div>\n )}\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {groups.map((group) => {\n const alert = group.lead\n const level = SEVERITY[alert.severity]\n const open = expanded.includes(group.key)\n const isSelected = selected.includes(group.key)\n\n return (\n <li\n key={group.key}\n data-severity={alert.severity}\n className={cn('relative ps-1.5', alert.acknowledged && 'opacity-60')}\n >\n <span\n aria-hidden=\"true\"\n className={cn('absolute inset-y-0 start-0 w-1', level.edge)}\n />\n\n <div className=\"flex items-start gap-2.5 p-3\">\n <Checkbox\n aria-label={selectAlertLabel(group.key)}\n checked={isSelected}\n className=\"mt-0.5\"\n onChange={() =>\n setSelected((current) =>\n isSelected\n ? current.filter((key) => key !== group.key)\n : [...current, group.key],\n )\n }\n />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\" color={level.color}>\n <AlertTriangle />\n {level.label}\n </Badge>\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {alert.title}\n </span>\n {group.count > 1 && (\n <Badge size=\"sm\">×{group.count}</Badge>\n )}\n {alert.acknowledged && (\n <Badge size=\"sm\" color=\"green\">\n <Check />\n {acknowledgedLabel}\n </Badge>\n )}\n </div>\n\n <p className=\"text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 text-xs\">\n {alert.source && <span>{alert.source}</span>}\n {alert.lastSeen && (\n <span>\n {lastSeenLabel} <Fmt type=\"relative\" value={alert.lastSeen} now={now} />\n </span>\n )}\n {alert.firstSeen && (\n <span>\n {firstSeenLabel} <Fmt type=\"relative\" value={alert.firstSeen} now={now} />\n </span>\n )}\n </p>\n </div>\n\n {alert.detail && (\n <button\n type=\"button\"\n aria-expanded={open}\n aria-label={toggleDetailLabel(open)}\n onClick={() =>\n setExpanded((current) =>\n open\n ? current.filter((key) => key !== group.key)\n : [...current, group.key],\n )\n }\n className={cn(\n 'text-muted-foreground hover:text-foreground shrink-0 p-1',\n radius.xs,\n interactive,\n focusRing,\n )}\n >\n {open ? (\n <ChevronDown className=\"size-4\" />\n ) : (\n <ChevronRight className=\"size-4\" />\n )}\n </button>\n )}\n </div>\n\n {open && alert.detail && (\n <div className=\"bg-muted/30 border-border/60 border-t p-3 text-sm\">\n {alert.detail}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { AlertTriage }\n"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"path": "components/ui/anomaly-chart.tsx",
|
|
15
15
|
"type": "registry:ui",
|
|
16
|
-
"content": "
|
|
16
|
+
"content": "'use client'\n\nimport { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { enterFade } from '@/lib/motion'\nimport { cn } from '@/lib/utils'\n\n/**\n * A metric plotted against its expected range, with outliers marked.\n *\n * The band is the point. A spike means nothing without knowing what normal\n * looked like at that moment — 400ms at 03:00 may be an incident while 400ms at\n * peak is Tuesday. Detection stays outside this component: it renders the\n * `expected` range you give it and marks whatever falls outside, rather than\n * inventing a threshold of its own.\n *\n * Points are drawn in a second, unstretched layer. The plot is stretched to fit\n * its container, and a circle inside that stretch would render as an ellipse.\n */\nexport type AnomalyPoint = {\n value: number\n /** Expected [low, high] at this moment. */\n expected?: [number, number]\n label?: string\n}\n\nconst PLOT = { width: 100, height: 60 } as const\n\nfunction AnomalyChart({\n points,\n height = 160,\n color = 'var(--blue)',\n bandColor = 'var(--blue)',\n anomalyColor = 'var(--destructive)',\n labels,\n observedLabel = 'Observed',\n expectedLabel = 'Expected range',\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'height'> & {\n points: AnomalyPoint[]\n height?: number\n color?: string\n bandColor?: string\n anomalyColor?: string\n labels?: string[]\n /** Legend entries. */\n observedLabel?: ReactNode\n expectedLabel?: ReactNode\n}) {\n const titleId = useId()\n\n const { min, span } = useMemo(() => {\n const values = points.flatMap((point) => [\n point.value,\n ...(point.expected ?? []),\n ])\n const low = Math.min(...values)\n const high = Math.max(...values)\n // A little headroom, so an anomaly never sits exactly on the edge.\n const pad = (high - low || 1) * 0.1\n return { min: low - pad, span: high - low + pad * 2 || 1 }\n }, [points])\n\n const x = (index: number) =>\n points.length === 1 ? PLOT.width / 2 : (index / (points.length - 1)) * PLOT.width\n const y = (value: number) => PLOT.height - ((value - min) / span) * PLOT.height\n\n const anomalies = points\n .map((point, index) => ({ point, index }))\n .filter(\n ({ point }) =>\n point.expected &&\n (point.value < point.expected[0] || point.value > point.expected[1]),\n )\n\n const banded = points.filter((point) => point.expected)\n const bandPath =\n banded.length === points.length && points.length > 0\n ? [\n ...points.map((point, index) => `${x(index)},${y(point.expected![1])}`),\n ...[...points]\n .reverse()\n .map((point, index) => `${x(points.length - 1 - index)},${y(point.expected![0])}`),\n ].join(' ')\n : undefined\n\n return (\n <figure\n data-slot=\"anomaly-chart\"\n className={cn(enterFade, 'flex min-w-0 flex-col gap-2', className)}\n {...props}\n >\n <div className=\"relative min-w-0\" style={{ height }}>\n <svg\n viewBox={`0 0 ${PLOT.width} ${PLOT.height}`}\n preserveAspectRatio=\"none\"\n role=\"img\"\n aria-labelledby={titleId}\n className=\"size-full\"\n >\n {/* One string: React cannot join multiple children into a <title>. */}\n <title id={titleId}>\n {`Metric against expected range${\n anomalies.length > 0 ? `, ${anomalies.length} outside the band` : ''\n }`}\n </title>\n\n {bandPath && <polygon points={bandPath} fill={bandColor} opacity={0.12} />}\n\n <polyline\n points={points.map((point, index) => `${x(index)},${y(point.value)}`).join(' ')}\n fill=\"none\"\n stroke={color}\n strokeWidth={1.75}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n vectorEffect=\"non-scaling-stroke\"\n />\n </svg>\n\n {/* Unstretched overlay: a circle in the layer above keeps its shape. */}\n <svg\n viewBox={`0 0 ${PLOT.width} ${PLOT.height}`}\n className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n aria-hidden=\"true\"\n >\n {anomalies.map(({ point, index }) => (\n <circle\n key={index}\n cx={x(index)}\n cy={y(point.value)}\n r={2.5}\n fill={anomalyColor}\n stroke=\"var(--background)\"\n strokeWidth={1}\n vectorEffect=\"non-scaling-stroke\"\n />\n ))}\n </svg>\n </div>\n\n {labels && (\n <div className=\"text-muted-foreground/70 flex justify-between text-[10px]\" aria-hidden=\"true\">\n {labels.map((label, index) => (\n <span key={index}>{label}</span>\n ))}\n </div>\n )}\n\n <figcaption className=\"text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs\">\n <span className=\"flex items-center gap-1.5\">\n <span className=\"h-0.5 w-3 rounded-full\" style={{ backgroundColor: color }} />\n {observedLabel}\n </span>\n <span className=\"flex items-center gap-1.5\">\n <span\n className=\"h-2.5 w-3 rounded-sm\"\n style={{ backgroundColor: bandColor, opacity: 0.25 }}\n />\n {expectedLabel}\n </span>\n {anomalies.length > 0 && (\n <span className=\"flex items-center gap-1.5\">\n <span\n className=\"size-2 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: anomalyColor }}\n />\n {anomalies.length} anomal{anomalies.length === 1 ? 'y' : 'ies'}\n </span>\n )}\n </figcaption>\n </figure>\n )\n}\n\nexport { AnomalyChart }\n"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "components/ui/api-keys.tsx",
|
|
22
22
|
"type": "registry:ui",
|
|
23
|
-
"content": "
|
|
23
|
+
"content": "'use client'\n\nimport { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Eye, EyeOff, KeyRound, Trash2 } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * API credentials, listed and revocable.\n *\n * A key is shown in full exactly once — when it is created. After that only the\n * prefix and last four characters are available, because a list that can reveal\n * every key on demand means anyone with a session has every secret. `secret` is\n * therefore optional and expected to be absent for existing keys.\n *\n * Revoking is confirmed inline rather than in a dialog. The confirmation needs\n * to sit beside the key it affects — a modal saying \"revoke this key?\" with the\n * list hidden behind it is how the wrong one gets revoked.\n */\nexport type ApiKey = {\n id: string\n name: string\n /** Shown as `sk_live_…abcd`. */\n prefix: string\n last4: string\n created: Date\n lastUsed?: Date\n scopes?: string[]\n /** Present only for a key just created. */\n secret?: string\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_REVOKE_ACTION_LABEL: (name: string) => string = (name) => `Revoke ${name}`\n\nfunction ApiKeys({\n keys,\n onRevoke,\n now,\n emptyLabel = 'No API keys yet',\n freshLabel = 'Copy it now',\n revealLabel = 'Reveal key',\n hideLabel = 'Hide key',\n copyLabel = 'Copy key',\n revokeLabel = 'Revoke',\n revokeActionLabel = DEFAULT_REVOKE_ACTION_LABEL,\n createdLabel = 'Created',\n lastUsedLabel = 'Last used',\n neverUsedLabel = 'Never used',\n confirmNote = 'Revoking is immediate and cannot be undone.',\n cancelLabel = 'Cancel',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n keys: ApiKey[]\n onRevoke?: (id: string) => void\n now?: Date\n emptyLabel?: ReactNode\n /** Badge on a key whose secret is still visible. */\n freshLabel?: ReactNode\n revealLabel?: string\n hideLabel?: string\n copyLabel?: string\n revokeLabel?: ReactNode\n /** Accessible name for the revoke control, given the key's name. */\n revokeActionLabel?: (name: string) => string\n createdLabel?: ReactNode\n lastUsedLabel?: ReactNode\n neverUsedLabel?: ReactNode\n confirmNote?: ReactNode\n cancelLabel?: ReactNode\n}) {\n const [revealed, setRevealed] = useState<string[]>([])\n const [confirming, setConfirming] = useState<string | null>(null)\n\n return (\n <div\n data-slot=\"api-keys\"\n className={cn(enterFade, surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {keys.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {keys.map((key) => {\n const open = revealed.includes(key.id)\n const display =\n key.secret && open\n ? key.secret\n : `${key.prefix}${'•'.repeat(8)}${key.last4}`\n\n return (\n <li key={key.id} className=\"flex flex-col gap-2 p-3\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <KeyRound className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {key.name}\n </span>\n {key.secret && (\n <Badge size=\"sm\" color=\"amber\">\n {freshLabel}\n </Badge>\n )}\n {key.scopes?.map((scope) => (\n <Badge key={scope} size=\"sm\">\n {scope}\n </Badge>\n ))}\n </div>\n\n <div className=\"flex items-center gap-1\">\n <code className=\"text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs\">\n {display}\n </code>\n\n {/* Only a freshly created key can be revealed at all. */}\n {key.secret && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={open ? hideLabel : revealLabel}\n aria-pressed={open}\n onClick={() =>\n setRevealed((current) =>\n open ? current.filter((id) => id !== key.id) : [...current, key.id],\n )\n }\n >\n {open ? <EyeOff /> : <Eye />}\n </Button>\n )}\n\n {key.secret && <CopyButton value={key.secret} label={copyLabel} />}\n\n {onRevoke && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={revokeActionLabel(String(key.name))}\n className=\"text-[var(--destructive-soft-foreground)]\"\n onClick={() => setConfirming(key.id)}\n >\n <Trash2 />\n </Button>\n )}\n </div>\n\n <p className=\"text-muted-foreground/70 flex flex-wrap gap-x-3 text-xs\">\n <span>\n {createdLabel} <Fmt type=\"relative\" value={key.created} now={now} />\n </span>\n <span>\n {key.lastUsed ? (\n <>\n {lastUsedLabel} <Fmt type=\"relative\" value={key.lastUsed} now={now} />\n </>\n ) : (\n neverUsedLabel\n )}\n </span>\n </p>\n\n {confirming === key.id && (\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border p-2 text-xs\">\n <span className=\"text-muted-foreground min-w-0 flex-1\">\n {confirmNote}\n </span>\n <Button\n size=\"xs\"\n color=\"destructive\"\n onClick={() => {\n onRevoke?.(key.id)\n setConfirming(null)\n }}\n >\n {revokeLabel}\n </Button>\n <Button size=\"xs\" variant=\"secondary\" onClick={() => setConfirming(null)}>\n {cancelLabel}\n </Button>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { ApiKeys }\n"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
{
|
|
19
19
|
"path": "components/ui/asset-grid.tsx",
|
|
20
20
|
"type": "registry:ui",
|
|
21
|
-
"content": "
|
|
21
|
+
"content": "'use client'\n\nimport { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, FileText, Film, Music } from 'lucide-react'\nimport { Image } from '@/components/ui/image'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A media library: images and files as a selectable grid.\n *\n * The picker half of an asset store. Built on this kit's `Image`, so every tile\n * gets the placeholder mask, the reserved box and the failure state for free —\n * a grid of raw `<img>` tags is the fastest way to make a library page jump\n * around while it loads.\n *\n * **A broken asset stays in the grid.** An image whose URL has rotted is\n * exactly the one someone needs to find and replace, and a library that hides\n * failures makes it invisible while its reference is still live in production.\n *\n * Selection is controlled and multi-capable, because the operations people\n * actually perform here — delete these six, move these to another folder — are\n * bulk ones.\n */\nexport type Asset = {\n id: string\n /** Image URL, or a thumbnail for a non-image file. */\n src?: string\n name: string\n /** MIME type. Drives the fallback glyph when there is no thumbnail. */\n type?: string\n /** Bytes. */\n size?: number\n /** Already formatted. */\n modified?: string\n /** Shown on the tile — dimensions, a duration, a tag. */\n meta?: ReactNode\n}\n\nfunction glyphFor(type: string | undefined) {\n if (!type) return FileText\n if (type.startsWith('video/')) return Film\n if (type.startsWith('audio/')) return Music\n return FileText\n}\n\ntype AssetGridProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n assets: Asset[]\n /** Selected ids. Omit both to render a read-only library. */\n value?: string[]\n onValueChange?: (next: string[]) => void\n /** One at a time. */\n single?: boolean\n onOpen?: (asset: Asset) => void\n /** Minimum tile width; the grid fills the rest. */\n minTile?: number\n ratio?: number\n emptyLabel?: string\n format?: (bytes: number) => string\n}\n\nfunction AssetGrid({\n assets,\n value,\n onValueChange,\n single = false,\n onOpen,\n minTile = 150,\n ratio = 1,\n emptyLabel = 'Nothing in this library yet.',\n format = formatBytes,\n className,\n ...props\n}: AssetGridProps) {\n const [failed, setFailed] = useState<Set<string>>(new Set())\n const selectable = value !== undefined && onValueChange !== undefined\n const selected = new Set(value ?? [])\n\n function toggle(id: string) {\n if (!selectable) return\n if (single) return onValueChange!(selected.has(id) ? [] : [id])\n onValueChange!(selected.has(id) ? (value ?? []).filter((item) => item !== id) : [...(value ?? []), id])\n }\n\n if (assets.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'p-6 text-center', className)} {...props}>\n <p className=\"text-muted-foreground text-xs\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"asset-grid\"\n className={cn(enterFade, 'grid list-none gap-3', className)}\n style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${minTile}px, 1fr))` }}\n {...(props as ComponentProps<'ul'>)}\n >\n {assets.map((asset) => {\n const on = selected.has(asset.id)\n const Glyph = glyphFor(asset.type)\n const broken = failed.has(asset.id)\n\n return (\n <li key={asset.id}>\n <button\n type=\"button\"\n aria-pressed={selectable ? on : undefined}\n onClick={() => (selectable ? toggle(asset.id) : onOpen?.(asset))}\n onDoubleClick={() => onOpen?.(asset)}\n className={cn(\n 'group block w-full text-start',\n radius.surface,\n focusRing,\n 'border transition-colors motion-reduce:transition-none',\n on ? 'border-primary ring-ring/40 ring-2' : 'border-border hover:border-foreground/25',\n )}\n >\n <div className={cn('relative overflow-hidden', radius.surface)}>\n {asset.src && !broken ? (\n <Image\n src={asset.src}\n alt={asset.name}\n ratio={ratio}\n mask=\"skeleton\"\n className=\"rounded-none\"\n onStatusChange={(status) => {\n // Kept in the grid, marked. The rotted asset is the one\n // someone came here to find.\n if (status === 'error') {\n setFailed((current) => new Set(current).add(asset.id))\n }\n }}\n />\n ) : (\n <div\n className=\"bg-muted text-muted-foreground flex items-center justify-center\"\n style={{ aspectRatio: ratio }}\n >\n <Glyph className=\"size-6\" aria-hidden=\"true\" />\n </div>\n )}\n\n {selectable && on && (\n <span className=\"bg-primary text-primary-foreground absolute end-2 top-2 flex size-5 items-center justify-center rounded-full\">\n <Check className=\"size-3\" aria-hidden=\"true\" />\n </span>\n )}\n </div>\n\n <div className=\"min-w-0 px-2.5 py-2\">\n <p className=\"truncate text-xs font-medium\">{asset.name}</p>\n <p className=\"text-muted-foreground/60 truncate text-[11px] tabular-nums\">\n {[asset.size !== undefined ? format(asset.size) : null, asset.modified]\n .filter(Boolean)\n .join(' · ')}\n {asset.meta}\n </p>\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { AssetGrid }\nexport type { AssetGridProps }\n"
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
{
|
|
16
16
|
"path": "components/ui/attribution.tsx",
|
|
17
17
|
"type": "registry:ui",
|
|
18
|
-
"content": "
|
|
18
|
+
"content": "'use client'\n\nimport { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { SegmentedControl } from '@/components/ui/segmented-control'\nimport { enterFade, growIn } from '@/lib/motion'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Credit for a conversion, split across the touchpoints that led to it.\n *\n * **The model is a control, not a setting buried in a config.** Last-touch says\n * paid search won; first-touch says the blog post did; linear says everyone\n * helped. These are the *same journeys* — the disagreement is entirely in the\n * rule, and any single number presented without its model is a claim\n * masquerading as a measurement. Switching models in place is the point of the\n * component: a channel whose share collapses when you change the rule was never\n * really driving conversions.\n *\n * **Five models, computed here from the raw journeys:**\n * - *First touch* — all credit to the first interaction.\n * - *Last touch* — all credit to the last. The default in most tools, and the\n * most flattering to bottom-of-funnel spend.\n * - *Linear* — split evenly. No assumption, which is its virtue and its flaw.\n * - *Time decay* — later touches earn more, on a configurable half-life.\n * - *Position based* — 40/20/40 across first, middle and last.\n *\n * **None of them are causal.** Every model here divides observed credit among\n * touchpoints that were present; none establishes that a touchpoint *caused*\n * anything, and none can see the channels that never got a click. Incrementality\n * testing answers that question; this one shows you how sensitive your answer is\n * to an arbitrary rule, which is the honest thing a chart can do.\n */\nexport type Touchpoint = {\n channel: string\n at: Date | string\n}\n\nexport type Journey = {\n id: string\n touchpoints: Touchpoint[]\n /** Revenue or count. Defaults to 1 conversion. */\n value?: number\n}\n\nexport type AttributionModel = 'first' | 'last' | 'linear' | 'decay' | 'position'\n\ntype AttributionProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n journeys: Journey[]\n model?: AttributionModel\n defaultModel?: AttributionModel\n onModelChange?: (model: AttributionModel) => void\n /** Models offered in the switcher. */\n models?: AttributionModel[]\n /** Half-life in days for time decay. */\n halfLife?: number\n valueFormat?: (value: number) => string\n emptyLabel?: string\n label?: string\n footnote?: ReactNode\n}\n\nconst MODEL_LABELS: Record<AttributionModel, string> = {\n first: 'First touch',\n last: 'Last touch',\n linear: 'Linear',\n decay: 'Time decay',\n position: 'Position based',\n}\n\nconst asTime = (value: Date | string) =>\n (value instanceof Date ? value : new Date(value)).getTime()\n\n/** Weights for one journey, summing to 1. */\nfunction weightsFor(journey: Journey, model: AttributionModel, halfLife: number): number[] {\n const points = journey.touchpoints\n const n = points.length\n if (n === 0) return []\n if (n === 1) return [1]\n\n switch (model) {\n case 'first':\n return points.map((_, index) => (index === 0 ? 1 : 0))\n case 'last':\n return points.map((_, index) => (index === n - 1 ? 1 : 0))\n case 'linear':\n return points.map(() => 1 / n)\n case 'position': {\n // 40% first, 40% last, 20% shared by the middle.\n if (n === 2) return [0.5, 0.5]\n const middle = 0.2 / (n - 2)\n return points.map((_, index) => (index === 0 || index === n - 1 ? 0.4 : middle))\n }\n case 'decay': {\n const last = asTime(points[n - 1].at)\n const dayMs = 86_400_000\n const raw = points.map((point) => {\n const daysBefore = (last - asTime(point.at)) / dayMs\n return Math.pow(2, -daysBefore / halfLife)\n })\n const sum = raw.reduce((total, weight) => total + weight, 0) || 1\n return raw.map((weight) => weight / sum)\n }\n }\n}\n\nfunction Attribution({\n journeys,\n model: modelProp,\n defaultModel = 'last',\n onModelChange,\n models = ['first', 'last', 'linear', 'decay', 'position'],\n halfLife = 7,\n valueFormat = (value) => value.toFixed(1),\n emptyLabel = 'No journeys.',\n label = 'Attribution',\n footnote,\n className,\n ...props\n}: AttributionProps) {\n const titleId = useId()\n const [internal, setInternal] = useState<AttributionModel>(defaultModel)\n const model = modelProp ?? internal\n\n const rows = useMemo(() => {\n const credit = new Map<string, number>()\n let total = 0\n\n for (const journey of journeys) {\n const value = journey.value ?? 1\n const weights = weightsFor(journey, model, halfLife)\n journey.touchpoints.forEach((point, index) => {\n credit.set(point.channel, (credit.get(point.channel) ?? 0) + value * (weights[index] ?? 0))\n })\n total += value\n }\n\n return {\n total,\n channels: [...credit.entries()]\n .map(([channel, amount]) => ({ channel, amount }))\n .sort((a, b) => b.amount - a.amount),\n }\n }, [journeys, model, halfLife])\n\n if (journeys.length === 0) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const max = Math.max(...rows.channels.map((row) => row.amount), 1)\n\n const setModel = (next: AttributionModel) => {\n if (modelProp === undefined) setInternal(next)\n onModelChange?.(next)\n }\n\n return (\n <div\n data-slot=\"attribution\"\n className={cn(enterFade, 'flex flex-col gap-3', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <p id={titleId} className=\"text-sm font-medium\">\n {label}\n </p>\n\n {/* The model belongs on the chart, not in a settings panel: the number\n means nothing without it.\n\n The kit's SegmentedControl rather than a row of pills: this picks a\n value from a handful of visible options, which is exactly what that\n component is, and a hand-rolled radiogroup here had none of its\n roving focus or its sliding indicator. */}\n <SegmentedControl\n size=\"sm\"\n label=\"Attribution model\"\n value={model}\n onValueChange={(next) => setModel(next as AttributionModel)}\n options={models.map((option) => ({\n value: option,\n label: MODEL_LABELS[option],\n }))}\n />\n </div>\n\n <ul className=\"flex list-none flex-col gap-2\">\n {rows.channels.map((row, index) => {\n const share = rows.total > 0 ? row.amount / rows.total : 0\n return (\n <li key={row.channel} className=\"flex items-center gap-3\">\n <span className=\"w-28 shrink-0 truncate text-xs\">{row.channel}</span>\n <span className=\"bg-muted relative h-5 min-w-0 flex-1 overflow-hidden rounded-[3px]\">\n <span\n className={cn(growIn, 'absolute inset-y-0 start-0 rounded-[3px]')}\n style={{\n width: `${(row.amount / max) * 100}%`,\n background: dataPalette[index % dataPalette.length].fill,\n opacity: 0.75,\n }}\n />\n </span>\n <span className=\"text-muted-foreground w-24 shrink-0 text-end text-xs tabular-nums\">\n {valueFormat(row.amount)}\n <span className=\"ms-1 opacity-70\">({(share * 100).toFixed(0)}%)</span>\n </span>\n </li>\n )\n })}\n </ul>\n\n <p className=\"text-muted-foreground text-xs\">\n {footnote ?? (\n <>\n {journeys.length.toLocaleString()} journeys under {MODEL_LABELS[model].toLowerCase()}.\n Attribution divides observed credit; it does not establish cause.\n </>\n )}\n </p>\n </div>\n )\n}\n\nexport { Attribution }\nexport type { AttributionProps }\n"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
}
|