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,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "order-book",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Order Book",
|
|
5
|
+
"description": "Bids and asks with cumulative depth. The bar is the running total from the top of book, not the size at that level — what matters when taking liquidity is how much sits between you and a price.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/order-book.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Bids and asks with cumulative depth.\n *\n * The depth bar is the *cumulative* total from the top of book down, not the\n * size at that single level. Per-level bars are the common mistake and they\n * misrepresent the book completely: what matters when you are about to take\n * liquidity is how much sits between you and a price, which is a running sum.\n *\n * Asks are rendered ascending but displayed bottom-up so the spread sits in the\n * middle, which is the convention every trader reads without thinking.\n */\nexport type OrderLevel = {\n price: number\n size: number\n}\n\nfunction OrderBook({\n bids,\n asks,\n depth = 8,\n pricePrecision = 2,\n sizePrecision = 4,\n locale = 'en-GB',\n quoteSymbol,\n baseSymbol,\n totalHeader = 'Total',\n spreadLabel = 'Spread',\n className,\n ...props\n}: ComponentProps<'div'> & {\n bids: OrderLevel[]\n asks: OrderLevel[]\n depth?: number\n pricePrecision?: number\n sizePrecision?: number\n locale?: string\n quoteSymbol?: string\n baseSymbol?: string\n totalHeader?: ReactNode\n spreadLabel?: ReactNode\n}) {\n const { bidRows, askRows, max, spread, spreadPct } = useMemo(() => {\n const cumulate = (levels: OrderLevel[]) => {\n let total = 0\n return levels.slice(0, depth).map((level) => {\n total += level.size\n return { ...level, total }\n })\n }\n\n const b = cumulate([...bids].sort((x, y) => y.price - x.price))\n const a = cumulate([...asks].sort((x, y) => x.price - y.price))\n\n const best = { bid: b[0]?.price, ask: a[0]?.price }\n const gap = best.ask !== undefined && best.bid !== undefined ? best.ask - best.bid : undefined\n\n return {\n bidRows: b,\n askRows: a,\n // One scale for both sides, or the bars lie about the imbalance.\n max: Math.max(b.at(-1)?.total ?? 0, a.at(-1)?.total ?? 0, 1),\n spread: gap,\n spreadPct: gap !== undefined && best.ask ? (gap / best.ask) * 100 : undefined,\n }\n }, [bids, asks, depth])\n\n const num = (value: number, digits: number) =>\n new Intl.NumberFormat(locale, {\n minimumFractionDigits: digits,\n maximumFractionDigits: digits,\n }).format(value)\n\n const Row = ({\n level,\n side,\n }: {\n level: OrderLevel & { total: number }\n side: 'bid' | 'ask'\n }) => (\n <div className=\"relative flex items-center px-3 py-0.5 font-mono text-xs tabular-nums\">\n <span\n aria-hidden=\"true\"\n className={cn(\n 'absolute inset-y-0 end-0',\n side === 'bid'\n ? 'bg-[color-mix(in_oklab,var(--green),transparent_88%)]'\n : 'bg-[color-mix(in_oklab,var(--destructive),transparent_88%)]',\n )}\n style={{ width: `${(level.total / max) * 100}%` }}\n />\n <span\n className={cn(\n 'relative z-10 flex-1',\n side === 'bid'\n ? 'text-[var(--green-soft-foreground)]'\n : 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n {num(level.price, pricePrecision)}\n </span>\n <span className=\"text-muted-foreground relative z-10 w-24 text-end\">\n {num(level.size, sizePrecision)}\n </span>\n <span className=\"text-muted-foreground/60 relative z-10 hidden w-24 text-end sm:block\">\n {num(level.total, sizePrecision)}\n </span>\n </div>\n )\n\n return (\n <div\n data-slot=\"order-book\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border text-muted-foreground flex items-center border-b p-3 text-[10px] font-medium tracking-wide uppercase\">\n <span className=\"flex-1\">Price {quoteSymbol && `(${quoteSymbol})`}</span>\n <span className=\"w-24 text-end\">Size {baseSymbol && `(${baseSymbol})`}</span>\n <span className=\"hidden w-24 text-end sm:block\">{totalHeader}</span>\n </div>\n\n {/* Asks bottom-up, so the spread sits between the two sides. */}\n <div className=\"flex flex-col-reverse\">\n {askRows.map((level, index) => (\n <Row key={`ask-${index}`} level={level} side=\"ask\" />\n ))}\n </div>\n\n <div className=\"border-border bg-muted/40 flex items-baseline gap-2 border-y p-3\">\n <span className=\"text-muted-foreground text-[10px] font-medium tracking-wide uppercase\">\n {spreadLabel}\n </span>\n <span className=\"font-mono text-xs tabular-nums\">\n {spread !== undefined ? num(spread, pricePrecision) : '—'}\n </span>\n {spreadPct !== undefined && (\n <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">\n {spreadPct.toFixed(3)}%\n </span>\n )}\n </div>\n\n <div className=\"flex flex-col\">\n {bidRows.map((level, index) => (\n <Row key={`bid-${index}`} level={level} side=\"bid\" />\n ))}\n </div>\n </div>\n )\n}\n\nexport { OrderBook }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "page-header",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Page Header",
|
|
5
|
+
"description": "The masthead a page opens with: eyebrow, title, lead and a meta row. Extracted after four pages in this catalogue had each grown their own copy and drifted apart.",
|
|
6
|
+
"category": "Utility",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-utils"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "components/ui/page-header.tsx",
|
|
14
|
+
"type": "registry:ui",
|
|
15
|
+
"content": "import type { ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\n/**\n * The masthead every page in the catalogue opens with.\n *\n * Extracted because four pages had each grown their own copy of the same\n * markup, and they had already drifted — different bottom padding, different\n * measure on the lead paragraph. One header keeps the rhythm identical as you\n * move between a doc, a component and an index.\n *\n * `eyebrow` is the line above the title: a breadcrumb on a component page, the\n * source path, a section label. `meta` sits under the lead for counts, badges\n * or links.\n */\nfunction PageHeader({\n eyebrow,\n title,\n description,\n meta,\n className,\n}: {\n eyebrow?: ReactNode\n title: string\n description?: ReactNode\n meta?: ReactNode\n className?: string\n}) {\n return (\n <header className={cn('border-border mb-10 border-b pb-6', className)}>\n {eyebrow && <div className=\"mb-2\">{eyebrow}</div>}\n <h1 className=\"text-3xl font-semibold tracking-tight text-balance\">\n {title}\n </h1>\n {description && (\n <p className=\"text-muted-foreground mt-2 max-w-2xl text-sm text-pretty\">\n {description}\n </p>\n )}\n {meta && <div className=\"mt-4 flex flex-wrap items-center gap-2\">{meta}</div>}\n </header>\n )\n}\n\nexport { PageHeader }\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pagination",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Pagination",
|
|
5
|
+
"description": "Move between pages of results. The windowing rule — how many neighbours to show and where the gaps fall — is exported as pageItems, so it can be reused or tested on its own.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/pagination.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import type { ComponentProps } from 'react'\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'\nimport { Button, type ButtonProps } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\n/**\n * Move between pages of results.\n *\n * `pageItems` is exported because the windowing rule — how many neighbours to\n * show, and where gaps fall — is the part worth testing and reusing, not the\n * markup around it.\n */\ntype PageItem = number | 'gap'\n\n/**\n * Pages to render for `page` of `count`, with `siblings` neighbours either side\n * and the first and last always present. Gaps collapse to a single ellipsis,\n * and the window is only ever elided when it saves at least one slot — so the\n * row never shows \"… 4 …\" where the number itself would have been shorter.\n */\nfunction pageItems(page: number, count: number, siblings = 1): PageItem[] {\n // first + last + current + 2 gaps + siblings on both sides\n const slots = siblings * 2 + 5\n\n if (count <= slots) {\n return Array.from({ length: count }, (_, index) => index + 1)\n }\n\n const left = Math.max(page - siblings, 1)\n const right = Math.min(page + siblings, count)\n\n const showLeftGap = left > 2\n const showRightGap = right < count - 1\n\n if (!showLeftGap && showRightGap) {\n const size = siblings * 2 + 3\n return [\n ...Array.from({ length: size }, (_, index) => index + 1),\n 'gap',\n count,\n ]\n }\n\n if (showLeftGap && !showRightGap) {\n const size = siblings * 2 + 3\n return [\n 1,\n 'gap',\n ...Array.from({ length: size }, (_, index) => count - size + 1 + index),\n ]\n }\n\n return [\n 1,\n 'gap',\n ...Array.from({ length: right - left + 1 }, (_, index) => left + index),\n 'gap',\n count,\n ]\n}\n\ntype PaginationProps = Omit<ComponentProps<'nav'>, 'onChange'> & {\n page: number\n count: number\n onPageChange?: (page: number) => void\n /** Neighbours shown either side of the current page. */\n siblings?: number\n size?: ButtonProps['size']\n /** Hide the numbers and show only Previous / Next. */\n compact?: boolean\n /** Accessible name for the nav landmark. */\n label?: string\n previousLabel?: string\n nextLabel?: string\n /** Accessible name for a page button. */\n pageLabel?: (page: number) => string\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_PAGE_LABEL: (page: number) => string = (page) => `Page ${page}`\n\nfunction Pagination({\n className,\n page,\n count,\n onPageChange,\n siblings = 1,\n size = 'icon-sm',\n compact = false,\n label = 'Pagination',\n previousLabel = 'Previous page',\n nextLabel = 'Next page',\n pageLabel = DEFAULT_PAGE_LABEL,\n ...props\n}: PaginationProps) {\n const items = compact ? [] : pageItems(page, count, siblings)\n const go = (next: number) => {\n if (next >= 1 && next <= count && next !== page) onPageChange?.(next)\n }\n\n return (\n <nav\n aria-label={label}\n data-slot=\"pagination\"\n className={cn('flex items-center gap-1', className)}\n {...props}\n >\n <Button\n variant=\"ghost\"\n size={size}\n aria-label={previousLabel}\n disabled={page <= 1}\n onClick={() => go(page - 1)}\n >\n <ChevronLeft />\n </Button>\n\n {items.map((item, index) =>\n item === 'gap' ? (\n <span\n key={`gap-${index}`}\n aria-hidden=\"true\"\n className=\"text-muted-foreground flex size-8 items-center justify-center\"\n >\n <MoreHorizontal className=\"size-3.5\" />\n </span>\n ) : (\n <Button\n key={item}\n variant={item === page ? 'default' : 'ghost'}\n size={size}\n // Icon sizes carry no text size, so a number in one inherited the\n // ambient page size — 16px here — and changed with wherever the\n // component was dropped. Pinned, and two pixels down from that.\n className=\"text-sm tabular-nums\"\n aria-label={pageLabel(item)}\n aria-current={item === page ? 'page' : undefined}\n onClick={() => go(item)}\n >\n {item}\n </Button>\n ),\n )}\n\n {compact && (\n <span className=\"text-muted-foreground px-2 text-sm tabular-nums\">\n {page} / {count}\n </span>\n )}\n\n <Button\n variant=\"ghost\"\n size={size}\n aria-label={nextLabel}\n disabled={page >= count}\n onClick={() => go(page + 1)}\n >\n <ChevronRight />\n </Button>\n </nav>\n )\n}\n\nexport { Pagination, pageItems }\nexport type { PaginationProps, PageItem }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "password-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Password Input",
|
|
5
|
+
"description": "A password field with a reveal toggle and an optional strength meter that rewards length over symbols — the thing naive meters get wrong when they demand punctuation from a passphrase.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-styles",
|
|
12
|
+
"lib-utils"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/password-input.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useId, useState, type ComponentProps } from 'react'\nimport { Eye, EyeOff } from 'lucide-react'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A password field with a reveal toggle and an optional strength meter.\n *\n * The toggle is a button, not a checkbox, and it is `tabIndex={-1}` so Tab goes\n * from the field to the next field rather than through it. Reaching it is a\n * pointer gesture; a keyboard user typing a password does not want a stop\n * between the field and the submit button.\n *\n * Strength is scored on length and variety only, and the component says so —\n * `zxcvbn`-style dictionary scoring is a 400 kB dependency and a genuinely\n * different claim. What is here rewards length above all else, which is the one\n * thing the naive meters get wrong when they demand a symbol from a passphrase.\n */\nconst BANDS = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const\n\nconst BAND_TONE = [\n 'bg-[var(--destructive)]',\n 'bg-[var(--destructive)]',\n 'bg-[var(--amber)]',\n 'bg-[var(--green)]',\n 'bg-[var(--green)]',\n]\n\n/** 0–4. Length dominates; variety is worth less than one more character. */\nfunction scorePassword(value: string) {\n if (!value) return 0\n let score = 0\n if (value.length >= 8) score++\n if (value.length >= 12) score++\n if (value.length >= 16) score++\n\n const variety = [/[a-z]/, /[A-Z]/, /\\d/, /[^\\w\\s]/].filter((re) => re.test(value)).length\n if (variety >= 3) score++\n\n return Math.min(score, 4)\n}\n\nfunction PasswordInput({\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n strength = false,\n value,\n strengthLabel = 'Password strength',\n className,\n onChange,\n ...props\n}: Omit<ComponentProps<'input'>, 'type' | 'size'> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Show the strength meter under the field. */\n strength?: boolean\n /** Accessible name for the strength meter. */\n strengthLabel?: string\n}) {\n const [visible, setVisible] = useState(false)\n const [internal, setInternal] = useState('')\n const meterId = useId()\n\n const text = value !== undefined ? String(value) : internal\n const score = scorePassword(text)\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n }[variant]\n\n return (\n <div className={cn('flex w-full flex-col gap-1.5', className)}>\n <div\n data-slot=\"password-input\"\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT,\n error && 'border-destructive',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n )}\n >\n <input\n type={visible ? 'text' : 'password'}\n disabled={disabled}\n value={value}\n aria-describedby={strength ? meterId : undefined}\n onChange={(event) => {\n if (value === undefined) setInternal(event.target.value)\n onChange?.(event)\n }}\n className={cn(fieldInput)}\n {...props}\n />\n\n <button\n type=\"button\"\n // Out of the tab order deliberately: Tab should go field → submit.\n tabIndex={-1}\n disabled={disabled}\n aria-label={visible ? 'Hide password' : 'Show password'}\n aria-pressed={visible}\n onClick={() => setVisible((current) => !current)}\n className={cn(\n 'text-muted-foreground hover:text-foreground flex size-6 shrink-0 items-center justify-center',\n radius.xs,\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n )}\n >\n {visible ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n </button>\n </div>\n\n {strength && (\n <div id={meterId} className=\"flex flex-col gap-1\">\n <div\n role=\"meter\"\n aria-valuenow={score}\n aria-valuemin={0}\n aria-valuemax={4}\n aria-valuetext={BANDS[score]}\n aria-label={strengthLabel}\n className=\"flex gap-1\"\n >\n {[0, 1, 2, 3].map((index) => (\n <span\n key={index}\n className={cn(\n 'h-1 flex-1 rounded-full [corner-shape:round]',\n text && index < score ? BAND_TONE[score] : 'bg-secondary',\n )}\n />\n ))}\n </div>\n {text && (\n <p className=\"text-muted-foreground text-xs\">\n {BANDS[score]} — length matters more than symbols\n </p>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { PasswordInput, scorePassword }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payment-method",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Payment Method",
|
|
5
|
+
"description": "Saved cards and accounts, with expiry warnings. A card expiring next month is the reason a subscription fails, and saying so before it does is the entire value of this list.",
|
|
6
|
+
"category": "Finance",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/payment-method.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Building2, Check, CreditCard, TriangleAlert, Wallet } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, interactive, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Saved payment methods, selectable, with one default.\n *\n * An expired or expiring card is flagged in the list rather than at checkout.\n * The card that fails is almost always one that expired months ago and nobody\n * told the customer — surfacing it here is the difference between a prompt and\n * an involuntary churn.\n *\n * Only the last four digits are ever rendered. A saved method has no business\n * holding a full PAN client-side, and a component that accepts one invites\n * somebody to pass it.\n */\nexport type PaymentMethod = {\n id: string\n kind: 'card' | 'bank' | 'wallet'\n /** Last four digits or account suffix. Never the full number. */\n last4: string\n brand?: ReactNode\n /** MM/YY. Cards only. */\n expiry?: string\n holder?: ReactNode\n isDefault?: boolean\n}\n\nconst ICON = { card: CreditCard, bank: Building2, wallet: Wallet } as const\n\n/** Expired, or inside the window where the issuer reissues. */\nfunction expiryState(expiry: string | undefined, now: Date) {\n if (!expiry) return 'none' as const\n const [mm, yy] = expiry.split('/').map((part) => Number(part))\n if (!mm || Number.isNaN(yy)) return 'none' as const\n // End of the expiry month is the actual cutoff.\n const end = new Date(2000 + yy, mm, 0, 23, 59, 59)\n if (end < now) return 'expired' as const\n const soon = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 60)\n return end < soon ? 'expiring' as const : 'ok' as const\n}\n\nfunction PaymentMethodList({\n methods,\n selected,\n onSelect,\n onRemove,\n onSetDefault,\n now,\n emptyLabel = 'No payment methods',\n defaultLabel = 'Default',\n expiredLabel = 'Expired',\n expiringLabel = 'Expiring soon',\n expiresLabel = 'Expires',\n setDefaultLabel = 'Make default',\n removeLabel = 'Remove',\n expiredNote = 'An expired card will decline. Update it before the next charge.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n methods: PaymentMethod[]\n selected?: string\n onSelect?: (id: string) => void\n onRemove?: (id: string) => void\n onSetDefault?: (id: string) => void\n now?: Date\n emptyLabel?: ReactNode\n /** Badge on the method charged by default. */\n defaultLabel?: ReactNode\n expiredLabel?: ReactNode\n /** Badge on a card within 60 days of expiry. */\n expiringLabel?: ReactNode\n /** Precedes the MM/YY expiry. */\n expiresLabel?: ReactNode\n setDefaultLabel?: ReactNode\n removeLabel?: ReactNode\n /** Note shown when any card has already expired. Pass `null` to drop it. */\n expiredNote?: ReactNode\n}) {\n const reference = now ?? new Date()\n\n if (methods.length === 0) {\n return (\n <div className={cn(surface, radius.surface, className)} {...props}>\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <div\n data-slot=\"payment-method-list\"\n role={onSelect ? 'radiogroup' : undefined}\n className={cn('flex flex-col gap-2', className)}\n {...props}\n >\n {methods.map((method) => {\n const Icon = ICON[method.kind]\n const state = expiryState(method.expiry, reference)\n const chosen = selected === method.id\n\n const body = (\n <>\n <Icon className=\"text-muted-foreground size-5 shrink-0\" aria-hidden=\"true\" />\n\n <span className=\"min-w-0 flex-1 text-start\">\n <span className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-sm font-medium\">\n {method.brand ?? method.kind} •••• {method.last4}\n </span>\n {method.isDefault && <Badge size=\"sm\">{defaultLabel}</Badge>}\n {state === 'expired' && (\n <Badge size=\"sm\" color=\"destructive\">\n {expiredLabel}\n </Badge>\n )}\n {state === 'expiring' && (\n <Badge size=\"sm\" color=\"amber\">\n {expiringLabel}\n </Badge>\n )}\n </span>\n {(method.holder || method.expiry) && (\n <span className=\"text-muted-foreground mt-0.5 block text-xs\">\n {method.holder}\n {method.holder && method.expiry && ' · '}\n {method.expiry && (\n <>\n {expiresLabel} {method.expiry}\n </>\n )}\n </span>\n )}\n </span>\n\n {chosen && <Check className=\"size-4 shrink-0\" aria-hidden=\"true\" />}\n </>\n )\n\n return (\n <div\n key={method.id}\n className={cn(\n surface,\n radius.control,\n 'flex items-center gap-3 p-3',\n chosen && 'border-primary',\n state === 'expired' && 'opacity-70',\n )}\n >\n {onSelect ? (\n <button\n type=\"button\"\n role=\"radio\"\n aria-checked={chosen}\n onClick={() => onSelect(method.id)}\n className={cn(\n 'flex min-w-0 flex-1 items-center gap-3 text-start',\n radius.control,\n interactive,\n focusRing,\n )}\n >\n {body}\n </button>\n ) : (\n <div className=\"flex min-w-0 flex-1 items-center gap-3\">{body}</div>\n )}\n\n <span className=\"flex shrink-0 items-center gap-1\">\n {onSetDefault && !method.isDefault && state !== 'expired' && (\n <Button variant=\"ghost\" size=\"xs\" onClick={() => onSetDefault(method.id)}>\n {setDefaultLabel}\n </Button>\n )}\n {onRemove && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"text-[var(--destructive-soft-foreground)]\"\n onClick={() => onRemove(method.id)}\n >\n {removeLabel}\n </Button>\n )}\n </span>\n </div>\n )\n })}\n\n {expiredNote && methods.some((m) => expiryState(m.expiry, reference) === 'expired') && (\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {expiredNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { PaymentMethodList, expiryState }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payout-status",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Payout Status",
|
|
5
|
+
"description": "Where a payout is and when the money lands. \"Expected Thursday\" is the thing people open this to find out; the status word alone never answers it.",
|
|
6
|
+
"category": "Finance",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"fmt",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"stepper"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/payout-status.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Clock, RotateCcw, TriangleAlert, X } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Fmt } from '@/components/ui/fmt'\nimport { Stepper, type Step } from '@/components/ui/stepper'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A payout from initiation to arrival.\n *\n * \"Paid\" and \"arrived\" are different events and the gap between them is days,\n * not seconds. A payout marked complete the moment it leaves is the reason\n * support queues fill with \"where is my money\" — so the estimated arrival is\n * part of the component, not an optional extra.\n *\n * A returned payout is distinct from a failed one: the money left, bounced, and\n * came back. The remedy differs (fix the bank details, not the balance), and\n * collapsing the two sends people to the wrong place.\n */\nexport type PayoutState =\n | 'pending'\n | 'in_transit'\n | 'paid'\n | 'failed'\n | 'returned'\n | 'cancelled'\n\nconst STATE = {\n pending: { label: 'Pending', color: 'neutral', Icon: Clock },\n in_transit: { label: 'In transit', color: 'blue', Icon: Clock },\n paid: { label: 'Paid', color: 'green', Icon: Check },\n failed: { label: 'Failed', color: 'destructive', Icon: X },\n returned: { label: 'Returned', color: 'amber', Icon: RotateCcw },\n cancelled: { label: 'Cancelled', color: 'neutral', Icon: X },\n} as const\n\nfunction PayoutStatus({\n amount,\n currency = 'USD',\n locale = 'en-GB',\n state,\n destination,\n initiatedAt,\n expectedAt,\n arrivedAt,\n now,\n reference,\n failureReason,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n /** Minor units, matching LedgerTable, MoneyInput and Cart. */\n amount: number\n currency?: string\n locale?: string\n state: PayoutState\n destination?: ReactNode\n initiatedAt?: Date\n /** When the money should land. The number people actually want. */\n expectedAt?: Date\n arrivedAt?: Date\n now?: Date\n reference?: ReactNode\n failureReason?: ReactNode\n}) {\n const meta = STATE[state]\n const broken = state === 'failed' || state === 'returned' || state === 'cancelled'\n\n const steps: Step[] = [\n { id: 'initiated', label: 'Initiated', status: 'complete' },\n {\n id: 'transit',\n label: 'In transit',\n status:\n state === 'pending'\n ? 'pending'\n : broken\n ? state === 'cancelled'\n ? 'skipped'\n : 'failed'\n : state === 'in_transit'\n ? 'active'\n : 'complete',\n },\n {\n id: 'arrived',\n label: 'Arrived',\n status: state === 'paid' ? 'complete' : broken ? 'skipped' : 'pending',\n },\n ]\n\n return (\n <div\n data-slot=\"payout-status\"\n data-state={state}\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline gap-x-3 gap-y-1\">\n <span className=\"text-2xl font-semibold tabular-nums\">\n <Fmt type=\"currency\" value={amount / 100} currency={currency} locale={locale} decimals={2} />\n </span>\n <Badge size=\"sm\" color={meta.color}>\n <meta.Icon />\n {meta.label}\n </Badge>\n {destination && (\n <span className=\"text-muted-foreground ms-auto text-xs\">to {destination}</span>\n )}\n </div>\n\n <Stepper steps={steps} current={state === 'paid' ? 2 : 1} />\n\n {/* The number people actually want. */}\n {state !== 'paid' && !broken && expectedAt && (\n <p className=\"text-sm\">\n Expected <Fmt type=\"relative\" value={expectedAt} now={now} locale={locale} />\n <span className=\"text-muted-foreground\">\n {' · '}\n <Fmt type=\"date\" value={expectedAt} format=\"D MMMM\" locale={locale} />\n </span>\n </p>\n )}\n\n {state === 'paid' && arrivedAt && (\n <p className=\"text-sm text-[var(--green-soft-foreground)]\">\n Arrived <Fmt type=\"relative\" value={arrivedAt} now={now} locale={locale} />\n </p>\n )}\n\n {broken && failureReason && (\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {failureReason}\n </p>\n )}\n\n <div className=\"text-muted-foreground/80 flex flex-wrap gap-x-3 gap-y-1 text-xs\">\n {initiatedAt && (\n <span>\n Initiated <Fmt type=\"relative\" value={initiatedAt} now={now} locale={locale} />\n </span>\n )}\n {reference && <span className=\"font-mono\">{reference}</span>}\n </div>\n </div>\n )\n}\n\nexport { PayoutStatus }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "permission-matrix",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Permission Matrix",
|
|
5
|
+
"description": "Roles against permissions, in a grid. Inherited grants are drawn differently from direct ones, because \"why can this role still delete things\" is the question a flat list of ticks cannot answer.",
|
|
6
|
+
"category": "Auth",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"checkbox",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/permission-matrix.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { Fragment, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Minus } from 'lucide-react'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Roles against permissions, as a grid.\n *\n * Inherited permissions render differently from directly granted ones. A role\n * that gets `billing.read` because it implies `billing.write` is not the same\n * as one granted it explicitly, and an admin who cannot tell them apart will\n * revoke the wrong thing and be surprised when access remains.\n *\n * Locked cells are shown, not hidden. A permission an admin cannot change still\n * needs to be visible, or they will assume it is off and grant it elsewhere.\n */\nexport type Permission = {\n id: string\n label: ReactNode\n group?: string\n description?: ReactNode\n}\n\nexport type PermissionRole = {\n id: string\n label: ReactNode\n /** Directly granted permission ids. */\n granted: string[]\n /** Held via implication — shown, but not directly revocable. */\n inherited?: string[]\n /** Cannot be changed here at all. */\n locked?: boolean\n}\n\nfunction PermissionMatrix({\n permissions,\n roles,\n onToggle,\n permissionHeader = 'Permission',\n lockedLabel = 'locked',\n inheritedLabel = 'Inherited',\n inheritedTitle = 'Inherited from another permission',\n grantedLabel = 'Granted',\n notGrantedLabel = 'Not granted',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onToggle'> & {\n permissions: Permission[]\n roles: PermissionRole[]\n onToggle?: (roleId: string, permissionId: string, granted: boolean) => void\n /** Heading over the permission column. */\n permissionHeader?: ReactNode\n /** Note under a role that cannot be edited. */\n lockedLabel?: ReactNode\n inheritedLabel?: string\n /** Tooltip on an inherited grant. */\n inheritedTitle?: string\n grantedLabel?: string\n notGrantedLabel?: string\n}) {\n const groups = useMemo(() => {\n const map = new Map<string, Permission[]>()\n for (const permission of permissions) {\n const key = permission.group ?? ''\n map.set(key, [...(map.get(key) ?? []), permission])\n }\n return [...map.entries()]\n }, [permissions])\n\n return (\n <div\n data-slot=\"permission-matrix\"\n className={cn(surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-xs font-medium\">\n {permissionHeader}\n </th>\n {roles.map((role) => (\n <th\n key={role.id}\n className=\"text-muted-foreground px-3 py-2 text-center text-xs font-medium whitespace-nowrap\"\n >\n {role.label}\n {role.locked && (\n <span className=\"text-muted-foreground/60 block font-normal\">{lockedLabel}</span>\n )}\n </th>\n ))}\n </tr>\n </thead>\n\n <tbody>\n {groups.map(([group, items]) => (\n <Fragment key={group || 'ungrouped'}>\n {group && (\n <tr className=\"bg-muted/40\">\n <td\n colSpan={roles.length + 1}\n className=\"text-muted-foreground px-3 py-1.5 text-xs font-medium tracking-wide uppercase\"\n >\n {group}\n </td>\n </tr>\n )}\n\n {items.map((permission) => (\n <tr key={permission.id} className=\"border-border/60 border-b last:border-b-0\">\n <td className=\"sticky start-0 bg-[var(--card)] px-3 py-2\">\n <span className=\"block\">{permission.label}</span>\n {permission.description && (\n <span className=\"text-muted-foreground block text-xs\">\n {permission.description}\n </span>\n )}\n </td>\n\n {roles.map((role) => {\n const direct = role.granted.includes(permission.id)\n const inherited = role.inherited?.includes(permission.id) ?? false\n\n return (\n <td key={role.id} className=\"px-3 py-2 text-center\">\n {inherited && !direct ? (\n // Inherited: visible, but not revocable from here.\n <span\n className=\"text-muted-foreground/60 inline-flex items-center justify-center\"\n title={inheritedTitle}\n >\n <Check className=\"size-4\" aria-label={inheritedLabel} />\n </span>\n ) : role.locked || !onToggle ? (\n direct ? (\n <Check className=\"mx-auto size-4\" aria-label={grantedLabel} />\n ) : (\n <Minus\n className=\"text-muted-foreground/30 mx-auto size-4\"\n aria-label={notGrantedLabel}\n />\n )\n ) : (\n <Checkbox\n checked={direct}\n aria-label={`${role.label} — ${permission.label}`}\n onChange={() => onToggle(role.id, permission.id, !direct)}\n containerClassName=\"justify-center\"\n />\n )}\n </td>\n )\n })}\n </tr>\n ))}\n </Fragment>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nexport { PermissionMatrix }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pipeline",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Pipeline",
|
|
5
|
+
"description": "CI stages left to right with parallel jobs stacked inside each. A stage is as bad as its worst job, so its state is derived rather than being another field to keep in sync.",
|
|
6
|
+
"category": "Operations",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"fmt",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"spinner"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/pipeline.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { Fragment, type ComponentProps } from 'react'\nimport { Check, ChevronRight, CircleSlash, Clock, X } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { Fmt } from '@/components/ui/fmt'\nimport { focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A CI pipeline: stages left to right, parallel jobs stacked within each.\n *\n * The distinction from `Stepper` is the second dimension. A stepper is one\n * sequence of stages; a pipeline runs several jobs inside each stage, and the\n * stage's own state is the roll-up of theirs — which is why `status` on a stage\n * is derived here rather than being another field to keep in sync.\n *\n * Scrolls horizontally rather than wrapping, because a pipeline read out of\n * order is meaningless, and stacks to a single column on narrow screens.\n */\nexport type JobStatus = 'success' | 'failure' | 'running' | 'pending' | 'skipped'\n\nexport type PipelineJob = {\n id: string\n name: string\n status: JobStatus\n /** Seconds. Rendered through Fmt. */\n duration?: number\n onSelect?: () => void\n}\n\nexport type PipelineStage = {\n id: string\n name: string\n jobs: PipelineJob[]\n}\n\nconst JOB_TONE = {\n success: 'border-[var(--green)]/40 bg-[color-mix(in_oklab,var(--green),transparent_92%)]',\n failure: 'border-[var(--destructive)]/40 bg-[color-mix(in_oklab,var(--destructive),transparent_92%)]',\n running: 'border-[var(--blue)]/40 bg-[color-mix(in_oklab,var(--blue),transparent_92%)]',\n pending: 'border-border bg-muted/40',\n skipped: 'border-border bg-muted/20 opacity-70',\n} as const\n\nconst JOB_ICON = {\n success: { Icon: Check, tone: 'text-[var(--green-soft-foreground)]' },\n failure: { Icon: X, tone: 'text-[var(--destructive-soft-foreground)]' },\n running: { Icon: null, tone: 'text-[var(--blue-soft-foreground)]' },\n pending: { Icon: Clock, tone: 'text-muted-foreground' },\n skipped: { Icon: CircleSlash, tone: 'text-muted-foreground' },\n} as const\n\n/** A stage is as bad as its worst job — failure beats running beats pending. */\nfunction stageStatus(stage: PipelineStage): JobStatus {\n const has = (status: JobStatus) => stage.jobs.some((job) => job.status === status)\n if (has('failure')) return 'failure'\n if (has('running')) return 'running'\n if (has('pending')) return 'pending'\n if (stage.jobs.every((job) => job.status === 'skipped')) return 'skipped'\n return 'success'\n}\n\nfunction Pipeline({\n stages,\n className,\n ...props\n}: ComponentProps<'div'> & { stages: PipelineStage[] }) {\n return (\n <div\n data-slot=\"pipeline\"\n className={cn('w-full overflow-x-auto', className)}\n {...props}\n >\n <ol className=\"flex list-none flex-col gap-3 md:flex-row md:items-start md:gap-0\">\n {stages.map((stage, index) => {\n const status = stageStatus(stage)\n const last = index === stages.length - 1\n\n return (\n <Fragment key={stage.id}>\n <li className=\"min-w-0 md:min-w-44 md:flex-1\">\n <div className=\"mb-2 flex items-center gap-2\">\n <span\n className={cn(\n 'size-2 shrink-0 rounded-full [corner-shape:round]',\n status === 'success' && 'bg-[var(--green)]',\n status === 'failure' && 'bg-[var(--destructive)]',\n status === 'running' && 'bg-[var(--blue)]',\n status === 'pending' && 'bg-border',\n status === 'skipped' && 'bg-border',\n )}\n aria-hidden=\"true\"\n />\n <h3 className=\"truncate text-xs font-medium tracking-wide uppercase\">\n {stage.name}\n </h3>\n <span className=\"text-muted-foreground/70 ms-auto text-xs tabular-nums\">\n {stage.jobs.length}\n </span>\n </div>\n\n <ul className=\"flex list-none flex-col gap-1.5 md:pe-3\">\n {stage.jobs.map((job) => {\n const { Icon, tone } = JOB_ICON[job.status]\n const body = (\n <>\n {Icon ? (\n <Icon className={cn('size-3.5 shrink-0', tone)} aria-hidden=\"true\" />\n ) : (\n <Spinner size=\"xs\" className={cn('shrink-0', tone)} label=\"Running\" />\n )}\n <span className=\"min-w-0 flex-1 truncate text-start\">\n {job.name}\n </span>\n {job.duration !== undefined && (\n <span className=\"text-muted-foreground shrink-0 text-[10px] tabular-nums\">\n <Fmt type=\"duration\" value={job.duration} />\n </span>\n )}\n </>\n )\n\n return (\n <li key={job.id}>\n {job.onSelect ? (\n <button\n type=\"button\"\n onClick={job.onSelect}\n className={cn(\n 'flex w-full items-center gap-2 border px-2.5 py-1.5 text-xs',\n radius.control,\n JOB_TONE[job.status],\n interactive,\n focusRing,\n )}\n >\n {body}\n </button>\n ) : (\n <div\n className={cn(\n 'flex w-full items-center gap-2 border px-2.5 py-1.5 text-xs',\n radius.control,\n JOB_TONE[job.status],\n )}\n >\n {body}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n </li>\n\n {!last && (\n <li\n aria-hidden=\"true\"\n className=\"text-muted-foreground/40 hidden shrink-0 self-center md:block\"\n >\n <ChevronRight className=\"size-4\" />\n </li>\n )}\n </Fragment>\n )\n })}\n </ol>\n </div>\n )\n}\n\nexport { Pipeline, stageStatus }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "popover",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Popover",
|
|
5
|
+
"description": "Rich content anchored to a trigger. Positioned with a fixed-position popper that flips when it would overflow, and dismissed by Escape or an outside press.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils",
|
|
11
|
+
"primitive-dismissable",
|
|
12
|
+
"primitive-popper",
|
|
13
|
+
"primitive-slot"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/popover.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import {\n createContext,\n use,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper, type Align, type Side } from '@/components/primitives/popper'\nimport { Slot } from '@/components/primitives/slot'\nimport { menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/** Rich content anchored to a trigger, dismissed by Escape or an outside press. */\ntype PopoverContextValue = {\n open: boolean\n setOpen: (open: boolean) => void\n anchorRef: React.RefObject<HTMLElement | null>\n contentRef: React.RefObject<HTMLDivElement | null>\n}\n\nconst PopoverContext = createContext<PopoverContextValue | null>(null)\n\nfunction usePopover() {\n const context = use(PopoverContext)\n if (!context) throw new Error('Must be used inside <Popover>')\n return context\n}\n\nfunction Popover({\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n children,\n}: {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const open = controlled ? openProp : uncontrolled\n\n const anchorRef = useRef<HTMLElement>(null)\n const contentRef = useRef<HTMLDivElement>(null)\n\n const context = useMemo<PopoverContextValue>(\n () => ({\n open,\n anchorRef,\n contentRef,\n setOpen: (next) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n }),\n [open, controlled, onOpenChange],\n )\n\n return <PopoverContext value={context}>{children}</PopoverContext>\n}\n\nfunction PopoverTrigger({\n asChild = false,\n onClick,\n ...props\n}: ComponentProps<'button'> & { asChild?: boolean }) {\n const { open, setOpen, anchorRef } = usePopover()\n const Comp = asChild ? Slot : 'button'\n\n return (\n <Comp\n // The anchor ref is typed for any element, since `asChild` can make this\n // an anchor, a div, anything — the cast keeps that generality.\n ref={anchorRef as React.Ref<HTMLButtonElement>}\n data-slot=\"popover-trigger\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n onClick?.(event)\n if (!event.defaultPrevented) setOpen(!open)\n }}\n {...props}\n />\n )\n}\n\nfunction PopoverContent({\n className,\n side = 'bottom',\n align = 'center',\n offset = 6,\n ...props\n}: ComponentProps<'div'> & {\n side?: Side\n align?: Align\n offset?: number\n}) {\n const { open, setOpen, anchorRef, contentRef } = usePopover()\n\n const { style, side: resolved } = usePopper({\n open,\n anchorRef,\n floatingRef: contentRef,\n side,\n align,\n offset,\n })\n\n useDismissable({\n open,\n onDismiss: () => setOpen(false),\n refs: [anchorRef, contentRef],\n })\n\n if (!open) return null\n\n return (\n <div\n ref={contentRef}\n role=\"dialog\"\n data-slot=\"popover-content\"\n data-side={resolved}\n style={style}\n className={cn(menuSurface, radius.surface, 'w-72 p-4 text-sm', className)}\n {...props}\n />\n )\n}\n\nexport { Popover, PopoverContent, PopoverTrigger, usePopover }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "port-table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Port Table",
|
|
5
|
+
"description": "Published ports with their bind address. `0.0.0.0:5432` and `127.0.0.1:5432` are the difference between a database on the internet and a database on the machine — printing only the number hides that.",
|
|
6
|
+
"category": "Runtime",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "components/ui/port-table.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Globe, Lock, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Published ports, with what is actually listening.\n *\n * The bind address is shown, not just the port. `0.0.0.0:5432` and\n * `127.0.0.1:5432` are the difference between a database on the internet and a\n * database on the machine, and a table that prints only the number hides the\n * one that matters.\n *\n * Ports bound to all interfaces without TLS are flagged. That combination is\n * the actual finding; either alone is often fine.\n *\n * Well-known ports get their conventional service name, because 5432 means\n * nothing to someone who does not already know it is Postgres.\n *\n * One grid owns the columns and every row inherits them through `subgrid`.\n * Giving each row its own grid looks identical in a mock-up and drifts in\n * practice: `1fr` and `auto` resolve against whatever that row happens to\n * contain, so a row carrying an \"exposed\" badge squeezes its neighbours while\n * an empty one does not, and no two rows line up.\n */\nexport type PortBinding = {\n id: string\n /** Port on the host. */\n port: number\n /** Port inside the container or process. */\n targetPort?: number\n protocol?: 'tcp' | 'udp'\n /** Interface it is bound to. `0.0.0.0` is every interface. */\n address?: string\n process?: ReactNode\n tls?: boolean\n service?: string\n}\n\nconst WELL_KNOWN: Record<number, string> = {\n 22: 'ssh', 25: 'smtp', 53: 'dns', 80: 'http', 443: 'https',\n 3000: 'node', 3306: 'mysql', 5432: 'postgres', 5672: 'amqp',\n 6379: 'redis', 8080: 'http-alt', 9000: 'minio', 9090: 'prometheus',\n 9200: 'elasticsearch', 27017: 'mongodb',\n}\n\nconst PUBLIC = new Set(['0.0.0.0', '::', '*', ''])\n\nfunction PortTable({\n ports,\n emptyLabel = 'No published ports',\n portHeader = 'Port',\n addressHeader = 'Bind',\n processHeader = 'Process',\n exposedLabel = 'exposed',\n tlsLabel = 'TLS',\n exposedNote = 'Bound to every interface without TLS — reachable from outside the host in the clear.',\n className,\n ...props\n}: ComponentProps<'div'> & {\n ports: PortBinding[]\n emptyLabel?: ReactNode\n portHeader?: ReactNode\n addressHeader?: ReactNode\n processHeader?: ReactNode\n exposedLabel?: ReactNode\n /** Accessible name for the TLS marker. */\n tlsLabel?: string\n exposedNote?: ReactNode\n}) {\n if (ports.length === 0) {\n return (\n <div className={cn(surface, radius.surface, className)} {...props}>\n <p className=\"text-muted-foreground p-8 text-center text-sm\">{emptyLabel}</p>\n </div>\n )\n }\n\n const risky = ports.some((p) => PUBLIC.has(p.address ?? '0.0.0.0') && !p.tls)\n\n return (\n <div\n data-slot=\"port-table\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"grid grid-cols-[6rem_minmax(0,1fr)_minmax(0,1fr)_auto]\">\n <div className=\"border-border text-muted-foreground col-span-4 grid grid-cols-subgrid items-center gap-3 border-b p-3 text-xs font-medium\">\n <span>{portHeader}</span>\n <span>{addressHeader}</span>\n <span>{processHeader}</span>\n <span />\n </div>\n\n <ul className=\"divide-border/60 col-span-4 grid grid-cols-subgrid list-none divide-y\">\n {ports.map((binding) => {\n const address = binding.address ?? '0.0.0.0'\n const open = PUBLIC.has(address)\n const name = binding.service ?? WELL_KNOWN[binding.port]\n\n return (\n <li\n key={binding.id}\n className=\"col-span-4 grid grid-cols-subgrid items-center gap-3 p-3\"\n >\n <span className=\"font-mono text-xs tabular-nums\">\n {binding.port}\n {binding.targetPort !== undefined && binding.targetPort !== binding.port && (\n <span className=\"text-muted-foreground/60\">→{binding.targetPort}</span>\n )}\n {binding.protocol === 'udp' && (\n <span className=\"text-muted-foreground/60\">/udp</span>\n )}\n </span>\n\n {/* The bind address is the finding, not the port number. */}\n <span className=\"flex min-w-0 items-center gap-1.5\">\n <code\n className={cn(\n 'truncate font-mono text-xs',\n open ? 'text-[var(--amber-soft-foreground)]' : 'text-muted-foreground',\n )}\n >\n {address}\n </code>\n {open && (\n <Globe className=\"text-muted-foreground/60 size-3 shrink-0\" aria-hidden=\"true\" />\n )}\n </span>\n\n <span className=\"min-w-0 truncate text-xs\">\n {binding.process ?? <span className=\"text-muted-foreground/50\">—</span>}\n {name && <span className=\"text-muted-foreground/60 ms-1.5 font-mono\">{name}</span>}\n </span>\n\n <span className=\"flex shrink-0 items-center gap-1.5\">\n {binding.tls && (\n <Lock\n className=\"size-3.5 text-[var(--green-soft-foreground)]\"\n aria-label={tlsLabel}\n />\n )}\n {open && !binding.tls && (\n <Badge size=\"sm\" color=\"amber\">\n {exposedLabel}\n </Badge>\n )}\n </span>\n </li>\n )\n })}\n </ul>\n </div>\n\n {risky && exposedNote && (\n <p className=\"border-border text-[var(--amber-soft-foreground)] flex items-start gap-1.5 border-t p-3 text-xs\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {exposedNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { PortTable, WELL_KNOWN as wellKnownPorts }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portfolio-balance",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Portfolio Balance",
|
|
5
|
+
"description": "Total value with its allocation. The hide toggle is part of the component, not an app-level bolt-on — people check balances on trains. Allocation is computed from the values, never taken as a percentage prop.",
|
|
6
|
+
"category": "Crypto",
|
|
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/portfolio-balance.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, Eye, EyeOff } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { dataFills, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Total portfolio value with its allocation.\n *\n * The hide toggle is not a novelty. People check balances on trains and in\n * open-plan offices, and every serious wallet ships one — so it is part of the\n * component rather than something each app bolts on.\n *\n * Allocation is computed from the values given, never taken as a percentage\n * prop. Two sources for the same number drift, and a bar that does not add to\n * 100 is the kind of thing people screenshot.\n *\n * Holdings below `groupBelow` collapse into \"Other\". A bar with forty\n * one-pixel slivers communicates less than six slices and a remainder.\n */\nexport type Holding = {\n id: string\n symbol: ReactNode\n /** Fiat value. Allocation is derived from these. */\n value: number\n amount?: ReactNode\n change24h?: number\n color?: string\n icon?: ReactNode\n}\n\nfunction PortfolioBalance({\n holdings,\n change24h,\n currency = 'USD',\n locale = 'en-GB',\n hidden: hiddenProp,\n onHiddenChange,\n groupBelow = 0.03,\n totalLabel = 'Total value',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n holdings: Holding[]\n /** Portfolio-level change, as a percentage. */\n change24h?: number\n currency?: string\n locale?: string\n hidden?: boolean\n onHiddenChange?: (hidden: boolean) => void\n /** Share below which a holding folds into \"Other\". */\n groupBelow?: number\n totalLabel?: ReactNode\n}) {\n const controlled = hiddenProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(false)\n const hidden = controlled ? hiddenProp : uncontrolled\n\n const money = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n maximumFractionDigits: 2,\n })\n\n const { rows, total } = useMemo(() => {\n const sum = holdings.reduce((n, h) => n + h.value, 0) || 1\n const big = holdings.filter((h) => h.value / sum >= groupBelow)\n const small = holdings.filter((h) => h.value / sum < groupBelow)\n const rest = small.reduce((n, h) => n + h.value, 0)\n\n const list = [...big].sort((a, b) => b.value - a.value)\n if (rest > 0) list.push({ id: '__other', symbol: 'Other', value: rest })\n return { rows: list, total: sum }\n }, [holdings, groupBelow])\n\n const up = (change24h ?? 0) > 0\n const down = (change24h ?? 0) < 0\n\n return (\n <div\n data-slot=\"portfolio-balance\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-muted-foreground text-xs\">{totalLabel}</p>\n <p className=\"mt-0.5 text-3xl font-semibold tabular-nums\">\n {hidden ? '••••••' : money.format(total)}\n </p>\n\n {change24h !== undefined && !hidden && (\n <p\n className={cn(\n 'mt-1 inline-flex items-center gap-1 text-sm tabular-nums',\n up && 'text-[var(--green-soft-foreground)]',\n down && 'text-[var(--destructive-soft-foreground)]',\n !up && !down && 'text-muted-foreground',\n )}\n >\n {up && <ArrowUp className=\"size-3.5\" aria-hidden=\"true\" />}\n {down && <ArrowDown className=\"size-3.5\" aria-hidden=\"true\" />}\n {Math.abs(change24h).toFixed(2)}%\n <span className=\"text-muted-foreground/70\">24h</span>\n </p>\n )}\n </div>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={hidden ? 'Show balances' : 'Hide balances'}\n aria-pressed={hidden}\n onClick={() => {\n const next = !hidden\n if (!controlled) setUncontrolled(next)\n onHiddenChange?.(next)\n }}\n >\n {hidden ? <Eye /> : <EyeOff />}\n </Button>\n </div>\n\n {/* One bar, derived from the values — never a percentage prop. */}\n <div\n className=\"flex h-2 w-full overflow-hidden rounded-full [corner-shape:round]\"\n role=\"img\"\n aria-label={rows\n .map((h) => `${h.symbol}: ${Math.round((h.value / total) * 100)}%`)\n .join(', ')}\n >\n {rows.map((holding, index) => (\n <span\n key={holding.id}\n style={{\n width: `${(holding.value / total) * 100}%`,\n backgroundColor: holding.color ?? dataFills[index % dataFills.length],\n }}\n />\n ))}\n </div>\n\n <ul className=\"flex list-none flex-col gap-2\">\n {rows.map((holding, index) => (\n <li key={holding.id} className=\"flex items-center gap-2.5\">\n <span\n aria-hidden=\"true\"\n className=\"size-2.5 shrink-0 rounded-full [corner-shape:round]\"\n style={{ backgroundColor: holding.color ?? dataFills[index % dataFills.length] }}\n />\n {holding.icon}\n\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n {holding.symbol}\n </span>\n\n {holding.change24h !== undefined && (\n <span\n className={cn(\n 'shrink-0 text-xs tabular-nums',\n holding.change24h > 0\n ? 'text-[var(--green-soft-foreground)]'\n : holding.change24h < 0\n ? 'text-[var(--destructive-soft-foreground)]'\n : 'text-muted-foreground',\n )}\n >\n {holding.change24h > 0 ? '+' : ''}\n {holding.change24h.toFixed(2)}%\n </span>\n )}\n\n <span className=\"text-muted-foreground w-12 shrink-0 text-end text-xs tabular-nums\">\n {Math.round((holding.value / total) * 100)}%\n </span>\n\n <span className=\"min-w-20 shrink-0 text-end text-sm tabular-nums whitespace-nowrap\">\n {hidden ? '••••' : money.format(holding.value)}\n </span>\n </li>\n ))}\n </ul>\n </div>\n )\n}\n\nexport { PortfolioBalance }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "postback-config",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Postback Config",
|
|
5
|
+
"description": "A server-to-server postback URL, validated against its macros. A postback missing {click_id} returns 200 and attributes nothing — this says so before it ships rather than in a reconciliation weeks later.",
|
|
6
|
+
"category": "Affiliate",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"copy-button",
|
|
13
|
+
"input",
|
|
14
|
+
"label",
|
|
15
|
+
"lib-styles",
|
|
16
|
+
"lib-utils",
|
|
17
|
+
"select"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/ui/postback-config.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Select } from '@/components/ui/select'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A server-to-server postback URL with its macros.\n *\n * The available macros are listed and validated against the URL. A postback\n * missing `{click_id}` silently attributes nothing — the requests arrive, they\n * return 200, and no conversion is ever matched — so an unresolved or unknown\n * macro is called out before it ships rather than discovered in a reconciliation\n * weeks later.\n *\n * `http://` is flagged. Postbacks carry payout amounts and click identifiers,\n * and sending them in the clear is a real leak, not a style preference.\n */\nexport type Macro = { token: string; description: ReactNode; required?: boolean }\n\n/**\n * Default formatters at module scope — an inline arrow default is a value\n * the React Compiler cannot reorder, and it bails on the whole component.\n */\nconst DEFAULT_UNKNOWN_NOTE = (tokens: string[]) =>\n `Unknown macro${tokens.length > 1 ? 's' : ''}: ${tokens.join(', ')}`\nconst DEFAULT_MISSING_NOTE = (tokens: string[]) =>\n `Missing ${tokens.join(', ')} — conversions will arrive but match nothing.`\n\nfunction PostbackConfig({\n url: urlProp,\n onUrlChange,\n macros,\n method = 'GET',\n onMethodChange,\n lastFired,\n status,\n urlLabel = 'Postback URL',\n urlPlaceholder = 'https://tracker.example.com/pb?cid={click_id}&payout={payout}',\n macrosLabel = 'Available macros',\n requiredLabel = 'required',\n inUseLabel = 'in use',\n insecureNote = 'This posts click identifiers and payouts over plain HTTP.',\n missingNote = DEFAULT_MISSING_NOTE,\n unknownNote = DEFAULT_UNKNOWN_NOTE,\n statusLabels = { ok: 'Firing', failing: 'Failing', untested: 'Not tested' },\n lastFiredLabel = 'last fired',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n url?: string\n onUrlChange?: (url: string) => void\n macros: Macro[]\n method?: 'GET' | 'POST'\n onMethodChange?: (method: 'GET' | 'POST') => void\n lastFired?: ReactNode\n status?: 'ok' | 'failing' | 'untested'\n urlLabel?: ReactNode\n urlPlaceholder?: string\n macrosLabel?: ReactNode\n /** Badge on a macro that must appear in the URL. */\n requiredLabel?: ReactNode\n /** Accessible name for the tick beside a macro already in the URL. */\n inUseLabel?: string\n insecureNote?: ReactNode\n /** Warning for required macros absent from the URL. */\n missingNote?: (tokens: string[]) => ReactNode\n unknownNote?: (tokens: string[]) => ReactNode\n statusLabels?: { ok?: ReactNode; failing?: ReactNode; untested?: ReactNode }\n /** Precedes the last-fired time. */\n lastFiredLabel?: ReactNode\n}) {\n const controlled = urlProp !== undefined\n const [uncontrolled, setUncontrolled] = useState('')\n const url = controlled ? urlProp : uncontrolled\n\n const analysis = useMemo(() => {\n const used = [...url.matchAll(/\\{(\\w+)\\}/g)].map((match) => match[1])\n const known = new Set(macros.map((macro) => macro.token))\n return {\n used,\n unknown: used.filter((token) => !known.has(token)),\n missing: macros.filter((macro) => macro.required && !used.includes(macro.token)),\n insecure: url.startsWith('http://'),\n }\n }, [url, macros])\n\n const set = (next: string) => {\n if (!controlled) setUncontrolled(next)\n onUrlChange?.(next)\n }\n\n return (\n <div\n data-slot=\"postback-config\"\n className={cn(surface, radius.surface, 'flex flex-col gap-4 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-end gap-2\">\n <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n <Label htmlFor=\"postback-url\">{urlLabel}</Label>\n <Input\n id=\"postback-url\"\n value={url}\n placeholder={urlPlaceholder}\n error={analysis.missing.length > 0 || analysis.unknown.length > 0}\n onChange={(event) => set(event.target.value)}\n className=\"font-mono text-xs\"\n />\n </div>\n\n {onMethodChange && (\n <Select\n size=\"default\"\n value={method}\n onValueChange={(next) => onMethodChange(next as 'GET' | 'POST')}\n options={[\n { value: 'GET', label: 'GET' },\n { value: 'POST', label: 'POST' },\n ]}\n className=\"w-24 shrink-0\"\n />\n )}\n\n {url && <CopyButton value={url} label=\"Copy postback URL\" />}\n </div>\n\n {/* Called out before it ships, not found in a reconciliation later. */}\n {analysis.missing.length > 0 && (\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {missingNote(analysis.missing.map((macro) => `{${macro.token}}`))}\n </p>\n )}\n\n {analysis.unknown.length > 0 && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n {unknownNote(analysis.unknown.map((token) => `{${token}}`))}\n </p>\n )}\n\n {analysis.insecure && (\n <p className=\"text-[var(--amber-soft-foreground)] text-xs\">\n {insecureNote}\n </p>\n )}\n\n <div className=\"flex flex-col gap-2\">\n <span className=\"text-muted-foreground text-xs font-medium\">{macrosLabel}</span>\n <ul className=\"grid list-none gap-1.5 sm:grid-cols-2\">\n {macros.map((macro) => {\n const used = analysis.used.includes(macro.token)\n return (\n <li key={macro.token} className=\"flex items-start gap-2 text-xs\">\n <button\n type=\"button\"\n onClick={() => set(url + `{${macro.token}}`)}\n className={cn(\n 'bg-secondary hover:bg-accent shrink-0 px-1.5 py-0.5 font-mono',\n radius.xs,\n )}\n >\n {`{${macro.token}}`}\n </button>\n <span className=\"text-muted-foreground min-w-0 flex-1\">{macro.description}</span>\n {used && <Check className=\"size-3.5 shrink-0 text-[var(--green-soft-foreground)]\" aria-label={inUseLabel} />}\n {macro.required && !used && (\n <Badge size=\"sm\" color=\"destructive\">\n {requiredLabel}\n </Badge>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n\n {(lastFired || status) && (\n <div className=\"border-border text-muted-foreground flex flex-wrap items-center gap-2 border-t pt-3 text-xs\">\n {status && (\n <Badge\n size=\"sm\"\n color={status === 'ok' ? 'green' : status === 'failing' ? 'destructive' : 'neutral'}\n >\n {status === 'ok'\n ? statusLabels.ok\n : status === 'failing'\n ? statusLabels.failing\n : statusLabels.untested}\n </Badge>\n )}\n {lastFired && (\n <span>\n {lastFiredLabel} {lastFired}\n </span>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { PostbackConfig }\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "presence",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Presence",
|
|
5
|
+
"description": "Who else is here. The overflow count carries its own tooltip listing the rest — a \"+5\" that cannot tell you who is unhelpful in exactly the moment you care.",
|
|
6
|
+
"category": "Product",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"avatar",
|
|
10
|
+
"lib-utils",
|
|
11
|
+
"tooltip"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/presence.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import type { ComponentProps } from 'react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\n\n/**\n * Who else is here.\n *\n * Stacked avatars with a status ring. The overflow count is a real element with\n * its own tooltip listing the rest, not a truncated visual — \"+5\" that cannot\n * tell you who is unhelpful in exactly the moment you care.\n *\n * The ring colour is the only signal that changes, so someone going idle does\n * not reflow the row.\n */\nexport type Peer = {\n id: string\n name: string\n status?: 'active' | 'idle' | 'away'\n color?: string\n}\n\nconst RING = {\n active: 'ring-[var(--green)]',\n idle: 'ring-[var(--amber)]',\n away: 'ring-border',\n} as const\n\nfunction Presence({\n peers,\n max = 4,\n size = 'sm',\n label = 'Viewing now',\n className,\n ...props\n}: ComponentProps<'div'> & {\n peers: Peer[]\n max?: number\n size?: 'xs' | 'sm' | 'default'\n label?: string\n}) {\n const shown = peers.slice(0, max)\n const rest = peers.slice(max)\n\n return (\n <div\n data-slot=\"presence\"\n role=\"group\"\n aria-label={`${label}: ${peers.map((peer) => peer.name).join(', ')}`}\n className={cn('flex items-center -space-x-2', className)}\n {...props}\n >\n {shown.map((peer) => (\n <Tooltip\n key={peer.id}\n content={\n <span>\n {peer.name}\n {peer.status && peer.status !== 'active' && (\n <span className=\"opacity-70\"> · {peer.status}</span>\n )}\n </span>\n }\n >\n <span\n tabIndex={0}\n className={cn(\n 'ring-background relative rounded-full ring-2 outline-none [corner-shape:round]',\n 'focus-visible:ring-ring focus-visible:ring-2',\n )}\n >\n <Avatar\n size={size}\n name={peer.name}\n className={cn(\n 'ring-2 [corner-shape:round]',\n RING[peer.status ?? 'active'],\n )}\n style={peer.color ? { backgroundColor: peer.color } : undefined}\n />\n </span>\n </Tooltip>\n ))}\n\n {rest.length > 0 && (\n <Tooltip content={rest.map((peer) => peer.name).join(', ')}>\n <span\n tabIndex={0}\n className={cn(\n 'bg-secondary text-muted-foreground ring-background relative flex items-center justify-center rounded-full ring-2 outline-none [corner-shape:round]',\n 'focus-visible:ring-ring focus-visible:ring-2',\n size === 'xs' ? 'size-6 text-[10px]' : size === 'sm' ? 'size-8 text-xs' : 'size-10 text-sm',\n )}\n >\n +{rest.length}\n </span>\n </Tooltip>\n )}\n </div>\n )\n}\n\nexport { Presence }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "price-ticker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Price Ticker",
|
|
5
|
+
"description": "A live price with its change. The flash on update is colour only — a ticker that moves makes the whole page twitch — and it compares against the previous rendered price, not the day’s direction.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"lib-utils",
|
|
12
|
+
"sparkline"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/price-ticker.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp } from 'lucide-react'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live price with its change over a window.\n *\n * The flash on update is a colour transition and nothing else — no scale, no\n * slide — which keeps it inside the kit's motion rule and, more practically,\n * stops a ticker updating every second from making the whole page twitch.\n *\n * The flash compares against the *previous rendered* price, held in a ref.\n * Deriving it from the change percentage instead would flash green on every\n * tick of a day that is up, which tells you nothing about the tick.\n *\n * Prices are given as numbers here rather than base units: a quote is a market\n * rate, not a balance, so it never needs `bigint` exactness — but it does need\n * enough fraction digits, hence `precision` defaulting by magnitude.\n */\nexport type PriceTickerProps = Omit<ComponentProps<'div'>, 'children'> & {\n symbol: ReactNode\n price: number\n /** Percentage change over the window, e.g. -2.4. */\n change?: number\n window?: string\n currency?: string\n locale?: string\n /** Fraction digits. Defaults by magnitude — a sub-cent token needs more. */\n precision?: number\n history?: number[]\n icon?: ReactNode\n size?: 'sm' | 'default' | 'lg'\n}\n\nfunction PriceTicker({\n symbol,\n price,\n change,\n window: windowLabel = '24h',\n currency = 'USD',\n locale = 'en-GB',\n precision,\n history,\n icon,\n size = 'default',\n className,\n ...props\n}: PriceTickerProps) {\n const previous = useRef(price)\n const [flash, setFlash] = useState<'up' | 'down' | null>(null)\n\n useEffect(() => {\n if (price === previous.current) return\n setFlash(price > previous.current ? 'up' : 'down')\n previous.current = price\n const timer = setTimeout(() => setFlash(null), 600)\n return () => clearTimeout(timer)\n }, [price])\n\n // A $0.000042 token and a $67,000 one need different precision.\n const digits =\n precision ?? (price >= 1000 ? 2 : price >= 1 ? 2 : price >= 0.01 ? 4 : 8)\n\n const formatted = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n minimumFractionDigits: digits,\n maximumFractionDigits: digits,\n }).format(price)\n\n const up = change !== undefined && change > 0\n const down = change !== undefined && change < 0\n\n return (\n <div\n data-slot=\"price-ticker\"\n className={cn('flex min-w-0 items-center gap-3', className)}\n {...props}\n >\n {icon}\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-baseline gap-2\">\n <span\n className={cn(\n 'truncate font-medium',\n size === 'sm' && 'text-xs',\n size === 'lg' && 'text-base',\n size === 'default' && 'text-sm',\n )}\n >\n {symbol}\n </span>\n\n <span\n aria-live=\"polite\"\n className={cn(\n 'tabular-nums transition-colors duration-500 ease-out motion-reduce:transition-none',\n size === 'lg' ? 'text-xl font-semibold' : 'text-sm',\n // Colour only: a ticker that moves makes the page twitch.\n flash === 'up' && 'text-[var(--green-soft-foreground)]',\n flash === 'down' && 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n {formatted}\n </span>\n </div>\n\n {change !== undefined && (\n <div className=\"flex items-center gap-1 text-xs\">\n <span\n className={cn(\n 'inline-flex items-center gap-0.5 tabular-nums',\n up && 'text-[var(--green-soft-foreground)]',\n down && 'text-[var(--destructive-soft-foreground)]',\n !up && !down && 'text-muted-foreground',\n )}\n >\n {up && <ArrowUp className=\"size-3\" aria-hidden=\"true\" />}\n {down && <ArrowDown className=\"size-3\" aria-hidden=\"true\" />}\n {Math.abs(change).toFixed(2)}%\n </span>\n <span className=\"text-muted-foreground/70\">{windowLabel}</span>\n </div>\n )}\n </div>\n\n {history && history.length > 1 && (\n <Sparkline\n values={history}\n color={\n up\n ? 'var(--green)'\n : down\n ? 'var(--destructive)'\n : 'var(--muted-foreground)'\n }\n className=\"h-8 w-20 shrink-0\"\n />\n )}\n </div>\n )\n}\n\nexport { PriceTicker }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pricing-table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Pricing Table",
|
|
5
|
+
"description": "Plans side by side. Every plan lists every feature, including the ones it lacks — omitting them makes the columns uneven and quietly hides what you would give up.",
|
|
6
|
+
"category": "Commerce",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"button",
|
|
13
|
+
"fmt",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/pricing-table.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { Check, Minus } from 'lucide-react'\nimport type { ComponentProps, ReactNode } from 'react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Fmt } from '@/components/ui/fmt'\nimport { radius, surface, type Responsive } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Plans side by side, with a feature matrix.\n *\n * Every plan lists every feature — including the ones it does not have, shown\n * as an explicit dash. Omitting them makes the columns different lengths and\n * quietly hides what you would be giving up, which is the difference between a\n * comparison and an advertisement.\n *\n * The highlighted plan is marked with a border and a badge, never by being\n * taller or wider. Scaling one column up is the standard trick and it makes the\n * others hard to read.\n */\nexport type PricingFeature = {\n label: ReactNode\n /** `true`/`false` for a tick or dash, or text for a limit. */\n value: boolean | string\n}\n\nexport type PricingPlan = {\n id: string\n name: string\n price: number\n /** Appended after the price — \"/mo\", \"per seat\". */\n period?: string\n currency?: string\n description?: ReactNode\n features: PricingFeature[]\n highlighted?: boolean\n badge?: ReactNode\n cta?: ReactNode\n onSelect?: () => void\n}\n\nconst RESPONSIVE_COLUMNS = {\n sm: 'grid-cols-1 sm:grid-cols-2',\n md: 'grid-cols-1 md:grid-cols-3',\n lg: 'grid-cols-1 lg:grid-cols-3',\n} as const\n\nfunction PricingTable({\n plans,\n responsive = 'md',\n popularLabel = 'Popular',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n plans: PricingPlan[]\n responsive?: Responsive\n /** Default badge on the highlighted plan. */\n popularLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"pricing-table\"\n className={cn(\n 'grid gap-4',\n responsive === false ? 'grid-cols-3' : RESPONSIVE_COLUMNS[responsive],\n className,\n )}\n {...props}\n >\n {plans.map((plan) => (\n <article\n key={plan.id}\n data-highlighted={plan.highlighted || undefined}\n className={cn(\n surface,\n radius.surface,\n 'flex flex-col p-5',\n // A border and a badge, never a size change.\n plan.highlighted && 'border-primary',\n )}\n >\n <header className=\"mb-4\">\n <div className=\"mb-2 flex items-center gap-2\">\n <h3 className=\"text-sm font-semibold\">{plan.name}</h3>\n {plan.highlighted && !plan.badge && (\n <Badge size=\"sm\" color=\"violet\">\n {popularLabel}\n </Badge>\n )}\n {plan.badge}\n </div>\n\n <p className=\"flex items-baseline gap-1\">\n <span className=\"text-3xl font-semibold tabular-nums\">\n <Fmt\n type=\"currency\"\n value={plan.price}\n currency={plan.currency ?? 'USD'}\n decimals={plan.price % 1 === 0 ? 0 : 2}\n />\n </span>\n {plan.period && (\n <span className=\"text-muted-foreground text-sm\">{plan.period}</span>\n )}\n </p>\n\n {plan.description && (\n <p className=\"text-muted-foreground mt-2 text-sm text-pretty\">\n {plan.description}\n </p>\n )}\n </header>\n\n <ul className=\"mb-5 flex flex-1 list-none flex-col gap-2\">\n {plan.features.map((feature, index) => {\n const included = feature.value !== false\n return (\n <li key={index} className=\"flex items-start gap-2 text-sm\">\n {included ? (\n <Check\n className=\"mt-0.5 size-4 shrink-0 text-[var(--green-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n ) : (\n <Minus\n className=\"text-muted-foreground/40 mt-0.5 size-4 shrink-0\"\n aria-hidden=\"true\"\n />\n )}\n <span\n className={cn(\n 'min-w-0 flex-1',\n included ? 'text-muted-foreground' : 'text-muted-foreground/50',\n )}\n >\n {feature.label}\n {typeof feature.value === 'string' && (\n <span className=\"text-foreground ms-1 font-medium\">\n {feature.value}\n </span>\n )}\n <span className=\"sr-only\">\n {included ? ' included' : ' not included'}\n </span>\n </span>\n </li>\n )\n })}\n </ul>\n\n {plan.cta ?? (\n <Button\n variant={plan.highlighted ? 'default' : 'secondary'}\n className=\"w-full\"\n onClick={plan.onSelect}\n >\n Choose {plan.name}\n </Button>\n )}\n </article>\n ))}\n </div>\n )\n}\n\nexport { PricingTable }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-collapsible",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "Collapsible",
|
|
5
|
+
"description": "A single panel that expands and collapses. The open height is measured rather than assumed, because height: auto cannot be transitioned.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/collapsible.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'\n\n/**\n * Animate a panel between zero and its natural height.\n *\n * `height: auto` cannot be transitioned, so the open height has to be measured\n * and applied as a pixel value.\n *\n * The first measurement is synchronous, in a layout effect, and seeds state\n * before the browser paints. Deferring it to `requestAnimationFrame` looks\n * equivalent and is not: a panel that starts open then has one frame at zero\n * height, and any observer callback arriving in that window cancels the pending\n * frame and reschedules — so a panel rendered open could sit collapsed\n * indefinitely while still reporting `aria-expanded=\"true\"`.\n *\n * A ResizeObserver keeps the value honest afterwards, when content loads late\n * or reflows while open. Those updates stay on a frame, since they are\n * reactions to layout rather than the initial paint.\n */\nexport function useCollapsibleHeight(\n ref: RefObject<HTMLElement | null>,\n open: boolean,\n) {\n const [height, setHeight] = useState(0)\n const raf = useRef(0)\n\n // Seeds the open height before paint, so a panel that mounts open is never\n // rendered at zero.\n useLayoutEffect(() => {\n const element = ref.current\n if (element) setHeight(element.scrollHeight)\n }, [ref, open])\n\n useEffect(() => {\n const element = ref.current\n if (!element) return\n\n const observer = new ResizeObserver(() => {\n cancelAnimationFrame(raf.current)\n raf.current = requestAnimationFrame(() => {\n // Re-read rather than closing over a stale value: the observer fires\n // precisely because the size changed.\n setHeight(element.scrollHeight)\n })\n })\n observer.observe(element)\n\n return () => {\n cancelAnimationFrame(raf.current)\n observer.disconnect()\n }\n }, [ref])\n\n return open ? height : 0\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-dialog",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "Dialog",
|
|
5
|
+
"description": "A modal window, built on the native <dialog> element — so the focus trap, background inerting, Escape handling and top-layer stacking come from the platform rather than from JavaScript.",
|
|
6
|
+
"category": "Overlays",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/primitives/dialog.tsx",
|
|
12
|
+
"type": "registry:primitive",
|
|
13
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\n\n/**\n * Modal state on top of the native `<dialog>` element.\n *\n * `showModal()` gives the focus trap, the inert background, Escape-to-close and\n * top-layer stacking for free — all of which a div-based modal has to\n * reimplement, usually incompletely. This owns the open state and keeps the DOM\n * element in step with it.\n */\ntype DialogContextValue = {\n open: boolean\n setOpen: (open: boolean) => void\n dialogRef: React.RefObject<HTMLDialogElement | null>\n ids: { title: string; description: string }\n}\n\nconst DialogContext = createContext<DialogContextValue | null>(null)\n\nexport function useDialog() {\n const context = use(DialogContext)\n if (!context) throw new Error('Must be used inside <Dialog>')\n return context\n}\n\ntype DialogProviderProps = {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n children: ReactNode\n}\n\nexport function DialogProvider({\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n children,\n}: DialogProviderProps) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const open = controlled ? openProp : uncontrolled\n\n const dialogRef = useRef<HTMLDialogElement>(null)\n const id = useId()\n const ids = useMemo(\n () => ({ title: `${id}-title`, description: `${id}-description` }),\n [id],\n )\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n [controlled, onOpenChange],\n )\n\n // Drive the DOM element from state. `showModal` throws if already open, so\n // both calls are guarded by the element's own `open` property.\n useEffect(() => {\n const element = dialogRef.current\n if (!element) return\n\n if (open && !element.open) element.showModal()\n if (!open && element.open) element.close()\n }, [open])\n\n // Escape and the close button both fire `close`; `cancel` fires for Escape\n // only. Listening to `close` keeps React state in step however it happened.\n useEffect(() => {\n const element = dialogRef.current\n if (!element) return\n\n const onClose = () => setOpen(false)\n element.addEventListener('close', onClose)\n return () => element.removeEventListener('close', onClose)\n }, [setOpen])\n\n const context = useMemo(\n () => ({ open, setOpen, dialogRef, ids }),\n [open, setOpen, ids],\n )\n\n return <DialogContext value={context}>{children}</DialogContext>\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "primitive-dismissable",
|
|
3
|
+
"type": "registry:primitive",
|
|
4
|
+
"title": "dismissable",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "components/primitives/dismissable.tsx",
|
|
10
|
+
"type": "registry:primitive",
|
|
11
|
+
"content": "import { useEffect, type RefObject } from 'react'\n\n/**\n * Close on Escape, or on a pointer press outside every given element.\n *\n * Uses `pointerdown` rather than `click`: a click fires after the press\n * completes, so a press that starts inside and drags out would close the layer\n * on release. Escape is bound on keydown at the document, so it works whether\n * focus is inside the layer or still on the trigger.\n */\nexport function useDismissable({\n open,\n onDismiss,\n refs,\n closeOnEscape = true,\n closeOnOutside = true,\n}: {\n open: boolean\n onDismiss: () => void\n /** Presses inside any of these are not \"outside\". */\n refs: RefObject<HTMLElement | null>[]\n closeOnEscape?: boolean\n closeOnOutside?: boolean\n}) {\n useEffect(() => {\n if (!open) return\n\n function onPointerDown(event: PointerEvent) {\n if (!closeOnOutside) return\n const target = event.target as Node\n if (refs.some((ref) => ref.current?.contains(target))) return\n onDismiss()\n }\n\n function onKeyDown(event: KeyboardEvent) {\n if (!closeOnEscape || event.key !== 'Escape') return\n event.stopPropagation()\n onDismiss()\n }\n\n document.addEventListener('pointerdown', onPointerDown)\n document.addEventListener('keydown', onKeyDown)\n\n return () => {\n document.removeEventListener('pointerdown', onPointerDown)\n document.removeEventListener('keydown', onKeyDown)\n }\n // `refs` is a fresh array each render; its contents are stable refs.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open, onDismiss, closeOnEscape, closeOnOutside])\n}\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|