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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dependency-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dependency List",
|
|
5
|
+
"description": "Packages with their update and vulnerability state. Severity outranks staleness in the sort, because ordering by how out of date things are buries a critical advisory behind forty patch bumps.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"input",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/dependency-list.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowUpRight, Package, ShieldAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Input } from '@/components/ui/input'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Package dependencies with their update and vulnerability state.\n *\n * Severity outranks staleness in the sort. A list ordered by how out of date\n * things are buries a critical advisory behind forty cosmetic patch bumps,\n * which is precisely backwards from how anyone triages this.\n *\n * The update badge distinguishes major from minor, because \"12 outdated\" is not\n * actionable while \"1 major, 11 patch\" is — one is an afternoon and the other\n * is a sprint.\n */\nexport type Dependency = {\n name: string\n current: string\n latest?: string\n /** Derived from the version pair when omitted. */\n update?: 'major' | 'minor' | 'patch' | null\n dev?: boolean\n vulnerability?: { severity: 'critical' | 'high' | 'moderate' | 'low'; id?: string }\n size?: number\n homepage?: string\n}\n\nconst SEVERITY_RANK = { critical: 0, high: 1, moderate: 2, low: 3 } as const\nconst UPDATE_RANK = { major: 0, minor: 1, patch: 2 } as const\n\nconst SEVERITY_COLOR = {\n critical: 'destructive',\n high: 'destructive',\n moderate: 'amber',\n low: 'neutral',\n} as const\n\nconst UPDATE_COLOR = { major: 'destructive', minor: 'amber', patch: 'blue' } as const\n\n/** Compare two semver-ish strings; anything unparseable means no update shown. */\nfunction updateKind(current: string, latest?: string) {\n if (!latest) return null\n const parse = (value: string) =>\n value.replace(/^[^\\d]*/, '').split('.').map((part) => Number.parseInt(part, 10))\n const [a = 0, b = 0, c = 0] = parse(current)\n const [x = 0, y = 0, z = 0] = parse(latest)\n if ([a, b, c, x, y, z].some(Number.isNaN)) return null\n if (x > a) return 'major' as const\n if (x === a && y > b) return 'minor' as const\n if (x === a && y === b && z > c) return 'patch' as const\n return null\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_HOMEPAGE_LABEL: (name: string) => string = (name) => `${name} homepage`\n\nfunction DependencyList({\n dependencies,\n searchable = true,\n devLabel = 'dev',\n currentLabel = 'current',\n filterPlaceholder = 'Filter packages',\n filterLabel = 'Filter packages',\n emptyMessage = 'No packages match.',\n homepageLabel = DEFAULT_HOMEPAGE_LABEL,\n className,\n ...props\n}: ComponentProps<'div'> & {\n dependencies: Dependency[]\n searchable?: boolean\n /** Badge on a dev-only dependency. */\n devLabel?: ReactNode\n /** Marks a package already on its latest version. */\n currentLabel?: ReactNode\n filterPlaceholder?: string\n /** Accessible name for the filter field. */\n filterLabel?: string\n emptyMessage?: ReactNode\n /** Accessible name for a package's homepage link. */\n homepageLabel?: (name: string) => string\n}) {\n const [query, setQuery] = useState('')\n\n const rows = useMemo(() => {\n const resolved = dependencies.map((dep) => ({\n ...dep,\n update: dep.update !== undefined ? dep.update : updateKind(dep.current, dep.latest),\n }))\n\n // Vulnerabilities first, then the size of the version jump, then name.\n return resolved\n .filter((dep) => dep.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => {\n const severity =\n (a.vulnerability ? SEVERITY_RANK[a.vulnerability.severity] : 9) -\n (b.vulnerability ? SEVERITY_RANK[b.vulnerability.severity] : 9)\n if (severity !== 0) return severity\n\n const update =\n (a.update ? UPDATE_RANK[a.update] : 9) - (b.update ? UPDATE_RANK[b.update] : 9)\n if (update !== 0) return update\n\n return a.name.localeCompare(b.name)\n })\n }, [dependencies, query])\n\n const vulnerable = rows.filter((dep) => dep.vulnerability).length\n const outdated = rows.filter((dep) => dep.update).length\n\n return (\n <div\n data-slot=\"dependency-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-col gap-2 border-b p-2 sm:flex-row sm:items-center\">\n {/* `shrink-0` on the summary, not just `whitespace-nowrap`: the filter\n beside it is `w-full`, so without this the group is squeezed until\n the count wraps onto a second line. */}\n <div className=\"flex shrink-0 items-center gap-2 px-1\">\n <Package className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium whitespace-nowrap\">\n {rows.length} packages\n </span>\n {vulnerable > 0 && (\n <Badge size=\"sm\" color=\"destructive\">\n <ShieldAlert />\n {vulnerable}\n </Badge>\n )}\n {outdated > 0 && <Badge size=\"sm\">{outdated} outdated</Badge>}\n </div>\n\n {searchable && (\n <Input\n size=\"sm\"\n variant=\"secondary\"\n placeholder={filterPlaceholder}\n aria-label={filterLabel}\n value={query}\n clearable\n onChange={(event) => setQuery(event.target.value)}\n containerClassName=\"sm:ms-auto sm:w-44\"\n />\n )}\n </div>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {rows.map((dep) => (\n <li key={dep.name} className=\"flex flex-wrap items-center gap-x-3 gap-y-1 p-3\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex items-center gap-2\">\n <code className=\"truncate font-mono text-sm font-medium\">{dep.name}</code>\n {dep.dev && <Badge size=\"sm\">{devLabel}</Badge>}\n {dep.homepage && (\n <a\n href={dep.homepage}\n aria-label={homepageLabel(dep.name)}\n className=\"text-muted-foreground hover:text-foreground\"\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )}\n </p>\n\n {dep.vulnerability && (\n <p className=\"mt-1 text-xs text-[var(--destructive-soft-foreground)]\">\n {dep.vulnerability.severity} severity\n {dep.vulnerability.id && ` · ${dep.vulnerability.id}`}\n </p>\n )}\n </div>\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-xs tabular-nums\">\n {dep.current}\n {dep.update && dep.latest && (\n <>\n <span className=\"mx-1.5\" aria-hidden=\"true\">→</span>\n <span className=\"text-foreground\">{dep.latest}</span>\n </>\n )}\n </span>\n\n {dep.update ? (\n <Badge size=\"sm\" color={UPDATE_COLOR[dep.update]}>\n {dep.update}\n </Badge>\n ) : (\n <Badge size=\"sm\" color=\"green\">\n {currentLabel}\n </Badge>\n )}\n\n {dep.vulnerability && (\n <Badge size=\"sm\" color={SEVERITY_COLOR[dep.vulnerability.severity]}>\n <ShieldAlert />\n {dep.vulnerability.severity}\n </Badge>\n )}\n </li>\n ))}\n\n {rows.length === 0 && (\n <li className=\"text-muted-foreground p-8 text-center text-sm\">\n {emptyMessage}\n </li>\n )}\n </ul>\n </div>\n )\n}\n\nexport { DependencyList, updateKind }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deploy-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Deploy List",
|
|
5
|
+
"description": "Deployments, newest first. A running deploy shows an indeterminate bar rather than a fabricated percentage — a number nobody can compute is worse than admitting you cannot.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"button",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"progress",
|
|
17
|
+
"spinner",
|
|
18
|
+
"tooltip"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"path": "components/ui/deploy-list.tsx",
|
|
23
|
+
"type": "registry:ui",
|
|
24
|
+
"content": "import type { ComponentProps } from 'react'\nimport {\n ExternalLink, GitBranch, RotateCcw, Timer,\n} from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Progress } from '@/components/ui/progress'\nimport { Spinner } from '@/components/ui/spinner'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Deployments, newest first.\n *\n * A running deploy shows an indeterminate bar rather than a fake percentage —\n * a progress number nobody can compute is worse than admitting you cannot.\n */\nexport type Deploy = {\n id: string\n environment: 'production' | 'preview' | 'staging'\n status: 'ready' | 'building' | 'failed' | 'canceled'\n branch: string\n commit: string\n message: string\n author: string\n /** Seconds. Omit while building. */\n duration?: number\n when: string\n url?: string\n}\n\nconst STATUS = {\n ready: { color: 'green', label: 'Ready' },\n building: { color: 'blue', label: 'Building' },\n failed: { color: 'destructive', label: 'Failed' },\n canceled: { color: 'neutral', label: 'Canceled' },\n} as const\n\nconst ENVIRONMENT = {\n production: 'violet',\n preview: 'cyan',\n staging: 'amber',\n} as const\n\nfunction formatDuration(seconds: number) {\n const m = Math.floor(seconds / 60)\n const s = seconds % 60\n return m ? `${m}m ${s}s` : `${s}s`\n}\n\nfunction DeployList({\n className,\n deploys,\n onRedeploy,\n ...props\n}: ComponentProps<'div'> & {\n deploys: Deploy[]\n onRedeploy?: (id: string) => void\n}) {\n return (\n <div\n data-slot=\"deploy-list\"\n // Rows are full-bleed, so the container has to clip or the first and last\n // lose the rounded corners the moment they take a background.\n className={cn(\n surface,\n radius.panel,\n 'divide-border divide-y overflow-hidden',\n className,\n )}\n {...props}\n >\n {deploys.map((deploy) => {\n const status = STATUS[deploy.status]\n const building = deploy.status === 'building'\n\n return (\n <div key={deploy.id} data-slot=\"deploy\" className=\"space-y-3 p-4\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Badge size=\"sm\" color={status.color}>\n {building && <Spinner size=\"xs\" />}\n {status.label}\n </Badge>\n <Badge size=\"sm\" color={ENVIRONMENT[deploy.environment]}>\n {deploy.environment}\n </Badge>\n <span className=\"text-muted-foreground ms-auto text-xs whitespace-nowrap\">\n {deploy.when}\n </span>\n </div>\n\n <div className=\"min-w-0 space-y-1\">\n <p className=\"truncate text-sm font-medium\">{deploy.message}</p>\n <div className=\"text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs\">\n <span className=\"flex items-center gap-1\">\n <GitBranch className=\"size-3\" /> {deploy.branch}\n </span>\n <span className=\"font-mono\">{deploy.commit.slice(0, 7)}</span>\n <span className=\"flex items-center gap-1.5\">\n <Avatar size=\"xs\" name={deploy.author} /> {deploy.author}\n </span>\n {deploy.duration !== undefined && (\n <span className=\"flex items-center gap-1\">\n <Timer className=\"size-3\" /> {formatDuration(deploy.duration)}\n </span>\n )}\n </div>\n </div>\n\n {building && <Progress size=\"sm\" color=\"blue\" />}\n\n <div className=\"flex items-center gap-2\">\n {deploy.url && (\n <Button asChild size=\"xs\" variant=\"secondary\">\n <a href={deploy.url} target=\"_blank\" rel=\"noreferrer\">\n <ExternalLink /> Visit\n </a>\n </Button>\n )}\n {onRedeploy && (\n <Tooltip content=\"Run this deployment again\">\n <Button\n size=\"xs\"\n variant=\"ghost\"\n onClick={() => onRedeploy(deploy.id)}\n >\n <RotateCcw /> Redeploy\n </Button>\n </Tooltip>\n )}\n </div>\n </div>\n )\n })}\n </div>\n )\n}\n\nexport { DeployList, formatDuration }\n"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "description-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Description List",
|
|
5
|
+
"description": "Key/value metadata as a real <dl>, so a screen reader announces each term with its value. Columns on wide screens, stacked on narrow ones.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/description-list.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * Key/value metadata — the panel that sits beside almost every detail view.\n *\n * A real `<dl>`, so the pairing is in the markup rather than implied by a\n * two-column grid. That matters for a screen reader, which announces a term\n * and its description together.\n *\n * `columns` lays terms beside values on wide screens and stacks them on narrow\n * ones. Stacking is not a fallback: at phone width a 40/60 split leaves both\n * halves too narrow to read, and a long value wraps into a column an inch wide.\n */\nfunction DescriptionList({\n className,\n columns = true,\n divided = false,\n ...props\n}: ComponentProps<'dl'> & {\n /** Term beside value from `sm` up. Off keeps them stacked at every width. */\n columns?: boolean\n divided?: boolean\n}) {\n return (\n <dl\n data-slot=\"description-list\"\n data-columns={columns}\n className={cn(\n 'grid gap-x-6 gap-y-3 text-sm',\n columns && 'sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]',\n divided && '[&>dd]:border-border [&>dd]:not-last:border-b [&>dd]:not-last:pb-3',\n divided && columns && '[&>dt]:border-border [&>dt]:sm:not-last:border-b [&>dt]:sm:not-last:pb-3',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction DescriptionTerm({ className, ...props }: ComponentProps<'dt'>) {\n return (\n <dt\n data-slot=\"description-term\"\n className={cn('text-muted-foreground min-w-0 font-medium', className)}\n {...props}\n />\n )\n}\n\nfunction DescriptionDetails({ className, ...props }: ComponentProps<'dd'>) {\n return (\n <dd\n data-slot=\"description-details\"\n className={cn('ms-0 min-w-0 break-words', className)}\n {...props}\n />\n )\n}\n\n/** Convenience for the common case: a flat list of pairs. */\nfunction DescriptionPairs({\n items,\n ...props\n}: ComponentProps<typeof DescriptionList> & {\n items: { term: ReactNode; details: ReactNode }[]\n}) {\n return (\n <DescriptionList {...props}>\n {items.map((item, index) => (\n <div key={index} className=\"contents\">\n <DescriptionTerm>{item.term}</DescriptionTerm>\n <DescriptionDetails>{item.details}</DescriptionDetails>\n </div>\n ))}\n </DescriptionList>\n )\n}\n\nexport {\n DescriptionDetails,\n DescriptionList,\n DescriptionPairs,\n DescriptionTerm,\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "device-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Device List",
|
|
5
|
+
"description": "Devices trusted to skip the second factor, and when that trust lapses. A grant that expires in three days is worth seeing before you are asked for a code at an airport.",
|
|
6
|
+
"category": "Auth",
|
|
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/device-list.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Laptop, ShieldCheck, Smartphone, Tablet, 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 } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Devices trusted to skip a second factor.\n *\n * Distinct from `SessionList`: a session is a login that can be signed out, a\n * trusted device is a standing exemption from 2FA that survives sign-out. They\n * are revoked separately and confusing them is how someone \"signs out\n * everywhere\" and is still not protected.\n *\n * Trust expiry is shown, because these grants are time-boxed and a device\n * listed as trusted with no end date reads as permanent.\n */\nexport type TrustedDevice = {\n id: string\n name: ReactNode\n kind: 'desktop' | 'mobile' | 'tablet'\n os?: ReactNode\n addedAt?: Date\n lastSeen?: Date\n /** When the trust grant lapses. */\n trustedUntil?: Date\n current?: boolean\n}\n\nconst ICON = { desktop: Laptop, mobile: Smartphone, tablet: Tablet } as const\n\nfunction DeviceList({\n devices,\n onRevoke,\n now,\n locale = 'en-GB',\n title = 'Trusted devices',\n emptyLabel = 'No trusted devices',\n currentLabel = 'This device',\n expiredLabel = 'Trust expired',\n revokeLabel = 'Revoke',\n seenLabel = 'seen',\n trustedUntilLabel = 'trusted until',\n expiredPrefix = 'expired',\n footer = 'Revoking trust asks for a second factor next time. It does not sign the device out — do that from active sessions.',\n className,\n ...props\n}: ComponentProps<'div'> & {\n devices: TrustedDevice[]\n onRevoke?: (id: string) => void\n now?: Date\n locale?: string\n title?: ReactNode\n emptyLabel?: ReactNode\n /** Badge on the device being used right now. */\n currentLabel?: ReactNode\n /** Badge on a device whose trust grant has lapsed. */\n expiredLabel?: ReactNode\n revokeLabel?: ReactNode\n /** Precedes the last-seen time. */\n seenLabel?: ReactNode\n /** Precedes the expiry date while the grant still holds. */\n trustedUntilLabel?: ReactNode\n /** Precedes the expiry date once it has lapsed. */\n expiredPrefix?: ReactNode\n /** Explanatory note beneath the list. Pass `null` to drop it. */\n footer?: ReactNode\n}) {\n const reference = now ?? new Date()\n\n return (\n <div\n data-slot=\"device-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex items-center gap-2 border-b p-3\">\n <ShieldCheck className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{title}</span>\n <Badge size=\"sm\" className=\"ms-auto\">\n {devices.length}\n </Badge>\n </div>\n\n {devices.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {devices.map((device) => {\n const Icon = ICON[device.kind]\n const lapsed = device.trustedUntil && device.trustedUntil < reference\n\n return (\n <li key={device.id} className={cn('flex items-start gap-3 p-3', lapsed && 'opacity-60')}>\n <Icon className=\"text-muted-foreground mt-0.5 size-5 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex flex-wrap items-center gap-2 text-sm font-medium\">\n {device.name}\n {device.current && (\n <Badge size=\"sm\" color=\"green\">\n {currentLabel}\n </Badge>\n )}\n {lapsed && (\n <Badge size=\"sm\" color=\"amber\">\n <TriangleAlert />\n {expiredLabel}\n </Badge>\n )}\n </p>\n\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap gap-x-3 text-xs\">\n {device.os && <span>{device.os}</span>}\n {device.lastSeen && (\n <span>\n {seenLabel} <Fmt type=\"relative\" value={device.lastSeen} now={reference} locale={locale} />\n </span>\n )}\n {/* Time-boxed grants need their end date stated. */}\n {device.trustedUntil && (\n <span>\n {lapsed ? expiredPrefix : trustedUntilLabel}{' '}\n <Fmt type=\"date\" value={device.trustedUntil} format=\"D MMM YYYY\" locale={locale} />\n </span>\n )}\n </p>\n </div>\n\n {onRevoke && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"shrink-0 text-[var(--destructive-soft-foreground)]\"\n onClick={() => onRevoke(device.id)}\n >\n {revokeLabel}\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n )}\n\n {footer && (\n <p className=\"border-border text-muted-foreground border-t p-3 text-xs\">\n {footer}\n </p>\n )}\n </div>\n )\n}\n\nexport { DeviceList }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dialog",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dialog",
|
|
5
|
+
"description": "A modal window, built on the native <dialog> element — so the focus trap, background inerting, Escape handling and top-layer stacking come from the platform rather than from JavaScript.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dialog",
|
|
15
|
+
"primitive-slot"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/dialog.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { X } from 'lucide-react'\nimport { DialogProvider, useDialog } from '@/components/primitives/dialog'\nimport { Slot } from '@/components/primitives/slot'\nimport { Button } from '@/components/ui/button'\nimport { cardPadding, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A modal window, built on the native `<dialog>` element.\n *\n * That gets the focus trap, background inerting, Escape handling and top-layer\n * stacking from the platform rather than from JavaScript — the parts a hand-made\n * modal usually gets subtly wrong.\n */\nfunction Dialog({\n open,\n defaultOpen,\n onOpenChange,\n children,\n}: {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}) {\n return (\n <DialogProvider\n open={open}\n defaultOpen={defaultOpen}\n onOpenChange={onOpenChange}\n >\n {children}\n </DialogProvider>\n )\n}\n\nfunction DialogTrigger({\n asChild = false,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { setOpen } = useDialog()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n data-slot=\"dialog-trigger\"\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(true)\n }}\n {...props}\n />\n )\n}\n\nfunction DialogClose({\n asChild = false,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { setOpen } = useDialog()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n data-slot=\"dialog-close\"\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(false)\n }}\n {...props}\n />\n )\n}\n\ntype DialogContentProps = ComponentProps<'dialog'> & {\n /** Show the corner close button. */\n showClose?: boolean\n /** Clicking the backdrop closes the dialog. */\n dismissable?: boolean\n size?: 'sm' | 'default' | 'lg' | 'xl'\n /** Accessible name for the close button. */\n closeLabel?: string\n}\n\nconst WIDTH = {\n sm: 'max-w-sm',\n default: 'max-w-md',\n lg: 'max-w-lg',\n xl: 'max-w-2xl',\n}\n\nfunction DialogContent({\n className,\n children,\n showClose = true,\n dismissable = true,\n size = 'default',\n closeLabel = 'Close',\n ...props\n}: DialogContentProps) {\n const { dialogRef, setOpen, ids } = useDialog()\n\n return (\n <dialog\n ref={dialogRef}\n data-slot=\"dialog\"\n aria-labelledby={ids.title}\n // The backdrop is part of the dialog's own box, so a press is \"outside\"\n // only when it lands beyond the element's rectangle.\n onClick={(event) => {\n if (!dismissable || event.target !== event.currentTarget) return\n const rect = event.currentTarget.getBoundingClientRect()\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (!inside) setOpen(false)\n }}\n className={cn(\n surface,\n radius.panel,\n 'text-card-foreground m-auto w-[calc(100vw-2rem)] p-0',\n WIDTH[size],\n 'backdrop:bg-transparent',\n className,\n )}\n {...props}\n >\n <div className=\"relative flex flex-col\">\n {children}\n {showClose && (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={closeLabel}\n onClick={() => setOpen(false)}\n className=\"absolute end-3 top-3\"\n >\n <X />\n </Button>\n )}\n </div>\n </dialog>\n )\n}\n\nfunction DialogHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"dialog-header\"\n className={cn('flex flex-col gap-1.5 pe-10', cardPadding.lg, className)}\n {...props}\n />\n )\n}\n\nfunction DialogTitle({ className, ...props }: ComponentProps<'h2'>) {\n const { ids } = useDialog()\n\n return (\n <h2\n id={ids.title}\n data-slot=\"dialog-title\"\n className={cn('text-base font-semibold', className)}\n {...props}\n />\n )\n}\n\nfunction DialogDescription({ className, ...props }: ComponentProps<'p'>) {\n const { ids } = useDialog()\n\n return (\n <p\n id={ids.description}\n data-slot=\"dialog-description\"\n className={cn('text-muted-foreground text-sm', className)}\n {...props}\n />\n )\n}\n\nfunction DialogBody({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"dialog-body\"\n className={cn('px-6 pb-6 text-sm', className)}\n {...props}\n />\n )\n}\n\nfunction DialogFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"dialog-footer\"\n className={cn(\n 'border-border flex flex-col-reverse gap-2 border-t sm:flex-row sm:justify-end',\n cardPadding.lg,\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n Dialog,\n DialogBody,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "diff-proposal",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Diff Proposal",
|
|
5
|
+
"description": "A model-proposed edit with accept and reject. The decision is reported, never applied — and once made, the actions are replaced by the outcome so a stale card cannot be clicked twice.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"diff-view",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/diff-proposal.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Sparkles, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { DiffView, type DiffFile } from '@/components/ui/diff-view'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A model-proposed edit, with accept and reject.\n *\n * The decision is reported, never applied here — this component has no idea\n * what the file system or editor buffer is. It also refuses to look decided:\n * once a choice is made the actions are replaced by a stated outcome, so a\n * stale card in a transcript cannot be clicked a second time.\n *\n * `rationale` sits above the diff rather than below it. The reason for a change\n * is what you read before deciding, and putting it after a forty-line diff means\n * nobody reads it at all.\n */\nexport type ProposalDecision = 'accepted' | 'rejected'\n\nfunction DiffProposal({\n file,\n title,\n rationale,\n view = 'unified',\n decision: decisionProp,\n onDecide,\n acceptLabel = 'Accept',\n rejectLabel = 'Reject',\n acceptedLabel = 'Accepted',\n rejectedLabel = 'Rejected',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'title'> & {\n file: DiffFile\n title?: ReactNode\n rationale?: ReactNode\n view?: 'unified' | 'split'\n decision?: ProposalDecision\n onDecide?: (decision: ProposalDecision) => void\n acceptLabel?: ReactNode\n rejectLabel?: ReactNode\n /** Shown once the change is accepted. */\n acceptedLabel?: ReactNode\n rejectedLabel?: ReactNode\n}) {\n const controlled = decisionProp !== undefined\n const [uncontrolled, setUncontrolled] = useState<ProposalDecision | undefined>()\n const decision = controlled ? decisionProp : uncontrolled\n\n function decide(next: ProposalDecision) {\n if (!controlled) setUncontrolled(next)\n onDecide?.(next)\n }\n\n return (\n <div\n data-slot=\"diff-proposal\"\n data-decision={decision}\n className={cn(\n 'border-border overflow-hidden border',\n radius.surface,\n decision && 'opacity-80',\n className,\n )}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex flex-col gap-2 border-b p-3\">\n <div className=\"flex items-center gap-2\">\n <Sparkles className=\"text-[var(--violet-soft-foreground)] size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {title ?? 'Proposed change'}\n </span>\n </div>\n\n {rationale && (\n <p className=\"text-muted-foreground text-sm\">{rationale}</p>\n )}\n </div>\n\n <DiffView file={file} view={view} collapsible={false} className=\"rounded-none border-0\" />\n\n <div className=\"border-border flex flex-wrap items-center gap-2 border-t p-2\">\n {decision ? (\n <span\n className={cn(\n 'inline-flex items-center gap-1.5 px-1 text-sm font-medium',\n decision === 'accepted'\n ? 'text-[var(--green-soft-foreground)]'\n : 'text-muted-foreground',\n )}\n >\n {decision === 'accepted' ? <Check className=\"size-4\" /> : <X className=\"size-4\" />}\n {decision === 'accepted' ? acceptedLabel : rejectedLabel}\n </span>\n ) : (\n <>\n <Button size=\"xs\" onClick={() => decide('accepted')}>\n <Check />\n {acceptLabel}\n </Button>\n <Button variant=\"secondary\" size=\"xs\" onClick={() => decide('rejected')}>\n <X />\n {rejectLabel}\n </Button>\n </>\n )}\n </div>\n </div>\n )\n}\n\nexport { DiffProposal }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "diff-stat",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Diff Stat",
|
|
5
|
+
"description": "Added and removed line counts with the five-square bar. The squares are proportional but never empty — one line changed in a thousand still lights one square.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/diff-stat.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * Added and removed line counts, with the five-square bar every forge uses.\n *\n * The squares are proportional but never empty: a change of one line in a\n * thousand still shows one lit square, because rounding it to zero would read\n * as \"nothing changed\".\n */\nfunction DiffStat({\n className,\n additions,\n deletions,\n showCounts = true,\n squares = 5,\n ...props\n}: ComponentProps<'span'> & {\n additions: number\n deletions: number\n showCounts?: boolean\n squares?: number\n}) {\n const total = additions + deletions\n const added = total === 0 ? 0 : Math.max(1, Math.round((additions / total) * squares))\n const removed = total === 0 ? 0 : Math.max(1, Math.min(squares - added, Math.round((deletions / total) * squares)))\n\n return (\n <span\n data-slot=\"diff-stat\"\n className={cn('inline-flex items-center gap-2 font-mono text-xs', className)}\n {...props}\n >\n {showCounts && (\n <>\n <span className=\"text-[var(--green-soft-foreground)]\">+{additions.toLocaleString()}</span>\n <span className=\"text-[var(--destructive-soft-foreground)]\">\n −{deletions.toLocaleString()}\n </span>\n </>\n )}\n <span\n className=\"flex gap-0.5\"\n role=\"img\"\n aria-label={`${additions} additions, ${deletions} deletions`}\n >\n {Array.from({ length: squares }, (_, index) => (\n <span\n key={index}\n className={cn(\n 'size-2 rounded-[var(--radius-check-xs)]',\n index < added\n ? 'bg-[var(--green)]'\n : index < added + removed\n ? 'bg-[var(--destructive)]'\n : 'bg-border',\n )}\n />\n ))}\n </span>\n </span>\n )\n}\n\nexport { DiffStat }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "diff-view",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Diff View",
|
|
5
|
+
"description": "A unified or side-by-side diff. Takes parsed hunks rather than a patch string, with parseUnifiedDiff exported alongside for when you only have the text.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"diff-stat",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/diff-view.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight, FileText } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { DiffStat } from '@/components/ui/diff-stat'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A unified or split diff.\n *\n * Takes parsed hunks rather than a raw patch string: parsing unified-diff text\n * is a separate concern with its own edge cases (renames, binary files, \"\\ No\n * newline at end of file\"), and baking a half-parser into a view component\n * means every caller inherits its bugs. `parseUnifiedDiff` is exported\n * alongside for the common case.\n *\n * Split mode pairs deletions with insertions positionally within a hunk, which\n * is what a side-by-side view actually shows — not a semantic word-level match,\n * which needs a real diff algorithm and a lot more code than this earns.\n */\nexport type DiffLine = {\n type: 'add' | 'remove' | 'context' | 'meta'\n content: string\n /** 1-based line number in the old file. */\n oldLine?: number\n /** 1-based line number in the new file. */\n newLine?: number\n}\n\nexport type DiffHunk = {\n header: string\n lines: DiffLine[]\n}\n\nexport type DiffFile = {\n path: string\n /** Set when the file was moved. */\n previousPath?: string\n status?: 'modified' | 'added' | 'removed' | 'renamed'\n hunks: DiffHunk[]\n}\n\nconst LINE_TONE = {\n add: 'bg-[color-mix(in_oklab,var(--green),transparent_88%)]',\n remove: 'bg-[color-mix(in_oklab,var(--destructive),transparent_88%)]',\n context: '',\n meta: 'bg-muted/50 text-muted-foreground',\n} as const\n\nconst SIGN = { add: '+', remove: '-', context: ' ', meta: '' } as const\n\n/** Parse a standard unified diff body for one file. */\nfunction parseUnifiedDiff(patch: string): DiffHunk[] {\n const hunks: DiffHunk[] = []\n let current: DiffHunk | undefined\n let oldLine = 0\n let newLine = 0\n\n for (const raw of patch.split('\\n')) {\n const header = /^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@/.exec(raw)\n if (header) {\n oldLine = Number(header[1])\n newLine = Number(header[2])\n current = { header: raw, lines: [] }\n hunks.push(current)\n continue\n }\n if (!current) continue\n\n if (raw.startsWith('+')) {\n current.lines.push({ type: 'add', content: raw.slice(1), newLine: newLine++ })\n } else if (raw.startsWith('-')) {\n current.lines.push({ type: 'remove', content: raw.slice(1), oldLine: oldLine++ })\n } else if (raw.startsWith('\\\\')) {\n current.lines.push({ type: 'meta', content: raw.slice(1).trim() })\n } else {\n current.lines.push({\n type: 'context',\n content: raw.startsWith(' ') ? raw.slice(1) : raw,\n oldLine: oldLine++,\n newLine: newLine++,\n })\n }\n }\n return hunks\n}\n\n/** Pair removals with additions positionally, for the side-by-side view. */\nfunction pairRows(lines: DiffLine[]) {\n const rows: { left?: DiffLine; right?: DiffLine }[] = []\n let index = 0\n\n while (index < lines.length) {\n const line = lines[index]\n if (line.type === 'context' || line.type === 'meta') {\n rows.push({ left: line, right: line })\n index++\n continue\n }\n const removals: DiffLine[] = []\n const additions: DiffLine[] = []\n while (lines[index]?.type === 'remove') removals.push(lines[index++])\n while (lines[index]?.type === 'add') additions.push(lines[index++])\n const height = Math.max(removals.length, additions.length)\n for (let i = 0; i < height; i++) {\n rows.push({ left: removals[i], right: additions[i] })\n }\n }\n return rows\n}\n\nfunction Gutter({ value }: { value?: number }) {\n return (\n <span\n aria-hidden=\"true\"\n className=\"text-muted-foreground/50 w-10 shrink-0 select-none px-2 text-end tabular-nums\"\n >\n {value ?? ''}\n </span>\n )\n}\n\nfunction DiffView({\n file,\n view = 'unified',\n collapsible = true,\n defaultOpen = true,\n className,\n ...props\n}: ComponentProps<'div'> & {\n file: DiffFile\n view?: 'unified' | 'split'\n collapsible?: boolean\n defaultOpen?: boolean\n}) {\n const [open, setOpen] = useState(defaultOpen)\n\n const counts = useMemo(() => {\n let additions = 0\n let deletions = 0\n for (const hunk of file.hunks) {\n for (const line of hunk.lines) {\n if (line.type === 'add') additions++\n if (line.type === 'remove') deletions++\n }\n }\n return { additions, deletions }\n }, [file])\n\n const Chevron = open ? ChevronDown : ChevronRight\n\n const header = (\n <>\n {collapsible && <Chevron className=\"text-muted-foreground size-4 shrink-0\" />}\n <FileText className=\"text-muted-foreground size-4 shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate text-start font-mono text-xs\">\n {file.previousPath && (\n <span className=\"text-muted-foreground/70\">{file.previousPath} → </span>\n )}\n {file.path}\n </span>\n {file.status && file.status !== 'modified' && (\n <Badge size=\"sm\">{file.status}</Badge>\n )}\n <DiffStat additions={counts.additions} deletions={counts.deletions} />\n </>\n )\n\n return (\n <div\n data-slot=\"diff-view\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n {collapsible ? (\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() => setOpen((value) => !value)}\n className={cn(\n 'bg-muted/40 hover:bg-muted flex w-full items-center gap-2 p-3',\n interactive,\n focusRing,\n )}\n >\n {header}\n </button>\n ) : (\n <div className=\"bg-muted/40 flex items-center gap-2 p-3\">{header}</div>\n )}\n\n {open && (\n <div className=\"overflow-x-auto font-mono text-xs leading-relaxed\">\n {file.hunks.map((hunk, hunkIndex) => (\n <div key={hunkIndex} className=\"border-border not-first:border-t\">\n <div className=\"bg-muted/50 text-muted-foreground px-3 py-1 whitespace-nowrap\">\n {hunk.header}\n </div>\n\n {view === 'unified'\n ? hunk.lines.map((line, index) => (\n <div\n key={index}\n data-type={line.type}\n className={cn('flex min-w-max', LINE_TONE[line.type])}\n >\n <Gutter value={line.oldLine} />\n <Gutter value={line.newLine} />\n <span className=\"text-muted-foreground/50 w-4 shrink-0 select-none text-center\">\n {SIGN[line.type]}\n </span>\n <span className=\"flex-1 pe-3 whitespace-pre\">{line.content}</span>\n </div>\n ))\n : pairRows(hunk.lines).map((row, index) => (\n <div key={index} className=\"flex min-w-max\">\n <div\n className={cn(\n 'flex w-1/2 min-w-0 flex-1',\n row.left && LINE_TONE[row.left.type],\n !row.left && 'bg-muted/20',\n )}\n >\n <Gutter value={row.left?.oldLine} />\n <span className=\"flex-1 pe-3 whitespace-pre\">\n {row.left?.content ?? ''}\n </span>\n </div>\n <div\n className={cn(\n 'border-border flex w-1/2 min-w-0 flex-1 border-s',\n row.right && LINE_TONE[row.right.type],\n !row.right && 'bg-muted/20',\n )}\n >\n <Gutter value={row.right?.newLine} />\n <span className=\"flex-1 pe-3 whitespace-pre\">\n {row.right?.content ?? ''}\n </span>\n </div>\n </div>\n ))}\n </div>\n ))}\n </div>\n )}\n </div>\n )\n}\n\n/** Several files in one review. */\nfunction DiffViewList({\n files,\n view,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n files: DiffFile[]\n view?: 'unified' | 'split'\n children?: ReactNode\n}) {\n return (\n <div className={cn('flex flex-col gap-3', className)} {...props}>\n {files.map((file) => (\n <DiffView key={file.path} file={file} view={view} />\n ))}\n </div>\n )\n}\n\nexport { DiffView, DiffViewList, parseUnifiedDiff }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "donut",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Donut",
|
|
5
|
+
"description": "Parts against a whole. Arcs are stroke-dasharray on a circle rather than wedge paths — one number per slice, and no arc-flag branch at the halfway point.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/donut.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useId, type ComponentProps, type ReactNode } from 'react'\nimport { dataFills } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A donut or pie chart of parts against a whole.\n *\n * Arcs are drawn with `stroke-dasharray` on a circle rather than as wedge\n * paths: one number per slice, no arc-flag branch at the halfway point, and a\n * gap between slices is a dash pattern rather than trigonometry.\n *\n * The centre label is not the accessible content — the `<title>` lists every\n * slice with its share, because a ring with \"62%\" in the middle tells a screen\n * reader nothing about the other 38.\n *\n * Slices below `minSlice` are folded into an \"Other\" entry. A pie with fourteen\n * one-percent wedges communicates less than one with five and a remainder.\n */\nexport type DonutSlice = {\n label: string\n value: number\n color?: string\n}\n\nfunction Donut({\n slices,\n size = 160,\n thickness = 18,\n gap = 1,\n minSlice = 0.02,\n centerLabel,\n centerValue,\n legend = true,\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'children'> & {\n slices: DonutSlice[]\n size?: number\n /** 0 renders a pie rather than a donut. */\n thickness?: number\n /** Degrees of space between slices. */\n gap?: number\n /** Fold anything under this share into \"Other\". */\n minSlice?: number\n centerLabel?: ReactNode\n centerValue?: ReactNode\n legend?: boolean\n}) {\n const titleId = useId()\n const total = slices.reduce((sum, slice) => sum + slice.value, 0) || 1\n\n const big = slices.filter((slice) => slice.value / total >= minSlice)\n const small = slices.filter((slice) => slice.value / total < minSlice)\n const rest = small.reduce((sum, slice) => sum + slice.value, 0)\n\n const data = rest > 0 ? [...big, { label: 'Other', value: rest }] : big\n\n // A 100-unit viewBox: geometry stays independent of the rendered size.\n const radius = 50 - thickness / 2\n const circumference = 2 * Math.PI * radius\n const gapLength = (gap / 360) * circumference\n\n let offset = 0\n\n return (\n <figure\n data-slot=\"donut\"\n className={cn('flex min-w-0 flex-wrap items-center gap-4', className)}\n {...props}\n >\n <div className=\"relative shrink-0\" style={{ width: size, height: size }}>\n <svg viewBox=\"0 0 100 100\" role=\"img\" aria-labelledby={titleId} className=\"size-full -rotate-90\">\n <title id={titleId}>\n {data\n .map((slice) => `${slice.label}: ${Math.round((slice.value / total) * 100)}%`)\n .join(', ')}\n </title>\n\n {data.map((slice, index) => {\n const share = slice.value / total\n const length = Math.max(share * circumference - gapLength, 0)\n const dash = `${length} ${circumference - length}`\n const element = (\n <circle\n key={slice.label}\n cx=\"50\"\n cy=\"50\"\n r={radius}\n fill=\"none\"\n stroke={slice.color ?? dataFills[index % dataFills.length]}\n strokeWidth={thickness || 100}\n strokeDasharray={dash}\n strokeDashoffset={-offset}\n />\n )\n offset += share * circumference\n return element\n })}\n </svg>\n\n {(centerLabel || centerValue) && (\n <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n {centerValue && (\n <span className=\"text-xl font-semibold tabular-nums\">{centerValue}</span>\n )}\n {centerLabel && (\n <span className=\"text-muted-foreground text-xs\">{centerLabel}</span>\n )}\n </div>\n )}\n </div>\n\n {legend && (\n <figcaption className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n {data.map((slice, index) => (\n <span key={slice.label} className=\"flex items-center gap-2 text-xs\">\n <span\n aria-hidden=\"true\"\n className=\"size-2.5 shrink-0 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: slice.color ?? dataFills[index % dataFills.length] }}\n />\n <span className=\"text-muted-foreground min-w-0 flex-1 truncate\">\n {slice.label}\n </span>\n <span className=\"shrink-0 font-medium tabular-nums\">\n {Math.round((slice.value / total) * 100)}%\n </span>\n </span>\n ))}\n </figcaption>\n )}\n </figure>\n )\n}\n\nexport { Donut }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drawer",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Drawer",
|
|
5
|
+
"description": "A bottom sheet you can drag away. Built on a native dialog, so focus containment and inerting the page come from the platform rather than a hand-rolled trap.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/drawer.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A sheet that slides from the bottom and can be dragged away.\n *\n * `Sheet` covers side panels; this is the mobile gesture — a grab handle, a\n * drag that follows the finger, and a release that either snaps back or\n * dismisses.\n *\n * Built on a native `<dialog>` with `showModal()`, like the rest of the\n * overlays here, so focus containment, inerting the page and the top layer come\n * from the platform rather than from a hand-rolled trap.\n *\n * Two details make the drag feel right. Movement is applied as a transform with\n * the transition switched off while the finger is down — animating a value you\n * are also setting every frame produces lag against the pointer. And dismissal\n * is decided by velocity as well as distance, so a quick flick closes it\n * without dragging the whole height.\n */\nfunction Drawer({\n open,\n onOpenChange,\n children,\n className,\n ...props\n}: Omit<ComponentProps<'dialog'>, 'open' | 'onToggle'> & {\n open: boolean\n onOpenChange: (open: boolean) => void\n children: ReactNode\n}) {\n const dialogRef = useRef<HTMLDialogElement>(null)\n const [offset, setOffset] = useState(0)\n const drag = useRef<{ startY: number; startTime: number } | null>(null)\n\n useEffect(() => {\n const dialog = dialogRef.current\n if (!dialog) return\n\n if (open && !dialog.open) {\n setOffset(0)\n dialog.showModal()\n } else if (!open && dialog.open) {\n dialog.close()\n }\n }, [open])\n\n function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n drag.current = { startY: event.clientY, startTime: performance.now() }\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n if (!drag.current) return\n // Downward only: dragging up should not lift the drawer off the edge.\n setOffset(Math.max(0, event.clientY - drag.current.startY))\n }\n\n function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {\n if (!drag.current) return\n event.currentTarget.releasePointerCapture(event.pointerId)\n\n const distance = offset\n const elapsed = performance.now() - drag.current.startTime\n const velocity = distance / Math.max(elapsed, 1)\n drag.current = null\n\n const height = dialogRef.current?.offsetHeight ?? 1\n // A flick counts even when it barely moved.\n if (distance > height * 0.4 || velocity > 0.5) onOpenChange(false)\n else setOffset(0)\n }\n\n return (\n <dialog\n ref={dialogRef}\n data-slot=\"drawer\"\n onClose={() => onOpenChange(false)}\n onCancel={(event) => {\n event.preventDefault()\n onOpenChange(false)\n }}\n onClick={(event) => {\n // The dialog element itself is the backdrop area.\n if (event.target === dialogRef.current) onOpenChange(false)\n }}\n style={{ transform: offset ? `translateY(${offset}px)` : undefined }}\n className={cn(\n 'bg-card text-card-foreground border-border w-full max-w-none border-t',\n // The UA centres a dialog; release the other edges so it sits flush.\n 'top-auto bottom-0 m-0 mt-auto max-h-[90svh] p-0',\n radius.panel,\n 'rounded-b-none',\n 'backdrop:bg-black/50',\n offset === 0 && 'transition-transform duration-200 ease-out motion-reduce:transition-none',\n className,\n )}\n {...props}\n >\n <div\n // The grab handle. Pointer-only by design: keyboard users dismiss with\n // Escape, which the native dialog already handles.\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n className=\"flex touch-none justify-center py-3\"\n >\n <span aria-hidden=\"true\" className=\"bg-border h-1 w-10 rounded-full [corner-shape:round]\" />\n </div>\n\n <div className=\"max-h-[calc(90svh-2.5rem)] overflow-y-auto px-4 pb-6\">\n {children}\n </div>\n </dialog>\n )\n}\n\nfunction DrawerHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"drawer-header\"\n className={cn('mb-3 flex flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\nfunction DrawerTitle({ className, ...props }: ComponentProps<'h2'>) {\n return (\n <h2\n data-slot=\"drawer-title\"\n className={cn('text-base font-semibold', className)}\n {...props}\n />\n )\n}\n\nfunction DrawerDescription({ className, ...props }: ComponentProps<'p'>) {\n return (\n <p\n data-slot=\"drawer-description\"\n className={cn('text-muted-foreground text-sm', className)}\n {...props}\n />\n )\n}\n\nfunction DrawerFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"drawer-footer\"\n className={cn('mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}\n {...props}\n />\n )\n}\n\nexport { Drawer, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-menu",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Menu",
|
|
5
|
+
"description": "A menu of actions hung off a trigger. Unlike Select this is a list of commands, not a value — items are buttons, the menu closes on activation, and focus returns to the trigger.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-dismissable",
|
|
14
|
+
"primitive-popper",
|
|
15
|
+
"primitive-slot"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/dropdown-menu.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import {\n createContext,\n use,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Check } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper, type Align, type Side } from '@/components/primitives/popper'\nimport { Slot } from '@/components/primitives/slot'\nimport { menuItem, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A menu of actions hung off a trigger.\n *\n * Unlike Select this is a list of commands, not a value — so items are buttons\n * with `role=\"menuitem\"`, the menu closes on activation, and focus goes back to\n * the trigger. Roving focus is done by querying the menu on keydown, which keeps\n * conditionally rendered items in the right order.\n */\ntype MenuContextValue = {\n open: boolean\n setOpen: (open: boolean) => void\n triggerRef: React.RefObject<HTMLElement | null>\n contentRef: React.RefObject<HTMLDivElement | null>\n}\n\nconst MenuContext = createContext<MenuContextValue | null>(null)\n\nfunction useMenu() {\n const context = use(MenuContext)\n if (!context) throw new Error('Must be used inside <DropdownMenu>')\n return context\n}\n\nfunction items(container: HTMLElement | null) {\n if (!container) return []\n return Array.from(\n container.querySelectorAll<HTMLElement>(\n '[role=\"menuitem\"]:not([data-disabled=\"true\"]),[role=\"menuitemcheckbox\"]:not([data-disabled=\"true\"])',\n ),\n )\n}\n\nfunction DropdownMenu({\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n children,\n}: {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const open = controlled ? openProp : uncontrolled\n\n const triggerRef = useRef<HTMLElement>(null)\n const contentRef = useRef<HTMLDivElement>(null)\n\n const context = useMemo<MenuContextValue>(\n () => ({\n open,\n triggerRef,\n contentRef,\n setOpen: (next) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n if (!next) triggerRef.current?.focus()\n },\n }),\n [open, controlled, onOpenChange],\n )\n\n return <MenuContext value={context}>{children}</MenuContext>\n}\n\nfunction DropdownMenuTrigger({\n asChild = false,\n onClick,\n onKeyDown,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { open, setOpen, triggerRef, contentRef } = useMenu()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n ref={triggerRef as React.Ref<HTMLButtonElement>}\n data-slot=\"dropdown-menu-trigger\"\n aria-haspopup=\"menu\"\n aria-expanded={open}\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(!open)\n }}\n onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n if (['ArrowDown', 'Enter', ' '].includes(event.key)) {\n event.preventDefault()\n setOpen(true)\n // Wait a frame so the menu exists before focus moves into it.\n requestAnimationFrame(() => items(contentRef.current)[0]?.focus())\n }\n }}\n {...props}\n />\n )\n}\n\nfunction DropdownMenuContent({\n className,\n side = 'bottom',\n align = 'start',\n offset = 6,\n ...props\n}: ComponentProps<'div'> & { side?: Side; align?: Align; offset?: number }) {\n const { open, setOpen, triggerRef, contentRef } = useMenu()\n\n const { style, side: resolved } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: contentRef,\n side,\n align,\n offset,\n })\n\n useDismissable({\n open,\n onDismiss: () => setOpen(false),\n refs: [triggerRef, contentRef],\n })\n\n useEffect(() => {\n if (!open) return\n const frame = requestAnimationFrame(() =>\n items(contentRef.current)[0]?.focus(),\n )\n return () => cancelAnimationFrame(frame)\n }, [open, contentRef])\n\n if (!open) return null\n\n return (\n <div\n ref={contentRef}\n role=\"menu\"\n data-slot=\"dropdown-menu-content\"\n data-side={resolved}\n style={style}\n onKeyDown={(event) => {\n const list = items(contentRef.current)\n if (list.length === 0) return\n const index = list.indexOf(document.activeElement as HTMLElement)\n\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n list[(index + 1) % list.length]?.focus()\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n list[(index - 1 + list.length) % list.length]?.focus()\n } else if (event.key === 'Home') {\n event.preventDefault()\n list[0]?.focus()\n } else if (event.key === 'End') {\n event.preventDefault()\n list[list.length - 1]?.focus()\n }\n }}\n className={cn(menuSurface, radius.surface, 'min-w-48', className)}\n {...props}\n />\n )\n}\n\nfunction DropdownMenuItem({\n className,\n disabled,\n onSelect,\n onClick,\n ...props\n}: Omit<ComponentProps<'button'>, 'onSelect'> & {\n /** Fires on activation, before the menu closes. */\n onSelect?: () => void\n}) {\n const { setOpen } = useMenu()\n\n return (\n <button\n type=\"button\"\n role=\"menuitem\"\n data-slot=\"dropdown-menu-item\"\n data-disabled={disabled || undefined}\n disabled={disabled}\n onClick={(event) => {\n onClick?.(event)\n if (event.defaultPrevented) return\n onSelect?.()\n setOpen(false)\n }}\n className={cn(menuItem, radius.control, className)}\n {...props}\n />\n )\n}\n\nfunction DropdownMenuCheckboxItem({\n className,\n checked = false,\n disabled,\n onCheckedChange,\n children,\n ...props\n}: Omit<ComponentProps<'button'>, 'onChange'> & {\n checked?: boolean\n onCheckedChange?: (checked: boolean) => void\n}) {\n return (\n <button\n type=\"button\"\n role=\"menuitemcheckbox\"\n aria-checked={checked}\n data-slot=\"dropdown-menu-checkbox-item\"\n data-disabled={disabled || undefined}\n disabled={disabled}\n onClick={() => onCheckedChange?.(!checked)}\n className={cn(menuItem, radius.control, className)}\n {...props}\n >\n <span className=\"flex-1\">{children}</span>\n {checked && <Check className=\"size-3.5 shrink-0\" />}\n </button>\n )\n}\n\nfunction DropdownMenuLabel({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"dropdown-menu-label\"\n className={cn(\n 'text-muted-foreground px-2.5 py-1.5 text-xs font-medium',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction DropdownMenuSeparator({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n role=\"separator\"\n data-slot=\"dropdown-menu-separator\"\n className={cn('bg-border -mx-1 my-1 h-px', className)}\n {...props}\n />\n )\n}\n\nfunction DropdownMenuShortcut({ className, ...props }: ComponentProps<'span'>) {\n return (\n <span\n data-slot=\"dropdown-menu-shortcut\"\n className={cn(\n 'text-muted-foreground ms-auto font-mono text-xs tracking-widest',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropzone",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropzone",
|
|
5
|
+
"description": "A drop target that is also a button, so the file picker stays reachable from the keyboard. Drag highlighting counts enter and leave rather than tracking a flag, which is what stops it flickering over child elements.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/dropzone.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import {\n useId,\n useRef,\n useState,\n type ComponentProps,\n type DragEvent,\n type ReactNode,\n} from 'react'\nimport { File as FileIcon, Upload, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { disabledState, focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A drop target that is also a button.\n *\n * Drag and drop is a pointer-only affordance, so the zone is a real `<button>`\n * wrapping a visually hidden `<input type=\"file\">` — keyboard and screen-reader\n * users get the file picker, everyone else can drag onto it. A div with a drop\n * handler and no keyboard path is the usual version of this component and it\n * is unusable without a mouse.\n *\n * `dragDepth` counts enter/leave rather than tracking a boolean: dragging over\n * a child fires `dragleave` on the parent, so a boolean flickers the highlight\n * off every time the pointer crosses the icon inside the zone.\n */\nfunction formatBytes(bytes: number) {\n if (bytes < 1024) return `${bytes} B`\n const units = ['KB', 'MB', 'GB']\n let value = bytes / 1024\n let unit = 0\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024\n unit++\n }\n return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`\n}\n\ntype DropzoneProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n onFiles?: (files: File[]) => void\n accept?: string\n multiple?: boolean\n disabled?: boolean\n /** Rejected above this, in bytes. */\n maxSize?: number\n label?: ReactNode\n hint?: ReactNode\n /** Show the accepted files under the zone. */\n showList?: boolean\n}\n\nfunction Dropzone({\n onFiles,\n accept,\n multiple = false,\n disabled = false,\n maxSize,\n label = 'Drop files here, or browse',\n hint,\n showList = true,\n className,\n ...props\n}: DropzoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [dragDepth, setDragDepth] = useState(0)\n const [files, setFiles] = useState<File[]>([])\n const [error, setError] = useState<string | null>(null)\n const errorId = useId()\n\n const over = dragDepth > 0\n\n function accepted(list: FileList | null) {\n if (!list) return\n const incoming = [...list]\n const tooBig = maxSize ? incoming.find((f) => f.size > maxSize) : undefined\n if (tooBig) {\n setError(`${tooBig.name} is larger than ${formatBytes(maxSize!)}`)\n return\n }\n setError(null)\n const next = multiple ? [...files, ...incoming] : incoming.slice(0, 1)\n setFiles(next)\n onFiles?.(next)\n }\n\n function onDrop(event: DragEvent<HTMLElement>) {\n event.preventDefault()\n setDragDepth(0)\n if (disabled) return\n accepted(event.dataTransfer.files)\n }\n\n function remove(index: number) {\n const next = files.filter((_, i) => i !== index)\n setFiles(next)\n onFiles?.(next)\n }\n\n return (\n <div data-slot=\"dropzone\" className={cn('flex flex-col gap-2', className)} {...props}>\n <button\n type=\"button\"\n disabled={disabled}\n aria-describedby={error ? errorId : undefined}\n onClick={() => inputRef.current?.click()}\n onDragEnter={(event) => {\n event.preventDefault()\n setDragDepth((depth) => depth + 1)\n }}\n onDragLeave={() => setDragDepth((depth) => Math.max(0, depth - 1))}\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n data-dragging={over}\n className={cn(\n 'border-border flex w-full flex-col items-center justify-center gap-2 border border-dashed px-6 py-8 text-center',\n radius.surface,\n interactive,\n focusRing,\n disabledState,\n over\n ? 'border-primary bg-accent text-foreground'\n : 'text-muted-foreground hover:bg-accent/40 hover:text-foreground',\n )}\n >\n <Upload className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{label}</span>\n {hint && <span className=\"text-muted-foreground text-xs\">{hint}</span>}\n {maxSize && !hint && (\n <span className=\"text-muted-foreground text-xs\">\n Up to {formatBytes(maxSize)}\n </span>\n )}\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled}\n // The button above is the control; this stays out of the tab order so\n // there is one stop, not two.\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"sr-only\"\n onChange={(event) => {\n accepted(event.target.files)\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n />\n\n {error && (\n <p id={errorId} role=\"alert\" className=\"text-[var(--destructive-soft-foreground)] text-xs\">\n {error}\n </p>\n )}\n\n {showList && files.length > 0 && (\n <ul className=\"flex list-none flex-col gap-1\">\n {files.map((file, index) => (\n <li\n key={`${file.name}-${index}`}\n className={cn(\n 'border-border bg-card flex items-center gap-2 border p-3 text-sm',\n radius.control,\n )}\n >\n <FileIcon className=\"text-muted-foreground size-4 shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate\">{file.name}</span>\n <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n {formatBytes(file.size)}\n </span>\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={`Remove ${file.name}`}\n onClick={() => remove(index)}\n >\n <X />\n </Button>\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { Dropzone }\nexport type { DropzoneProps }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "empty",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Empty",
|
|
5
|
+
"description": "The state a list is in before it has anything in it. An icon, a reason and a next action are what make the empty case useful rather than merely honest.",
|
|
6
|
+
"category": "Display",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/empty.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The state a list is in before it has anything in it.\n *\n * Worth a component because the empty case is the one most often left as a bare\n * sentence — an icon, a reason and a next action are what make it useful rather\n * than merely honest.\n */\nfunction Empty({\n className,\n icon,\n title,\n description,\n action,\n bordered = true,\n ...props\n}: ComponentProps<'div'> & {\n icon?: ReactNode\n title?: ReactNode\n description?: ReactNode\n action?: ReactNode\n bordered?: boolean\n}) {\n return (\n <div\n data-slot=\"empty\"\n className={cn(\n 'flex flex-col items-center justify-center gap-3 px-6 py-12 text-center',\n bordered && `border-border border border-dashed ${radius.panel}`,\n className,\n )}\n {...props}\n >\n {icon && (\n <div className=\"bg-secondary text-muted-foreground grid size-10 place-items-center rounded-full [&_svg]:size-5\">\n {icon}\n </div>\n )}\n <div className=\"space-y-1\">\n {title && <p className=\"text-sm font-medium\">{title}</p>}\n {description && (\n <p className=\"text-muted-foreground mx-auto max-w-sm text-xs\">\n {description}\n </p>\n )}\n </div>\n {action && <div className=\"pt-1\">{action}</div>}\n </div>\n )\n}\n\nexport { Empty }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "endpoint-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Endpoint List",
|
|
5
|
+
"description": "API routes, grouped and filterable. The method badge is fixed-width because a route table is scanned down the method column, and a ragged left edge makes that scan impossible.",
|
|
6
|
+
"category": "API",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"http-status",
|
|
13
|
+
"input",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/endpoint-list.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Lock, Search } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { HttpStatus } from '@/components/ui/http-status'\nimport { Input } from '@/components/ui/input'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * API routes, grouped and filterable.\n *\n * The method badge is fixed-width and colour-coded. A route table is scanned\n * down the method column, and a ragged left edge where GET and DELETE differ in\n * width makes that scan impossible.\n *\n * Deprecated routes are struck through but kept. Removing them from the list is\n * how a client keeps calling one for another year — the point of marking it is\n * that someone sees it.\n *\n * Paths are matched on a plain substring including the method, so \"post /users\"\n * filters the way people actually type.\n */\nexport type Endpoint = {\n id: string\n method: string\n path: string\n summary?: ReactNode\n group?: string\n /** Requires credentials. */\n auth?: boolean\n deprecated?: boolean\n /** Last observed status, if you have one. */\n status?: number\n}\n\ntype BadgeColor = 'blue' | 'green' | 'amber' | 'destructive' | 'neutral'\n\nconst METHOD_COLOR: Record<string, BadgeColor> = {\n GET: 'blue',\n POST: 'green',\n PUT: 'amber',\n PATCH: 'amber',\n DELETE: 'destructive',\n HEAD: 'neutral',\n OPTIONS: 'neutral',\n}\n\nfunction EndpointList({\n endpoints,\n searchable = true,\n onSelect,\n selected,\n searchPlaceholder = 'Filter routes',\n searchLabel = 'Filter routes',\n emptyMessage = 'No routes match.',\n authLabel = 'requires auth',\n deprecatedLabel = 'deprecated',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n endpoints: Endpoint[]\n searchable?: boolean\n onSelect?: (id: string) => void\n selected?: string\n searchPlaceholder?: string\n searchLabel?: string\n emptyMessage?: ReactNode\n /** Accessible name for the lock icon. */\n authLabel?: string\n deprecatedLabel?: ReactNode\n}) {\n const [query, setQuery] = useState('')\n\n const groups = useMemo(() => {\n const needle = query.trim().toLowerCase()\n // Method included in the haystack, so \"post /users\" filters as typed.\n const rows = needle\n ? endpoints.filter((e) =>\n `${e.method} ${e.path} ${e.group ?? ''}`.toLowerCase().includes(needle),\n )\n : endpoints\n\n const map = new Map<string, Endpoint[]>()\n for (const endpoint of rows) {\n const key = endpoint.group ?? ''\n map.set(key, [...(map.get(key) ?? []), endpoint])\n }\n return [...map.entries()]\n }, [endpoints, query])\n\n const total = groups.reduce((sum, [, rows]) => sum + rows.length, 0)\n\n return (\n <div\n data-slot=\"endpoint-list\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n {searchable && (\n <div className=\"border-border border-b p-3\">\n <Input\n size=\"sm\"\n value={query}\n icon={<Search />}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n )}\n\n {total === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyMessage}</p>\n ) : (\n <div className=\"divide-border divide-y\">\n {groups.map(([group, rows]) => (\n <section key={group || '_'}>\n {group && (\n <h3 className=\"bg-muted/40 text-muted-foreground p-3 text-xs font-medium\">\n {group}\n </h3>\n )}\n <ul className=\"divide-border/60 list-none divide-y\">\n {rows.map((endpoint) => {\n const body = (\n <>\n {/* Fixed width: the method column is what gets scanned. */}\n <Badge\n size=\"sm\"\n color={METHOD_COLOR[endpoint.method.toUpperCase()] ?? 'neutral'}\n className=\"w-16 shrink-0 justify-center font-mono\"\n >\n {endpoint.method.toUpperCase()}\n </Badge>\n\n <span className=\"min-w-0 flex-1 text-start\">\n <span\n className={cn(\n 'block truncate font-mono text-xs',\n endpoint.deprecated && 'text-muted-foreground line-through',\n )}\n >\n {endpoint.path}\n </span>\n {endpoint.summary && (\n <span className=\"text-muted-foreground block truncate text-xs\">\n {endpoint.summary}\n </span>\n )}\n </span>\n\n {endpoint.deprecated && (\n <Badge size=\"sm\" color=\"amber\" className=\"shrink-0\">\n {deprecatedLabel}\n </Badge>\n )}\n {endpoint.auth && (\n <Lock\n className=\"text-muted-foreground/60 size-3.5 shrink-0\"\n aria-label={authLabel}\n />\n )}\n {endpoint.status !== undefined && (\n <HttpStatus status={endpoint.status} showPhrase={false} className=\"shrink-0\" />\n )}\n </>\n )\n\n const classes = cn(\n 'flex w-full items-center gap-3 p-3',\n selected === endpoint.id && 'bg-accent/50',\n )\n\n return (\n <li key={endpoint.id}>\n {onSelect ? (\n <button\n type=\"button\"\n className={cn(classes, 'text-start', interactive, focusRing)}\n onClick={() => onSelect(endpoint.id)}\n >\n {body}\n </button>\n ) : (\n <div className={classes}>{body}</div>\n )}\n </li>\n )\n })}\n </ul>\n </section>\n ))}\n </div>\n )}\n </div>\n )\n}\n\nexport { EndpointList }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "env-diff",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Env Diff",
|
|
5
|
+
"description": "Two environments side by side, values masked by default. A variable missing from one side is drawn differently from one whose value merely differs — \"not set\" and \"set to something else\" have different causes.",
|
|
6
|
+
"category": "Runtime",
|
|
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/env-diff.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Eye, EyeOff, TriangleAlert } 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 environments' variables side by side.\n *\n * Values are masked by default and revealed per row. This is the screen someone\n * opens when staging behaves differently from production, and it is exactly the\n * screen that gets screenshotted into a shared channel with a database password\n * in it.\n *\n * A variable missing from one side is the finding, and it is drawn differently\n * from one whose value merely differs — \"not set\" and \"set to something else\"\n * have completely different causes. Both are ranked above equal rows.\n *\n * Whitespace and length differences are called out even when the masked values\n * look identical, because a trailing newline in a secret is a genuinely common\n * and genuinely invisible outage.\n *\n * One grid owns the columns and every row inherits them through `subgrid`.\n * Per-row grids only agree on fixed tracks: `1fr` and `auto` resolve against\n * whatever that row holds, so a row with a reveal button lines up differently\n * from one without.\n */\nexport type EnvEntry = { key: string; value: string | undefined; secret?: boolean }\n\ntype Row = {\n key: string\n left: string | undefined\n right: string | undefined\n secret: boolean\n state: 'missing-left' | 'missing-right' | 'differs' | 'same'\n whitespace: boolean\n}\n\nconst RANK = { 'missing-left': 0, 'missing-right': 0, differs: 1, same: 2 }\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_TOGGLE_SAME_LABEL: (count: number) => ReactNode = (count) => `Show ${count} identical`\n\nfunction EnvDiff({\n left,\n right,\n leftLabel = 'Left',\n rightLabel = 'Right',\n notSetLabel = 'not set',\n whitespaceNote = 'differs only in whitespace',\n revealLabel = 'Reveal value',\n hideLabel = 'Hide value',\n sameLabel = 'identical',\n showSame = false,\n toggleSameLabel = DEFAULT_TOGGLE_SAME_LABEL,\n hideSameLabel = 'Hide identical',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n left: EnvEntry[]\n right: EnvEntry[]\n leftLabel?: ReactNode\n rightLabel?: ReactNode\n notSetLabel?: ReactNode\n whitespaceNote?: ReactNode\n revealLabel?: string\n hideLabel?: string\n sameLabel?: ReactNode\n showSame?: boolean\n toggleSameLabel?: (count: number) => ReactNode\n hideSameLabel?: ReactNode\n}) {\n const [revealed, setRevealed] = useState<string[]>([])\n const [same, setSame] = useState(showSame)\n\n const rows = useMemo<Row[]>(() => {\n const leftMap = new Map(left.map((e) => [e.key, e]))\n const rightMap = new Map(right.map((e) => [e.key, e]))\n const keys = [...new Set([...leftMap.keys(), ...rightMap.keys()])].sort()\n\n return keys\n .map((key) => {\n const a = leftMap.get(key)\n const b = rightMap.get(key)\n const state: Row['state'] =\n a?.value === undefined && b?.value !== undefined\n ? 'missing-left'\n : b?.value === undefined && a?.value !== undefined\n ? 'missing-right'\n : a?.value === b?.value\n ? 'same'\n : 'differs'\n return {\n key,\n left: a?.value,\n right: b?.value,\n secret: Boolean(a?.secret || b?.secret),\n state,\n // A trailing newline in a secret is invisible and takes a service down.\n whitespace:\n state === 'differs' &&\n (a?.value ?? '').trim() === (b?.value ?? '').trim(),\n }\n })\n .sort((x, y) => RANK[x.state] - RANK[y.state] || x.key.localeCompare(y.key))\n }, [left, right])\n\n const sameCount = rows.filter((row) => row.state === 'same').length\n const visible = same ? rows : rows.filter((row) => row.state !== 'same')\n\n const show = (row: Row, value: string | undefined) => {\n if (value === undefined) {\n return <span className=\"text-muted-foreground/60 italic\">{notSetLabel}</span>\n }\n if (row.secret && !revealed.includes(row.key)) {\n return <span className=\"tracking-widest\">{'•'.repeat(Math.min(value.length, 16))}</span>\n }\n return value === '' ? <span className=\"text-muted-foreground/60 italic\">\"\"</span> : value\n }\n\n return (\n <div\n data-slot=\"env-diff\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"grid grid-cols-[minmax(0,12rem)_minmax(0,1fr)_minmax(0,1fr)_auto]\">\n <div className=\"border-border col-span-4 grid grid-cols-subgrid items-center gap-3 border-b p-3 text-xs font-medium\">\n <span className=\"text-muted-foreground\">Variable</span>\n <span>{leftLabel}</span>\n <span>{rightLabel}</span>\n {sameCount > 0 && (\n <Button variant=\"ghost\" size=\"xs\" onClick={() => setSame((v) => !v)}>\n {same ? hideSameLabel : toggleSameLabel(sameCount)}\n </Button>\n )}\n </div>\n\n <ul className=\"divide-border/60 col-span-4 grid grid-cols-subgrid list-none divide-y\">\n {visible.map((row) => (\n <li\n key={row.key}\n className=\"col-span-4 grid grid-cols-subgrid items-baseline gap-3 p-3\"\n >\n <span className=\"flex min-w-0 items-center gap-1.5\">\n <code className=\"truncate font-mono text-xs font-medium\">{row.key}</code>\n {row.state === 'same' && (\n <Badge size=\"sm\" color=\"neutral\">\n {sameLabel}\n </Badge>\n )}\n </span>\n\n <code\n className={cn(\n 'min-w-0 truncate font-mono text-xs',\n row.state === 'missing-left' && 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-1',\n )}\n >\n {show(row, row.left)}\n </code>\n\n <code\n className={cn(\n 'min-w-0 truncate font-mono text-xs',\n row.state === 'missing-right' && 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-1',\n row.state === 'differs' && 'bg-[color-mix(in_oklab,var(--amber)_14%,transparent)] px-1',\n )}\n >\n {show(row, row.right)}\n </code>\n\n {row.secret ? (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={revealed.includes(row.key) ? hideLabel : revealLabel}\n aria-pressed={revealed.includes(row.key)}\n onClick={() =>\n setRevealed((current) =>\n current.includes(row.key)\n ? current.filter((k) => k !== row.key)\n : [...current, row.key],\n )\n }\n >\n {revealed.includes(row.key) ? <EyeOff /> : <Eye />}\n </Button>\n ) : (\n <span />\n )}\n\n {row.whitespace && (\n <p className=\"text-[var(--amber-soft-foreground)] col-span-4 flex items-center gap-1.5 text-xs\">\n <TriangleAlert className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n {whitespaceNote}\n </p>\n )}\n </li>\n ))}\n </ul>\n </div>\n </div>\n )\n}\n\nexport { EnvDiff }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "env-vars",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Env Vars",
|
|
5
|
+
"description": "Environment variables with secrets masked until asked for. Reveal is per-row and never sticky — nothing survives a remount, so a shared screen cannot leak what someone expanded an hour ago.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"copy-button",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/env-vars.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useState, type ComponentProps } from 'react'\nimport { Eye, EyeOff, Lock } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Environment variables, with secrets masked until asked for.\n *\n * Reveal is per-row and never sticky — there is no \"show all\", and nothing is\n * remembered across a remount. Someone screen-sharing a deploy dashboard should\n * not have every production secret on screen because they expanded one an hour\n * ago.\n *\n * The mask is a fixed width rather than the value's real length, since the\n * length of a secret is itself a hint about what it is.\n */\nexport type EnvVar = {\n key: string\n value: string\n /** Masked until revealed. */\n secret?: boolean\n /** Which environments it applies to. */\n scopes?: string[]\n updated?: string\n}\n\nconst MASK = '••••••••••••'\n\nfunction EnvVars({\n vars,\n className,\n ...props\n}: ComponentProps<'div'> & { vars: EnvVar[] }) {\n const [revealed, setRevealed] = useState<string[]>([])\n\n return (\n <div\n data-slot=\"env-vars\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {vars.map((entry) => {\n const open = revealed.includes(entry.key)\n const masked = entry.secret && !open\n\n return (\n <li\n key={entry.key}\n className=\"flex flex-col gap-2 p-3 sm:flex-row sm:items-center sm:gap-3\"\n >\n <div className=\"flex min-w-0 items-center gap-2 sm:w-56\">\n {entry.secret && (\n <Lock className=\"text-muted-foreground size-3.5 shrink-0\" aria-hidden=\"true\" />\n )}\n <code className=\"min-w-0 truncate font-mono text-xs font-medium\">\n {entry.key}\n </code>\n </div>\n\n <code\n className={cn(\n 'text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs',\n masked && 'tracking-widest',\n )}\n >\n {masked ? MASK : entry.value}\n </code>\n\n {entry.scopes && (\n <div className=\"flex shrink-0 flex-wrap gap-1\">\n {entry.scopes.map((scope) => (\n <Badge key={scope} size=\"sm\">\n {scope}\n </Badge>\n ))}\n </div>\n )}\n\n <div className=\"flex shrink-0 items-center gap-1\">\n {entry.secret && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={open ? `Hide ${entry.key}` : `Reveal ${entry.key}`}\n aria-pressed={open}\n onClick={() =>\n setRevealed((current) =>\n open\n ? current.filter((key) => key !== entry.key)\n : [...current, entry.key],\n )\n }\n >\n {open ? <EyeOff /> : <Eye />}\n </Button>\n )}\n {/* Copies the real value even while masked — the usual reason\n to open one of these is to paste it somewhere. */}\n <CopyButton value={entry.value} label={`Copy ${entry.key}`} />\n </div>\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { EnvVars }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "error-boundary",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Error Boundary",
|
|
5
|
+
"description": "Catches a render error in its subtree and shows a fallback. The one component here that must be a class — getDerivedStateFromError has no hook equivalent.",
|
|
6
|
+
"category": "Utility",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/error-boundary.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { Component, type ErrorInfo, type ReactNode } from 'react'\nimport { RotateCcw, TriangleAlert } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Catches a render error in its subtree and shows a fallback.\n *\n * The one component here that must be a class: `componentDidCatch` and\n * `getDerivedStateFromError` have no hook equivalent, and React has said they\n * are not getting one.\n *\n * `resetKeys` is what makes recovery work. Clearing the error alone re-renders\n * the same children with the same props and throws again immediately — the\n * boundary has to be told what changed. Pass whatever caused the failure (a\n * route, a record id) and it resets when that changes.\n *\n * It deliberately does not report anywhere. Wire `onError` to your own\n * telemetry rather than having a UI component decide where errors go.\n */\ntype ErrorBoundaryProps = {\n children: ReactNode\n /** Rendered instead of the subtree. Receives the error and a reset callback. */\n fallback?: (error: Error, reset: () => void) => ReactNode\n onError?: (error: Error, info: ErrorInfo) => void\n /** Clears the error whenever any of these change. */\n resetKeys?: unknown[]\n title?: string\n /** Text on the reset button in the default fallback. */\n retryLabel?: ReactNode\n}\n\ntype ErrorBoundaryState = { error: Error | null }\n\nclass ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n state: ErrorBoundaryState = { error: null }\n\n static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo) {\n this.props.onError?.(error, info)\n }\n\n componentDidUpdate(previous: ErrorBoundaryProps) {\n const before = previous.resetKeys\n const after = this.props.resetKeys\n if (!this.state.error || !before || !after) return\n\n const changed =\n before.length !== after.length ||\n before.some((key, index) => !Object.is(key, after[index]))\n\n if (changed) this.reset()\n }\n\n reset = () => this.setState({ error: null })\n\n render() {\n const { error } = this.state\n if (!error) return this.props.children\n\n if (this.props.fallback) return this.props.fallback(error, this.reset)\n\n return (\n <div\n role=\"alert\"\n data-slot=\"error-boundary\"\n className={cn(surface, radius.surface, 'flex flex-col items-start gap-3 p-4')}\n >\n <div className=\"flex items-center gap-2\">\n <TriangleAlert\n className=\"size-4 shrink-0 text-[var(--destructive-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <h2 className=\"text-sm font-medium\">\n {this.props.title ?? 'Something went wrong'}\n </h2>\n </div>\n\n <p className=\"text-muted-foreground font-mono text-xs break-words\">\n {error.message}\n </p>\n\n <Button variant=\"secondary\" size=\"sm\" onClick={this.reset}>\n <RotateCcw />\n {this.props.retryLabel ?? 'Try again'}\n </Button>\n </div>\n )\n }\n}\n\nexport { ErrorBoundary }\nexport type { ErrorBoundaryProps }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|