astralyx-ui 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/index.d.ts +2 -0
- package/bin/index.js +404 -0
- package/bin/registry.d.ts +37 -0
- package/bin/registry.js +69 -0
- package/package.json +89 -0
- package/registry/index.json +3914 -0
- package/registry/items/accordion.json +22 -0
- package/registry/items/address-input.json +21 -0
- package/registry/items/agent-steps.json +23 -0
- package/registry/items/alert-dialog.json +22 -0
- package/registry/items/alert-triage.json +25 -0
- package/registry/items/alert.json +21 -0
- package/registry/items/anomaly-chart.json +18 -0
- package/registry/items/api-keys.json +25 -0
- package/registry/items/aspect-ratio.json +18 -0
- package/registry/items/attachment-preview.json +23 -0
- package/registry/items/audit-log.json +26 -0
- package/registry/items/avatar.json +21 -0
- package/registry/items/badge.json +22 -0
- package/registry/items/banner.json +22 -0
- package/registry/items/benchmark-table.json +20 -0
- package/registry/items/bet-input.json +23 -0
- package/registry/items/blame-view.json +22 -0
- package/registry/items/branch-select.json +21 -0
- package/registry/items/breadcrumb.json +22 -0
- package/registry/items/bridge-status.json +24 -0
- package/registry/items/build-log.json +23 -0
- package/registry/items/bulk-action-bar.json +21 -0
- package/registry/items/bundle-treemap.json +20 -0
- package/registry/items/button-group.json +21 -0
- package/registry/items/button.json +22 -0
- package/registry/items/cache-stats.json +21 -0
- package/registry/items/calendar.json +25 -0
- package/registry/items/campaign-card.json +22 -0
- package/registry/items/candlestick-chart.json +18 -0
- package/registry/items/card-input.json +22 -0
- package/registry/items/card.json +21 -0
- package/registry/items/carousel.json +22 -0
- package/registry/items/cart.json +23 -0
- package/registry/items/chain-select.json +24 -0
- package/registry/items/changelog-entry.json +21 -0
- package/registry/items/chart.json +19 -0
- package/registry/items/checkbox.json +22 -0
- package/registry/items/checkout-summary.json +20 -0
- package/registry/items/citations.json +22 -0
- package/registry/items/code-block.json +23 -0
- package/registry/items/code-search.json +22 -0
- package/registry/items/cohort-table.json +19 -0
- package/registry/items/collapsible.json +23 -0
- package/registry/items/color-picker.json +24 -0
- package/registry/items/combobox.json +23 -0
- package/registry/items/command.json +23 -0
- package/registry/items/commit-graph.json +20 -0
- package/registry/items/commit-list.json +26 -0
- package/registry/items/composer.json +30 -0
- package/registry/items/confidence-meter.json +18 -0
- package/registry/items/connection-string.json +24 -0
- package/registry/items/container-list.json +24 -0
- package/registry/items/context-menu.json +20 -0
- package/registry/items/context-picker.json +25 -0
- package/registry/items/coordinate-input.json +21 -0
- package/registry/items/copy-button.json +22 -0
- package/registry/items/cost-breakdown.json +20 -0
- package/registry/items/coverage-report.json +22 -0
- package/registry/items/cron-schedule.json +24 -0
- package/registry/items/curl-command.json +18 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/date-picker.json +24 -0
- package/registry/items/date-range-compare.json +24 -0
- package/registry/items/dependency-list.json +23 -0
- package/registry/items/deploy-list.json +27 -0
- package/registry/items/description-list.json +18 -0
- package/registry/items/device-list.json +24 -0
- package/registry/items/dialog.json +24 -0
- package/registry/items/diff-proposal.json +23 -0
- package/registry/items/diff-stat.json +18 -0
- package/registry/items/diff-view.json +23 -0
- package/registry/items/donut.json +19 -0
- package/registry/items/drawer.json +19 -0
- package/registry/items/dropdown-menu.json +24 -0
- package/registry/items/dropzone.json +22 -0
- package/registry/items/empty.json +19 -0
- package/registry/items/endpoint-list.json +24 -0
- package/registry/items/env-diff.json +23 -0
- package/registry/items/env-vars.json +24 -0
- package/registry/items/error-boundary.json +22 -0
- package/registry/items/eval-results.json +21 -0
- package/registry/items/feature-flag.json +23 -0
- package/registry/items/feedback.json +23 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-tree.json +21 -0
- package/registry/items/fingerprint-diff.json +23 -0
- package/registry/items/flame-graph.json +19 -0
- package/registry/items/fmt.json +16 -0
- package/registry/items/form.json +21 -0
- package/registry/items/fraud-verdict.json +24 -0
- package/registry/items/funnel.json +18 -0
- package/registry/items/gas-tracker.json +21 -0
- package/registry/items/gauge.json +18 -0
- package/registry/items/governance-proposal.json +24 -0
- package/registry/items/group.json +21 -0
- package/registry/items/health-checks.json +23 -0
- package/registry/items/heatmap-grid.json +19 -0
- package/registry/items/hover-card.json +20 -0
- package/registry/items/http-status.json +19 -0
- package/registry/items/incident-card.json +24 -0
- package/registry/items/input-file.json +22 -0
- package/registry/items/input-otp.json +19 -0
- package/registry/items/input.json +23 -0
- package/registry/items/invoice-list.json +24 -0
- package/registry/items/ip-cluster.json +22 -0
- package/registry/items/json-viewer.json +21 -0
- package/registry/items/jwt-inspector.json +25 -0
- package/registry/items/kanban.json +20 -0
- package/registry/items/kbd.json +18 -0
- package/registry/items/label-picker.json +24 -0
- package/registry/items/label.json +18 -0
- package/registry/items/leaderboard.json +23 -0
- package/registry/items/ledger-table.json +22 -0
- package/registry/items/lib-date.json +14 -0
- package/registry/items/lib-highlighter.json +16 -0
- package/registry/items/lib-styles.json +14 -0
- package/registry/items/lib-use-clipboard.json +14 -0
- package/registry/items/lib-utils.json +16 -0
- package/registry/items/lighthouse-score.json +19 -0
- package/registry/items/liquidity-position.json +23 -0
- package/registry/items/live-announcer.json +16 -0
- package/registry/items/location-picker.json +26 -0
- package/registry/items/log-viewer.json +24 -0
- package/registry/items/login-form.json +26 -0
- package/registry/items/map-embed.json +22 -0
- package/registry/items/market-table.json +23 -0
- package/registry/items/mask-input.json +19 -0
- package/registry/items/media-gallery.json +25 -0
- package/registry/items/mention-input.json +21 -0
- package/registry/items/menubar.json +21 -0
- package/registry/items/merge-conflict.json +20 -0
- package/registry/items/message.json +25 -0
- package/registry/items/migration-list.json +23 -0
- package/registry/items/mint-panel.json +23 -0
- package/registry/items/model-comparison.json +24 -0
- package/registry/items/model-select.json +20 -0
- package/registry/items/moderation-queue.json +26 -0
- package/registry/items/money-input.json +19 -0
- package/registry/items/multi-select.json +23 -0
- package/registry/items/multiplier-chart.json +18 -0
- package/registry/items/navigation-menu.json +23 -0
- package/registry/items/network-status.json +23 -0
- package/registry/items/nft-card.json +23 -0
- package/registry/items/notification-inbox.json +25 -0
- package/registry/items/number-input.json +21 -0
- package/registry/items/odds-display.json +21 -0
- package/registry/items/on-call-schedule.json +25 -0
- package/registry/items/order-book.json +19 -0
- package/registry/items/page-header.json +18 -0
- package/registry/items/pagination.json +21 -0
- package/registry/items/password-input.json +21 -0
- package/registry/items/payment-method.json +23 -0
- package/registry/items/payout-status.json +24 -0
- package/registry/items/permission-matrix.json +22 -0
- package/registry/items/pipeline.json +23 -0
- package/registry/items/popover.json +22 -0
- package/registry/items/port-table.json +22 -0
- package/registry/items/portfolio-balance.json +22 -0
- package/registry/items/postback-config.json +26 -0
- package/registry/items/presence.json +20 -0
- package/registry/items/price-ticker.json +21 -0
- package/registry/items/pricing-table.json +24 -0
- package/registry/items/primitive-collapsible.json +16 -0
- package/registry/items/primitive-dialog.json +16 -0
- package/registry/items/primitive-dismissable.json +14 -0
- package/registry/items/primitive-field.json +16 -0
- package/registry/items/primitive-focus-trap.json +14 -0
- package/registry/items/primitive-media-query.json +14 -0
- package/registry/items/primitive-popper.json +14 -0
- package/registry/items/primitive-portal.json +14 -0
- package/registry/items/primitive-radio-group.json +16 -0
- package/registry/items/primitive-router.json +14 -0
- package/registry/items/primitive-slot.json +16 -0
- package/registry/items/primitive-tabs.json +16 -0
- package/registry/items/progress.json +21 -0
- package/registry/items/prompt-input.json +25 -0
- package/registry/items/prompt-template.json +23 -0
- package/registry/items/pull-request-card.json +25 -0
- package/registry/items/query-editor.json +25 -0
- package/registry/items/query-plan.json +23 -0
- package/registry/items/queue-monitor.json +22 -0
- package/registry/items/radio-group.json +22 -0
- package/registry/items/range-slider.json +19 -0
- package/registry/items/rate-limit-meter.json +20 -0
- package/registry/items/rating.json +21 -0
- package/registry/items/reasoning-block.json +23 -0
- package/registry/items/regex-tester.json +25 -0
- package/registry/items/release-list.json +22 -0
- package/registry/items/request-builder.json +28 -0
- package/registry/items/resizable.json +22 -0
- package/registry/items/resource-meter.json +19 -0
- package/registry/items/response-viewer.json +26 -0
- package/registry/items/retrieval-results.json +23 -0
- package/registry/items/revenue-share-table.json +20 -0
- package/registry/items/review-thread.json +25 -0
- package/registry/items/risk-score.json +18 -0
- package/registry/items/root-cause-tree.json +20 -0
- package/registry/items/round-history.json +21 -0
- package/registry/items/runbook-steps.json +24 -0
- package/registry/items/schema-table.json +22 -0
- package/registry/items/schema-viewer.json +22 -0
- package/registry/items/scroll-area.json +18 -0
- package/registry/items/seed-phrase.json +22 -0
- package/registry/items/select.json +24 -0
- package/registry/items/separator.json +18 -0
- package/registry/items/service-status.json +21 -0
- package/registry/items/session-list.json +24 -0
- package/registry/items/sheet.json +24 -0
- package/registry/items/shortcut-sheet.json +19 -0
- package/registry/items/sidebar.json +24 -0
- package/registry/items/skeleton.json +21 -0
- package/registry/items/skip-link.json +19 -0
- package/registry/items/slider.json +21 -0
- package/registry/items/slo-budget.json +19 -0
- package/registry/items/sparkline.json +18 -0
- package/registry/items/spinner.json +20 -0
- package/registry/items/stack-trace.json +23 -0
- package/registry/items/staking-panel.json +24 -0
- package/registry/items/stat.json +21 -0
- package/registry/items/status-checks.json +23 -0
- package/registry/items/stepper.json +22 -0
- package/registry/items/streaming-text.json +19 -0
- package/registry/items/subscription-state.json +22 -0
- package/registry/items/suggestions.json +19 -0
- package/registry/items/swap-panel.json +22 -0
- package/registry/items/switch.json +21 -0
- package/registry/items/symbol-outline.json +22 -0
- package/registry/items/table-of-contents.json +19 -0
- package/registry/items/table.json +19 -0
- package/registry/items/tabs.json +22 -0
- package/registry/items/tag-input.json +21 -0
- package/registry/items/terminal.json +20 -0
- package/registry/items/test-results.json +24 -0
- package/registry/items/textarea.json +21 -0
- package/registry/items/theme-toggle.json +20 -0
- package/registry/items/theme.json +18 -0
- package/registry/items/thread-list.json +24 -0
- package/registry/items/ticket-card.json +24 -0
- package/registry/items/time-picker.json +23 -0
- package/registry/items/timeline.json +19 -0
- package/registry/items/timezone-select.json +18 -0
- package/registry/items/toast.json +22 -0
- package/registry/items/toggle.json +21 -0
- package/registry/items/token-amount.json +18 -0
- package/registry/items/token-approvals.json +25 -0
- package/registry/items/token-select.json +25 -0
- package/registry/items/token-usage.json +20 -0
- package/registry/items/tool-call.json +24 -0
- package/registry/items/toolbar.json +19 -0
- package/registry/items/tooltip.json +19 -0
- package/registry/items/topology-map.json +20 -0
- package/registry/items/tour.json +25 -0
- package/registry/items/traffic-split.json +20 -0
- package/registry/items/transaction-list.json +23 -0
- package/registry/items/transaction-status.json +25 -0
- package/registry/items/tree.json +21 -0
- package/registry/items/two-factor-setup.json +25 -0
- package/registry/items/typing-indicator.json +19 -0
- package/registry/items/typography.json +18 -0
- package/registry/items/upload-list.json +24 -0
- package/registry/items/uptime-strip.json +19 -0
- package/registry/items/user-menu.json +24 -0
- package/registry/items/utm-builder.json +24 -0
- package/registry/items/validator-list.json +23 -0
- package/registry/items/video-player.json +23 -0
- package/registry/items/virtual-list.json +18 -0
- package/registry/items/visually-hidden.json +19 -0
- package/registry/items/wallet-address.json +22 -0
- package/registry/items/wallet-connect.json +25 -0
- package/registry/items/webhook-inspector.json +26 -0
- package/registry/items/websocket-frames.json +24 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eval-results",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Eval Results",
|
|
5
|
+
"description": "A model-versus-benchmark score matrix. The best result is marked per benchmark rather than overall — a model can lead on reasoning and trail on latency, and one winner badge hides that.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/eval-results.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Minus, TrendingDown, TrendingUp } from 'lucide-react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A model-versus-benchmark score matrix.\n *\n * Cells are tinted by score, and the best result in each row is marked. The\n * mark is per benchmark rather than overall, because \"which model wins\" is\n * rarely one answer — a model can lead on reasoning and trail on latency, and a\n * single winner badge hides exactly that.\n *\n * A missing score renders as an explicit dash. Blank cells read as zero, which\n * is a very different claim from \"not run\".\n */\nexport type EvalScore = {\n /** 0–1. Undefined means the benchmark was not run. */\n value?: number\n /** Change against the previous run, in points. */\n delta?: number\n}\n\nexport type EvalBenchmark = {\n id: string\n label: ReactNode\n /** Lower is better — latency, cost, error rate. */\n lowerIsBetter?: boolean\n scores: Record<string, EvalScore>\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_FORMAT: (value: number) => string = (value: number) => `${Math.round(value * 100)}`\n\nfunction EvalResults({\n models,\n benchmarks,\n format = DEFAULT_FORMAT,\n benchmarkHeader = 'Benchmark',\n lowerIsBetterLabel = 'lower is better',\n notRunLabel = 'Not run',\n bestLabel = 'best for this benchmark',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n models: string[]\n benchmarks: EvalBenchmark[]\n format?: (value: number) => string\n benchmarkHeader?: ReactNode\n /** Note on a benchmark where a lower score wins. */\n lowerIsBetterLabel?: ReactNode\n /** Accessible name for a benchmark a model was not run on. */\n notRunLabel?: string\n /** Screen-reader note on the winning cell. */\n bestLabel?: ReactNode\n}) {\n const best = useMemo(() => {\n const map = new Map<string, string | undefined>()\n for (const benchmark of benchmarks) {\n let winner: string | undefined\n let bestValue = benchmark.lowerIsBetter ? Infinity : -Infinity\n\n for (const model of models) {\n const score = benchmark.scores[model]?.value\n if (score === undefined) continue\n const better = benchmark.lowerIsBetter ? score < bestValue : score > bestValue\n if (better) {\n bestValue = score\n winner = model\n }\n }\n map.set(benchmark.id, winner)\n }\n return map\n }, [models, benchmarks])\n\n return (\n <div\n data-slot=\"eval-results\"\n className={cn(surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">\n {benchmarkHeader}\n </th>\n {models.map((model) => (\n <th\n key={model}\n className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium whitespace-nowrap\"\n >\n {model}\n </th>\n ))}\n </tr>\n </thead>\n\n <tbody>\n {benchmarks.map((benchmark) => (\n <tr key={benchmark.id} className=\"border-border/60 border-b last:border-b-0\">\n <th className=\"px-3 py-2 text-start text-sm font-medium whitespace-nowrap\">\n {benchmark.label}\n {benchmark.lowerIsBetter && (\n <span className=\"text-muted-foreground/70 ms-1.5 text-xs font-normal\">\n {lowerIsBetterLabel}\n </span>\n )}\n </th>\n\n {models.map((model) => {\n const score = benchmark.scores[model]\n const isBest = best.get(benchmark.id) === model\n\n if (score?.value === undefined) {\n return (\n <td\n key={model}\n className=\"text-muted-foreground/40 px-3 py-2 text-end tabular-nums\"\n >\n {/* Not run — deliberately not blank, which reads as zero. */}\n <Minus className=\"ms-auto size-3.5\" aria-label={notRunLabel} />\n </td>\n )\n }\n\n const Trend =\n score.delta === undefined || score.delta === 0\n ? null\n : score.delta > 0\n ? TrendingUp\n : TrendingDown\n const good =\n score.delta === undefined || score.delta === 0\n ? undefined\n : benchmark.lowerIsBetter\n ? score.delta < 0\n : score.delta > 0\n\n return (\n <td\n key={model}\n style={{\n backgroundColor: `color-mix(in oklab, var(--green), transparent ${\n 100 - Math.round(score.value * 22)\n }%)`,\n }}\n className=\"px-3 py-2 text-end tabular-nums\"\n >\n <span className=\"inline-flex items-center justify-end gap-1.5\">\n <span className={cn(isBest && 'font-semibold')}>\n {format(score.value)}\n </span>\n {isBest && (\n <span className=\"sr-only\">{bestLabel}</span>\n )}\n {Trend && (\n <span\n className={cn(\n 'inline-flex items-center text-xs',\n good ? 'text-[var(--green-soft-foreground)]' : 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n <Trend className=\"size-3\" aria-hidden=\"true\" />\n {Math.abs(score.delta!)}\n </span>\n )}\n </span>\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nexport { EvalResults }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "feature-flag",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Feature Flag",
|
|
5
|
+
"description": "A flag with its rollout percentage and targeting rules. The kill switch is a separate control from the rollout — during an incident, turning it off must be one action, not a slider drag.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"switch"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/feature-flag.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Flag } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Switch } from '@/components/ui/switch'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A feature flag with its rollout percentage and targeting rules.\n *\n * The kill switch and the rollout are separate controls on purpose. Turning a\n * flag off has to be one unambiguous action during an incident — folding it\n * into \"set rollout to 0%\" means the emergency path is a slider drag, and a\n * slider is the wrong instrument when something is on fire.\n *\n * The rollout bar is shown even at 100%, so \"fully rolled out\" and \"off\" are\n * visually distinct states rather than both being an empty control.\n */\nexport type FlagRule = {\n id: string\n label: ReactNode\n /** e.g. \"plan is enterprise\" */\n condition: string\n enabled?: boolean\n}\n\nfunction FeatureFlag({\n name,\n description,\n enabled,\n onEnabledChange,\n rollout,\n onRolloutChange,\n rules,\n environment,\n rolloutLabel = 'Rollout',\n targetingLabel = 'Targeting',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n name: string\n description?: ReactNode\n enabled: boolean\n onEnabledChange?: (enabled: boolean) => void\n /** 0–100. Omit for a flag with no gradual rollout. */\n rollout?: number\n onRolloutChange?: (rollout: number) => void\n rules?: FlagRule[]\n environment?: string\n rolloutLabel?: ReactNode\n targetingLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"feature-flag\"\n data-enabled={enabled || undefined}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-3\">\n <Flag\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n enabled ? 'text-[var(--green-soft-foreground)]' : 'text-muted-foreground/50',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-sm font-medium\">{name}</code>\n {environment && <Badge size=\"sm\">{environment}</Badge>}\n <Badge size=\"sm\" color={enabled ? 'green' : 'neutral'}>\n {enabled ? 'On' : 'Off'}\n </Badge>\n </div>\n {description && (\n <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n )}\n </div>\n\n {/* The kill switch, kept separate from the rollout. */}\n <Switch\n checked={enabled}\n onChange={(event) => onEnabledChange?.(event.target.checked)}\n aria-label={`${name} enabled`}\n className=\"shrink-0\"\n />\n </div>\n\n {rollout !== undefined && (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-muted-foreground text-xs font-medium whitespace-nowrap\">\n {rolloutLabel}\n </span>\n <span\n className={cn(\n 'text-xs tabular-nums whitespace-nowrap',\n enabled ? 'text-foreground' : 'text-muted-foreground/50',\n )}\n >\n {rollout}% of users\n </span>\n </div>\n\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n value={rollout}\n disabled={!enabled || !onRolloutChange}\n aria-label={`${name} rollout percentage`}\n onChange={(event) => onRolloutChange?.(Number(event.target.value))}\n className=\"w-full accent-[var(--primary)] disabled:opacity-40\"\n />\n </div>\n )}\n\n {rules && rules.length > 0 && (\n <div className=\"border-border flex flex-col gap-1.5 border-t pt-3\">\n <span className=\"text-muted-foreground text-xs font-medium\">{targetingLabel}</span>\n <ul className=\"flex list-none flex-col gap-1\">\n {rules.map((rule) => (\n <li\n key={rule.id}\n className={cn(\n 'flex items-center gap-2 text-xs',\n rule.enabled === false && 'opacity-50',\n )}\n >\n <span className=\"bg-secondary rounded-sm px-1.5 py-0.5 font-mono\">\n {rule.condition}\n </span>\n <span className=\"text-muted-foreground min-w-0 flex-1 truncate\">\n {rule.label}\n </span>\n </li>\n ))}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { FeatureFlag }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "feedback",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Feedback",
|
|
5
|
+
"description": "Thumbs up or down, with an optional reason. The rating sends on click and the comment is a separate follow-up — holding the rating hostage to a comment box is how feedback widgets collect nothing.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"textarea"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/feedback.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { ThumbsDown, ThumbsUp } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Thumbs up or down on a response, with an optional reason.\n *\n * The rating is sent the moment it is clicked; the reason is a separate,\n * optional follow-up. Holding the rating hostage to a comment box is how\n * feedback widgets collect almost nothing — the signal you actually get is the\n * click, and asking for prose first loses it.\n *\n * A second click on the same thumb clears the rating, because the most common\n * correction is having hit the wrong one.\n */\nexport type Rating = 'up' | 'down'\n\nfunction Feedback({\n rating: ratingProp,\n onRate,\n onComment,\n reasons,\n prompt = 'Was this helpful?',\n commentLabel = 'Additional feedback',\n commentPlaceholder = 'Anything else? (optional)',\n sendLabel = 'Send',\n thanksLabel = 'Thanks — that helps.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n rating?: Rating | null\n onRate?: (rating: Rating | null) => void\n onComment?: (comment: string, rating: Rating | null) => void\n /** Quick-pick reasons offered after a thumbs down. */\n reasons?: string[]\n prompt?: ReactNode\n /** Accessible name for the free-text field. */\n commentLabel?: string\n commentPlaceholder?: string\n sendLabel?: ReactNode\n /** Confirmation shown once feedback is sent. */\n thanksLabel?: ReactNode\n}) {\n const controlled = ratingProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<Rating | null>(null)\n const rating = controlled ? ratingProp : uncontrolled\n\n const [comment, setComment] = useState('')\n const [sent, setSent] = useState(false)\n\n function rate(next: Rating) {\n const value = rating === next ? null : next\n if (!controlled) setUncontrolled(value)\n onRate?.(value)\n if (value === null) setSent(false)\n }\n\n return (\n <div\n data-slot=\"feedback\"\n className={cn('flex flex-col gap-2', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-muted-foreground text-xs\">{prompt}</span>\n\n <div className=\"flex items-center gap-1\">\n {(['up', 'down'] as const).map((value) => {\n const active = rating === value\n const Icon = value === 'up' ? ThumbsUp : ThumbsDown\n return (\n <button\n key={value}\n type=\"button\"\n aria-pressed={active}\n aria-label={value === 'up' ? 'Helpful' : 'Not helpful'}\n onClick={() => rate(value)}\n className={cn(\n 'inline-flex size-7 items-center justify-center',\n radius.control,\n interactive,\n focusRing,\n active\n ? value === 'up'\n ? 'bg-[color-mix(in_oklab,var(--green),transparent_85%)] text-[var(--green-soft-foreground)]'\n : 'bg-[color-mix(in_oklab,var(--destructive),transparent_85%)] text-[var(--destructive-soft-foreground)]'\n : 'text-muted-foreground hover:bg-accent hover:text-foreground',\n )}\n >\n <Icon className=\"size-3.5\" />\n </button>\n )\n })}\n </div>\n </div>\n\n {rating === 'down' && reasons && reasons.length > 0 && !sent && (\n <div className=\"flex flex-wrap gap-1.5\">\n {reasons.map((reason) => (\n <button\n key={reason}\n type=\"button\"\n onClick={() => {\n onComment?.(reason, rating)\n setSent(true)\n }}\n className={cn(\n 'bg-secondary text-muted-foreground hover:text-foreground h-6 px-2.5 text-xs',\n radius.control,\n interactive,\n focusRing,\n )}\n >\n {reason}\n </button>\n ))}\n </div>\n )}\n\n {rating && onComment && !sent && (\n <div className=\"flex flex-col gap-2\">\n <Textarea\n rows={2}\n autoResize\n aria-label={commentLabel}\n placeholder={commentPlaceholder}\n value={comment}\n onChange={(event) => setComment(event.target.value)}\n />\n <div>\n <Button\n size=\"xs\"\n variant=\"secondary\"\n disabled={!comment.trim()}\n onClick={() => {\n onComment(comment, rating)\n setSent(true)\n }}\n >\n {sendLabel}\n </Button>\n </div>\n </div>\n )}\n\n {sent && (\n <p className=\"text-muted-foreground text-xs\" role=\"status\">\n {thanksLabel}\n </p>\n )}\n </div>\n )\n}\n\nexport { Feedback }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "field",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Field",
|
|
5
|
+
"description": "Wires a label, a control, help text and an error message together — id, aria-describedby and aria-invalid, all of which are easy to get one short of by hand.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"label",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/field.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import {\n createContext,\n use,\n useId,\n useMemo,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Label } from '@/components/ui/label'\nimport { cn } from '@/lib/utils'\n\n/**\n * Wires a label, a control, help text and an error message together.\n *\n * The plumbing is the point: the control gets an id, `aria-describedby`\n * pointing at whichever of description and error is present, and `aria-invalid`\n * when there is an error. Done by hand at every call site, one of those is\n * always missing.\n *\n * Deliberately not tied to a form library — it describes one field's\n * accessibility, and works the same whether state comes from useState, React\n * Hook Form or a server action.\n */\ntype FieldContextValue = {\n ids: { control: string; description: string; error: string }\n invalid: boolean\n hasDescription: boolean\n}\n\nconst FieldContext = createContext<FieldContextValue | null>(null)\n\nfunction useField() {\n const context = use(FieldContext)\n if (!context) throw new Error('Must be used inside <Field>')\n return context\n}\n\n/**\n * Props to spread onto the control. Named rather than injected by cloning, so\n * it works with any control, including ones this kit does not own.\n */\nexport function useFieldControl() {\n const { ids, invalid, hasDescription } = useField()\n\n const described = [\n hasDescription ? ids.description : null,\n invalid ? ids.error : null,\n ].filter(Boolean)\n\n return {\n id: ids.control,\n 'aria-describedby': described.length ? described.join(' ') : undefined,\n 'aria-invalid': invalid || undefined,\n }\n}\n\nfunction Field({\n className,\n error,\n description,\n children,\n ...props\n}: ComponentProps<'div'> & {\n /** Message shown under the control. Its presence marks the field invalid. */\n error?: ReactNode\n description?: ReactNode\n}) {\n const id = useId()\n\n const context = useMemo<FieldContextValue>(\n () => ({\n ids: {\n control: `${id}-control`,\n description: `${id}-description`,\n error: `${id}-error`,\n },\n invalid: Boolean(error),\n hasDescription: Boolean(description),\n }),\n [id, error, description],\n )\n\n return (\n <FieldContext value={context}>\n <div\n data-slot=\"field\"\n data-invalid={error ? true : undefined}\n className={cn('flex flex-col gap-2', className)}\n {...props}\n >\n {children}\n {description && !error && (\n <p\n id={context.ids.description}\n data-slot=\"field-description\"\n className=\"text-muted-foreground text-xs\"\n >\n {description}\n </p>\n )}\n {error && (\n <p\n id={context.ids.error}\n data-slot=\"field-error\"\n className=\"text-destructive text-xs\"\n >\n {error}\n </p>\n )}\n </div>\n </FieldContext>\n )\n}\n\nfunction FieldLabel({ className, ...props }: ComponentProps<typeof Label>) {\n const { ids } = useField()\n\n return (\n <Label\n htmlFor={ids.control}\n data-slot=\"field-label\"\n className={className}\n {...props}\n />\n )\n}\n\nexport { Field, FieldLabel }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "file-tree",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "File Tree",
|
|
5
|
+
"description": "Files and directories as a tree. Uses the ARIA tree pattern with roving focus — arrow keys move between rows, so a repository with 200 files does not cost 200 tab stops.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/file-tree.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport {\n ChevronRight, File, FileCode, FileJson, FileText, Folder, FolderOpen,\n} from 'lucide-react'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file and directory tree.\n *\n * Uses the ARIA tree pattern: one `role=\"tree\"`, nested `role=\"group\"`, and\n * every row a `role=\"treeitem\"` carrying `aria-expanded` and `aria-level`. Only\n * the selected row is in the tab order, and arrow keys move between rows — the\n * same roving pattern Tabs uses, because a tree with 200 files should not cost\n * 200 tab stops.\n */\nexport type FileNode = {\n name: string\n /** Children make it a directory; a leaf is a file. */\n children?: FileNode[]\n /** Extra text on the right — a commit message, a size, a status. */\n meta?: ReactNode\n /** Marks the row, e.g. a modified file. */\n badge?: ReactNode\n defaultOpen?: boolean\n}\n\nconst ICON = 'size-3.5 shrink-0'\n\n/**\n * Pick an icon from the extension, so callers do not have to.\n *\n * Returns an element rather than a component type: assigning a component to a\n * capitalised variable during render creates a new type on every pass, which\n * remounts the node and resets any state inside it.\n */\nfunction iconFor(name: string) {\n const ext = name.split('.').pop()?.toLowerCase() ?? ''\n if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'css'].includes(ext)) return <FileCode className={ICON} />\n if (['json', 'lock'].includes(ext)) return <FileJson className={ICON} />\n if (['md', 'txt', 'mdx'].includes(ext)) return <FileText className={ICON} />\n return <File className={ICON} />\n}\n\ntype FileTreeProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n nodes: FileNode[]\n /** Path of the selected row, joined with \"/\". */\n value?: string\n onSelect?: (path: string) => void\n /** Show the meta column. */\n showMeta?: boolean\n /** Accessible name for the tree. */\n label?: string\n}\n\nfunction FileTree({\n label = 'Files',\n className,\n nodes,\n value,\n onSelect,\n showMeta = true,\n ...props\n}: FileTreeProps) {\n const [selected, setSelected] = useState(value)\n const current = value ?? selected\n\n function select(path: string) {\n setSelected(path)\n onSelect?.(path)\n }\n\n return (\n <div\n role=\"tree\"\n aria-label={label}\n data-slot=\"file-tree\"\n className={cn('text-sm', className)}\n onKeyDown={(event) => {\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return\n const rows = Array.from(\n event.currentTarget.querySelectorAll<HTMLElement>('[role=\"treeitem\"]'),\n )\n if (!rows.length) return\n const at = rows.indexOf(document.activeElement as HTMLElement)\n event.preventDefault()\n const next =\n event.key === 'Home' ? 0\n : event.key === 'End' ? rows.length - 1\n : event.key === 'ArrowDown' ? Math.min(at + 1, rows.length - 1)\n : Math.max(at - 1, 0)\n rows[next]?.focus()\n }}\n {...props}\n >\n {nodes.map((node) => (\n <Node\n key={node.name}\n node={node}\n path={node.name}\n level={1}\n selected={current}\n onSelect={select}\n showMeta={showMeta}\n />\n ))}\n </div>\n )\n}\n\nfunction Node({\n node,\n path,\n level,\n selected,\n onSelect,\n showMeta,\n}: {\n node: FileNode\n path: string\n level: number\n selected?: string\n onSelect: (path: string) => void\n showMeta: boolean\n /** Accessible name for the tree. */\n label?: string\n}) {\n const [open, setOpen] = useState(node.defaultOpen ?? false)\n const isDirectory = Boolean(node.children)\n const isSelected = selected === path\n const icon = isDirectory ? (\n open ? <FolderOpen className={ICON} /> : <Folder className={ICON} />\n ) : (\n iconFor(node.name)\n )\n\n return (\n <div>\n <button\n type=\"button\"\n role=\"treeitem\"\n aria-level={level}\n aria-selected={isSelected}\n aria-expanded={isDirectory ? open : undefined}\n // Roving: only the selected row is reachable by Tab.\n tabIndex={isSelected ? 0 : -1}\n onClick={() => {\n if (isDirectory) setOpen(!open)\n onSelect(path)\n }}\n onKeyDown={(event) => {\n if (!isDirectory) return\n if (event.key === 'ArrowRight' && !open) {\n event.preventDefault()\n setOpen(true)\n } else if (event.key === 'ArrowLeft' && open) {\n event.preventDefault()\n setOpen(false)\n }\n }}\n // Indentation is padding rather than a margin so the hover and selected\n // backgrounds still span the full row.\n style={{ paddingInlineStart: `${level * 12}px` }}\n className={cn(\n 'flex w-full items-center gap-1.5 py-1 pe-2 text-start',\n radius.control,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n isSelected\n ? 'bg-accent text-accent-foreground'\n : 'hover:bg-accent/50 text-muted-foreground hover:text-foreground',\n )}\n >\n <ChevronRight\n className={cn(\n 'size-3.5 shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n isDirectory ? (open ? 'rotate-90' : '') : 'invisible',\n )}\n />\n {icon}\n <span className=\"truncate\">{node.name}</span>\n {node.badge}\n {showMeta && node.meta && (\n <span className=\"text-muted-foreground/70 ms-auto shrink-0 truncate ps-3 text-xs\">\n {node.meta}\n </span>\n )}\n </button>\n\n {isDirectory && open && (\n <div role=\"group\">\n {node.children!.map((child) => (\n <Node\n key={child.name}\n node={child}\n path={`${path}/${child.name}`}\n level={level + 1}\n selected={selected}\n onSelect={onSelect}\n showMeta={showMeta}\n />\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport { FileTree }\nexport type { FileTreeProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fingerprint-diff",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Fingerprint Diff",
|
|
5
|
+
"description": "Two device fingerprints, with only the differing attributes shown. The question is never what a device looks like — it is what changed between two sessions, and twenty-eight matching rows hide the two that matter.",
|
|
6
|
+
"category": "Security",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/fingerprint-diff.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowRight } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Two device fingerprints side by side, with the differing attributes called\n * out.\n *\n * Fingerprints are thirty-odd attributes of which twenty-eight usually match, so\n * matching rows are collapsed by default. The question is never \"what does this\n * device look like\" — it is \"what changed between these two sessions\", and\n * everything identical is noise hiding the two lines that matter.\n *\n * A missing attribute is shown as absent rather than blank. A fingerprint that\n * *stopped reporting* its canvas hash is itself a signal — usually an\n * anti-detect browser — and rendering it as an empty cell loses that entirely.\n */\nexport type FingerprintAttribute = {\n key: string\n label?: ReactNode\n left?: string | number | null\n right?: string | number | null\n /** Weight this attribute carries when it differs. */\n significance?: 'high' | 'normal'\n}\n\nfunction display(value: string | number | null | undefined) {\n if (value === null || value === undefined || value === '') return null\n return String(value)\n}\n\nfunction FingerprintDiff({\n attributes,\n leftLabel = 'Session A',\n rightLabel = 'Session B',\n defaultShowMatching = false,\n identicalMessage = 'Every attribute matches.',\n significanceLabel = 'high significance',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n attributes: FingerprintAttribute[]\n leftLabel?: ReactNode\n rightLabel?: ReactNode\n defaultShowMatching?: boolean\n identicalMessage?: ReactNode\n /** Accessible name for the high-significance marker. */\n significanceLabel?: string\n}) {\n const [showMatching, setShowMatching] = useState(defaultShowMatching)\n\n const rows = useMemo(\n () =>\n attributes.map((attribute) => {\n const left = display(attribute.left)\n const right = display(attribute.right)\n return {\n ...attribute,\n left,\n right,\n differs: left !== right,\n // Stopping to report an attribute is itself a signal.\n absent: left === null || right === null,\n }\n }),\n [attributes],\n )\n\n const changed = rows.filter((row) => row.differs)\n const visible = showMatching ? rows : changed\n\n return (\n <div\n data-slot=\"fingerprint-diff\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"text-xs font-medium\">{leftLabel}</span>\n <ArrowRight className=\"text-muted-foreground size-3.5\" aria-hidden=\"true\" />\n <span className=\"text-xs font-medium\">{rightLabel}</span>\n <Badge size=\"sm\" color={changed.length > 0 ? 'amber' : 'green'} className=\"ms-1\">\n {changed.length === 0\n ? 'identical'\n : `${changed.length} of ${rows.length} differ`}\n </Badge>\n {changed.length < rows.length && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"ms-auto\"\n onClick={() => setShowMatching((current) => !current)}\n >\n {showMatching ? 'Hide matching' : `Show ${rows.length - changed.length} matching`}\n </Button>\n )}\n </div>\n\n <dl className=\"divide-border grid list-none grid-cols-[minmax(0,10rem)_minmax(0,1fr)_minmax(0,1fr)] divide-y\">\n {visible.map((row) => (\n <div key={row.key} className=\"col-span-3 grid grid-cols-subgrid items-baseline px-3 py-2\">\n <dt className=\"text-muted-foreground truncate font-mono text-xs\">\n {row.label ?? row.key}\n {row.differs && row.significance === 'high' && (\n <span className=\"text-[var(--destructive-soft-foreground)]\" aria-label={significanceLabel}>\n {' '}\n !\n </span>\n )}\n </dt>\n <dd\n className={cn(\n 'min-w-0 truncate font-mono text-xs',\n row.differs && 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-1',\n row.left === null && 'text-muted-foreground/60 italic',\n )}\n >\n {row.left ?? 'not reported'}\n </dd>\n <dd\n className={cn(\n 'min-w-0 truncate font-mono text-xs',\n row.differs && 'bg-[color-mix(in_oklab,var(--green)_12%,transparent)] px-1',\n row.right === null && 'text-muted-foreground/60 italic',\n )}\n >\n {row.right ?? 'not reported'}\n </dd>\n </div>\n ))}\n\n {visible.length === 0 && (\n <p className=\"text-muted-foreground col-span-3 px-3 py-4 text-center text-xs\">\n {identicalMessage}\n </p>\n )}\n </dl>\n </div>\n )\n}\n\nexport { FingerprintDiff }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flame-graph",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Flame Graph",
|
|
5
|
+
"description": "Width is time, depth is stack. Frames stay in call order rather than being sorted by cost — reordering them destroys the shape a flame graph exists to show.",
|
|
6
|
+
"category": "Build & Perf",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/flame-graph.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { dataFills, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A flame graph of a profile.\n *\n * Width is time, depth is stack. That is the whole grammar, and it is why the\n * frames are not sorted by cost: their order is the call order, and reordering\n * them would destroy the shape people read a flame graph for.\n *\n * Clicking a frame zooms to it and the ancestors stay as a breadcrumb. A deep\n * profile is unreadable at full width, and losing your place while zooming is\n * what makes most flame graph implementations frustrating.\n *\n * Self time is separated from total. A frame 400ms wide that spends 3ms of it\n * in itself is not the problem; its child is. Total time alone cannot tell you\n * which.\n *\n * Frames narrower than a pixel are dropped rather than drawn as slivers —\n * thousands of sub-pixel rectangles cost real render time and show nothing.\n */\nexport type FlameFrame = {\n name: string\n /** Total time in this frame, including children. */\n value: number\n children?: FlameFrame[]\n}\n\ntype Laid = { frame: FlameFrame; depth: number; start: number; width: number; self: number }\n\n/** Flattens the tree into positioned rows within [0,1] of the zoom root. */\nfunction layout(root: FlameFrame, zoomPath: FlameFrame[]): Laid[] {\n const target = zoomPath[zoomPath.length - 1] ?? root\n const out: Laid[] = []\n\n const walk = (frame: FlameFrame, depth: number, start: number, span: number) => {\n const childTotal = (frame.children ?? []).reduce((sum, c) => sum + c.value, 0)\n out.push({ frame, depth, start, width: span, self: Math.max(0, frame.value - childTotal) })\n\n let cursor = start\n for (const child of frame.children ?? []) {\n const width = frame.value > 0 ? (child.value / frame.value) * span : 0\n // Sub-pixel frames cost render time and show nothing.\n if (width > 0.0015) walk(child, depth + 1, cursor, width)\n cursor += width\n }\n }\n\n walk(target, 0, 0, 1)\n return out\n}\n\nfunction FlameGraph({\n root,\n rowHeight = 20,\n unit = 'ms',\n selfLabel = 'self',\n totalLabel = 'total',\n resetLabel = 'Reset zoom',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n root: FlameFrame\n rowHeight?: number\n unit?: string\n selfLabel?: ReactNode\n totalLabel?: ReactNode\n resetLabel?: ReactNode\n}) {\n const [zoom, setZoom] = useState<FlameFrame[]>([])\n const [hover, setHover] = useState<Laid | null>(null)\n\n const rows = useMemo(() => layout(root, zoom), [root, zoom])\n const depth = rows.reduce((max, row) => Math.max(max, row.depth), 0) + 1\n\n return (\n <div data-slot=\"flame-graph\" className={cn('flex flex-col gap-2', className)} {...props}>\n {/* Ancestors stay visible: losing your place is what makes most flame\n graph implementations frustrating. */}\n {zoom.length > 0 && (\n <nav className=\"text-muted-foreground flex flex-wrap items-center gap-1 text-xs\">\n <button type=\"button\" className=\"hover:text-foreground underline-offset-4 hover:underline\" onClick={() => setZoom([])}>\n {resetLabel}\n </button>\n {zoom.map((frame, index) => (\n <span key={index} className=\"flex items-center gap-1\">\n <span aria-hidden=\"true\">/</span>\n <button\n type=\"button\"\n className=\"hover:text-foreground font-mono underline-offset-4 hover:underline\"\n onClick={() => setZoom(zoom.slice(0, index + 1))}\n >\n {frame.name}\n </button>\n </span>\n ))}\n </nav>\n )}\n\n <div\n className={cn(surface, radius.surface, 'relative w-full overflow-hidden')}\n style={{ height: depth * rowHeight + 2 }}\n onMouseLeave={() => setHover(null)}\n >\n {rows.map((row, index) => (\n <button\n key={`${row.depth}-${row.start}-${index}`}\n type=\"button\"\n title={`${row.frame.name} — ${row.frame.value}${unit}`}\n onClick={() => setZoom([...zoom, row.frame])}\n onMouseEnter={() => setHover(row)}\n className=\"absolute overflow-hidden border-e border-[var(--card)] text-start text-[10px] leading-none whitespace-nowrap\"\n style={{\n insetInlineStart: `${row.start * 100}%`,\n width: `${row.width * 100}%`,\n top: row.depth * rowHeight + 1,\n height: rowHeight - 1,\n background: dataFills[(row.depth + row.frame.name.length) % dataFills.length],\n opacity: hover && hover.frame !== row.frame ? 0.75 : 1,\n paddingInline: 4,\n color: 'var(--background)',\n }}\n >\n {row.width > 0.04 ? row.frame.name : ''}\n </button>\n ))}\n </div>\n\n {/* Self vs total: a 400ms frame with 3ms of self time is not the problem. */}\n <p className=\"text-muted-foreground min-h-4 font-mono text-xs\">\n {hover ? (\n <>\n {hover.frame.name}\n {' '}\n <span className=\"opacity-70\">{totalLabel}</span> {hover.frame.value}\n {unit}\n {' '}\n <span className=\"opacity-70\">{selfLabel}</span> {hover.self.toFixed(1)}\n {unit}\n </>\n ) : null}\n </p>\n </div>\n )\n}\n\nexport { FlameGraph, layout as layoutFlame }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fmt",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Fmt",
|
|
5
|
+
"description": "One component for every formatted value — dates, numbers, currency, bytes, durations, relative times — so the locale is a single decision rather than three developers reaching for toLocaleString, toFixed and padStart on one screen.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/ui/fmt.tsx",
|
|
12
|
+
"type": "registry:ui",
|
|
13
|
+
"content": "import type { ComponentProps } from 'react'\n\n/**\n * One component for every formatted value: dates, numbers, currency, bytes,\n * durations, relative times.\n *\n * Formatting is where dashboards drift. Three developers reach for\n * `toLocaleString`, `toFixed` and a hand-rolled `padStart` on the same screen,\n * and the result is 1,234.5 beside 1234.50 beside 1 234,5. Routing all of it\n * through one component makes the locale a single decision.\n *\n * Numbers, currency and percentages go through `Intl`, which already knows\n * every locale's grouping and symbol placement. Dates support an `Intl` style\n * *and* a token pattern, because \"DD.MM.YYYY\" is a real requirement that\n * `Intl` deliberately cannot express — it formats by locale convention, not by\n * an exact layout someone specified.\n *\n * Note the token casing, which follows the moment/dayjs convention rather than\n * strftime: **MM is the month and mm is the minute**. `HH:MM:SS` would print\n * the month where you wanted minutes, so it is worth reading twice.\n */\nexport type FmtType =\n | 'date'\n | 'number'\n | 'currency'\n | 'percent'\n | 'bytes'\n | 'duration'\n | 'relative'\n\nconst PAD = (value: number, length = 2) => String(value).padStart(length, '0')\n\n/**\n * Token pattern for dates.\n *\n * Ordered longest-first and applied in a single pass. Replacing token by token\n * would corrupt the output — substituting `MMMM` with \"September\" leaves a \"D\"\n * and an \"S\" behind for a later rule to hit.\n */\nconst DATE_TOKENS = /YYYY|YY|MMMM|MMM|MM|M|DDDD|DDD|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|SSS|A|a/g\n\nfunction formatDate(value: Date, pattern: string, locale: string) {\n const month = (style: 'long' | 'short') =>\n new Intl.DateTimeFormat(locale, { month: style }).format(value)\n const weekday = (style: 'long' | 'short') =>\n new Intl.DateTimeFormat(locale, { weekday: style }).format(value)\n\n const hours12 = value.getHours() % 12 || 12\n\n const map: Record<string, string> = {\n YYYY: String(value.getFullYear()),\n YY: PAD(value.getFullYear() % 100),\n MMMM: month('long'),\n MMM: month('short'),\n MM: PAD(value.getMonth() + 1),\n M: String(value.getMonth() + 1),\n DDDD: weekday('long'),\n DDD: weekday('short'),\n DD: PAD(value.getDate()),\n D: String(value.getDate()),\n dddd: weekday('long'),\n ddd: weekday('short'),\n HH: PAD(value.getHours()),\n H: String(value.getHours()),\n hh: PAD(hours12),\n h: String(hours12),\n mm: PAD(value.getMinutes()),\n m: String(value.getMinutes()),\n ss: PAD(value.getSeconds()),\n s: String(value.getSeconds()),\n SSS: PAD(value.getMilliseconds(), 3),\n A: value.getHours() < 12 ? 'AM' : 'PM',\n a: value.getHours() < 12 ? 'am' : 'pm',\n }\n\n return pattern.replace(DATE_TOKENS, (token) => map[token] ?? token)\n}\n\n/** Binary-prefixed size. `1000` is a kilobyte to marketing, `1024` to a disk. */\nfunction formatBytes(bytes: number, decimals: number | undefined, locale: string) {\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']\n let value = Math.abs(bytes)\n let unit = 0\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024\n unit++\n }\n const places = decimals ?? (unit === 0 ? 0 : value < 10 ? 1 : 0)\n const text = new Intl.NumberFormat(locale, {\n minimumFractionDigits: places,\n maximumFractionDigits: places,\n }).format(bytes < 0 ? -value : value)\n return `${text} ${units[unit]}`\n}\n\n/** Seconds as `1h 04m 12s`, dropping leading units that are zero. */\nfunction formatDuration(totalSeconds: number) {\n const sign = totalSeconds < 0 ? '-' : ''\n const seconds = Math.floor(Math.abs(totalSeconds))\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n\n if (h) return `${sign}${h}h ${PAD(m)}m ${PAD(s)}s`\n if (m) return `${sign}${m}m ${PAD(s)}s`\n return `${sign}${s}s`\n}\n\nconst RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [\n ['second', 60],\n ['minute', 60],\n ['hour', 24],\n ['day', 7],\n ['week', 4.34524],\n ['month', 12],\n ['year', Infinity],\n]\n\n/** Largest unit that keeps the number under its own threshold. */\nfunction formatRelative(value: Date, locale: string, now: Date) {\n let delta = (value.getTime() - now.getTime()) / 1000\n const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })\n\n for (const [unit, size] of RELATIVE_STEPS) {\n if (Math.abs(delta) < size) {\n return formatter.format(Math.round(delta), unit)\n }\n delta /= size\n }\n return formatter.format(Math.round(delta), 'year')\n}\n\ntype FmtProps = Omit<ComponentProps<'span'>, 'children'> & {\n type: FmtType\n value: number | string | Date\n /**\n * Date: a token pattern (`'DD.MM.YYYY HH:mm'`) or an `Intl` style\n * (`'short' | 'medium' | 'long' | 'full'`).\n * Currency: `'symbol' | 'code' | 'name'`.\n */\n format?: string\n /** ISO 4217 code. Required for `type=\"currency\"`. */\n currency?: string\n /** Fraction digits. Defaults per type. */\n decimals?: number\n locale?: string\n /** Thousands separators. Numbers and currency only. */\n grouping?: boolean\n /** Reference point for `type=\"relative\"`. Defaults to now. */\n now?: Date\n /** Line up in a column. On by default for every numeric type. */\n tabular?: boolean\n}\n\nconst DATE_STYLES = new Set(['short', 'medium', 'long', 'full'])\n\nfunction Fmt({\n type,\n value,\n format,\n currency,\n decimals,\n locale = 'en-GB',\n grouping = true,\n now,\n tabular,\n className,\n ...props\n}: FmtProps) {\n // Every type except a formatted date is a number in a column.\n const numeric = type !== 'date'\n const date =\n value instanceof Date ? value : type === 'date' || type === 'relative' ? new Date(value) : undefined\n\n let text: string\n let dateTime: string | undefined\n\n switch (type) {\n case 'date': {\n if (!date || Number.isNaN(date.getTime())) {\n text = String(value)\n break\n }\n dateTime = date.toISOString()\n text =\n format && !DATE_STYLES.has(format)\n ? formatDate(date, format, locale)\n : new Intl.DateTimeFormat(locale, {\n dateStyle: (format as Intl.DateTimeFormatOptions['dateStyle']) ?? 'medium',\n }).format(date)\n break\n }\n\n case 'relative': {\n if (!date || Number.isNaN(date.getTime())) {\n text = String(value)\n break\n }\n dateTime = date.toISOString()\n text = formatRelative(date, locale, now ?? new Date())\n break\n }\n\n case 'currency': {\n text = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency: currency ?? 'USD',\n currencyDisplay: (format as Intl.NumberFormatOptions['currencyDisplay']) ?? 'symbol',\n useGrouping: grouping,\n ...(decimals !== undefined && {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }),\n }).format(Number(value))\n break\n }\n\n case 'percent': {\n text = new Intl.NumberFormat(locale, {\n style: 'percent',\n useGrouping: grouping,\n minimumFractionDigits: decimals ?? 0,\n maximumFractionDigits: decimals ?? 0,\n }).format(Number(value))\n break\n }\n\n case 'bytes':\n text = formatBytes(Number(value), decimals, locale)\n break\n\n case 'duration':\n text = formatDuration(Number(value))\n break\n\n default: {\n text = new Intl.NumberFormat(locale, {\n useGrouping: grouping,\n ...(decimals !== undefined && {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }),\n }).format(Number(value))\n }\n }\n\n const classes = [(tabular ?? numeric) && 'tabular-nums', className]\n .filter(Boolean)\n .join(' ')\n\n // A real <time> for anything on a clock, so the machine-readable value\n // travels with the display one.\n if (dateTime) {\n return (\n <time\n dateTime={dateTime}\n data-slot=\"fmt\"\n className={classes || undefined}\n // The prop type is span-shaped; `ref` is the only member that differs,\n // and this component never forwards one.\n {...(props as ComponentProps<'time'>)}\n >\n {text}\n </time>\n )\n }\n\n return (\n <span data-slot=\"fmt\" className={classes || undefined} {...props}>\n {text}\n </span>\n )\n}\n\nexport { Fmt, Fmt as FMT, formatBytes, formatDate, formatDuration, formatRelative }\nexport type { FmtProps }\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "form",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Form",
|
|
5
|
+
"description": "A thin bridge between React Hook Form and Field. Field owns the accessibility wiring and knows nothing about any form library; this adds only state and validation.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react-hook-form@^7.87.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"field",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/form.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport {\n Controller,\n FormProvider,\n useFormContext,\n type ControllerRenderProps,\n type FieldPath,\n type FieldValues,\n type UseFormReturn,\n} from 'react-hook-form'\nimport { Field, FieldLabel, useFieldControl } from '@/components/ui/field'\nimport { cn } from '@/lib/utils'\n\n/**\n * A thin bridge between React Hook Form and this kit's `Field`.\n *\n * The split is deliberate: `Field` owns the accessibility wiring — id,\n * `aria-describedby`, `aria-invalid` — and knows nothing about any form\n * library, so it keeps working with `useState`, a server action, or nothing at\n * all. This file adds only state and validation on top.\n *\n * React Hook Form was chosen on the numbers: 241M downloads a month against\n * 10.6M for the nearest actively maintained alternative, zero runtime\n * dependencies, and 26 releases in 2026. Formik has more downloads than\n * TanStack Form but shipped nothing at all in 2026.\n */\nfunction Form<\n TFieldValues extends FieldValues,\n TContext = unknown,\n // A resolver can transform values on the way out (a Zod schema with\n // coercions, say), so the submitted type is not always the field type.\n // Mirroring all three of React Hook Form's parameters keeps inference working\n // instead of collapsing to `FieldValues`.\n TTransformed extends FieldValues = TFieldValues,\n>({\n form,\n onSubmit,\n className,\n children,\n ...props\n}: Omit<ComponentProps<'form'>, 'onSubmit'> & {\n form: UseFormReturn<TFieldValues, TContext, TTransformed>\n onSubmit: (values: TTransformed) => void | Promise<void>\n}) {\n return (\n <FormProvider {...form}>\n <form\n data-slot=\"form\"\n // `noValidate` hands validation to the resolver: the browser's own\n // bubbles cannot be styled and would fire before the schema runs.\n noValidate\n onSubmit={form.handleSubmit(onSubmit)}\n className={cn('space-y-5', className)}\n {...props}\n >\n {children}\n </form>\n </FormProvider>\n )\n}\n\n/**\n * One controlled field, wired to both the form and the accessibility plumbing.\n *\n * `children` is a render prop rather than a cloned element: half the controls\n * in this kit take `value`/`onValueChange` and half take `checked`/`onChange`,\n * so mapping is the caller's job and guessing would be wrong half the time.\n */\nfunction FormField<\n T extends FieldValues,\n N extends FieldPath<T> = FieldPath<T>,\n>({\n name,\n label,\n description,\n required,\n className,\n children,\n}: {\n name: N\n label?: ReactNode\n description?: ReactNode\n required?: boolean\n className?: string\n children: (\n field: ControllerRenderProps<T, N> & ReturnType<typeof useFieldControl>,\n ) => ReactNode\n}) {\n const form = useFormContext<T>()\n const error = form.formState.errors[name]\n\n return (\n <Field\n className={className}\n description={description}\n // Passing the message here is what makes Field flip to invalid; there is\n // no second flag to keep in step with it.\n error={error?.message as ReactNode}\n >\n {label && <FieldLabel required={required}>{label}</FieldLabel>}\n <Controller\n name={name}\n control={form.control}\n render={({ field }) => <FieldControl field={field}>{children}</FieldControl>}\n />\n </Field>\n )\n}\n\n/** Merges the Field's a11y props into the controller's field props. */\nfunction FieldControl<T extends FieldValues, N extends FieldPath<T>>({\n field,\n children,\n}: {\n field: ControllerRenderProps<T, N>\n children: (\n field: ControllerRenderProps<T, N> & ReturnType<typeof useFieldControl>,\n ) => ReactNode\n}) {\n return children({ ...field, ...useFieldControl() })\n}\n\nexport { Form, FormField }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fraud-verdict",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Fraud Verdict",
|
|
5
|
+
"description": "The decision at the end of a review: what was decided, on what basis, by whom. A settled verdict offers to reopen rather than accept a second one, because two conflicting verdicts on one case is the failure this shape prevents.",
|
|
6
|
+
"category": "Security",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/fraud-verdict.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { CircleCheck, CircleSlash, ShieldAlert, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface, tintStyle } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The decision panel at the end of a review: what was decided, on what basis,\n * by whom.\n *\n * Automated and human verdicts are visually distinct. An analyst overriding a\n * model needs to see at a glance whether the thing they are disagreeing with was\n * a rule, a model or a colleague, and \"flagged by system\" versus \"declined by\n * [an analyst]\" are different weights of evidence.\n *\n * A pending verdict shows its actions; a settled one shows who settled it and\n * when, and offers to reopen rather than silently accepting a second decision.\n * Two conflicting verdicts on one case is the failure mode this shape prevents.\n *\n * Reasons are listed rather than summarised into a sentence. They end up quoted\n * in appeals and chargeback responses, and a paraphrase is not quotable.\n */\nexport type Verdict = 'approved' | 'declined' | 'review' | 'pending'\n\nconst VERDICT = {\n approved: { label: 'Approved', color: 'var(--green)', icon: CircleCheck },\n declined: { label: 'Declined', color: 'var(--destructive)', icon: CircleSlash },\n review: { label: 'Manual review', color: 'var(--amber)', icon: TriangleAlert },\n pending: { label: 'Awaiting decision', color: 'var(--muted-foreground)', icon: ShieldAlert },\n} as const\n\nfunction FraudVerdict({\n verdict,\n subject,\n reasons,\n decidedBy,\n decidedAt,\n automated = false,\n actions,\n onReopen,\n reopenLabel = 'Reopen',\n className,\n ...props\n}: Omit<ComponentProps<'section'>, 'children'> & {\n verdict: Verdict\n /** What was decided on — a withdrawal, an account, a transaction. */\n subject?: ReactNode\n reasons?: ReactNode[]\n decidedBy?: ReactNode\n decidedAt?: Date | string\n /** A rule or model decided this, rather than a person. */\n automated?: boolean\n /** Shown while pending. */\n actions?: ReactNode\n onReopen?: () => void\n reopenLabel?: ReactNode\n}) {\n const meta = VERDICT[verdict]\n const Icon = meta.icon\n const settled = verdict !== 'pending'\n\n return (\n <section\n data-slot=\"fraud-verdict\"\n data-verdict={verdict}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <header className=\"flex items-start gap-3\">\n <span\n className={cn('flex size-9 shrink-0 items-center justify-center', radius.control)}\n style={tintStyle(meta.color)}\n >\n <Icon className=\"size-4.5 text-[var(--ui-soft-fg)]\" aria-hidden=\"true\" />\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <h3 className=\"flex flex-wrap items-center gap-2 text-sm font-medium\">\n {meta.label}\n {/* A rule, a model, or a colleague — different weights of evidence. */}\n {settled && (\n <Badge size=\"sm\" color={automated ? 'blue' : 'neutral'}>\n {automated ? 'automated' : 'manual'}\n </Badge>\n )}\n </h3>\n {subject && <p className=\"text-muted-foreground truncate text-xs\">{subject}</p>}\n </div>\n </header>\n\n {reasons && reasons.length > 0 && (\n <ul className=\"flex list-none flex-col gap-1.5\">\n {reasons.map((reason, index) => (\n <li key={index} className=\"flex items-start gap-2 text-xs\">\n <span\n aria-hidden=\"true\"\n className=\"mt-1.5 size-1 shrink-0 rounded-full\"\n style={{ background: meta.color }}\n />\n <span className=\"min-w-0 flex-1\">{reason}</span>\n </li>\n ))}\n </ul>\n )}\n\n <footer className=\"border-border flex flex-wrap items-center gap-2 border-t pt-3\">\n {settled ? (\n <>\n <span className=\"text-muted-foreground text-xs\">\n {decidedBy && <>by {decidedBy}</>}\n {decidedBy && decidedAt && ' · '}\n {decidedAt && <Fmt type=\"relative\" value={decidedAt} />}\n </span>\n {/* Reopen rather than allow a second, conflicting verdict. */}\n {onReopen && (\n <Button variant=\"ghost\" size=\"xs\" className=\"ms-auto\" onClick={onReopen}>\n {reopenLabel}\n </Button>\n )}\n </>\n ) : (\n <div className=\"flex flex-wrap gap-2\">{actions}</div>\n )}\n </footer>\n </section>\n )\n}\n\nexport { FraudVerdict }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "funnel",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Funnel",
|
|
5
|
+
"description": "Conversion through ordered stages, with both the overall rate and the step rate. A funnel showing only the overall rate hides which single step is losing people.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/funnel.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * Conversion through ordered stages.\n *\n * Two rates per stage, and the distinction matters more than the picture:\n * *overall* is against the first stage, *step* is against the one immediately\n * before. A funnel showing only the overall rate hides which single step is\n * losing people, which is the entire question a funnel is built to answer.\n *\n * Bar width is proportional to the count rather than to the step rate, so the\n * shape reflects volume. A stage keeping 90% of a tiny cohort should not look\n * wider than one keeping 40% of everyone.\n */\nexport type FunnelStage = {\n label: ReactNode\n value: number\n hint?: ReactNode\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_FORMAT: (value: number) => string = (value: number) => value.toLocaleString('en-GB')\n\nfunction Funnel({\n stages,\n color = 'var(--blue)',\n showStepRate = true,\n format = DEFAULT_FORMAT,\n className,\n ...props\n}: Omit<ComponentProps<'ol'>, 'children'> & {\n stages: FunnelStage[]\n color?: string\n showStepRate?: boolean\n format?: (value: number) => string\n}) {\n const first = stages[0]?.value || 1\n\n return (\n <ol\n data-slot=\"funnel\"\n className={cn('flex list-none flex-col gap-2', className)}\n {...props}\n >\n {stages.map((stage, index) => {\n const overall = stage.value / first\n const previous = index === 0 ? undefined : stages[index - 1].value\n const step = previous ? stage.value / previous : 1\n const dropped = previous ? previous - stage.value : 0\n\n return (\n <li key={index} className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5\">\n <span className=\"text-sm font-medium\">{stage.label}</span>\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {format(stage.value)}\n <span className=\"ms-2\">{Math.round(overall * 100)}%</span>\n </span>\n </div>\n\n <div className=\"bg-secondary h-7 w-full overflow-hidden rounded-md\">\n <div\n className=\"h-full rounded-md transition-[width] duration-300 ease-out motion-reduce:transition-none\"\n style={{\n // Width tracks volume, not the step rate.\n width: `${Math.max(overall * 100, 1)}%`,\n backgroundColor: color,\n opacity: 1 - index * 0.12,\n }}\n />\n </div>\n\n {showStepRate && index > 0 && (\n <p className=\"text-muted-foreground/80 text-xs\">\n {Math.round(step * 100)}% from previous\n {dropped > 0 && ` · ${format(dropped)} dropped`}\n </p>\n )}\n {stage.hint && (\n <p className=\"text-muted-foreground/70 text-xs\">{stage.hint}</p>\n )}\n </li>\n )\n })}\n </ol>\n )\n}\n\nexport { Funnel }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gas-tracker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Gas Tracker",
|
|
5
|
+
"description": "Gas tiers with what each actually costs. Gwei alone is not a decision — the same gas price costs four times more for a swap than a transfer, which is why gasLimit is a prop.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/gas-tracker.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Fuel } from 'lucide-react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Gas price tiers, with what each actually costs.\n *\n * Gwei alone is not a decision. \"32 gwei\" means nothing without the fiat cost\n * of the transaction you are about to send, which is why every tier carries\n * both — and why `gasLimit` is a prop: the same gas price costs four times more\n * for a swap than for a transfer.\n *\n * Tiers are shown with their expected wait, because the trade being made is\n * time against money and a price with no time attached only shows one side.\n */\nexport type GasTier = {\n id: string\n label: ReactNode\n /** Gwei. */\n price: number\n /** Human wait, e.g. \"~15s\". */\n wait?: string\n recommended?: boolean\n}\n\nfunction GasTracker({\n tiers,\n selected,\n onSelect,\n gasLimit = 21000,\n nativePrice,\n nativeSymbol = 'ETH',\n currency = 'USD',\n locale = 'en-GB',\n baseFee,\n title = 'Gas',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n tiers: GasTier[]\n selected?: string\n onSelect?: (id: string) => void\n /** Units of gas for the transaction being priced. 21000 is a bare transfer. */\n gasLimit?: number\n /** Fiat price of the native token, for the cost estimate. */\n nativePrice?: number\n nativeSymbol?: string\n currency?: string\n locale?: string\n baseFee?: number\n title?: ReactNode\n}) {\n const money = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n maximumFractionDigits: 2,\n })\n\n // gwei × gas units = gwei total; ÷ 1e9 = native token.\n const nativeCost = (gwei: number) => (gwei * gasLimit) / 1e9\n\n return (\n <div\n data-slot=\"gas-tracker\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex items-center gap-2 border-b p-3\">\n <Fuel className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{title}</span>\n {baseFee !== undefined && (\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n base {baseFee.toFixed(1)} gwei\n </span>\n )}\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {tiers.map((tier) => {\n const chosen = selected === tier.id\n const native = nativeCost(tier.price)\n\n const row = (\n <>\n <span className=\"min-w-0 flex-1 text-start\">\n <span className=\"block text-sm font-medium\">{tier.label}</span>\n {tier.wait && (\n <span className=\"text-muted-foreground block text-xs\">\n {tier.wait}\n </span>\n )}\n </span>\n\n <span className=\"shrink-0 text-end\">\n <span className=\"block text-sm tabular-nums\">\n {tier.price.toFixed(1)} gwei\n </span>\n <span className=\"text-muted-foreground block text-xs tabular-nums\">\n {nativePrice !== undefined\n ? money.format(native * nativePrice)\n : `${native.toFixed(5)} ${nativeSymbol}`}\n </span>\n </span>\n </>\n )\n\n return (\n <li key={tier.id}>\n {onSelect ? (\n <button\n type=\"button\"\n aria-pressed={chosen}\n onClick={() => onSelect(tier.id)}\n className={cn(\n 'hover:bg-accent/40 flex w-full items-center gap-3 px-3 py-2.5',\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] focus-visible:ring-inset',\n chosen && 'bg-accent/60',\n )}\n >\n {row}\n </button>\n ) : (\n <div className=\"flex items-center gap-3 px-3 py-2.5\">{row}</div>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { GasTracker }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gauge",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Gauge",
|
|
5
|
+
"description": "A radial meter — the circular counterpart to Progress. Drawn with stroke-dasharray rather than an arc path, so the sweep is one number and there is no arc-flag maths to get wrong.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/gauge.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import { useId, type ComponentProps, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A radial meter — the circular counterpart to Progress.\n *\n * Drawn with `stroke-dasharray` on a circle rather than an arc path, so the\n * sweep is one number to animate and there is no arc-flag maths to get wrong\n * near 180 degrees.\n *\n * It reports as a real `progressbar` with `aria-valuenow`, because a ring of\n * SVG is otherwise invisible to a screen reader — the number in the middle is\n * decoration, not an accessible value.\n */\nfunction Gauge({\n value,\n max = 100,\n size = 96,\n thickness = 8,\n label,\n hint,\n tone,\n showValue = true,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n value: number\n max?: number\n size?: number\n thickness?: number\n label?: ReactNode\n hint?: ReactNode\n /** Any CSS colour. Defaults to a threshold ramp: green, amber, red. */\n tone?: string\n showValue?: boolean\n}) {\n const id = useId()\n const ratio = Math.max(0, Math.min(value / max, 1))\n const percent = Math.round(ratio * 100)\n\n // A 100-unit viewBox keeps the geometry independent of the rendered size.\n const radius = 50 - thickness / 2\n const circumference = 2 * Math.PI * radius\n\n const colour =\n tone ??\n (ratio >= 0.9\n ? 'var(--destructive)'\n : ratio >= 0.75\n ? 'var(--amber)'\n : 'var(--green)')\n\n return (\n <div\n data-slot=\"gauge\"\n className={cn('inline-flex flex-col items-center gap-1', className)}\n {...props}\n >\n <div className=\"relative\" style={{ width: size, height: size }}>\n <svg\n viewBox=\"0 0 100 100\"\n role=\"progressbar\"\n aria-valuenow={value}\n aria-valuemin={0}\n aria-valuemax={max}\n aria-labelledby={label ? id : undefined}\n aria-label={label ? undefined : `${percent} percent`}\n className=\"size-full -rotate-90\"\n >\n <circle\n cx=\"50\"\n cy=\"50\"\n r={radius}\n fill=\"none\"\n stroke=\"var(--border)\"\n strokeWidth={thickness}\n />\n <circle\n cx=\"50\"\n cy=\"50\"\n r={radius}\n fill=\"none\"\n stroke={colour}\n strokeWidth={thickness}\n strokeLinecap=\"round\"\n strokeDasharray={circumference}\n strokeDashoffset={circumference * (1 - ratio)}\n className=\"transition-[stroke-dashoffset,stroke] duration-300 ease-out motion-reduce:transition-none\"\n />\n </svg>\n\n {showValue && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n <span className=\"text-lg font-semibold tabular-nums\">{percent}%</span>\n {hint && (\n <span className=\"text-muted-foreground text-[10px]\">{hint}</span>\n )}\n </div>\n )}\n </div>\n\n {label && (\n <span id={id} className=\"text-muted-foreground text-xs font-medium\">\n {label}\n </span>\n )}\n </div>\n )\n}\n\nexport { Gauge }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "governance-proposal",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Governance Proposal",
|
|
5
|
+
"description": "A proposal with its tally. Quorum is tracked separately from the majority because they are separate tests — a 92% \"for\" reads as passing right up until it fails for turnout.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/governance-proposal.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Clock, TriangleAlert, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A governance proposal with its tally.\n *\n * Quorum is tracked separately from the majority, because they are separate\n * tests and a proposal routinely passes one and fails the other. A bar showing\n * 92% \"for\" reads as passing right up until it fails for turnout, so the quorum\n * line is always rendered — never hidden once met.\n *\n * Abstain counts toward quorum but not toward the majority. That is the whole\n * point of an abstain vote, and a component that folds it into \"against\" gets\n * the outcome wrong.\n */\nexport type ProposalState = 'pending' | 'active' | 'passed' | 'defeated' | 'executed' | 'cancelled'\n\nconst STATE = {\n pending: { label: 'Pending', color: 'neutral' },\n active: { label: 'Active', color: 'blue' },\n passed: { label: 'Passed', color: 'green' },\n defeated: { label: 'Defeated', color: 'destructive' },\n executed: { label: 'Executed', color: 'green' },\n cancelled: { label: 'Cancelled', color: 'neutral' },\n} as const\n\nfunction GovernanceProposal({\n id,\n title,\n state,\n forVotes,\n againstVotes,\n abstainVotes = 0,\n quorum,\n endsAt,\n now,\n summary,\n proposer,\n onVote,\n locale = 'en-GB',\n endsLabel = 'ends',\n forLabel = 'For',\n againstLabel = 'Against',\n abstainLabel = 'Abstain',\n quorumNote = 'Without quorum this proposal fails regardless of the vote.',\n proposedByLabel = 'Proposed by',\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'title' | 'id'> & {\n id: ReactNode\n title: ReactNode\n state: ProposalState\n forVotes: number\n againstVotes: number\n /** Counts toward quorum, never toward the majority. */\n abstainVotes?: number\n /** Votes required for the result to count at all. */\n quorum?: number\n endsAt?: Date\n now?: Date\n summary?: ReactNode\n proposer?: ReactNode\n onVote?: (choice: 'for' | 'against' | 'abstain') => void\n locale?: string\n /** Precedes the closing time. */\n endsLabel?: ReactNode\n forLabel?: ReactNode\n againstLabel?: ReactNode\n abstainLabel?: ReactNode\n /** Shown while the vote is short of quorum. */\n quorumNote?: ReactNode\n /** Precedes the proposer. */\n proposedByLabel?: ReactNode\n}) {\n const meta = STATE[state]\n const decisive = forVotes + againstVotes\n const turnout = decisive + abstainVotes\n const forShare = decisive > 0 ? forVotes / decisive : 0\n const quorumMet = quorum === undefined || turnout >= quorum\n\n const num = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 })\n\n return (\n <article\n data-slot=\"governance-proposal\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-muted-foreground font-mono text-xs\">{id}</span>\n <Badge size=\"sm\" color={meta.color}>\n {meta.label}\n </Badge>\n {endsAt && state === 'active' && (\n <span className=\"text-muted-foreground ms-auto inline-flex items-center gap-1 text-xs\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {endsLabel} <Fmt type=\"relative\" value={endsAt} now={now} locale={locale} />\n </span>\n )}\n </div>\n\n <h3 className=\"text-sm font-medium text-pretty\">{title}</h3>\n {summary && <p className=\"text-muted-foreground text-sm\">{summary}</p>}\n\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex h-2 w-full overflow-hidden rounded-full [corner-shape:round]\">\n <span\n className=\"bg-[var(--green)]\"\n style={{ width: `${forShare * 100}%` }}\n />\n <span\n className=\"bg-[var(--destructive)]\"\n style={{ width: `${(1 - forShare) * 100}%` }}\n />\n </div>\n\n <div className=\"flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs tabular-nums\">\n <span className=\"text-[var(--green-soft-foreground)]\">\n {forLabel} {num.format(forVotes)} · {Math.round(forShare * 100)}%\n </span>\n <span className=\"text-[var(--destructive-soft-foreground)]\">\n {againstLabel} {num.format(againstVotes)}\n </span>\n {abstainVotes > 0 && (\n <span className=\"text-muted-foreground\">\n {abstainLabel} {num.format(abstainVotes)}\n </span>\n )}\n </div>\n </div>\n\n {/* Always rendered — a 92% \"for\" reads as passing until turnout fails. */}\n {quorum !== undefined && (\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex items-baseline justify-between gap-2 text-xs\">\n <span className={cn(quorumMet ? 'text-muted-foreground' : 'text-[var(--amber-soft-foreground)]')}>\n Quorum {quorumMet ? 'met' : 'not met'}\n </span>\n <span className=\"text-muted-foreground tabular-nums\">\n {num.format(turnout)} / {num.format(quorum)}\n </span>\n </div>\n <div className=\"bg-secondary h-1 w-full overflow-hidden rounded-full [corner-shape:round]\">\n <span\n className={cn(\n 'block h-full rounded-full [corner-shape:round]',\n quorumMet ? 'bg-[var(--green)]' : 'bg-[var(--amber)]',\n )}\n style={{ width: `${Math.min(turnout / quorum, 1) * 100}%` }}\n />\n </div>\n </div>\n )}\n\n {!quorumMet && state === 'active' && (\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--amber-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {quorumNote}\n </p>\n )}\n\n {proposer && (\n <p className=\"text-muted-foreground/80 text-xs\">\n {proposedByLabel} {proposer}\n </p>\n )}\n\n {onVote && state === 'active' && (\n <div className=\"flex flex-wrap gap-2\">\n <Button size=\"sm\" className=\"flex-1\" onClick={() => onVote('for')}>\n <Check />\n {forLabel}\n </Button>\n <Button size=\"sm\" variant=\"secondary\" className=\"flex-1\" onClick={() => onVote('against')}>\n <X />\n {againstLabel}\n </Button>\n <Button size=\"sm\" variant=\"secondary\" onClick={() => onVote('abstain')}>\n {abstainLabel}\n </Button>\n </div>\n )}\n </article>\n )\n}\n\nexport { GovernanceProposal }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Group",
|
|
5
|
+
"description": "A recessed container that holds a set of cards. Padding and gap are the same value, and the corner radius is concentric with the cards inside it.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/group.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A recessed container that holds a set of cards.\n *\n * The padding and the gap are the same value at every size, so the space\n * between two cards matches the space between a card and the container edge.\n *\n * The radius is concentric: the group's corner is the card's corner plus the\n * padding between them (`--radius-group-*` in index.css). Reusing the card's own\n * radius would make the gap between the two curves pinch at the corners.\n *\n * A horizontal group stacks below `responsive` — three cards side by side on a\n * phone gives each of them about a hundred pixels, which is not a layout.\n */\nconst groupVariants = cva(\n [\n 'flex bg-[var(--group)]',\n // Cards inside a group lose their outline: the recess already separates\n // them from the page, and a border on top of that reads as a double edge.\n '[&>[data-slot=card]]:border-transparent',\n ].join(' '),\n {\n variants: {\n orientation: {\n vertical: 'flex-col',\n horizontal: 'flex-row flex-wrap items-stretch',\n },\n size: {\n sm: 'gap-3 p-3 rounded-[var(--radius-group-sm)]',\n default: 'gap-4.5 p-4.5 rounded-[var(--radius-group-md)]',\n lg: 'gap-6 p-6 rounded-[var(--radius-group-lg)]',\n },\n /** Give every child an equal share of the main axis. */\n even: {\n true: '[&>*]:min-w-0 [&>*]:flex-1',\n false: '',\n },\n },\n defaultVariants: {\n orientation: 'vertical',\n size: 'default',\n even: false,\n },\n },\n)\n\n/**\n * A horizontal group written so it only becomes a row at the breakpoint.\n *\n * Spelled out per breakpoint rather than composed, because Tailwind scans\n * source text — a prefix built by interpolation produces no CSS.\n */\nconst RESPONSIVE_ROW = {\n sm: 'flex-col sm:flex-row sm:flex-wrap sm:items-stretch',\n md: 'flex-col md:flex-row md:flex-wrap md:items-stretch',\n lg: 'flex-col lg:flex-row lg:flex-wrap lg:items-stretch',\n} as const\n\ntype GroupProps = ComponentProps<'div'> &\n VariantProps<typeof groupVariants> & {\n /** Breakpoint a horizontal group becomes a row at. `false` never stacks. */\n responsive?: Responsive\n }\n\nfunction Group({\n className,\n orientation = 'vertical',\n size,\n even,\n responsive = 'sm',\n ...props\n}: GroupProps) {\n const stacks = orientation === 'horizontal' && responsive !== false\n const rows = stacks ? RESPONSIVE_ROW[responsive] : undefined\n\n return (\n <div\n data-slot=\"group\"\n data-orientation={orientation}\n className={cn(\n // `orientation` is passed as vertical while stacking so the row classes\n // do not fight the responsive ones.\n groupVariants({ orientation: stacks ? 'vertical' : orientation, size, even }),\n rows,\n className,\n )}\n {...props}\n />\n )\n}\n\nexport { Group, groupVariants }\nexport type { GroupProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "health-checks",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Health Checks",
|
|
5
|
+
"description": "Endpoint probes with status, latency and history. A 200 that takes four seconds reads as degraded — a probe list that only reports the status code is why slow outages get noticed by customers first.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"sparkline"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/health-checks.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, TriangleAlert, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Endpoint probes: status, latency, and recent history.\n *\n * Latency carries a threshold so \"200 OK in 4 seconds\" reads as degraded rather\n * than healthy. A probe list that only reports the status code is why slow\n * outages get noticed by customers first.\n *\n * The sparkline is latency over recent probes, which turns \"is this getting\n * worse\" into something answerable at a glance — the single number cannot say\n * whether 400ms is the new normal or the start of something.\n */\nexport type HealthCheck = {\n id: string\n name: string\n url?: string\n status: 'healthy' | 'degraded' | 'down'\n /** Milliseconds. */\n latency?: number\n /** Above this, a healthy response still reads as degraded. */\n latencyThreshold?: number\n code?: number\n history?: number[]\n region?: ReactNode\n}\n\nconst STATUS = {\n healthy: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]', color: 'green', label: 'Healthy' },\n degraded: { Icon: TriangleAlert, tone: 'text-[var(--amber-soft-foreground)]', color: 'amber', label: 'Degraded' },\n down: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]', color: 'destructive', label: 'Down' },\n} as const\n\n/** A slow success is not a success. */\nfunction effectiveStatus(check: HealthCheck) {\n if (check.status === 'healthy' && check.latencyThreshold && check.latency) {\n return check.latency > check.latencyThreshold ? 'degraded' : 'healthy'\n }\n return check.status\n}\n\nfunction HealthChecks({\n checks,\n className,\n ...props\n}: ComponentProps<'div'> & { checks: HealthCheck[] }) {\n return (\n <div\n data-slot=\"health-checks\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {checks.map((check) => {\n const status = effectiveStatus(check)\n const { Icon, tone, color, label } = STATUS[status]\n const slow =\n status === 'degraded' && check.status === 'healthy'\n\n return (\n <li key={check.id} className=\"flex flex-wrap items-center gap-x-3 gap-y-2 p-3\">\n <Icon className={cn('size-4 shrink-0', tone)} aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex items-center gap-2\">\n <span className=\"truncate text-sm font-medium\">{check.name}</span>\n {check.region}\n </p>\n {check.url && (\n <p className=\"text-muted-foreground truncate font-mono text-xs\">\n {check.url}\n </p>\n )}\n </div>\n\n {check.history && check.history.length > 1 && (\n <Sparkline\n values={check.history}\n color={\n status === 'healthy'\n ? 'var(--green)'\n : status === 'degraded'\n ? 'var(--amber)'\n : 'var(--destructive)'\n }\n className=\"h-6 w-20 shrink-0\"\n />\n )}\n\n {check.latency !== undefined && (\n <span\n className={cn(\n 'min-w-16 shrink-0 text-end text-xs tabular-nums whitespace-nowrap',\n slow ? 'text-[var(--amber-soft-foreground)]' : 'text-muted-foreground',\n )}\n >\n {check.latency} ms\n </span>\n )}\n\n {check.code !== undefined && (\n <code className=\"text-muted-foreground shrink-0 font-mono text-xs\">\n {check.code}\n </code>\n )}\n\n <Badge size=\"sm\" color={color}>\n {label}\n </Badge>\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { HealthChecks, effectiveStatus }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "heatmap-grid",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Heatmap Grid",
|
|
5
|
+
"description": "A calendar heatmap — weeks as columns, weekdays as rows. UptimeStrip is the one-dimensional case; this is the grid.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils",
|
|
10
|
+
"tooltip"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/heatmap-grid.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\n\n/**\n * A calendar heatmap — the contribution grid.\n *\n * Weeks are columns and weekdays are rows, which is the layout everyone reads\n * without a legend. Building it means padding to a week boundary at both ends;\n * without that the first column starts on the wrong weekday and every row is\n * shifted by a day.\n *\n * Intensity is bucketed rather than continuous. Five steps is what the eye can\n * actually distinguish in a 10px square, and a smooth gradient just makes\n * neighbouring cells look identical.\n */\nexport type HeatmapCell = {\n date: Date\n value: number\n detail?: ReactNode\n}\n\nconst WEEKDAYS = ['Mon', 'Wed', 'Fri']\n\nfunction startOfWeek(date: Date) {\n const copy = new Date(date)\n // Monday-first: getDay() is 0 for Sunday, so shift it to the end.\n const day = (copy.getDay() + 6) % 7\n copy.setDate(copy.getDate() - day)\n copy.setHours(0, 0, 0, 0)\n return copy\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_SUMMARY_LABEL: (total: number, days: number) => string = (total, days) => `${total} contributions across ${days} days`\n\nfunction HeatmapGrid({\n cells,\n levels = 5,\n color = 'var(--green)',\n locale = 'en-GB',\n showMonths = true,\n showWeekdays = true,\n lessLabel = 'Less',\n moreLabel = 'More',\n summaryLabel = DEFAULT_SUMMARY_LABEL,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n cells: HeatmapCell[]\n levels?: number\n color?: string\n locale?: string\n showMonths?: boolean\n showWeekdays?: boolean\n /** Ends of the intensity legend. */\n lessLabel?: ReactNode\n moreLabel?: ReactNode\n /** Accessible summary of the whole grid. */\n summaryLabel?: (total: number, days: number) => string\n}) {\n const { weeks, max, monthLabels } = useMemo(() => {\n if (cells.length === 0) return { weeks: [], max: 1, monthLabels: [] }\n\n const sorted = [...cells].sort((a, b) => a.date.getTime() - b.date.getTime())\n const byDay = new Map(\n sorted.map((cell) => [cell.date.toDateString(), cell] as const),\n )\n\n const start = startOfWeek(sorted[0].date)\n const end = sorted[sorted.length - 1].date\n\n const columns: (HeatmapCell | null)[][] = []\n const months: { index: number; label: string }[] = []\n const monthFormat = new Intl.DateTimeFormat(locale, { month: 'short' })\n\n const cursor = new Date(start)\n let lastMonth = -1\n\n while (cursor <= end) {\n const column: (HeatmapCell | null)[] = []\n for (let day = 0; day < 7; day++) {\n const key = cursor.toDateString()\n column.push(cursor > end ? null : (byDay.get(key) ?? { date: new Date(cursor), value: 0 }))\n cursor.setDate(cursor.getDate() + 1)\n }\n\n const monthOfColumn = column.find(Boolean)?.date.getMonth()\n if (monthOfColumn !== undefined && monthOfColumn !== lastMonth) {\n lastMonth = monthOfColumn\n months.push({\n index: columns.length,\n label: monthFormat.format(column.find(Boolean)!.date),\n })\n }\n\n columns.push(column)\n }\n\n return {\n weeks: columns,\n max: Math.max(...sorted.map((cell) => cell.value), 1),\n monthLabels: months,\n }\n }, [cells, locale])\n\n const dateFormat = useMemo(\n () => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }),\n [locale],\n )\n\n const total = cells.reduce((sum, cell) => sum + cell.value, 0)\n\n return (\n <div data-slot=\"heatmap-grid\" className={cn('flex flex-col gap-2', className)} {...props}>\n <div className=\"flex gap-1.5 overflow-x-auto\">\n {showWeekdays && (\n <div className=\"flex shrink-0 flex-col gap-[3px] pt-[18px]\">\n {Array.from({ length: 7 }, (_, day) => (\n <span\n key={day}\n className=\"text-muted-foreground/70 h-[11px] text-[9px] leading-[11px]\"\n >\n {day % 2 === 0 ? (WEEKDAYS[day / 2] ?? '') : ''}\n </span>\n ))}\n </div>\n )}\n\n <div className=\"flex min-w-0 flex-col gap-1\">\n {showMonths && (\n <div className=\"relative h-3.5\">\n {monthLabels.map((month) => (\n <span\n key={`${month.label}-${month.index}`}\n className=\"text-muted-foreground/70 absolute text-[10px]\"\n style={{ left: month.index * 14 }}\n >\n {month.label}\n </span>\n ))}\n </div>\n )}\n\n <div\n className=\"flex gap-[3px]\"\n role=\"img\"\n aria-label={summaryLabel(total, cells.length)}\n >\n {weeks.map((week, index) => (\n <div key={index} className=\"flex flex-col gap-[3px]\">\n {week.map((cell, day) =>\n cell ? (\n <Tooltip\n key={day}\n content={\n <span className=\"flex flex-col\">\n <span>\n {cell.value} on {dateFormat.format(cell.date)}\n </span>\n {cell.detail && <span className=\"opacity-70\">{cell.detail}</span>}\n </span>\n }\n >\n <span\n tabIndex={0}\n className=\"focus-visible:ring-ring/50 size-[11px] shrink-0 rounded-[2px] outline-none focus-visible:ring-2\"\n style={{\n backgroundColor:\n cell.value === 0\n ? 'var(--secondary)'\n : color,\n // Bucketed, not continuous: five steps is what the\n // eye can separate at this size.\n opacity:\n cell.value === 0\n ? 1\n : 0.25 +\n (Math.ceil((cell.value / max) * levels) / levels) * 0.75,\n }}\n />\n </Tooltip>\n ) : (\n <span key={day} className=\"size-[11px] shrink-0\" />\n ),\n )}\n </div>\n ))}\n </div>\n </div>\n </div>\n\n <div className=\"text-muted-foreground flex items-center gap-1.5 text-xs\">\n <span>{lessLabel}</span>\n {Array.from({ length: levels }, (_, index) => (\n <span\n key={index}\n className=\"size-[11px] rounded-[2px]\"\n style={{\n backgroundColor: index === 0 ? 'var(--secondary)' : color,\n opacity: index === 0 ? 1 : 0.25 + ((index + 1) / levels) * 0.75,\n }}\n />\n ))}\n <span>{moreLabel}</span>\n </div>\n </div>\n )\n}\n\nexport { HeatmapGrid }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hover-card",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Hover Card",
|
|
5
|
+
"description": "A preview card shown on hover — richer than a tooltip, and pointer-only. There is a close delay as well as an open delay, so the pointer can travel into the card without it vanishing.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils",
|
|
11
|
+
"primitive-popper"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/hover-card.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import {\n cloneElement,\n isValidElement,\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport { usePopper, type Align, type Side } from '@/components/primitives/popper'\nimport { menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A preview card shown on hover — richer than a tooltip, and pointer-only.\n *\n * There is a close delay as well as an open delay, so the pointer can travel\n * from the trigger into the card without it vanishing on the way.\n *\n * Deliberately not keyboard-triggered: the content is supplementary, and a\n * keyboard user reaches the same information by following the link. A Popover is\n * the right component when the content must be reachable.\n */\nfunction HoverCard({\n children,\n content,\n side = 'bottom',\n align = 'center',\n openDelay = 300,\n closeDelay = 200,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'content'> & {\n children: ReactElement<Record<string, unknown>>\n content: ReactNode\n side?: Side\n align?: Align\n openDelay?: number\n closeDelay?: number\n}) {\n const [open, setOpen] = useState(false)\n const anchorRef = useRef<HTMLElement>(null)\n const floatingRef = useRef<HTMLDivElement>(null)\n const timer = useRef(0)\n\n const { style, side: resolved } = usePopper({\n open,\n anchorRef,\n floatingRef,\n side,\n align,\n offset: 8,\n })\n\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const schedule = (next: boolean, delay: number) => {\n clearTimeout(timer.current)\n timer.current = window.setTimeout(() => setOpen(next), delay)\n }\n\n if (!isValidElement(children)) return children\n\n // Passing the ref object as a prop is React 19's ref-as-prop, not a read of\n // `.current` during render — the rule cannot tell the two apart.\n // oxlint-disable-next-line react/refs\n const trigger = cloneElement(children, {\n ref: anchorRef,\n onPointerEnter: () => schedule(true, openDelay),\n onPointerLeave: () => schedule(false, closeDelay),\n })\n\n return (\n <>\n {trigger}\n {open && (\n <div\n ref={floatingRef}\n data-slot=\"hover-card\"\n data-side={resolved}\n style={style}\n onPointerEnter={() => clearTimeout(timer.current)}\n onPointerLeave={() => schedule(false, closeDelay)}\n className={cn(menuSurface, radius.surface, 'w-64 p-4 text-sm', className)}\n {...props}\n >\n {content}\n </div>\n )}\n </>\n )\n}\n\nexport { HoverCard }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|