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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ticket-card",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Ticket Card",
|
|
5
|
+
"description": "A support ticket with its SLA. Breach risk is computed from the due time, not from how old the ticket is — a two-week ticket with a monthly SLA is fine, and an hour-old one may not be.",
|
|
6
|
+
"category": "Messaging",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"avatar",
|
|
12
|
+
"badge",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/ticket-card.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Clock, MessageSquare, TriangleAlert } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A support ticket with its SLA state.\n *\n * The SLA clock is the point. A ticket list sorted by age is not the same as\n * one sorted by time-to-breach — a low-priority ticket from last week may have\n * days left while an urgent one from this morning is already overdue. Breach\n * risk is computed from the due time, not inferred from age.\n *\n * \"Waiting on customer\" pauses the clock, which is why it is a distinct status\n * rather than a flavour of open. Counting that time against the team is how SLA\n * reports become fiction.\n */\nexport type TicketStatus = 'open' | 'pending' | 'waiting' | 'resolved' | 'closed'\nexport type TicketPriority = 'urgent' | 'high' | 'normal' | 'low'\n\nconst STATUS = {\n open: { label: 'Open', color: 'blue' },\n pending: { label: 'In progress', color: 'violet' },\n waiting: { label: 'Waiting on customer', color: 'amber' },\n resolved: { label: 'Resolved', color: 'green' },\n closed: { label: 'Closed', color: 'neutral' },\n} as const\n\nconst PRIORITY = {\n urgent: { label: 'Urgent', color: 'destructive' },\n high: { label: 'High', color: 'amber' },\n normal: { label: 'Normal', color: 'neutral' },\n low: { label: 'Low', color: 'neutral' },\n} as const\n\nfunction TicketCard({\n id,\n subject,\n status,\n priority = 'normal',\n requester,\n requesterAvatar,\n assignee,\n updatedAt,\n dueAt,\n now,\n replies,\n tags,\n locale = 'en-GB',\n breachedLabel = 'SLA breached',\n dueSoonLabel = 'Due soon',\n updatedLabel = 'updated',\n dueLabel = 'due',\n pausedLabel = 'SLA clock paused',\n className,\n ...props\n}: Omit<ComponentProps<'article'>, 'id'> & {\n id: ReactNode\n subject: ReactNode\n status: TicketStatus\n priority?: TicketPriority\n requester?: ReactNode\n requesterAvatar?: ReactNode\n assignee?: ReactNode\n updatedAt?: Date\n /** SLA target. Breach risk is computed from this, not from age. */\n dueAt?: Date\n now?: Date\n replies?: number\n tags?: ReactNode\n locale?: string\n /** Badge once `dueAt` has passed. */\n breachedLabel?: ReactNode\n dueSoonLabel?: ReactNode\n /** Precedes the last-updated time. */\n updatedLabel?: ReactNode\n /** Precedes the due time. */\n dueLabel?: ReactNode\n /** Shown for a status that stops the SLA clock. */\n pausedLabel?: ReactNode\n}) {\n const reference = now ?? new Date()\n const meta = STATUS[status]\n const level = PRIORITY[priority]\n\n // The clock stops while waiting on the customer.\n const clockRunning = status === 'open' || status === 'pending'\n const overdue = clockRunning && dueAt !== undefined && dueAt < reference\n const soon =\n clockRunning &&\n dueAt !== undefined &&\n !overdue &&\n dueAt.getTime() - reference.getTime() < 1000 * 60 * 60 * 2\n\n return (\n <article\n data-slot=\"ticket-card\"\n data-status={status}\n className={cn(\n surface,\n radius.surface,\n 'relative flex flex-col gap-2 overflow-hidden p-4 ps-5',\n (status === 'resolved' || status === 'closed') && 'opacity-70',\n className,\n )}\n {...props}\n >\n <span\n aria-hidden=\"true\"\n className={cn(\n 'absolute inset-y-0 start-0 w-1.5',\n priority === 'urgent'\n ? 'bg-[var(--destructive)]'\n : priority === 'high'\n ? 'bg-[var(--amber)]'\n : 'bg-border',\n )}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-muted-foreground font-mono text-xs\">{id}</span>\n <Badge size=\"sm\" color={meta.color}>\n {meta.label}\n </Badge>\n {priority !== 'normal' && (\n <Badge size=\"sm\" color={level.color}>\n {level.label}\n </Badge>\n )}\n\n {(overdue || soon) && (\n <Badge size=\"sm\" color={overdue ? 'destructive' : 'amber'} className=\"ms-auto\">\n <TriangleAlert />\n {overdue ? breachedLabel : dueSoonLabel}\n </Badge>\n )}\n </div>\n\n <h3 className=\"text-sm font-medium text-pretty\">{subject}</h3>\n\n {tags && <div className=\"flex flex-wrap gap-1.5\">{tags}</div>}\n\n <div className=\"text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs\">\n {requester && (\n <span className=\"flex items-center gap-1.5\">\n {requesterAvatar ?? <Avatar size=\"xs\" name={String(requester)} />}\n {requester}\n </span>\n )}\n {replies !== undefined && (\n <span className=\"inline-flex items-center gap-1\">\n <MessageSquare className=\"size-3.5\" aria-hidden=\"true\" />\n {replies}\n </span>\n )}\n {updatedAt && (\n <span>\n {updatedLabel} <Fmt type=\"relative\" value={updatedAt} now={reference} locale={locale} />\n </span>\n )}\n {dueAt && clockRunning && (\n <span className={cn('inline-flex items-center gap-1', overdue && 'text-[var(--destructive-soft-foreground)]')}>\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {dueLabel} <Fmt type=\"relative\" value={dueAt} now={reference} locale={locale} />\n </span>\n )}\n {status === 'waiting' && (\n <span className=\"text-muted-foreground/70\">{pausedLabel}</span>\n )}\n {assignee && <span className=\"ms-auto\">{assignee}</span>}\n </div>\n </article>\n )\n}\n\nexport { TicketCard }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "time-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Time Picker",
|
|
5
|
+
"description": "Pick a time of day from generated slots. The value is always HH:mm in 24-hour form regardless of how it is displayed — the format is presentation, the value is data.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils",
|
|
13
|
+
"primitive-dismissable",
|
|
14
|
+
"primitive-popper"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/time-picker.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useRef, useState, type ComponentProps } from 'react'\nimport { Clock } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport {\n fieldBase,\n fieldSize,\n focusRing,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Pick a time of day.\n *\n * The value is `HH:mm` in 24-hour form regardless of how it is displayed. A\n * component that hands back \"2:30 PM\" pushes parsing onto every caller and\n * breaks the moment the locale changes; the display format is presentation and\n * the value is data.\n *\n * Options are generated at a fixed interval rather than offering free entry.\n * Typing a time is a parsing problem with a dozen ambiguous cases (\"230\",\n * \"2.30\", \"14h\"), and for scheduling a list of slots is what people actually\n * want.\n */\nfunction toMinutes(value: string) {\n const [h, m] = value.split(':').map(Number)\n return (h || 0) * 60 + (m || 0)\n}\n\nfunction toValue(minutes: number) {\n const h = Math.floor(minutes / 60) % 24\n const m = minutes % 60\n return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`\n}\n\nfunction TimePicker({\n value: valueProp,\n defaultValue,\n onValueChange,\n interval = 30,\n min = '00:00',\n max = '23:59',\n hour12 = false,\n placeholder = 'Select a time',\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n locale = 'en-GB',\n listLabel = 'Times',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n /** `HH:mm`, 24-hour. */\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n /** Minutes between options. */\n interval?: number\n min?: string\n max?: string\n hour12?: boolean\n placeholder?: string\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n disabled?: boolean\n locale?: string\n /** Accessible name for the listbox. */\n listLabel?: string\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const [open, setOpen] = useState(false)\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n matchAnchorWidth: true,\n })\n\n useDismissable({\n open,\n onDismiss: () => {\n setOpen(false)\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n const format = useMemo(\n () =>\n new Intl.DateTimeFormat(locale, {\n hour: '2-digit',\n minute: '2-digit',\n hour12,\n }),\n [locale, hour12],\n )\n\n const label = (time: string) => {\n const [h, m] = time.split(':').map(Number)\n // A fixed date; only the clock part is ever read.\n return format.format(new Date(2000, 0, 1, h, m))\n }\n\n const options = useMemo(() => {\n const start = toMinutes(min)\n const end = toMinutes(max)\n const list: string[] = []\n for (let minutes = start; minutes <= end; minutes += interval) {\n list.push(toValue(minutes))\n }\n return list\n }, [min, max, interval])\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'hover:bg-accent border border-transparent bg-transparent',\n }[variant]\n\n return (\n <div data-slot=\"time-picker\" className={cn('relative w-full', className)} {...props}>\n <button\n ref={triggerRef}\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n disabled={disabled}\n onClick={() => setOpen(!open)}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'justify-start text-start',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n <Clock className=\"text-muted-foreground shrink-0\" />\n <span className={cn('min-w-0 flex-1 truncate', !value && 'text-muted-foreground/70')}>\n {value ? label(value) : placeholder}\n </span>\n </button>\n\n {open && (\n <div\n ref={panelRef}\n style={style}\n className={cn(menuSurface, radius.surface, 'max-h-60 p-1')}\n >\n <ul role=\"listbox\" aria-label={listLabel} className=\"list-none\">\n {options.map((time) => {\n const selected = value === time\n return (\n <li key={time}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={selected}\n ref={(node) => {\n // Open with the current value in view rather than at\n // midnight — the list is 48 entries long.\n if (node && selected) node.scrollIntoView({ block: 'center' })\n }}\n onClick={() => {\n if (!controlled) setUncontrolled(time)\n onValueChange?.(time)\n setOpen(false)\n triggerRef.current?.focus()\n }}\n className={cn(\n 'hover:bg-accent flex w-full items-center justify-between px-2.5 py-1.5 text-sm tabular-nums',\n radius.control,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n selected && 'bg-accent text-accent-foreground font-medium',\n )}\n >\n {label(time)}\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { TimePicker }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "timeline",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Timeline",
|
|
5
|
+
"description": "A vertical run of events sharing one rail — deploys, incidents, review activity, an agent run. The connector stops at the last item instead of dangling past it.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/timeline.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 * A vertical run of events sharing one rail.\n *\n * The connector is drawn by each item rather than by the container, as a line\n * that stops at the last one (`last:before:hidden`). A single line behind the\n * whole list is easier to write and wrong at both ends: it overshoots the\n * final marker and starts above the first.\n *\n * An ordered list, because a timeline is one — sequence is the meaning, and a\n * screen reader should announce the count and position.\n */\nfunction Timeline({ className, ...props }: ComponentProps<'ol'>) {\n return (\n <ol\n data-slot=\"timeline\"\n className={cn('flex list-none flex-col', className)}\n {...props}\n />\n )\n}\n\ntype TimelineItemProps = ComponentProps<'li'> & {\n /** The marker. A dot is drawn when omitted. */\n icon?: ReactNode\n title: ReactNode\n /** Right-aligned on wide screens, under the title on narrow ones. */\n time?: ReactNode\n tone?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'muted'\n /** Draw the marker as an outline. For pending or skipped events. */\n pending?: boolean\n}\n\nconst TONE = {\n default: 'bg-primary text-primary-foreground border-primary',\n success: 'bg-[var(--green)] text-[var(--green-foreground)] border-[var(--green)]',\n warning: 'bg-[var(--amber)] text-[var(--amber-foreground)] border-[var(--amber)]',\n danger: 'bg-[var(--destructive)] text-[var(--destructive-foreground)] border-[var(--destructive)]',\n info: 'bg-[var(--blue)] text-[var(--blue-foreground)] border-[var(--blue)]',\n muted: 'bg-muted text-muted-foreground border-border',\n} as const\n\nconst PENDING_TONE = {\n default: 'text-muted-foreground border-border',\n success: 'text-[var(--green-soft-foreground)] border-[var(--green)]',\n warning: 'text-[var(--amber-soft-foreground)] border-[var(--amber)]',\n danger: 'text-[var(--destructive-soft-foreground)] border-[var(--destructive)]',\n info: 'text-[var(--blue-soft-foreground)] border-[var(--blue)]',\n muted: 'text-muted-foreground border-border',\n} as const\n\nfunction TimelineItem({\n icon,\n title,\n time,\n tone = 'default',\n pending = false,\n children,\n className,\n ...props\n}: TimelineItemProps) {\n return (\n <li\n data-slot=\"timeline-item\"\n className={cn(\n 'relative flex gap-3 pb-5 ps-0 last:pb-0',\n // The rail: a 1px line behind the marker column, hidden on the last\n // item so it never dangles past the final event.\n 'before:bg-border before:absolute before:top-6 before:bottom-0 before:start-[11px] before:w-px before:content-[\"\"]',\n 'last:before:hidden',\n className,\n )}\n {...props}\n >\n <span\n aria-hidden=\"true\"\n className={cn(\n 'relative z-10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border [corner-shape:round]',\n \"[&_svg:not([class*='size-'])]:size-3.5\",\n pending ? cn('bg-background', PENDING_TONE[tone]) : TONE[tone],\n )}\n >\n {icon ?? <span className=\"size-1.5 rounded-full bg-current [corner-shape:round]\" />}\n </span>\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-1 pt-0.5\">\n <div className=\"flex flex-col gap-x-3 gap-y-0.5 sm:flex-row sm:items-baseline sm:justify-between\">\n <div className=\"min-w-0 text-sm font-medium\">{title}</div>\n {time && (\n <div className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n {time}\n </div>\n )}\n </div>\n {children && (\n <div className=\"text-muted-foreground text-sm\">{children}</div>\n )}\n </div>\n </li>\n )\n}\n\n/** A framed body under an event — a log excerpt, a diff, a message. */\nfunction TimelineContent({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"timeline-content\"\n className={cn('border-border bg-muted/40 mt-1 border p-3 text-sm', radius.control, className)}\n {...props}\n />\n )\n}\n\nexport { Timeline, TimelineContent, TimelineItem }\nexport type { TimelineItemProps }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "timezone-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Timezone Select",
|
|
5
|
+
"description": "An IANA timezone picker showing each zone's current offset and local time. Zones are named by city, never by abbreviation — \"CST\" is Central, China and Cuba Standard Time.",
|
|
6
|
+
"category": "Geo",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"combobox"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/timezone-select.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import { useMemo, type ComponentProps } from 'react'\nimport { Combobox } from '@/components/ui/combobox'\n\n/**\n * An IANA timezone picker showing each zone's current offset and local time.\n *\n * The list comes from `Intl.supportedValuesOf('timeZone')`, so it is whatever\n * the runtime actually supports rather than a hardcoded table that goes stale\n * every time a government moves a DST boundary. Older runtimes without that\n * method fall back to a small common set — a short list beats a crash.\n *\n * Offsets are computed live rather than stored. A zone's offset is a function of\n * the date, and a list built in January is wrong for half the year.\n *\n * Zones are labelled by city, not by abbreviation. \"CST\" is Central Standard\n * Time, China Standard Time and Cuba Standard Time; `America/Chicago` is one\n * place. The abbreviation is shown alongside for recognition, never alone.\n */\nconst FALLBACK = [\n 'UTC',\n 'America/New_York',\n 'America/Chicago',\n 'America/Denver',\n 'America/Los_Angeles',\n 'America/Sao_Paulo',\n 'Europe/London',\n 'Europe/Berlin',\n 'Europe/Moscow',\n 'Africa/Lagos',\n 'Africa/Johannesburg',\n 'Asia/Dubai',\n 'Asia/Kolkata',\n 'Asia/Shanghai',\n 'Asia/Tokyo',\n 'Australia/Sydney',\n 'Pacific/Auckland',\n]\n\nfunction listZones(): string[] {\n const supported = (\n Intl as unknown as { supportedValuesOf?: (key: string) => string[] }\n ).supportedValuesOf\n try {\n // Whatever the runtime supports, not a table that rots.\n return supported ? supported('timeZone') : FALLBACK\n } catch {\n return FALLBACK\n }\n}\n\n/** Current UTC offset of a zone, in minutes, derived from a formatted date. */\nfunction offsetMinutes(zone: string, at: Date) {\n try {\n const parts = new Intl.DateTimeFormat('en-US', {\n timeZone: zone,\n timeZoneName: 'longOffset',\n }).formatToParts(at)\n const name = parts.find((part) => part.type === 'timeZoneName')?.value ?? ''\n const match = name.match(/GMT([+-])(\\d{1,2})(?::(\\d{2}))?/)\n if (!match) return 0\n const sign = match[1] === '-' ? -1 : 1\n return sign * (Number(match[2]) * 60 + Number(match[3] ?? 0))\n } catch {\n return 0\n }\n}\n\nfunction formatOffset(minutes: number) {\n if (minutes === 0) return 'UTC'\n const sign = minutes < 0 ? '-' : '+'\n const abs = Math.abs(minutes)\n const hours = Math.floor(abs / 60)\n const rest = abs % 60\n return `UTC${sign}${hours}${rest ? `:${String(rest).padStart(2, '0')}` : ''}`\n}\n\nfunction localTime(zone: string, at: Date) {\n try {\n return new Intl.DateTimeFormat('en-GB', {\n timeZone: zone,\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n }).format(at)\n } catch {\n return ''\n }\n}\n\n/** `Europe/Berlin` → `Berlin`, `America/Argentina/Salta` → `Salta`. */\nfunction cityOf(zone: string) {\n const last = zone.split('/').pop() ?? zone\n return last.replace(/_/g, ' ')\n}\n\ntype TimezoneSelectProps = Omit<\n ComponentProps<typeof Combobox>,\n 'options' | 'searchPlaceholder'\n> & {\n /** Restrict the list — a booking form rarely needs all 400-odd zones. */\n zones?: string[]\n /** Show each zone's current local time beside its offset. */\n showLocalTime?: boolean\n /** Reference instant for offsets. Defaults to now. */\n now?: Date\n}\n\nfunction TimezoneSelect({\n zones,\n showLocalTime = true,\n now,\n placeholder = 'Select timezone…',\n ...props\n}: TimezoneSelectProps) {\n const options = useMemo(() => {\n const at = now ?? new Date()\n return (zones ?? listZones())\n .map((zone) => {\n const offset = offsetMinutes(zone, at)\n const time = showLocalTime ? localTime(zone, at) : ''\n return {\n zone,\n offset,\n value: zone,\n // Region kept in the label so `Europe/London` is still searchable\n // by \"Europe\", and the city leads because that is what is unique.\n label: `${cityOf(zone)} · ${formatOffset(offset)}${time ? ` · ${time}` : ''} — ${zone}`,\n }\n })\n .sort((a, b) => a.offset - b.offset || a.zone.localeCompare(b.zone))\n .map(({ value, label }) => ({ value, label }))\n }, [zones, showLocalTime, now])\n\n return (\n <Combobox\n data-slot=\"timezone-select\"\n options={options}\n placeholder={placeholder}\n searchPlaceholder=\"Search city or region…\"\n emptyMessage=\"No matching timezone\"\n {...props}\n />\n )\n}\n\nexport { TimezoneSelect, formatOffset, offsetMinutes, listZones as listTimezones }\nexport type { TimezoneSelectProps }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toast",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toast",
|
|
5
|
+
"description": "Transient messages that stack and dismiss themselves. A provider plus a useToast() hook rather than a global singleton, so the queue lives in React state and two regions can coexist.",
|
|
6
|
+
"category": "Feedback",
|
|
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/toast.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { colorSet, menuSurface, radius, type ColorSet } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Transient messages that stack and dismiss themselves.\n *\n * A provider plus a `useToast()` hook, rather than a global singleton: the queue\n * lives in React state, so it participates in normal rendering and testing, and\n * two independent regions can coexist.\n *\n * The region is `aria-live=\"polite\"` so a toast is announced without stealing\n * focus — a toast that grabs focus interrupts whatever the user was typing.\n */\ntype ToastOptions = {\n title?: ReactNode\n description?: ReactNode\n color?: ColorSet\n /** Milliseconds before auto-dismiss. `0` keeps it until dismissed. */\n duration?: number\n action?: ReactNode\n}\n\ntype ToastRecord = ToastOptions & { id: number }\n\ntype ToastContextValue = {\n toasts: ToastRecord[]\n toast: (options: ToastOptions) => number\n dismiss: (id: number) => void\n}\n\nconst ToastContext = createContext<ToastContextValue | null>(null)\n\nexport function useToast() {\n const context = use(ToastContext)\n if (!context) throw new Error('useToast must be used inside <ToastProvider>')\n return context\n}\n\nfunction ToastProvider({\n children,\n max = 4,\n position = 'bottom-end',\n}: {\n children: ReactNode\n /** Oldest toasts fall off the end once this many are showing. */\n max?: number\n position?: 'top-end' | 'top-start' | 'bottom-end' | 'bottom-start'\n}) {\n const [toasts, setToasts] = useState<ToastRecord[]>([])\n const nextId = useRef(1)\n const timers = useRef(new Map<number, number>())\n\n const dismiss = useCallback((id: number) => {\n setToasts((current) => current.filter((item) => item.id !== id))\n const timer = timers.current.get(id)\n if (timer) {\n clearTimeout(timer)\n timers.current.delete(id)\n }\n }, [])\n\n const toast = useCallback(\n (options: ToastOptions) => {\n const id = nextId.current++\n const duration = options.duration ?? 4000\n\n setToasts((current) => [...current, { ...options, id }].slice(-max))\n\n if (duration > 0) {\n timers.current.set(\n id,\n window.setTimeout(() => dismiss(id), duration),\n )\n }\n\n return id\n },\n [dismiss, max],\n )\n\n // Clear every pending timer on unmount, or they fire into a dead tree.\n useEffect(() => {\n const pending = timers.current\n return () => {\n for (const timer of pending.values()) clearTimeout(timer)\n pending.clear()\n }\n }, [])\n\n const context = useMemo(\n () => ({ toasts, toast, dismiss }),\n [toasts, toast, dismiss],\n )\n\n return (\n <ToastContext value={context}>\n {children}\n <ToastRegion position={position} />\n </ToastContext>\n )\n}\n\nconst POSITION = {\n 'top-end': 'top-4 end-4 flex-col',\n 'top-start': 'top-4 start-4 flex-col',\n 'bottom-end': 'bottom-4 end-4 flex-col-reverse',\n 'bottom-start': 'bottom-4 start-4 flex-col-reverse',\n}\n\nfunction ToastRegion({\n position,\n label = 'Notifications',\n}: {\n position: keyof typeof POSITION\n /** Accessible name for the live region. */\n label?: string\n}) {\n const { toasts, dismiss } = useToast()\n\n return (\n <div\n role=\"region\"\n aria-label={label}\n aria-live=\"polite\"\n data-slot=\"toast-region\"\n className={cn(\n 'pointer-events-none fixed z-50 flex w-[min(24rem,100vw-2rem)] gap-2',\n POSITION[position],\n )}\n >\n {toasts.map(({ id, ...item }) => (\n <Toast key={id} {...item} onDismiss={() => dismiss(id)} />\n ))}\n </div>\n )\n}\n\n// `id` is deliberately not forwarded: a numeric record id is not a DOM id.\nfunction Toast({\n title,\n description,\n color = 'neutral',\n action,\n onDismiss,\n dismissLabel = 'Dismiss',\n className,\n ...props\n}: ToastOptions & {\n onDismiss: () => void\n /** Accessible name for the dismiss button. */\n dismissLabel?: string\n} & Omit<ComponentProps<'div'>, 'title' | 'color'>) {\n return (\n <div\n data-slot=\"toast\"\n className={cn(\n menuSurface,\n radius.surface,\n colorSet[color],\n 'pointer-events-auto flex items-start gap-3 overflow-hidden p-4',\n 'animate-[toast-in_200ms_ease-out] motion-reduce:animate-none',\n className,\n )}\n {...props}\n >\n <div className=\"min-w-0 flex-1 space-y-1\">\n {title && (\n <div className=\"text-sm font-medium text-[var(--ui-soft-fg)]\">\n {title}\n </div>\n )}\n {description && (\n <div className=\"text-muted-foreground text-xs\">{description}</div>\n )}\n {action && <div className=\"pt-1\">{action}</div>}\n </div>\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={dismissLabel}\n onClick={onDismiss}\n >\n <X />\n </Button>\n </div>\n )\n}\n\nexport { Toast, ToastProvider }\nexport type { ToastOptions }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toggle",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toggle",
|
|
5
|
+
"description": "A button that stays pressed. Uses aria-pressed rather than a checkbox, because this is a control that changes state, not a value submitted with a form.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"class-variance-authority@^0.7.1"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/toggle.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import {\n createContext,\n use,\n useMemo,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { controlBase, controlSize } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A button that stays pressed.\n *\n * `aria-pressed` rather than a checkbox: this is a control that changes state,\n * not a value submitted with a form. ToggleGroup adds single or multiple\n * selection on top without changing the item.\n */\nconst toggleVariants = cva(controlBase, {\n variants: {\n variant: {\n default:\n 'bg-transparent hover:bg-accent hover:text-accent-foreground aria-pressed:bg-primary aria-pressed:text-primary-foreground',\n outline:\n 'border-border border bg-transparent hover:bg-accent aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:border-primary',\n },\n size: {\n sm: controlSize.sm,\n default: controlSize.md,\n lg: controlSize.lg,\n icon: controlSize.icon,\n 'icon-sm': controlSize.iconSm,\n 'icon-lg': controlSize.iconLg,\n },\n },\n defaultVariants: { variant: 'default', size: 'default' },\n})\n\ntype ToggleProps = Omit<ComponentProps<'button'>, 'value'> &\n VariantProps<typeof toggleVariants> & {\n pressed?: boolean\n defaultPressed?: boolean\n onPressedChange?: (pressed: boolean) => void\n }\n\nfunction Toggle({\n className,\n variant,\n size,\n pressed: pressedProp,\n defaultPressed = false,\n onPressedChange,\n onClick,\n ...props\n}: ToggleProps) {\n const controlled = pressedProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultPressed)\n const pressed = controlled ? pressedProp : uncontrolled\n\n return (\n <button\n type=\"button\"\n data-slot=\"toggle\"\n aria-pressed={pressed}\n onClick={(event) => {\n onClick?.(event)\n if (event.defaultPrevented) return\n if (!controlled) setUncontrolled(!pressed)\n onPressedChange?.(!pressed)\n }}\n className={cn(toggleVariants({ variant, size }), className)}\n {...props}\n />\n )\n}\n\ntype GroupContextValue = {\n value: string[]\n toggle: (value: string) => void\n variant: ToggleProps['variant']\n size: ToggleProps['size']\n}\n\nconst GroupContext = createContext<GroupContextValue | null>(null)\n\ntype ToggleGroupProps = Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> & {\n type?: 'single' | 'multiple'\n value?: string[]\n defaultValue?: string[]\n onValueChange?: (value: string[]) => void\n variant?: ToggleProps['variant']\n size?: ToggleProps['size']\n children: ReactNode\n}\n\nfunction ToggleGroup({\n className,\n type = 'single',\n value: valueProp,\n defaultValue = [],\n onValueChange,\n variant,\n size,\n ...props\n}: ToggleGroupProps) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const context = useMemo<GroupContextValue>(\n () => ({\n value,\n variant,\n size,\n toggle: (item) => {\n const next =\n type === 'single'\n ? value.includes(item)\n ? []\n : [item]\n : value.includes(item)\n ? value.filter((v) => v !== item)\n : [...value, item]\n\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n },\n }),\n [value, variant, size, type, controlled, onValueChange],\n )\n\n return (\n <GroupContext value={context}>\n <div\n role=\"group\"\n data-slot=\"toggle-group\"\n className={cn('flex items-center gap-1', className)}\n {...props}\n />\n </GroupContext>\n )\n}\n\nfunction ToggleGroupItem({\n value,\n className,\n ...props\n}: Omit<ToggleProps, 'pressed' | 'onPressedChange'> & { value: string }) {\n const group = use(GroupContext)\n if (!group) throw new Error('<ToggleGroupItem> must be inside <ToggleGroup>')\n\n return (\n <Toggle\n pressed={group.value.includes(value)}\n onPressedChange={() => group.toggle(value)}\n variant={props.variant ?? group.variant}\n size={props.size ?? group.size}\n className={className}\n {...props}\n />\n )\n}\n\nexport { Toggle, ToggleGroup, ToggleGroupItem, toggleVariants }\nexport type { ToggleProps }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-amount",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Token Amount",
|
|
5
|
+
"description": "A balance formatted from base units without ever touching a float. 1 ETH is 10¹⁸ wei, which exceeds Number.MAX_SAFE_INTEGER by two orders of magnitude — a balance that passes through a JavaScript number is silently wrong.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/token-amount.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ComponentProps } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * A token balance, formatted from base units without ever touching a float.\n *\n * This is the one that has to be right. On-chain amounts are integers in the\n * token's smallest unit — 18 decimals for most ERC-20s — and 1 ETH is\n * 1000000000000000000 wei. That exceeds `Number.MAX_SAFE_INTEGER` by two orders\n * of magnitude, so the moment a balance passes through a JavaScript number it\n * is silently wrong. Every value here stays a `bigint` or a decimal string, and\n * the split into whole and fractional parts is done on the digit string.\n *\n * Display is deliberately lossy in one direction only: it rounds *down*. A\n * balance shown as more than the wallet holds produces a failed transaction,\n * which is a worse outcome than showing slightly less.\n */\nexport type TokenAmountProps = Omit<ComponentProps<'span'>, 'children'> & {\n /** Base units — wei, satoshi, lamports. A string avoids any float parsing. */\n value: bigint | string\n /** Token decimals. 18 for most ERC-20s, 8 for BTC, 6 for USDC. */\n decimals?: number\n symbol?: string\n /** Fraction digits shown. The rest is truncated, never rounded up. */\n precision?: number\n /** Strip trailing zeros from the fraction. */\n trim?: boolean\n /** Fiat value, already converted. */\n fiat?: number\n fiatCurrency?: string\n locale?: string\n /** Colour by sign, for a delta rather than a balance. */\n signed?: boolean\n size?: 'sm' | 'default' | 'lg'\n}\n\n/**\n * Split base units into whole and fraction on the digit string.\n *\n * No division, no `Number` — padding and slicing a string is exact at any\n * magnitude, which is the entire point.\n */\nfunction splitUnits(value: bigint, decimals: number) {\n const negative = value < 0n\n const digits = (negative ? -value : value).toString().padStart(decimals + 1, '0')\n const whole = digits.slice(0, digits.length - decimals) || '0'\n const fraction = decimals > 0 ? digits.slice(digits.length - decimals) : ''\n return { negative, whole, fraction }\n}\n\nfunction formatUnits(\n value: bigint,\n decimals: number,\n precision: number,\n trim: boolean,\n locale: string,\n) {\n const { negative, whole, fraction } = splitUnits(value, decimals)\n\n // Truncate, never round: showing more than is held breaks the transaction.\n let shown = fraction.slice(0, precision)\n if (trim) shown = shown.replace(/0+$/, '')\n\n const grouped = new Intl.NumberFormat(locale, { useGrouping: true }).format(BigInt(whole))\n return (negative ? '-' : '') + grouped + (shown ? '.' + shown : '')\n}\n\nfunction TokenAmount({\n value,\n decimals = 18,\n symbol,\n precision = 4,\n trim = true,\n fiat,\n fiatCurrency = 'USD',\n locale = 'en-GB',\n signed = false,\n size = 'default',\n className,\n ...props\n}: TokenAmountProps) {\n let units: bigint\n try {\n units = typeof value === 'bigint' ? value : BigInt(value)\n } catch {\n // A malformed value is reported, not silently rendered as zero.\n return (\n <span className={cn('text-muted-foreground tabular-nums', className)} {...props}>\n —\n </span>\n )\n }\n\n const text = formatUnits(units, decimals, precision, trim, locale)\n const positive = units > 0n\n const negative = units < 0n\n\n return (\n <span\n data-slot=\"token-amount\"\n className={cn(\n 'inline-flex items-baseline gap-1 tabular-nums',\n size === 'sm' && 'text-xs',\n size === 'lg' && 'text-lg font-semibold',\n signed && positive && 'text-[var(--green-soft-foreground)]',\n signed && negative && 'text-[var(--destructive-soft-foreground)]',\n className,\n )}\n {...props}\n >\n <span>\n {signed && positive ? '+' : ''}\n {text}\n </span>\n {symbol && (\n <span className={cn('shrink-0', !signed && 'text-muted-foreground')}>\n {symbol}\n </span>\n )}\n {fiat !== undefined && (\n <span className=\"text-muted-foreground/70 text-xs\">\n {new Intl.NumberFormat(locale, {\n style: 'currency',\n currency: fiatCurrency,\n maximumFractionDigits: 2,\n }).format(fiat)}\n </span>\n )}\n </span>\n )\n}\n\nexport { TokenAmount, formatUnits, splitUnits }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-approvals",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Token Approvals",
|
|
5
|
+
"description": "Spending allowances granted to contracts, with revoke. An unlimited approval is stated as \"unlimited\" rather than rendered as 2²⁵⁶−1, which reads like a display bug — it is a standing permission to move the whole balance, forever.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"wallet-address"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/token-approvals.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Infinity as InfinityIcon, ShieldAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { WalletAddress } from '@/components/ui/wallet-address'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Spending allowances granted to contracts, and a way to revoke them.\n *\n * An unlimited approval is called out as a risk rather than shown as a number.\n * Wallets grant `2^256-1` by default and it renders as an absurd figure that\n * reads like a display bug; stating \"unlimited\" is the only honest rendering,\n * because that is exactly what it is — a standing permission to move the entire\n * balance, forever, with no further signature.\n *\n * Revoking is confirmed inline beside the row. A modal that hides which\n * approval is being revoked is how the wrong one goes.\n */\nexport type Approval = {\n id: string\n token: ReactNode\n tokenIcon?: ReactNode\n /** The contract holding the allowance. */\n spender: string\n spenderName?: ReactNode\n /** Formatted amount, or omit for unlimited. */\n allowance?: ReactNode\n unlimited?: boolean\n lastUsed?: Date\n /** Fiat value currently at risk under this approval. */\n atRisk?: number\n explorerHref?: string\n}\n\nfunction TokenApprovals({\n approvals,\n onRevoke,\n currency = 'USD',\n locale = 'en-GB',\n now,\n emptyLabel = 'No active approvals',\n title = 'Approvals',\n unlimitedLabel = 'Unlimited',\n revokeLabel = 'Revoke',\n spenderLabel = 'spender',\n lastUsedLabel = 'last used',\n confirmNote = 'Revoking costs gas and requires a signature.',\n confirmLabel = 'Confirm',\n cancelLabel = 'Cancel',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n approvals: Approval[]\n onRevoke?: (id: string) => void\n currency?: string\n locale?: string\n now?: Date\n emptyLabel?: ReactNode\n title?: ReactNode\n /** Badge on an unbounded allowance. */\n unlimitedLabel?: ReactNode\n revokeLabel?: ReactNode\n /** Precedes the spender address. */\n spenderLabel?: ReactNode\n /** Precedes the last-used time. */\n lastUsedLabel?: ReactNode\n confirmNote?: ReactNode\n confirmLabel?: ReactNode\n cancelLabel?: ReactNode\n}) {\n const [confirming, setConfirming] = useState<string | null>(null)\n const unlimited = approvals.filter((a) => a.unlimited).length\n\n return (\n <div\n data-slot=\"token-approvals\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n <span className=\"text-sm font-medium\">{title}</span>\n <Badge size=\"sm\">{approvals.length}</Badge>\n {unlimited > 0 && (\n <Badge size=\"sm\" color=\"destructive\">\n <ShieldAlert />\n {unlimited} unlimited\n </Badge>\n )}\n </div>\n\n {approvals.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 {approvals.map((approval) => (\n <li key={approval.id} className=\"flex flex-col gap-2 p-3\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n <span className=\"flex min-w-0 items-center gap-2\">\n {approval.tokenIcon}\n <span className=\"truncate text-sm font-medium\">{approval.token}</span>\n </span>\n\n {approval.unlimited ? (\n <Badge size=\"sm\" color=\"destructive\">\n <InfinityIcon />\n {unlimitedLabel}\n </Badge>\n ) : (\n <span className=\"text-muted-foreground text-xs tabular-nums\">\n {approval.allowance}\n </span>\n )}\n\n {onRevoke && (\n <Button\n variant=\"secondary\"\n size=\"xs\"\n className=\"ms-auto\"\n onClick={() => setConfirming(approval.id)}\n >\n {revokeLabel}\n </Button>\n )}\n </div>\n\n <div className=\"text-muted-foreground/80 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs\">\n <span className=\"flex items-center gap-1\">\n {spenderLabel}\n <WalletAddress\n address={approval.spender}\n name={approval.spenderName}\n href={approval.explorerHref}\n size=\"sm\"\n copyable={false}\n />\n </span>\n {approval.lastUsed && (\n <span>\n {lastUsedLabel} <Fmt type=\"relative\" value={approval.lastUsed} now={now} />\n </span>\n )}\n {approval.atRisk !== undefined && (\n <span className=\"text-[var(--amber-soft-foreground)]\">\n <Fmt type=\"currency\" value={approval.atRisk} currency={currency} locale={locale} />{' '}\n at risk\n </span>\n )}\n </div>\n\n {confirming === approval.id && (\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border p-2 text-xs\">\n <span className=\"text-muted-foreground min-w-0 flex-1\">\n {confirmNote}\n </span>\n <Button\n size=\"xs\"\n color=\"destructive\"\n onClick={() => {\n onRevoke?.(approval.id)\n setConfirming(null)\n }}\n >\n {confirmLabel}\n </Button>\n <Button size=\"xs\" variant=\"secondary\" onClick={() => setConfirming(null)}>\n {cancelLabel}\n </Button>\n </div>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { TokenApprovals }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Token Select",
|
|
5
|
+
"description": "A token picker with balances and search. Symbols are not unique — anyone can deploy a contract called \"USDC\" — so unverified tokens are segregated, badged, and shown by contract address rather than the name they claim.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-dismissable",
|
|
15
|
+
"primitive-popper",
|
|
16
|
+
"wallet-address"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/token-select.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, Search, TriangleAlert } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { Badge } from '@/components/ui/badge'\nimport { WalletAddress } from '@/components/ui/wallet-address'\nimport {\n fieldBase,\n fieldSize,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A token picker with balances and search.\n *\n * The security-critical part is what happens for a token that is not on the\n * list. Symbols are not unique — anyone can deploy a contract called \"USDC\" —\n * so a search that matches on symbol alone is a scam vector. Unverified tokens\n * are therefore segregated below a divider, badged, and shown with their\n * contract address rather than trusting the name they claim.\n *\n * Balances sort the list, because the token you want is nearly always one you\n * hold. Zero-balance tokens keep their place in the catalogue but fall below\n * anything with a balance.\n */\nexport type TokenOption = {\n address: string\n symbol: string\n name?: ReactNode\n icon?: ReactNode\n /** Formatted balance. */\n balance?: string\n /** Numeric balance, used only for ordering. */\n balanceValue?: number\n fiat?: number\n /** False marks a token not on the trusted list. */\n verified?: boolean\n}\n\nfunction TokenSelect({\n tokens,\n value,\n onValueChange,\n size = 'md',\n placeholder = 'Select a token',\n disabled = false,\n locale = 'en-GB',\n currency = 'USD',\n unverifiedLabel = 'Unverified',\n unverifiedNote = 'Not on the trusted list. Anyone can deploy a token using an existing symbol — check the contract address.',\n searchPlaceholder = 'Search name or paste address',\n searchLabel = 'Search tokens',\n listLabel = 'Tokens',\n emptyMessage = 'No tokens match.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n tokens: TokenOption[]\n /** Contract address of the selected token. */\n value?: string\n onValueChange?: (address: string) => void\n size?: keyof typeof fieldSize\n placeholder?: string\n disabled?: boolean\n locale?: string\n currency?: string\n /** Badge on a token missing from the trusted list. */\n unverifiedLabel?: ReactNode\n unverifiedNote?: ReactNode\n searchPlaceholder?: string\n /** Accessible name for the search field. */\n searchLabel?: string\n /** Accessible name for the listbox. */\n listLabel?: string\n emptyMessage?: ReactNode\n}) {\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n const searchRef = useRef<HTMLInputElement>(null)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n matchAnchorWidth: true,\n })\n\n useDismissable({\n open,\n onDismiss: () => {\n setOpen(false)\n setQuery('')\n triggerRef.current?.focus()\n },\n refs: [triggerRef, panelRef],\n })\n\n const selected = tokens.find((token) => token.address === value)\n\n const { trusted, unverified } = useMemo(() => {\n const term = query.trim().toLowerCase()\n const matches = tokens.filter(\n (token) =>\n !term ||\n token.symbol.toLowerCase().includes(term) ||\n String(token.name ?? '').toLowerCase().includes(term) ||\n token.address.toLowerCase().includes(term),\n )\n // Held tokens first — the one you want is usually one you have.\n const byBalance = (a: TokenOption, b: TokenOption) =>\n (b.balanceValue ?? 0) - (a.balanceValue ?? 0)\n\n return {\n trusted: matches.filter((t) => t.verified !== false).sort(byBalance),\n unverified: matches.filter((t) => t.verified === false).sort(byBalance),\n }\n }, [tokens, query])\n\n const money = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n maximumFractionDigits: 2,\n })\n\n const Row = ({ token }: { token: TokenOption }) => (\n <li>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={token.address === value}\n onClick={() => {\n onValueChange?.(token.address)\n setOpen(false)\n setQuery('')\n }}\n className={cn(menuItem, radius.control, 'py-2')}\n >\n {token.icon ?? (\n <span className=\"bg-secondary text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-medium [corner-shape:round]\">\n {token.symbol.slice(0, 3)}\n </span>\n )}\n\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"flex items-center gap-1.5\">\n <span className=\"truncate font-medium\">{token.symbol}</span>\n {token.verified === false && (\n <Badge size=\"sm\" color=\"amber\">\n {unverifiedLabel}\n </Badge>\n )}\n </span>\n {token.verified === false ? (\n // Show the address, not the name it claims.\n <WalletAddress\n address={token.address}\n size=\"sm\"\n copyable={false}\n className=\"text-muted-foreground\"\n />\n ) : (\n token.name && (\n <span className=\"text-muted-foreground truncate text-xs\">{token.name}</span>\n )\n )}\n </span>\n\n {token.balance && (\n <span className=\"shrink-0 text-end\">\n <span className=\"block text-sm tabular-nums\">{token.balance}</span>\n {token.fiat !== undefined && (\n <span className=\"text-muted-foreground block text-xs tabular-nums\">\n {money.format(token.fiat)}\n </span>\n )}\n </span>\n )}\n </button>\n </li>\n )\n\n return (\n <div data-slot=\"token-select\" className={cn('relative', className)} {...props}>\n <button\n ref={triggerRef}\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n disabled={disabled}\n onClick={() => {\n setOpen(!open)\n if (!open) requestAnimationFrame(() => searchRef.current?.focus())\n }}\n className={cn(\n fieldBase,\n fieldSize[size],\n 'border-border bg-background justify-between border text-start',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {selected?.icon}\n <span className={cn('min-w-0 flex-1 truncate', !selected && 'text-muted-foreground/70')}>\n {selected?.symbol ?? placeholder}\n </span>\n {selected?.verified === false && (\n <TriangleAlert className=\"size-3.5 shrink-0 text-[var(--amber-soft-foreground)]\" />\n )}\n <ChevronDown\n className={cn(\n 'text-muted-foreground shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n open && 'rotate-180',\n )}\n />\n </button>\n\n {open && (\n <div ref={panelRef} style={style} className={cn(menuSurface, radius.surface, 'max-h-80 p-0')}>\n <div className=\"border-border flex items-center gap-2 border-b p-2.5\">\n <Search className=\"text-muted-foreground size-3.5 shrink-0\" />\n <input\n ref={searchRef}\n value={query}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n onChange={(event) => setQuery(event.target.value)}\n className=\"placeholder:text-muted-foreground/70 min-w-0 flex-1 bg-transparent text-sm outline-none\"\n />\n </div>\n\n <ul role=\"listbox\" aria-label={listLabel} className=\"max-h-64 list-none overflow-y-auto p-1\">\n {trusted.map((token) => (\n <Row key={token.address} token={token} />\n ))}\n\n {unverified.length > 0 && (\n <li>\n <div className=\"border-border mt-1 flex items-start gap-1.5 border-t p-2.5\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0 text-[var(--amber-soft-foreground)]\" />\n <p className=\"text-muted-foreground text-[11px]\">\n {unverifiedNote}\n </p>\n </div>\n </li>\n )}\n {unverified.map((token) => (\n <Row key={token.address} token={token} />\n ))}\n\n {trusted.length === 0 && unverified.length === 0 && (\n <li className=\"text-muted-foreground px-2.5 py-6 text-center text-sm\">\n {emptyMessage}\n </li>\n )}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { TokenSelect }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-usage",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Token Usage",
|
|
5
|
+
"description": "How much of a context window is spoken for. The colour crosses to amber at 75% and destructive at 90%, because the number only matters once it is close.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils",
|
|
10
|
+
"progress",
|
|
11
|
+
"tooltip"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/token-usage.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps } from 'react'\nimport { Progress } from '@/components/ui/progress'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\n\n/**\n * How much of a context window is spoken for.\n *\n * The colour crosses to amber at 75% and destructive at 90%, because the number\n * only matters once it is close — a green bar at 12% is noise, an amber one at\n * 80% is a warning you can act on before the conversation truncates.\n */\nfunction TokenUsage({\n className,\n used,\n limit,\n label = 'Context used',\n size = 'sm',\n showNumbers = true,\n ...props\n}: ComponentProps<'div'> & {\n used: number\n limit: number\n label?: string\n size?: 'sm' | 'default' | 'lg'\n showNumbers?: boolean\n}) {\n const share = limit === 0 ? 0 : Math.min(100, (used / limit) * 100)\n const color = share >= 90 ? 'destructive' : share >= 75 ? 'amber' : 'blue'\n\n const compact = (n: number) =>\n n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)\n\n return (\n <div\n data-slot=\"token-usage\"\n className={cn('w-full space-y-1.5', className)}\n {...props}\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-muted-foreground text-xs\">{label}</span>\n {showNumbers && (\n <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">\n {compact(used)} / {compact(limit)}\n </span>\n )}\n </div>\n <Tooltip content={`${used.toLocaleString()} of ${limit.toLocaleString()} tokens`}>\n <div>\n <Progress value={share} color={color} size={size} />\n </div>\n </Tooltip>\n </div>\n )\n}\n\nexport { TokenUsage }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tool-call",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tool Call",
|
|
5
|
+
"description": "One tool invocation inside an assistant turn. Collapsed by default — the arguments are long and the interesting part is that it ran — except a failure, which opens itself.",
|
|
6
|
+
"category": "AI",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"code-block",
|
|
12
|
+
"collapsible",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"spinner"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/tool-call.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { type ComponentProps, type ReactNode } from 'react'\nimport { Check, ChevronDown, TriangleAlert, Wrench } from 'lucide-react'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { Spinner } from '@/components/ui/spinner'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * One tool invocation inside an assistant turn.\n *\n * Collapsed by default: the arguments and the result are usually long, and the\n * interesting part is that the tool ran at all. A failure opens on its own,\n * because that is the case you always want to read.\n */\ntype ToolCallProps = Omit<ComponentProps<'div'>, 'title'> & {\n name: string\n status?: 'running' | 'done' | 'error'\n /** Short line beside the name — the target, the query, the file. */\n summary?: ReactNode\n input?: string\n output?: string\n language?: 'json' | 'bash' | 'tsx' | 'typescript'\n defaultOpen?: boolean\n inputLabel?: string\n outputLabel?: string\n}\n\nconst STATUS = {\n running: { icon: Spinner, className: 'text-[var(--blue-soft-foreground)]' },\n done: { icon: Check, className: 'text-[var(--green-soft-foreground)]' },\n error: { icon: TriangleAlert, className: 'text-[var(--destructive-soft-foreground)]' },\n} as const\n\nfunction ToolCall({\n className,\n name,\n status = 'done',\n summary,\n input,\n output,\n language = 'json',\n defaultOpen,\n inputLabel = 'Input',\n outputLabel = 'Output',\n ...props\n}: ToolCallProps) {\n const meta = STATUS[status]\n const Icon = meta.icon\n\n return (\n <Collapsible\n // A failed call opens itself; the others stay out of the way.\n defaultOpen={defaultOpen ?? status === 'error'}\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <CollapsibleTrigger showChevron={false} className=\"p-3\">\n <span className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Wrench className=\"text-muted-foreground size-3.5 shrink-0\" />\n <span className=\"font-mono text-xs\">{name}</span>\n {summary && (\n <span className=\"text-muted-foreground truncate text-xs\">\n {summary}\n </span>\n )}\n </span>\n <span className={cn('flex shrink-0 items-center gap-2', meta.className)}>\n {status === 'running' ? <Spinner size=\"xs\" /> : <Icon className=\"size-3.5\" />}\n <ChevronDown className=\"text-muted-foreground size-3.5\" />\n </span>\n </CollapsibleTrigger>\n\n <CollapsibleContent>\n <div className=\"space-y-2 p-2\">\n {input && (\n <CodeBlock title={inputLabel} language={language} code={input} maxLines={8} />\n )}\n {output && (\n <CodeBlock title={outputLabel} language={language} code={output} maxLines={12} />\n )}\n </div>\n </CollapsibleContent>\n </Collapsible>\n )\n}\n\nexport { ToolCall }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toolbar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toolbar",
|
|
5
|
+
"description": "A group of controls that arrow keys move between — the ARIA role ButtonGroup deliberately does not claim, because claiming it without implementing it is worse than not claiming it.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils",
|
|
10
|
+
"primitive-focus-trap"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/toolbar.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useRef, type ComponentProps, type KeyboardEvent } from 'react'\nimport { tabbableWithin } from '@/components/primitives/focus-trap'\nimport { cn } from '@/lib/utils'\n\n/**\n * A group of controls that arrow keys move between.\n *\n * This is the role `ButtonGroup` deliberately does not claim. `role=\"toolbar\"`\n * is a promise: the group is one tab stop and arrow keys move within it. Making\n * that claim without implementing it is worse than not claiming it, since a\n * screen-reader user is told to use arrows that do nothing.\n *\n * Focus roves over whatever is currently focusable, recomputed on each key.\n * Toolbars gain and lose buttons as selection changes, and a list cached on\n * mount sends focus to a control that is no longer there.\n */\nfunction Toolbar({\n orientation = 'horizontal',\n className,\n onKeyDown,\n ...props\n}: ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical' }) {\n const ref = useRef<HTMLDivElement>(null)\n\n function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n onKeyDown?.(event)\n if (event.defaultPrevented) return\n\n const forward = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'\n const back = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'\n const keys = [forward, back, 'Home', 'End']\n if (!keys.includes(event.key)) return\n\n const root = ref.current\n if (!root) return\n const items = tabbableWithin(root)\n if (items.length === 0) return\n\n const current = items.indexOf(document.activeElement as HTMLElement)\n event.preventDefault()\n\n const next =\n event.key === 'Home'\n ? 0\n : event.key === 'End'\n ? items.length - 1\n : event.key === forward\n ? (current + 1 + items.length) % items.length\n : (current - 1 + items.length) % items.length\n\n items[next]?.focus()\n }\n\n return (\n <div\n ref={ref}\n role=\"toolbar\"\n data-slot=\"toolbar\"\n aria-orientation={orientation}\n onKeyDown={handleKeyDown}\n className={cn(\n 'flex items-center gap-1',\n orientation === 'vertical' && 'flex-col items-stretch',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** A hairline between groups of controls. */\nfunction ToolbarSeparator({\n orientation = 'vertical',\n className,\n ...props\n}: ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical' }) {\n return (\n <div\n role=\"separator\"\n aria-orientation={orientation}\n data-slot=\"toolbar-separator\"\n className={cn(\n 'bg-border shrink-0',\n orientation === 'vertical' ? 'mx-1 h-5 w-px self-center' : 'my-1 h-px w-full',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport { Toolbar, ToolbarSeparator }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tooltip",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tooltip",
|
|
5
|
+
"description": "A short hint on hover or focus. Focus opens it immediately — a keyboard user has already committed — while pointer hover waits, so sweeping across a toolbar does not fire a row of them.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils",
|
|
10
|
+
"primitive-popper"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/tooltip.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import {\n cloneElement,\n isValidElement,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport { usePopper, type Align, type Side } from '@/components/primitives/popper'\nimport { cn } from '@/lib/utils'\n\n/**\n * A short hint shown on hover or focus.\n *\n * Focus opens it without a delay — a keyboard user has already committed, and\n * making them wait is just latency. Pointer hover waits, so sweeping across a\n * toolbar does not fire a row of tooltips.\n *\n * The trigger is cloned rather than wrapped, so the tooltip adds no element to\n * the layout and cannot break a flex or grid parent.\n */\nfunction Tooltip({\n children,\n content,\n side = 'top',\n align = 'center',\n delay = 400,\n offset = 6,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'content'> & {\n children: ReactElement<Record<string, unknown>>\n content: ReactNode\n side?: Side\n align?: Align\n delay?: number\n offset?: number\n}) {\n const [open, setOpen] = useState(false)\n const anchorRef = useRef<HTMLElement>(null)\n const floatingRef = useRef<HTMLDivElement>(null)\n const timer = useRef(0)\n const id = useId()\n\n const { style, side: resolved } = usePopper({\n open,\n anchorRef,\n floatingRef,\n side,\n align,\n offset,\n })\n\n useEffect(() => () => clearTimeout(timer.current), [])\n\n function show(immediate = false) {\n clearTimeout(timer.current)\n if (immediate) return setOpen(true)\n timer.current = window.setTimeout(() => setOpen(true), delay)\n }\n\n function hide() {\n clearTimeout(timer.current)\n setOpen(false)\n }\n\n // Escape must dismiss a tooltip that focus opened, per the ARIA practices.\n useEffect(() => {\n if (!open) return\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') hide()\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [open])\n\n if (!isValidElement(children)) return children\n\n // Passing the ref object as a prop is React 19's ref-as-prop, not a read of\n // `.current` during render — the rule cannot tell the two apart.\n // oxlint-disable-next-line react/refs\n const trigger = cloneElement(children, {\n ref: anchorRef,\n 'aria-describedby': open ? id : undefined,\n onPointerEnter: () => show(),\n onPointerLeave: hide,\n onFocus: () => show(true),\n onBlur: hide,\n })\n\n return (\n <>\n {trigger}\n {open && (\n <div\n ref={floatingRef}\n id={id}\n role=\"tooltip\"\n data-slot=\"tooltip\"\n data-side={resolved}\n style={style}\n className={cn(\n 'bg-foreground text-background pointer-events-none z-50 max-w-64 rounded-lg px-2.5 py-1.5 text-xs font-medium',\n className,\n )}\n {...props}\n >\n {content}\n </div>\n )}\n </>\n )\n}\n\nexport { Tooltip }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "topology-map",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Topology Map",
|
|
5
|
+
"description": "A service dependency graph with health. Laid out in dependency layers, not by a force simulation — during an incident nobody wants to re-find the database because the layout moved.",
|
|
6
|
+
"category": "AIOps",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils",
|
|
11
|
+
"tooltip"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/topology-map.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A service dependency graph with health.\n *\n * Laid out in layers by dependency depth rather than by a force simulation. A\n * force layout puts the same architecture in a different place on every render,\n * which makes it useless as a thing to learn — and during an incident nobody\n * wants to re-find the database.\n *\n * Depth is computed by walking edges, with a visited set: service graphs have\n * cycles (an API that calls a worker that calls the API), and a naive recursion\n * over one hangs the tab.\n *\n * Edges carry the downstream node's health, so a red line means the thing it\n * points at is unhealthy — you follow the colour to the cause.\n */\nexport type TopologyNode = {\n id: string\n label: ReactNode\n status: 'healthy' | 'degraded' | 'down' | 'unknown'\n /** Ids this depends on. */\n dependsOn?: string[]\n meta?: ReactNode\n}\n\nconst STATUS = {\n healthy: { fill: 'var(--green)', label: 'Healthy' },\n degraded: { fill: 'var(--amber)', label: 'Degraded' },\n down: { fill: 'var(--destructive)', label: 'Down' },\n unknown: { fill: 'var(--border)', label: 'Unknown' },\n} as const\n\nconst NODE_WIDTH = 132\nconst NODE_HEIGHT = 44\nconst GAP_X = 56\nconst GAP_Y = 20\n\n/** Longest path to a root, guarded against cycles. */\nfunction computeDepths(nodes: TopologyNode[]) {\n const byId = new Map(nodes.map((node) => [node.id, node]))\n const depths = new Map<string, number>()\n\n const visit = (id: string, seen: Set<string>): number => {\n if (depths.has(id)) return depths.get(id)!\n if (seen.has(id)) return 0\n const node = byId.get(id)\n if (!node?.dependsOn?.length) {\n depths.set(id, 0)\n return 0\n }\n\n const next = new Set(seen).add(id)\n const depth =\n 1 + Math.max(...node.dependsOn.map((parent) => visit(parent, next)), -1)\n depths.set(id, depth)\n return depth\n }\n\n for (const node of nodes) visit(node.id, new Set())\n return depths\n}\n\nfunction TopologyMap({\n nodes,\n healthyLabel = 'All services healthy',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n nodes: TopologyNode[]\n /** Shown when nothing is degraded. */\n healthyLabel?: ReactNode\n}) {\n const { positions, width, height } = useMemo(() => {\n const depths = computeDepths(nodes)\n const columns = new Map<number, TopologyNode[]>()\n\n for (const node of nodes) {\n const depth = depths.get(node.id) ?? 0\n columns.set(depth, [...(columns.get(depth) ?? []), node])\n }\n\n const placed = new Map<string, { x: number; y: number }>()\n let tallest = 0\n\n for (const [depth, column] of columns) {\n column.forEach((node, index) => {\n placed.set(node.id, {\n x: depth * (NODE_WIDTH + GAP_X),\n y: index * (NODE_HEIGHT + GAP_Y),\n })\n })\n tallest = Math.max(tallest, column.length)\n }\n\n return {\n positions: placed,\n width: (Math.max(...columns.keys(), 0) + 1) * (NODE_WIDTH + GAP_X) - GAP_X,\n height: tallest * (NODE_HEIGHT + GAP_Y) - GAP_Y,\n }\n }, [nodes])\n\n return (\n <div\n data-slot=\"topology-map\"\n className={cn(surface, radius.surface, 'w-full overflow-x-auto p-4', className)}\n {...props}\n >\n <svg\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n role=\"img\"\n aria-label={`Service topology: ${nodes\n .map((node) => `${typeof node.label === 'string' ? node.label : node.id} ${node.status}`)\n .join(', ')}`}\n className=\"min-w-full\"\n >\n {nodes.flatMap((node) =>\n (node.dependsOn ?? []).map((parentId) => {\n const from = positions.get(parentId)\n const to = positions.get(node.id)\n if (!from || !to) return null\n\n const x1 = from.x + NODE_WIDTH\n const y1 = from.y + NODE_HEIGHT / 2\n const x2 = to.x\n const y2 = to.y + NODE_HEIGHT / 2\n const mid = (x1 + x2) / 2\n\n return (\n <path\n key={`${parentId}-${node.id}`}\n d={`M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`}\n fill=\"none\"\n // The edge takes the downstream node's health: follow the red.\n stroke={STATUS[node.status].fill}\n strokeWidth={1.5}\n opacity={node.status === 'healthy' ? 0.3 : 0.7}\n />\n )\n }),\n )}\n\n {nodes.map((node) => {\n const position = positions.get(node.id)\n if (!position) return null\n const status = STATUS[node.status]\n\n return (\n <g key={node.id} transform={`translate(${position.x}, ${position.y})`}>\n <rect\n width={NODE_WIDTH}\n height={NODE_HEIGHT}\n rx={10}\n fill=\"var(--card)\"\n stroke={status.fill}\n strokeWidth={node.status === 'healthy' ? 1 : 2}\n />\n <circle cx={14} cy={NODE_HEIGHT / 2} r={4} fill={status.fill} />\n <text\n x={26}\n y={NODE_HEIGHT / 2}\n dominantBaseline=\"middle\"\n className=\"fill-[var(--foreground)] text-[11px] font-medium\"\n >\n {typeof node.label === 'string' ? node.label : node.id}\n </text>\n </g>\n )\n })}\n </svg>\n\n <ul className=\"mt-3 flex list-none flex-wrap gap-x-4 gap-y-1.5\">\n {nodes\n .filter((node) => node.status !== 'healthy')\n .map((node) => (\n <li key={node.id}>\n <Tooltip content={node.meta ?? STATUS[node.status].label}>\n <span\n tabIndex={0}\n className=\"focus-visible:ring-ring/50 flex items-center gap-1.5 text-xs outline-none focus-visible:ring-2\"\n >\n <span\n aria-hidden=\"true\"\n className=\"size-2 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: STATUS[node.status].fill }}\n />\n <span className=\"text-muted-foreground\">\n {typeof node.label === 'string' ? node.label : node.id}\n </span>\n <span className=\"font-medium\">{STATUS[node.status].label}</span>\n </span>\n </Tooltip>\n </li>\n ))}\n {nodes.every((node) => node.status === 'healthy') && (\n <li className=\"text-muted-foreground text-xs\">{healthyLabel}</li>\n )}\n </ul>\n </div>\n )\n}\n\nexport { TopologyMap, computeDepths }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tour",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tour",
|
|
5
|
+
"description": "Guided coach marks over the real interface. Steps point at selectors, so a tour is data next to the copy it belongs to rather than refs threaded through every component it visits.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"primitive-focus-trap",
|
|
15
|
+
"primitive-popper",
|
|
16
|
+
"primitive-portal"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/tour.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { X } from 'lucide-react'\nimport { Portal } from '@/components/primitives/portal'\nimport { useFocusTrap } from '@/components/primitives/focus-trap'\nimport { usePopper, type Side } from '@/components/primitives/popper'\nimport { Button } from '@/components/ui/button'\nimport { menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Guided coach marks over the real interface.\n *\n * Each step points at a selector rather than at a ref, so a tour can be defined\n * as data next to the copy it belongs to instead of being threaded through\n * every component it visits.\n *\n * A step whose target is missing is skipped rather than shown floating in the\n * middle of the page. Targets disappear all the time — a feature behind a flag,\n * a panel that is closed — and a tour that stops dead on the first absent one\n * is worse than a shorter tour.\n *\n * The spotlight is a ring around the target drawn with a huge outline: one\n * element, no four-panel overlay to keep in sync, and the target stays fully\n * interactive because nothing covers it.\n */\nexport type TourStep = {\n /** CSS selector for the element to point at. */\n target: string\n title: ReactNode\n content: ReactNode\n side?: Side\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_STEP_LABEL: (index: number, total: number) => string = (index, total) => `Step ${index} of ${total}`\n\nfunction Tour({\n steps,\n open,\n onOpenChange,\n onFinish,\n backLabel = 'Back',\n endLabel = 'End tour',\n stepLabel = DEFAULT_STEP_LABEL,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'content'> & {\n steps: TourStep[]\n open: boolean\n onOpenChange: (open: boolean) => void\n onFinish?: () => void\n backLabel?: ReactNode\n /** Accessible name for the dismiss button. */\n endLabel?: string\n /** Accessible name for the progress dots. */\n stepLabel?: (index: number, total: number) => string\n}) {\n const [index, setIndex] = useState(0)\n const [target, setTarget] = useState<HTMLElement | null>(null)\n const anchorRef = useRef<HTMLElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n\n const step = steps[index]\n\n const { style } = usePopper({\n open: open && Boolean(target),\n anchorRef,\n floatingRef: panelRef,\n side: step?.side ?? 'bottom',\n align: 'center',\n offset: 12,\n })\n\n useFocusTrap(panelRef, open && Boolean(target))\n\n // Resolve the target, skipping steps whose element is not on the page.\n useEffect(() => {\n if (!open) return\n\n let cursor = index\n let node: HTMLElement | null = null\n\n while (cursor < steps.length) {\n node = document.querySelector<HTMLElement>(steps[cursor].target)\n if (node) break\n cursor++\n }\n\n if (!node) {\n onOpenChange(false)\n onFinish?.()\n return\n }\n\n if (cursor !== index) setIndex(cursor)\n anchorRef.current = node\n setTarget(node)\n node.scrollIntoView({ block: 'center', behavior: 'smooth' })\n }, [open, index, steps, onOpenChange, onFinish])\n\n useEffect(() => {\n if (!open) setIndex(0)\n }, [open])\n\n if (!open || !step || !target) return null\n\n const last = index === steps.length - 1\n const rect = target.getBoundingClientRect()\n\n function finish() {\n onOpenChange(false)\n onFinish?.()\n }\n\n return (\n <Portal>\n {/* Spotlight: an outline large enough to cover the viewport dims\n everything except the target, in one element. */}\n <div\n aria-hidden=\"true\"\n style={{\n position: 'fixed',\n top: rect.top - 4,\n left: rect.left - 4,\n width: rect.width + 8,\n height: rect.height + 8,\n outline: '9999px solid color-mix(in oklab, black, transparent 40%)',\n borderRadius: 12,\n pointerEvents: 'none',\n zIndex: 60,\n }}\n />\n\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"false\"\n aria-label={stepLabel(index + 1, steps.length)}\n style={{ ...style, zIndex: 61 }}\n className={cn(menuSurface, radius.surface, 'w-72 p-4 outline-none', className)}\n onKeyDown={(event) => {\n if (event.key === 'Escape') finish()\n }}\n {...props}\n >\n <div className=\"mb-2 flex items-start gap-2\">\n <h2 className=\"min-w-0 flex-1 text-sm font-semibold\">{step.title}</h2>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={endLabel} onClick={finish}>\n <X />\n </Button>\n </div>\n\n <div className=\"text-muted-foreground mb-4 text-sm\">{step.content}</div>\n\n <div className=\"flex items-center gap-2\">\n <span className=\"text-muted-foreground text-xs tabular-nums whitespace-nowrap\">\n {index + 1} / {steps.length}\n </span>\n\n <div className=\"ms-auto flex gap-2\">\n {index > 0 && (\n <Button variant=\"secondary\" size=\"xs\" onClick={() => setIndex(index - 1)}>\n {backLabel}\n </Button>\n )}\n <Button size=\"xs\" onClick={() => (last ? finish() : setIndex(index + 1))}>\n {last ? 'Done' : 'Next'}\n </Button>\n </div>\n </div>\n </div>\n </Portal>\n )\n}\n\nexport { Tour }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "traffic-split",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Traffic Split",
|
|
5
|
+
"description": "Traffic weights across versions — canary or blue/green. Weights that do not total 100 are called out rather than silently rescaled, because a config adding to 97 is a real state during an edit.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"badge",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/traffic-split.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Traffic weights across versions — a canary or blue/green rollout.\n *\n * Weights are normalised for display rather than assumed to total 100. A\n * rollout config that adds up to 97 is a real state during an edit, and a bar\n * that renders it as a gap is more honest than one that silently rescales and\n * hides the mistake — so the shortfall is called out instead.\n *\n * Each segment is labelled inside when it is wide enough and in the legend\n * always, since a 3% canary has no room for text but is precisely the segment\n * you are watching.\n */\nexport type TrafficTarget = {\n id: string\n label: ReactNode\n weight: number\n version?: string\n color?: string\n /** Marks the version being rolled out. */\n canary?: boolean\n}\n\n\n\nfunction TrafficSplit({\n targets,\n label,\n canaryLabel = 'canary',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n targets: TrafficTarget[]\n label?: ReactNode\n /** Badge on the canary variant. */\n canaryLabel?: ReactNode\n}) {\n const total = targets.reduce((sum, target) => sum + target.weight, 0)\n const shortfall = Math.round((100 - total) * 100) / 100\n\n return (\n <div\n data-slot=\"traffic-split\"\n className={cn('flex flex-col gap-2', className)}\n {...props}\n >\n {(label || shortfall !== 0) && (\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n {label && <span className=\"text-sm font-medium\">{label}</span>}\n {shortfall !== 0 && (\n <span className=\"text-xs text-[var(--amber-soft-foreground)]\">\n {shortfall > 0\n ? `${shortfall}% unassigned`\n : `${Math.abs(shortfall)}% over 100`}\n </span>\n )}\n </div>\n )}\n\n <div\n className=\"bg-secondary flex h-8 w-full overflow-hidden rounded-lg\"\n role=\"img\"\n aria-label={targets\n .map((target) => `${target.version ?? target.id}: ${target.weight}%`)\n .join(', ')}\n >\n {targets.map((target, index) => {\n const share = total > 0 ? (target.weight / total) * 100 : 0\n return (\n <div\n key={target.id}\n style={{\n width: `${share}%`,\n backgroundColor: target.color ?? dataPalette[index % dataPalette.length].fill,\n // The label sits on the fill, so it reads the paired ink —\n // white on the amber entry fails contrast. A caller-supplied\n // colour has no known pairing, so it falls back to white.\n color: target.color ? 'white' : dataPalette[index % dataPalette.length].ink,\n }}\n className=\"flex items-center justify-center overflow-hidden transition-[width] duration-300 ease-out motion-reduce:transition-none\"\n >\n {/* Only label inside when there is genuinely room. */}\n {share > 12 && (\n <span className=\"truncate px-2 text-xs font-medium\">\n {target.weight}%\n </span>\n )}\n </div>\n )\n })}\n </div>\n\n <ul className=\"flex list-none flex-wrap gap-x-4 gap-y-1.5\">\n {targets.map((target, index) => (\n <li key={target.id} 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: target.color ?? dataPalette[index % dataPalette.length].fill }}\n />\n <span className=\"text-muted-foreground\">{target.label}</span>\n {target.version && (\n <code className=\"text-muted-foreground/70 font-mono\">{target.version}</code>\n )}\n {target.canary && (\n <Badge size=\"sm\" color=\"amber\">\n {canaryLabel}\n </Badge>\n )}\n <span className=\"font-medium tabular-nums\">{target.weight}%</span>\n </li>\n ))}\n </ul>\n </div>\n )\n}\n\nexport { TrafficSplit }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "transaction-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Transaction List",
|
|
5
|
+
"description": "Wallet activity grouped by day. Direction is derived against the connected account, not passed in — the same transfer is a send for one wallet and a receive for another, and a self-transfer is both.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"fmt",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"wallet-address"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/transaction-list.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDownLeft, ArrowUpRight, ExternalLink, RefreshCw, TriangleAlert } from 'lucide-react'\nimport { Fmt } from '@/components/ui/fmt'\nimport { WalletAddress } from '@/components/ui/wallet-address'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Wallet activity, grouped by day.\n *\n * Direction is relative to the connected wallet, not a property of the\n * transaction. The same transfer is a send for one account and a receive for\n * another, and a self-transfer is both — which is why `account` is required and\n * the direction is derived rather than passed in. Components that take a\n * `type: 'send' | 'receive'` prop get this wrong the moment two of your own\n * addresses transact.\n *\n * A failed transaction still appears. It consumed gas and it is on-chain;\n * hiding it makes the balance look unexplained.\n */\nexport type WalletTx = {\n hash: string\n /** Sender. Compared against `account` to derive direction. */\n from: string\n to: string\n /** Formatted amount with symbol. */\n amount?: ReactNode\n fiat?: number\n time: Date\n kind?: 'transfer' | 'swap' | 'approve' | 'contract'\n failed?: boolean\n href?: string\n}\n\nfunction TransactionList({\n transactions,\n account,\n locale = 'en-GB',\n currency = 'USD',\n now,\n emptyLabel = 'No activity yet',\n explorerLabel = 'View on block explorer',\n className,\n ...props\n}: ComponentProps<'div'> & {\n transactions: WalletTx[]\n /** The connected wallet. Direction is derived against this. */\n account: string\n locale?: string\n currency?: string\n now?: Date\n emptyLabel?: ReactNode\n explorerLabel?: string\n}) {\n const heading = useMemo(\n () => new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long' }),\n [locale],\n )\n const money = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n maximumFractionDigits: 2,\n })\n\n const groups = useMemo(() => {\n const out: [string, WalletTx[]][] = []\n for (const tx of transactions) {\n const key = heading.format(tx.time)\n const last = out.at(-1)\n if (last?.[0] === key) last[1].push(tx)\n else out.push([key, [tx]])\n }\n return out\n }, [transactions, heading])\n\n const lower = account.toLowerCase()\n\n return (\n <div\n data-slot=\"transaction-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {transactions.length === 0 ? (\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n ) : (\n groups.map(([day, rows]) => (\n <section key={day}>\n <h3 className=\"bg-muted/40 text-muted-foreground border-border sticky top-0 border-b p-3 text-xs font-medium\">\n {day}\n </h3>\n\n <ul className=\"list-none divide-y divide-[var(--border)]\">\n {rows.map((tx) => {\n // Derived, never passed: a self-transfer is both directions.\n const sent = tx.from.toLowerCase() === lower\n const received = tx.to.toLowerCase() === lower\n const self = sent && received\n\n const Icon = tx.failed\n ? TriangleAlert\n : tx.kind === 'swap'\n ? RefreshCw\n : sent && !self\n ? ArrowUpRight\n : ArrowDownLeft\n\n const label = tx.failed\n ? 'Failed'\n : self\n ? 'Self transfer'\n : tx.kind === 'swap'\n ? 'Swap'\n : tx.kind === 'approve'\n ? 'Approve'\n : tx.kind === 'contract'\n ? 'Contract'\n : sent\n ? 'Sent'\n : 'Received'\n\n return (\n <li key={tx.hash} className=\"flex items-center gap-3 p-3\">\n <span\n className={cn(\n 'flex size-8 shrink-0 items-center justify-center rounded-full [corner-shape:round]',\n tx.failed\n ? 'bg-[color-mix(in_oklab,var(--destructive),transparent_88%)] text-[var(--destructive-soft-foreground)]'\n : self || tx.kind === 'swap' || tx.kind === 'approve'\n ? 'bg-secondary text-muted-foreground'\n : sent\n ? 'bg-secondary text-muted-foreground'\n : 'bg-[color-mix(in_oklab,var(--green),transparent_88%)] text-[var(--green-soft-foreground)]',\n )}\n aria-hidden=\"true\"\n >\n <Icon className=\"size-4\" />\n </span>\n\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex items-center gap-2 text-sm font-medium\">\n {label}\n </p>\n <p className=\"text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-2 text-xs\">\n <WalletAddress\n address={sent && !self ? tx.to : tx.from}\n size=\"sm\"\n copyable={false}\n />\n <Fmt type=\"relative\" value={tx.time} now={now} locale={locale} />\n </p>\n </div>\n\n {tx.amount && (\n <span className=\"shrink-0 text-end\">\n <span\n className={cn(\n 'block text-sm tabular-nums whitespace-nowrap',\n tx.failed && 'text-muted-foreground line-through',\n !tx.failed && received && !self && 'text-[var(--green-soft-foreground)]',\n )}\n >\n {!tx.failed && !self && (received ? '+' : '−')}\n {tx.amount}\n </span>\n {tx.fiat !== undefined && (\n <span className=\"text-muted-foreground block text-xs tabular-nums\">\n {money.format(tx.fiat)}\n </span>\n )}\n </span>\n )}\n\n {tx.href && (\n <a\n href={tx.href}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n aria-label={explorerLabel}\n className={cn(\n 'text-muted-foreground hover:text-foreground shrink-0',\n radius.xs,\n focusRing,\n )}\n >\n <ExternalLink className=\"size-3.5\" />\n </a>\n )}\n </li>\n )\n })}\n </ul>\n </section>\n ))\n )}\n </div>\n )\n}\n\nexport { TransactionList }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|