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,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lib-styles",
|
|
3
|
+
"type": "registry:lib",
|
|
4
|
+
"title": "styles",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "lib/styles.ts",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"content": "import type { CSSProperties } from 'react'\n\n/**\n * The kit's global style contract.\n *\n * ---------------------------------------------------------------------------\n * Rule: prefer `secondary` over `outline`.\n *\n * Where a component offers both, reach for the tinted `secondary` treatment. An\n * outline draws a line around everything it touches; a screen full of them\n * turns into a grid of boxes with no hierarchy, and the borders start competing\n * with the ones that carry real structure — a card edge, a table rule, a\n * divider. A tint separates without adding another line.\n *\n * The same applies to fields: `variant=\"secondary\"` (filled) rather than the\n * bordered default. `outline` stays in the API for the case it is actually\n * right — a control sitting on an already-tinted surface, where a second fill\n * would compete instead of separate.\n * ---------------------------------------------------------------------------\n *\n * Every component composes these fragments instead of spelling out its own\n * focus ring, radius or control height — change a line here and the whole kit\n * follows. Anything colour- or radius-shaped resolves to a CSS variable\n * declared in `index.css`, so themes stay in one place too.\n */\n\n/** Focus treatment shared by every interactive element. */\nexport const focusRing =\n 'outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]'\n\n/** Inset variant, for controls that sit flush against a container edge. */\nexport const focusRingInset =\n 'outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:ring-inset'\n\nexport const disabledState = 'disabled:pointer-events-none disabled:opacity-50'\n\nexport const invalidState =\n 'aria-invalid:border-destructive aria-invalid:ring-destructive/20'\n\n/** Auto-size any icon a component is handed, unless it sets its own size. */\nexport const iconChild =\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n/**\n * Corner scale, driven by `--radius` in `index.css`. Generously rounded by\n * design, but every step stays under half its control height so nothing\n * collapses into a pill.\n *\n * Keep prose here free of bare utility names: Tailwind scans this file as\n * plain text and will emit CSS for any it finds, even inside a comment.\n */\nexport const radius = {\n /** Chips, gutters, inline code. */\n xs: 'rounded-sm',\n /** Buttons, inputs, tabs — anything control-sized. */\n control: 'rounded-lg',\n /** Cards, code blocks, popovers. */\n surface: 'rounded-2xl',\n /** Dialogs, sheets, page-level panels. */\n panel: 'rounded-3xl',\n} as const\n\n/**\n * Height / padding scale shared by controls, so a Button and an Input of the\n * same size line up. Each step carries its own radius: a fixed one would read\n * as a pill on the short sizes and as a square on the tall ones.\n */\nexport const controlSize = {\n // Sizes keep the kit-wide `corner-shape: squircle` — iOS-style continuous\n // corners at half their height, which is as round as a squircle goes.\n //\n // xs is the exception: it opts out to circular corners with\n // `[corner-shape:round]`, because a squircle at 50% reads as a rounded\n // rectangle and that size is meant to be a true pill.\n xs: \"h-7 gap-1.5 px-3.5 text-xs rounded-full [corner-shape:round] has-[>svg]:px-3 [&_svg:not([class*='size-'])]:size-3.5\",\n sm: 'h-8 gap-1.5 px-3.5 text-sm rounded-[var(--radius-control-sm)] has-[>svg]:px-3',\n md: 'h-9 gap-2 px-4.5 text-sm rounded-[var(--radius-control-md)] has-[>svg]:px-3.5',\n lg: 'h-10 gap-2 px-6 text-sm rounded-[var(--radius-control-lg)] has-[>svg]:px-4.5',\n xl: \"h-12 gap-2.5 px-8 text-base rounded-[var(--radius-control-xl)] has-[>svg]:px-6 [&_svg:not([class*='size-'])]:size-5\",\n icon: 'size-9 rounded-[var(--radius-control-md)]',\n iconXs: 'size-7 rounded-full [corner-shape:round]',\n iconSm: 'size-8 rounded-[var(--radius-control-sm)]',\n iconLg: 'size-10 rounded-[var(--radius-control-lg)]',\n iconXl: \"size-12 rounded-[var(--radius-control-xl)] [&_svg:not([class*='size-'])]:size-5\",\n} as const\n\n/**\n * Button label sizes: one 2px step below `controlSize`'s text scale.\n *\n * Kept separate from `controlSize` on purpose — a Button's label is a short,\n * semibold action word and reads fine tighter, while a field holds arbitrary\n * user text and should not shrink with it.\n */\nexport const buttonText = {\n xs: 'text-[10px]',\n sm: 'text-xs',\n md: 'text-xs',\n lg: 'text-xs',\n xl: 'text-sm',\n} as const\n\n/**\n * Hover and press feedback shared by every control.\n *\n * Colour only, by design: nothing in this kit moves, resizes or gains elevation\n * on interaction, and clicking animates nothing at all — only hover transitions.\n * State reads through background, border and text colour, so layout is never\n * disturbed by a pointer.\n */\nexport const interactive = [\n 'transition-colors duration-150 ease-out',\n // The press colour lands with no transition, so a click feels answered\n // rather than faded into; the release still eases back over 150ms.\n 'active:duration-0',\n 'motion-reduce:transition-none',\n].join(' ')\n\n/**\n * Colour sets. Each assigns the six `--ui-*` variables that colourable variants\n * read, so one prop restyles solid, soft, outline and ghost together.\n *\n * Hover values are real tokens rather than a derived mix: a mix has to pick a\n * direction, and the right direction flips between themes — light fills need to\n * darken, dark ones to lighten. `index.css` tunes both per theme. `neutral`\n * points at the theme's own primary/secondary pair.\n *\n * These MUST stay written out in full. Tailwind extracts class names by\n * scanning source text, so a helper that builds them with template literals\n * generates no CSS at all and every colour silently disappears.\n */\nexport const colorSet = {\n neutral:\n '[--ui:var(--primary)] [--ui-fg:var(--primary-foreground)] [--ui-hover:var(--primary-hover)] [--ui-soft:var(--secondary)] [--ui-soft-fg:var(--secondary-foreground)] [--ui-soft-hover:var(--secondary-hover)] [--ui-soft-fg-hover:var(--secondary-foreground-hover)]',\n blue: '[--ui:var(--blue)] [--ui-fg:var(--blue-foreground)] [--ui-hover:var(--blue-hover)] [--ui-soft:var(--blue-soft)] [--ui-soft-fg:var(--blue-soft-foreground)] [--ui-soft-hover:var(--blue-soft-hover)] [--ui-soft-fg-hover:var(--blue-soft-foreground-hover)]',\n violet:\n '[--ui:var(--violet)] [--ui-fg:var(--violet-foreground)] [--ui-hover:var(--violet-hover)] [--ui-soft:var(--violet-soft)] [--ui-soft-fg:var(--violet-soft-foreground)] [--ui-soft-hover:var(--violet-soft-hover)] [--ui-soft-fg-hover:var(--violet-soft-foreground-hover)]',\n cyan: '[--ui:var(--cyan)] [--ui-fg:var(--cyan-foreground)] [--ui-hover:var(--cyan-hover)] [--ui-soft:var(--cyan-soft)] [--ui-soft-fg:var(--cyan-soft-foreground)] [--ui-soft-hover:var(--cyan-soft-hover)] [--ui-soft-fg-hover:var(--cyan-soft-foreground-hover)]',\n green:\n '[--ui:var(--green)] [--ui-fg:var(--green-foreground)] [--ui-hover:var(--green-hover)] [--ui-soft:var(--green-soft)] [--ui-soft-fg:var(--green-soft-foreground)] [--ui-soft-hover:var(--green-soft-hover)] [--ui-soft-fg-hover:var(--green-soft-foreground-hover)]',\n amber:\n '[--ui:var(--amber)] [--ui-fg:var(--amber-foreground)] [--ui-hover:var(--amber-hover)] [--ui-soft:var(--amber-soft)] [--ui-soft-fg:var(--amber-soft-foreground)] [--ui-soft-hover:var(--amber-soft-hover)] [--ui-soft-fg-hover:var(--amber-soft-foreground-hover)]',\n rose: '[--ui:var(--rose)] [--ui-fg:var(--rose-foreground)] [--ui-hover:var(--rose-hover)] [--ui-soft:var(--rose-soft)] [--ui-soft-fg:var(--rose-soft-foreground)] [--ui-soft-hover:var(--rose-soft-hover)] [--ui-soft-fg-hover:var(--rose-soft-foreground-hover)]',\n destructive:\n '[--ui:var(--destructive)] [--ui-fg:var(--destructive-foreground)] [--ui-hover:var(--destructive-hover)] [--ui-soft:var(--destructive-soft)] [--ui-soft-fg:var(--destructive-soft-foreground)] [--ui-soft-hover:var(--destructive-soft-hover)] [--ui-soft-fg-hover:var(--destructive-soft-foreground-hover)]',\n} as const\n\n/**\n * The sidebar ground: black with light ink, identically in both themes.\n *\n * Deliberately not a themed surface. The rail is chrome, not content — it\n * frames the page rather than being part of it, so it stays put while the\n * document beside it switches between light and dark.\n *\n * Note what this cannot do: recolour the controls inside it. Every Button\n * carries its own colour set (`color` defaults to `neutral`), which reassigns\n * the `--ui-*` variables on the element itself and so outranks anything set on\n * an ancestor. Put a plain Button on this ground and it will style itself for\n * the page theme, not for the rail — prefer `SidebarMenuButton`, which is\n * built for it.\n */\nexport const sidebarSurface = 'bg-[var(--sidebar)] text-[var(--sidebar-foreground)]'\n\n/** Ink steps for content on `sidebarSurface`. */\nexport const sidebarInk = {\n label: 'text-[var(--sidebar-foreground)]/60',\n row: 'text-[var(--sidebar-foreground)]/70',\n strong: 'text-[var(--sidebar-foreground)]',\n hover: 'hover:bg-[var(--sidebar-foreground)]/10 hover:text-[var(--sidebar-foreground)]',\n active: 'bg-[var(--sidebar-foreground)]/15 text-[var(--sidebar-foreground)]',\n ring: 'outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--sidebar-foreground)]/40',\n} as const\n\n/**\n * Colour roles, measured rather than assumed.\n *\n * `--{hue}` a FILL. Pair it with `--{hue}-foreground`.\n * `--{hue}-soft-foreground` TEXT and icons, on any page surface.\n *\n * Using a base hue as text fails WCAG AA in the light theme — measured against\n * `--card`: amber 2.04:1, cyan 3.42:1, green 4.01:1, rose 4.40:1, all short of\n * the 4.5 needed. The soft-foreground variants land between 7.2 and 8.2 in\n * light and 11 to 14 in dark. The base hues look fine in dark, which is exactly\n * why this goes unnoticed when dark is the default.\n */\n\n/**\n * The categorical palette for data display — charts, segments, legends.\n *\n * Each entry pairs a fill with the ink that belongs on it. That pairing is the\n * whole point: `--amber` is a light oklch(0.78) whose foreground token is dark,\n * so white text on an amber segment fails contrast in both themes. Anything\n * that draws a label *on* a fill must read `ink`, not assume white.\n *\n * Previously copied into five components; a palette that drifts between a chart\n * and its own legend is worse than no palette.\n */\nexport const dataPalette = [\n { fill: 'var(--blue)', ink: 'var(--blue-foreground)' },\n { fill: 'var(--violet)', ink: 'var(--violet-foreground)' },\n { fill: 'var(--green)', ink: 'var(--green-foreground)' },\n { fill: 'var(--amber)', ink: 'var(--amber-foreground)' },\n { fill: 'var(--cyan)', ink: 'var(--cyan-foreground)' },\n { fill: 'var(--rose)', ink: 'var(--rose-foreground)' },\n] as const\n\n/** Fills alone, for charts that never draw text on a segment. */\nexport const dataFills = dataPalette.map((entry) => entry.fill)\n\nexport type ColorSet = keyof typeof colorSet\n\n/**\n * Build a one-off colour set from any CSS colour, for when the eight named sets\n * are not enough. Everything is derived from the single value:\n *\n * - solid fill the colour itself, shifted in lightness on hover\n * - solid text black or white, whichever the colour can carry\n * - tinted background the colour at 12% (20% on hover)\n * - tinted text the colour itself, shifted on hover\n * - border the same colour as the text it sits with\n *\n * Returned as inline styles on purpose: inline declarations beat the class that\n * a `color` prop would apply, so the two can never half-override each other.\n *\n * `oklch(from …)` is relative colour syntax — Chrome 119+, Safari 16.4+,\n * Firefox 128+. `color-mix` is older and safe. Accepts anything CSS parses as a\n * colour, including a `var(--token)`.\n */\nexport function tintStyle(color: string) {\n return {\n '--ui': color,\n // Lightness above ~0.62 takes black text, below it takes white. The\n // multiply-and-clamp collapses that comparison into a single number.\n '--ui-fg': `oklch(from ${color} clamp(0, (0.62 - l) * 1000, 1) 0 0)`,\n '--ui-hover': `oklch(from ${color} calc(l + var(--tint-shift)) c h)`,\n '--ui-soft': `color-mix(in oklab, ${color}, transparent 88%)`,\n // Not the raw colour: used as text on the page surface a mid-tone tint\n // measures around 3.5:1. Shifted away from the surface in both themes,\n // which is exactly what the named sets' `-soft-foreground` tokens do.\n '--ui-soft-fg': `oklch(from ${color} clamp(0, calc(l + var(--tint-text-shift)), 1) c h)`,\n '--ui-soft-fg-hover': `oklch(from ${color} clamp(0, calc(l + var(--tint-text-shift) + var(--tint-shift)), 1) c h)`,\n '--ui-soft-hover': `color-mix(in oklab, ${color}, transparent 80%)`,\n } as CSSProperties\n}\n\nexport const COLOR_SETS = Object.keys(colorSet) as ColorSet[]\n\n/** Bordered container on the card surface. */\nexport const surface = 'border-border bg-card text-card-foreground border'\n\n/** Base every interactive control starts from. */\nexport const controlBase = [\n 'inline-flex shrink-0 items-center justify-center whitespace-nowrap',\n 'font-semibold select-none',\n interactive,\n focusRing,\n disabledState,\n invalidState,\n iconChild,\n].join(' ')\n\n/**\n * Base for a field: a control-shaped box that wraps a real input, so the border,\n * ring and icon slots live on the wrapper while text and caret stay native.\n * Pairs with `controlSize`, exactly like `controlBase` does for buttons.\n */\nexport const fieldBase = [\n 'flex w-full items-center',\n interactive,\n // No ring — focus reads as a higher-contrast border and nothing else. The\n // border box is always reserved, even on the borderless variants, so this\n // never shifts layout.\n 'outline-none',\n 'focus-within:border-[var(--border-active)]',\n iconChild,\n // Adornments read as secondary text until the field is focused.\n '[&_[data-slot=field-slot]]:text-muted-foreground',\n 'focus-within:[&_[data-slot=field-slot]]:text-foreground',\n '[&_[data-slot=field-slot]]:flex [&_[data-slot=field-slot]]:shrink-0',\n 'has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50',\n].join(' ')\n\n/**\n * Field sizing. Heights, radii and text match `controlSize` exactly, so an Input\n * and a Button of the same size still line up in a row.\n *\n * Padding derives from one number per size: the *vertical* inset, which is half\n * the space a line box leaves inside the height. The trailing side uses it as\n * is, and the leading side takes it plus 4px — text reads from the leading edge,\n * so that side carries slightly more room than the other three. The icon-to-text\n * gap uses the plain inset.\n *\n * size height line box inset ps (inset+4) pe\n * xs 28px 16px 6px 10px 6px\n * sm 32px 20px 6px 10px 6px\n * md 36px 20px 8px 12px 8px\n * lg 40px 20px 10px 14px 10px\n * xl 48px 24px 12px 16px 12px\n *\n * Logical properties, so this follows writing direction rather than assuming\n * left-to-right. Icons run one step below the control text so they read as\n * adornments rather than competing with the value.\n */\nexport const fieldSize = {\n xs: \"h-7 ps-2.5 pe-1.5 gap-1.5 text-xs rounded-full [corner-shape:round] [&_svg:not([class*='size-'])]:size-3\",\n sm: \"h-8 ps-2.5 pe-1.5 gap-1.5 text-sm rounded-[var(--radius-control-sm)] [&_svg:not([class*='size-'])]:size-3.5\",\n md: \"h-9 ps-3 pe-2 gap-2 text-sm rounded-[var(--radius-control-md)] [&_svg:not([class*='size-'])]:size-3.5\",\n lg: \"h-10 ps-3.5 pe-2.5 gap-2.5 text-sm rounded-[var(--radius-control-lg)] [&_svg:not([class*='size-'])]:size-4\",\n xl: \"h-12 ps-4 pe-3 gap-3 text-base rounded-[var(--radius-control-xl)] [&_svg:not([class*='size-'])]:size-4.5\",\n} as const\n\n/**\n * Square indicators — checkbox, and the box a radio rounds off. Sized so the\n * label text beside them sits on the same optical line as the control.\n */\nexport const checkSize = {\n sm: 'size-4 rounded-[var(--radius-check-sm)]',\n default: 'size-5 rounded-[var(--radius-check-md)]',\n lg: 'size-6 rounded-[var(--radius-check-lg)]',\n} as const\n\n/** Radio boxes: the same footprint as a checkbox, but always circular. */\nexport const radioSize = {\n sm: 'size-4',\n default: 'size-5',\n lg: 'size-6',\n} as const\n\n/** Icon drawn inside a checkbox, one step under the box itself. */\nexport const checkIconSize = {\n sm: '[&_svg]:size-3',\n default: '[&_svg]:size-3.5',\n lg: '[&_svg]:size-4',\n} as const\n\n/**\n * Switch track and thumb. Each track is 1px border + 1px padding per side, and\n * every size is proportioned so the thumb's travel equals its own width — which\n * is why the checked state can just translate by `--thumb`:\n *\n * sm 28x16 track, 24x12 inner, 12px thumb, 12px travel\n * md 36x20 track, 32x16 inner, 16px thumb, 16px travel\n * lg 44x24 track, 40x20 inner, 20px thumb, 20px travel\n */\nexport const switchSize = {\n sm: 'h-4 w-7 p-px [--thumb:0.75rem]',\n default: 'h-5 w-9 p-px [--thumb:1rem]',\n lg: 'h-6 w-11 p-px [--thumb:1.25rem]',\n} as const\n\n/** Range track and thumb, read by the `.slider` rules in index.css. */\nexport const sliderSize = {\n sm: '[--slider-track:0.25rem] [--slider-thumb:0.875rem] h-3.5',\n default: '[--slider-track:0.375rem] [--slider-thumb:1rem] h-4',\n lg: '[--slider-track:0.5rem] [--slider-thumb:1.25rem] h-5',\n} as const\n\n/** Shared by every control that renders a box or track rather than text. */\nexport const indicatorBase = [\n 'inline-flex shrink-0 items-center justify-center',\n 'border-border border bg-background',\n interactive,\n focusRing,\n 'peer-disabled:pointer-events-none peer-disabled:opacity-50',\n].join(' ')\n\n/**\n * Where a horizontal layout stops stacking.\n *\n * Components that lay children out in a row take a `responsive` prop of this\n * type. The default is `'sm'`: a row of controls almost never fits a phone, and\n * a component that only works on a wide screen pushes the media query out to\n * every call site — which is where it gets forgotten.\n *\n * `false` opts out, for a row that is genuinely fine at any width, such as three\n * icon buttons.\n *\n * Each component spells its breakpoint classes out rather than composing them:\n * Tailwind reads source text, so a prefix built by interpolation generates no\n * CSS at all.\n */\nexport type Responsive = 'sm' | 'md' | 'lg' | false\n\n/**\n * Card section padding: square at every size, so the inset is the same on all\n * four edges. Shared by header, body and footer, so a card's sections line up\n * on one left edge and the dividers sit at consistent distances.\n */\nexport const cardPadding = {\n sm: 'p-3', // 12px\n default: 'p-4.5', // 18px — the midpoint of sm and lg\n lg: 'p-6', // 24px\n} as const\n\n/** Badge: short, dense, and always shorter than a control of the same name. */\nexport const badgeSize = {\n sm: \"h-5 gap-1 px-2 text-[10px] [&_svg:not([class*='size-'])]:size-3\",\n default: \"h-6 gap-1 px-2.5 text-xs [&_svg:not([class*='size-'])]:size-3\",\n lg: \"h-7 gap-1.5 px-3 text-sm [&_svg:not([class*='size-'])]:size-3.5\",\n} as const\n\n/** Avatar footprint, with the fallback initials scaled to match. */\nexport const avatarSize = {\n xs: 'size-6 text-[10px]',\n sm: 'size-8 text-xs',\n default: 'size-10 text-sm',\n lg: 'size-12 text-base',\n xl: 'size-14 text-base',\n} as const\n\n/** A floating panel: menus, dropdowns, popovers. */\nexport const menuSurface = [\n 'border-border bg-popover text-popover-foreground border',\n 'z-50 overflow-auto p-1',\n].join(' ')\n\n/**\n * One row inside a menu panel.\n *\n * `iconChild` is part of the row, not the caller's problem: a lucide icon\n * defaults to 24px, so a menu that passes icons through unsized ends up with\n * rows half again as tall as one that does not — which is exactly how the\n * context menu drifted away from the dropdown it is supposed to match.\n */\nexport const menuItem = [\n // `text-start` is load-bearing: every menu row is a <button>, and the UA\n // default for one is `text-align: center`. Without it a row whose label sits\n // in a `flex-1` span centres that label in the leftover space, which reads as\n // a wrong gap after the icon rather than as an alignment problem. Three\n // components had each worked around it locally before it was fixed here.\n 'flex w-full cursor-pointer items-center gap-2 px-2.5 py-1.5 text-start text-sm',\n iconChild,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'outline-none select-none',\n // Three sources for one highlight, because the menus drive it differently:\n //\n // data-highlighted Select, Combobox and Command track an index and set it\n // hover the button-based menus (Dropdown, Context) have none\n // focus Dropdown moves focus programmatically with arrow keys,\n // and `outline-none` above would otherwise leave that\n // completely invisible\n //\n // Plain `focus:` rather than `focus-visible:`: a programmatic `.focus()` does\n // not reliably match focus-visible, which is exactly how the dropdown moves\n // between rows.\n 'data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus:bg-accent focus:text-accent-foreground',\n 'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',\n 'disabled:pointer-events-none disabled:opacity-50',\n].join(' ')\n\n/** The input inside a `fieldBase` wrapper: invisible, so the wrapper shows. */\nexport const fieldInput = [\n 'w-full min-w-0 bg-transparent outline-none',\n 'placeholder:text-muted-foreground/70',\n 'disabled:cursor-not-allowed',\n // Chrome paints its own background on autofill; keep the wrapper's instead.\n '[&:-webkit-autofill]:[-webkit-text-fill-color:var(--foreground)]',\n].join(' ')\n\n/** Quiet, hover-lit affordance — icon buttons, tab triggers, menu items. */\nexport const ghostControl =\n 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lib-use-clipboard",
|
|
3
|
+
"type": "registry:lib",
|
|
4
|
+
"title": "use-clipboard",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "lib/use-clipboard.ts",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"content": "import { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Copy to the clipboard, with the \"copied\" state that always comes with it.\n *\n * Written four separate times in this kit before it was extracted — CodeBlock,\n * Terminal, EnvVars and Message each had their own timer and their own reset.\n *\n * Three things it gets right that an inline version usually does not: the\n * timeout is cleared on unmount, so copying and navigating away does not set\n * state on a gone component; a second copy restarts the timer instead of\n * stacking two; and a rejected write (no permission, insecure origin) reports\n * `error` rather than silently claiming success.\n */\ntype ClipboardState = 'idle' | 'copied' | 'error'\n\nfunction useClipboard({ timeout = 1600 } = {}) {\n const [state, setState] = useState<ClipboardState>('idle')\n const timer = useRef(0)\n\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(\n async (text: string) => {\n clearTimeout(timer.current)\n\n try {\n // `navigator.clipboard` is undefined on an insecure origin, which is a\n // rejection rather than a crash from the caller's point of view.\n if (!navigator.clipboard) throw new Error('Clipboard unavailable')\n await navigator.clipboard.writeText(text)\n setState('copied')\n } catch {\n setState('error')\n }\n\n timer.current = window.setTimeout(() => setState('idle'), timeout)\n },\n [timeout],\n )\n\n return { copy, state, copied: state === 'copied' }\n}\n\nexport { useClipboard }\nexport type { ClipboardState }\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lib-utils",
|
|
3
|
+
"type": "registry:lib",
|
|
4
|
+
"title": "utils",
|
|
5
|
+
"dependencies": [
|
|
6
|
+
"cn@^0.2.4"
|
|
7
|
+
],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "lib/utils.ts",
|
|
12
|
+
"type": "registry:lib",
|
|
13
|
+
"content": "import { cn as merge, type ClassValue } from 'cn'\n\n/**\n * Merge class names, resolving conflicting Tailwind utilities in favour of the\n * last one passed. Use it in every component that accepts a `className` prop.\n *\n * Backed by `cn`, a compiled merge engine that replaces `clsx` + `tailwind-merge`\n * behind the same API. The swap was checked rather than assumed: every class\n * string in this repo — 2,633 of them, including the arbitrary values and\n * `max-*` variants Tailwind v4 lets us write — plus 400 two-argument merges\n * produce byte-identical output under both engines.\n *\n * Re-exported through this module rather than imported directly at each call\n * site, so the engine stays one line to change.\n */\nexport function cn(...inputs: ClassValue[]) {\n return merge(...inputs)\n}\n\nexport type { ClassValue }\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lighthouse-score",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Lighthouse Score",
|
|
5
|
+
"description": "Category rings with the metrics behind them. A performance score of 62 is not actionable; \"LCP 4.1s\" is — so the metrics are shown, not hidden behind the summary.",
|
|
6
|
+
"category": "Build & Perf",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/lighthouse-score.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Category scores as rings, with the metrics behind them.\n *\n * Scores are banded at 50 and 90, matching the tool everyone compares against.\n * Inventing our own bands would make a \"green\" here mean something different\n * from a \"green\" in the report the number came from, which is worse than\n * useless.\n *\n * Metric values are shown in their own units next to the score. A performance\n * score of 62 is not actionable; \"LCP 4.1s\" is. The score is a summary of the\n * metrics, so hiding them behind the summary removes the only part you can act\n * on.\n *\n * The ring is drawn with `stroke-dasharray` on a circle rather than a conic\n * gradient, so it prints, scales, and respects the theme's own colours.\n */\nexport type LighthouseCategory = {\n id: string\n label: ReactNode\n /** 0–100. */\n score: number\n metrics?: { label: ReactNode; value: ReactNode; poor?: boolean }[]\n}\n\n/** Thresholds match the reference tool — diverging would make colours lie. */\nfunction band(score: number) {\n if (score >= 90) return { color: 'var(--green)', ink: 'var(--green-soft-foreground)', label: 'Good' }\n if (score >= 50) return { color: 'var(--amber)', ink: 'var(--amber-soft-foreground)', label: 'Needs work' }\n return { color: 'var(--destructive)', ink: 'var(--destructive-soft-foreground)', label: 'Poor' }\n}\n\nfunction Ring({ score, size = 56 }: { score: number; size?: number }) {\n const meta = band(score)\n const radiusPx = size / 2 - 4\n const circumference = 2 * Math.PI * radiusPx\n\n return (\n <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden=\"true\" className=\"shrink-0\">\n <circle\n cx={size / 2}\n cy={size / 2}\n r={radiusPx}\n fill=\"none\"\n stroke=\"var(--secondary)\"\n strokeWidth={4}\n />\n <circle\n cx={size / 2}\n cy={size / 2}\n r={radiusPx}\n fill=\"none\"\n stroke={meta.color}\n strokeWidth={4}\n strokeLinecap=\"round\"\n strokeDasharray={`${(score / 100) * circumference} ${circumference}`}\n transform={`rotate(-90 ${size / 2} ${size / 2})`}\n />\n <text\n x=\"50%\"\n y=\"50%\"\n dominantBaseline=\"central\"\n textAnchor=\"middle\"\n fill={meta.ink}\n style={{ fontSize: size * 0.3, fontWeight: 600 }}\n >\n {Math.round(score)}\n </text>\n </svg>\n )\n}\n\nfunction LighthouseScore({\n categories,\n ringSize = 56,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n categories: LighthouseCategory[]\n ringSize?: number\n}) {\n return (\n <div\n data-slot=\"lighthouse-score\"\n className={cn(surface, radius.surface, 'divide-border flex flex-col divide-y', className)}\n {...props}\n >\n {categories.map((category) => {\n const meta = band(category.score)\n return (\n <section key={category.id} className=\"flex items-start gap-4 p-4\">\n <Ring score={category.score} size={ringSize} />\n\n <div className=\"min-w-0 flex-1\">\n <h3 className=\"flex flex-wrap items-baseline gap-2 text-sm font-medium\">\n {category.label}\n <span className=\"text-xs font-normal\" style={{ color: meta.ink }}>\n {meta.label}\n </span>\n </h3>\n\n {/* The metrics are the actionable part; the score only summarises. */}\n {category.metrics && category.metrics.length > 0 && (\n <dl className=\"mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3\">\n {category.metrics.map((metric, index) => (\n <div key={index}>\n <dt className=\"text-muted-foreground truncate\">{metric.label}</dt>\n <dd\n className=\"mt-0.5 font-medium tabular-nums\"\n style={{ color: metric.poor ? 'var(--destructive-soft-foreground)' : undefined }}\n >\n {metric.value}\n </dd>\n </div>\n ))}\n </dl>\n )}\n </div>\n </section>\n )\n })}\n </div>\n )\n}\n\nexport { LighthouseScore, band as lighthouseBand }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "liquidity-position",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Liquidity Position",
|
|
5
|
+
"description": "A concentrated-liquidity position with its range. In-range versus out-of-range is the headline — a position outside its range earns nothing, and a card showing only \"fees earned\" while it stopped earning last week is misleading.",
|
|
6
|
+
"category": "Crypto",
|
|
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/liquidity-position.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A concentrated-liquidity position, with its range.\n *\n * In-range versus out-of-range is the headline, not a detail. A position\n * outside its range earns nothing at all — it has quietly become a single-sided\n * holding of whichever asset the price ran away from — and a card that shows\n * only \"fees earned: $412\" while the position stopped earning last Tuesday is\n * actively misleading.\n *\n * The range bar plots the current price against the bounds, so \"just inside\"\n * and \"about to exit\" look different. A pair of numbers cannot show that.\n */\nfunction LiquidityPosition({\n pair,\n fees,\n value,\n minPrice,\n maxPrice,\n currentPrice,\n priceLabel,\n apr,\n onCollect,\n onManage,\n collectDisabled = false,\n locale = 'en-GB',\n outOfRangeNote = 'Price has left the range — this position is not earning fees.',\n valueLabel = 'Position value',\n feesLabel = 'Uncollected fees',\n collectLabel = 'Collect fees',\n manageLabel = 'Manage',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n pair: ReactNode\n /** Uncollected fees, formatted. */\n fees?: ReactNode\n value?: ReactNode\n minPrice: number\n maxPrice: number\n currentPrice: number\n /** e.g. \"USDC per ETH\" */\n priceLabel?: ReactNode\n apr?: number\n onCollect?: () => void\n onManage?: () => void\n collectDisabled?: boolean\n locale?: string\n /** Shown when the current price sits outside min/max. */\n outOfRangeNote?: ReactNode\n valueLabel?: ReactNode\n feesLabel?: ReactNode\n collectLabel?: ReactNode\n manageLabel?: ReactNode\n}) {\n const inRange = currentPrice >= minPrice && currentPrice <= maxPrice\n\n // Plot with padding either side, so an out-of-range price is still visible.\n const span = maxPrice - minPrice || 1\n const low = minPrice - span * 0.25\n const high = maxPrice + span * 0.25\n const pct = (v: number) => ((v - low) / (high - low)) * 100\n\n const num = new Intl.NumberFormat(locale, { maximumFractionDigits: 4 })\n\n return (\n <div\n data-slot=\"liquidity-position\"\n data-in-range={inRange || undefined}\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-sm font-medium\">{pair}</span>\n {/* The headline: out of range means earning nothing. */}\n <Badge size=\"sm\" color={inRange ? 'green' : 'amber'}>\n {inRange ? 'In range' : 'Out of range'}\n </Badge>\n {apr !== undefined && (\n <span className=\"text-muted-foreground ms-auto text-xs tabular-nums\">\n {apr.toFixed(1)}% APR\n </span>\n )}\n </div>\n\n {!inRange && (\n <p className=\"flex items-start gap-1.5 text-xs text-[var(--amber-soft-foreground)]\">\n <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden=\"true\" />\n {outOfRangeNote}\n </p>\n )}\n\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"relative h-8\">\n <div className=\"bg-secondary absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full [corner-shape:round]\" />\n <div\n className={cn(\n 'absolute top-1/2 h-2 -translate-y-1/2 rounded-full [corner-shape:round]',\n inRange ? 'bg-[var(--green)]' : 'bg-[var(--amber)]',\n )}\n style={{ left: `${pct(minPrice)}%`, width: `${pct(maxPrice) - pct(minPrice)}%` }}\n />\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground absolute top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full\"\n style={{ left: `${Math.max(0, Math.min(pct(currentPrice), 100))}%` }}\n />\n </div>\n\n <div className=\"text-muted-foreground flex justify-between text-xs tabular-nums\">\n <span>{num.format(minPrice)}</span>\n <span className=\"text-foreground font-medium\">\n {num.format(currentPrice)}\n {priceLabel && <span className=\"text-muted-foreground ms-1 font-normal\">{priceLabel}</span>}\n </span>\n <span>{num.format(maxPrice)}</span>\n </div>\n </div>\n\n <dl className=\"grid grid-cols-2 gap-3\">\n <div className={cn('bg-secondary/60 p-3', radius.control)}>\n <dt className=\"text-muted-foreground text-xs\">{valueLabel}</dt>\n <dd className=\"mt-0.5 text-base font-semibold tabular-nums\">{value ?? '—'}</dd>\n </div>\n <div className={cn('bg-secondary/60 p-3', radius.control)}>\n <dt className=\"text-muted-foreground text-xs\">{feesLabel}</dt>\n <dd className=\"mt-0.5 text-base font-semibold tabular-nums text-[var(--green-soft-foreground)]\">\n {fees ?? '—'}\n </dd>\n </div>\n </dl>\n\n <div className=\"flex flex-wrap gap-2\">\n {onCollect && (\n <Button className=\"flex-1\" disabled={collectDisabled} onClick={onCollect}>\n {collectLabel}\n </Button>\n )}\n {onManage && (\n <Button variant=\"secondary\" className=\"flex-1\" onClick={onManage}>\n {manageLabel}\n </Button>\n )}\n </div>\n </div>\n )\n}\n\nexport { LiquidityPosition }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "live-announcer",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Live Announcer",
|
|
5
|
+
"description": "Imperative screen-reader announcements for things with no visible region of their own — a background save finishing, a filter narrowing a list.",
|
|
6
|
+
"category": "Utility",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "components/ui/live-announcer.tsx",
|
|
12
|
+
"type": "registry:ui",
|
|
13
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\n\n/**\n * Imperative screen-reader announcements.\n *\n * For things that happen without a visible change a reader would notice: a\n * background save finishing, a filter narrowing a list, a shortcut taking\n * effect. Anything with a visible region of its own should carry `aria-live`\n * there instead — this is the fallback, not the default.\n *\n * Two regions, not one. Politeness cannot be changed on a live region after the\n * fact in any reliable way, so `polite` and `assertive` each get their own and\n * the message goes to the matching one.\n *\n * Repeating an identical message is the classic failure here: setting the same\n * text twice is not a mutation, so nothing is announced the second time. The\n * counter appended below forces a change the observer will see.\n */\ntype Politeness = 'polite' | 'assertive'\n\ntype Announce = (message: string, politeness?: Politeness) => void\n\nconst AnnouncerContext = createContext<Announce | null>(null)\n\nfunction useAnnouncer() {\n const announce = use(AnnouncerContext)\n if (!announce) {\n throw new Error('useAnnouncer must be used inside a <LiveAnnouncer>')\n }\n return announce\n}\n\nfunction LiveAnnouncer({ children }: { children: ReactNode }) {\n const [polite, setPolite] = useState('')\n const [assertive, setAssertive] = useState('')\n const nonce = useRef(0)\n\n const announce = useCallback<Announce>((message, politeness = 'polite') => {\n // A zero-width space count, so an identical message still reads as a\n // change to the live region without altering what is spoken.\n nonce.current += 1\n const text = message + ''.repeat(nonce.current % 2)\n if (politeness === 'assertive') setAssertive(text)\n else setPolite(text)\n }, [])\n\n const value = useMemo(() => announce, [announce])\n\n return (\n <AnnouncerContext value={value}>\n {children}\n <div\n aria-live=\"polite\"\n aria-atomic=\"true\"\n data-slot=\"live-region\"\n className=\"sr-only\"\n >\n {polite}\n </div>\n <div\n aria-live=\"assertive\"\n aria-atomic=\"true\"\n data-slot=\"live-region\"\n className=\"sr-only\"\n >\n {assertive}\n </div>\n </AnnouncerContext>\n )\n}\n\nexport { LiveAnnouncer, useAnnouncer }\nexport type { Politeness }\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "location-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Location Picker",
|
|
5
|
+
"description": "Pick a place by searching, or by typing coordinates. Both routes stay open: search covers the common case, the fields cover the field site or mooring that no geocoder knows.",
|
|
6
|
+
"category": "Geo",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"coordinate-input",
|
|
13
|
+
"input",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"map-embed",
|
|
17
|
+
"spinner"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/ui/location-picker.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { MapPin, Search, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { CoordinateInput, type Coordinate } from '@/components/ui/coordinate-input'\nimport { Input } from '@/components/ui/input'\nimport { MapEmbed } from '@/components/ui/map-embed'\nimport { Spinner } from '@/components/ui/spinner'\nimport { menuItem, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Pick a place by searching for it, or by entering coordinates directly.\n *\n * Geocoding is the caller's: pass `onSearch` and it is called with the query.\n * A component library has no business choosing a geocoder for you — they need\n * keys, they have wildly different terms, and several forbid storing results.\n *\n * Both routes stay open. Search covers the common case; the coordinate fields\n * cover the one search cannot — a field site, a mooring, an address that no\n * geocoder knows. Hiding the fallback behind an \"advanced\" toggle just means\n * the people who need it cannot find it.\n *\n * Results are requested on a trailing debounce and stale responses are dropped\n * by sequence number. Typing \"Berlin\" fires five requests, and without the\n * guard the answer for \"Berl\" can land last and win.\n */\nexport type LocationResult = {\n id: string\n label: ReactNode\n detail?: ReactNode\n lat: number\n lng: number\n}\n\nfunction LocationPicker({\n value,\n onValueChange,\n onSearch,\n placeholder = 'Search for a place or address…',\n showMap = true,\n showCoordinates = true,\n debounce = 300,\n emptyMessage = 'No places matched.',\n searchLabel = 'Search for a place',\n clearLabel = 'Clear selected place',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange' | 'value'> & {\n value?: Coordinate | null\n onValueChange?: (value: Coordinate | null, result?: LocationResult) => void\n /** Your geocoder. Without it, only the coordinate fields are offered. */\n onSearch?: (query: string) => Promise<LocationResult[]>\n placeholder?: string\n showMap?: boolean\n showCoordinates?: boolean\n debounce?: number\n emptyMessage?: ReactNode\n /** Accessible name for the search field. */\n searchLabel?: string\n clearLabel?: string\n}) {\n const [query, setQuery] = useState('')\n const [results, setResults] = useState<LocationResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n const [chosen, setChosen] = useState<LocationResult | null>(null)\n // Sequence guard: the answer for \"Berl\" must not beat the one for \"Berlin\".\n const sequence = useRef(0)\n\n useEffect(() => {\n if (!onSearch) return\n const text = query.trim()\n if (text.length < 2) {\n setResults(null)\n setBusy(false)\n return\n }\n\n setBusy(true)\n const ticket = ++sequence.current\n const timer = setTimeout(async () => {\n try {\n const found = await onSearch(text)\n if (ticket !== sequence.current) return\n setResults(found)\n } catch {\n if (ticket === sequence.current) setResults([])\n } finally {\n if (ticket === sequence.current) setBusy(false)\n }\n }, debounce)\n\n return () => clearTimeout(timer)\n }, [query, onSearch, debounce])\n\n function choose(result: LocationResult) {\n setChosen(result)\n setQuery('')\n setResults(null)\n onValueChange?.({ lat: result.lat, lng: result.lng }, result)\n }\n\n return (\n <div data-slot=\"location-picker\" className={cn('flex flex-col gap-3', className)} {...props}>\n {onSearch && (\n <div className=\"relative flex flex-col gap-1.5\">\n <Input\n value={query}\n placeholder={placeholder}\n aria-label={searchLabel}\n // Input carries one icon slot, so the search glyph becomes the\n // spinner while a query is in flight rather than sitting beside it.\n icon={busy ? <Spinner size=\"xs\" /> : <Search />}\n onChange={(event) => setQuery(event.target.value)}\n />\n\n {results !== null && (\n <div\n className={cn(\n surface,\n radius.control,\n 'max-h-56 overflow-auto p-1 shadow-md',\n )}\n >\n {results.length === 0 && !busy ? (\n <p className=\"text-muted-foreground px-2 py-3 text-center text-xs\">\n {emptyMessage}\n </p>\n ) : (\n <ul className=\"flex list-none flex-col\">\n {results.map((result) => (\n <li key={result.id}>\n <button\n type=\"button\"\n className={cn(menuItem, 'w-full')}\n onClick={() => choose(result)}\n >\n <MapPin className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{result.label}</span>\n {result.detail && (\n <span className=\"text-muted-foreground block truncate text-xs\">\n {result.detail}\n </span>\n )}\n </span>\n </button>\n </li>\n ))}\n </ul>\n )}\n </div>\n )}\n </div>\n )}\n\n {chosen && (\n <div className={cn('bg-secondary flex items-center gap-2 p-3', radius.control)}>\n <MapPin className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-sm\">{chosen.label}</span>\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={clearLabel}\n onClick={() => {\n setChosen(null)\n onValueChange?.(null)\n }}\n >\n <X />\n </Button>\n </div>\n )}\n\n {/* Always reachable, never behind an \"advanced\" toggle. */}\n {showCoordinates && (\n <CoordinateInput\n value={value ?? null}\n onValueChange={(next) => {\n setChosen(null)\n onValueChange?.(next)\n }}\n />\n )}\n\n {showMap && value && (\n <MapEmbed lat={value.lat} lng={value.lng} height={220} label={chosen?.label} />\n )}\n </div>\n )\n}\n\nexport { LocationPicker }\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "log-viewer",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Log Viewer",
|
|
5
|
+
"description": "Structured logs with level filters, search and counts. Matches are highlighted in place rather than filtering the list down, so a search shows where the hits are in context.",
|
|
6
|
+
"category": "Development",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"badge",
|
|
12
|
+
"input",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"select"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "components/ui/log-viewer.tsx",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"content": "import { useMemo, useState, type ComponentProps } from 'react'\nimport { Search } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Input } from '@/components/ui/input'\nimport { Select } from '@/components/ui/select'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Structured logs: level filters, search, timestamps.\n *\n * Distinct from `Terminal`, which renders an opaque stream of bytes. Here every\n * line is a record with a level and a time, so it can be filtered and counted\n * — and that is the whole reason to prefer structured logging in the first\n * place.\n *\n * Matches are highlighted rather than the list being silently reduced, so a\n * search shows you where the hits are in context.\n */\nexport type LogLevel = 'debug' | 'info' | 'warn' | 'error'\n\nexport type LogEntry = {\n id: string\n level: LogLevel\n message: string\n time?: Date\n source?: string\n}\n\nconst LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']\n\n/**\n * Severity order. The filter is a threshold rather than a set of independent\n * toggles: \"show me warnings and worse\" is the question people actually ask of\n * a log, and it maps onto a single Select instead of four controls whose 16\n * combinations are mostly meaningless.\n */\nconst LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }\n\nconst LEVEL_LABEL: Record<LogLevel, string> = {\n debug: 'Debug and above',\n info: 'Info and above',\n warn: 'Warnings and errors',\n error: 'Errors only',\n}\n\nconst LEVEL_TONE = {\n debug: 'text-muted-foreground',\n info: 'text-[var(--blue-soft-foreground)]',\n warn: 'text-[var(--amber-soft-foreground)]',\n error: 'text-[var(--destructive-soft-foreground)]',\n} as const\n\nconst ROW_TONE = {\n debug: '',\n info: '',\n warn: 'bg-[color-mix(in_oklab,var(--amber),transparent_94%)]',\n error: 'bg-[color-mix(in_oklab,var(--destructive),transparent_94%)]',\n} as const\n\n/** Split a message at the query so the hit can be marked. */\nfunction highlight(message: string, query: string) {\n if (!query) return message\n const at = message.toLowerCase().indexOf(query.toLowerCase())\n if (at === -1) return message\n return (\n <>\n {message.slice(0, at)}\n <mark className=\"bg-[var(--amber)]/30 text-foreground rounded-sm\">\n {message.slice(at, at + query.length)}\n </mark>\n {message.slice(at + query.length)}\n </>\n )\n}\n\nfunction LogViewer({\n entries,\n locale = 'en-GB',\n searchable = true,\n filterable = true,\n searchPlaceholder = 'Filter logs',\n searchLabel = 'Filter logs',\n levelLabel = 'Minimum log level',\n className,\n ...props\n}: ComponentProps<'div'> & {\n entries: LogEntry[]\n locale?: string\n searchable?: boolean\n filterable?: boolean\n searchPlaceholder?: string\n /** Accessible name for the search field. */\n searchLabel?: string\n /** Accessible name for the level threshold select. */\n levelLabel?: string\n}) {\n const [query, setQuery] = useState('')\n const [minLevel, setMinLevel] = useState<LogLevel>('debug')\n\n const time = useMemo(\n () =>\n new Intl.DateTimeFormat(locale, {\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n }),\n [locale],\n )\n\n const matchesQuery = (entry: LogEntry) =>\n !query || entry.message.toLowerCase().includes(query.toLowerCase())\n\n /*\n * Counts follow the search but ignore the level threshold.\n *\n * Both halves matter. Counting the unfiltered list makes the numbers\n * contradict the rows the moment you type. Counting the fully filtered list\n * would show zero beside every level you are not currently showing, so the\n * option offering to widen the filter would claim there is nothing to widen\n * to.\n */\n const counts = useMemo(() => {\n const tally: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 }\n for (const entry of entries) {\n if (!query || entry.message.toLowerCase().includes(query.toLowerCase())) {\n tally[entry.level]++\n }\n }\n return tally\n }, [entries, query])\n\n const visible = entries.filter(\n (entry) => LEVEL_RANK[entry.level] >= LEVEL_RANK[minLevel] && matchesQuery(entry),\n )\n\n return (\n <div\n data-slot=\"log-viewer\"\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n {(searchable || filterable) && (\n <div className=\"border-border bg-muted/40 flex flex-col gap-2 border-b p-2 sm:flex-row sm:items-center\">\n {searchable && (\n <Input\n size=\"sm\"\n variant=\"secondary\"\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n icon={<Search />}\n value={query}\n clearable\n onChange={(event) => setQuery(event.target.value)}\n containerClassName=\"sm:w-44\"\n />\n )}\n\n {filterable && (\n <div className=\"sm:ms-auto sm:w-56\">\n <Select\n size=\"sm\"\n variant=\"secondary\"\n aria-label={levelLabel}\n value={minLevel}\n onValueChange={(value) => setMinLevel(value as LogLevel)}\n options={LEVELS.map((level) => ({\n value: level,\n label: `${LEVEL_LABEL[level]} (${LEVELS.filter(\n (l) => LEVEL_RANK[l] >= LEVEL_RANK[level],\n ).reduce((sum, l) => sum + counts[l], 0)})`,\n }))}\n />\n </div>\n )}\n </div>\n )}\n\n <div className=\"max-h-80 overflow-auto font-mono text-xs\">\n {visible.map((entry) => (\n <div\n key={entry.id}\n data-level={entry.level}\n className={cn(\n 'border-border/50 flex gap-3 border-b px-3 py-1.5 last:border-b-0',\n ROW_TONE[entry.level],\n )}\n >\n {entry.time && (\n <span className=\"text-muted-foreground/60 shrink-0 tabular-nums\">\n {time.format(entry.time)}\n </span>\n )}\n <span\n className={cn('w-11 shrink-0 font-medium uppercase', LEVEL_TONE[entry.level])}\n >\n {entry.level}\n </span>\n {entry.source && (\n <span className=\"text-muted-foreground/70 hidden shrink-0 sm:inline\">\n {entry.source}\n </span>\n )}\n <span className=\"min-w-0 flex-1 break-words\">\n {highlight(entry.message, query)}\n </span>\n </div>\n ))}\n\n {visible.length === 0 && (\n <p className=\"text-muted-foreground p-6 text-center text-sm\">\n {query\n ? `No entries match \"${query}\".`\n : `Nothing at ${LEVEL_LABEL[minLevel].toLowerCase()}.`}\n </p>\n )}\n </div>\n\n <div className=\"border-border text-muted-foreground flex items-center gap-2 border-t p-3 text-xs\">\n <Badge size=\"sm\">{visible.length}</Badge>\n <span>\n of {entries.length} entries\n {minLevel !== 'debug' && ` · ${LEVEL_LABEL[minLevel].toLowerCase()}`}\n </span>\n </div>\n </div>\n )\n}\n\nexport { LogViewer }\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "login-form",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Login Form",
|
|
5
|
+
"description": "Email and password, with somewhere for SSO buttons and an error. The error is announced, not just coloured — a failed sign-in that only turns red is invisible to a screen reader.",
|
|
6
|
+
"category": "Auth",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"input",
|
|
13
|
+
"label",
|
|
14
|
+
"lib-styles",
|
|
15
|
+
"lib-utils",
|
|
16
|
+
"password-input",
|
|
17
|
+
"separator"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "components/ui/login-form.tsx",
|
|
22
|
+
"type": "registry:ui",
|
|
23
|
+
"content": "import { useState, type ComponentProps, type FormEvent, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { PasswordInput } from '@/components/ui/password-input'\nimport { Separator } from '@/components/ui/separator'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Email and password sign-in, with providers.\n *\n * The error is deliberately generic and lives above the form, not on a field.\n * \"No account with that email\" tells an attacker which addresses are\n * registered; a single \"those details do not match\" on the form as a whole\n * leaks nothing and is what every audited implementation does.\n *\n * Field names and autocomplete tokens are the real ones — `username` and\n * `current-password` — because password managers key off them, and a login form\n * they cannot fill is one people work around by choosing weaker passwords.\n */\nfunction LoginForm({\n onSubmit,\n onForgot,\n providers,\n error,\n loading = false,\n title = 'Sign in',\n description,\n footer,\n emailLabel = 'Email',\n passwordLabel = 'Password',\n forgotLabel = 'Forgot password?',\n submitLabel = 'Sign in',\n dividerLabel = 'or',\n className,\n ...props\n}: Omit<ComponentProps<'form'>, 'onSubmit' | 'title'> & {\n onSubmit?: (credentials: { email: string; password: string }) => void\n onForgot?: () => void\n /** Buttons for SSO or OAuth. */\n providers?: ReactNode\n error?: ReactNode\n loading?: boolean\n title?: ReactNode\n description?: ReactNode\n footer?: ReactNode\n emailLabel?: ReactNode\n passwordLabel?: ReactNode\n forgotLabel?: ReactNode\n submitLabel?: ReactNode\n /** Separates the provider buttons from the fields. */\n dividerLabel?: ReactNode\n}) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n\n function submit(event: FormEvent) {\n event.preventDefault()\n onSubmit?.({ email, password })\n }\n\n return (\n <form\n data-slot=\"login-form\"\n onSubmit={submit}\n className={cn(surface, radius.surface, 'flex w-full flex-col gap-4 p-6', className)}\n {...props}\n >\n <div className=\"flex flex-col gap-1\">\n <h1 className=\"text-lg font-semibold tracking-tight\">{title}</h1>\n {description && <p className=\"text-muted-foreground text-sm\">{description}</p>}\n </div>\n\n {/* One generic message on the form, never per field: naming the wrong\n field tells an attacker which addresses are registered. */}\n {error && (\n <p\n role=\"alert\"\n className={cn(\n 'flex items-start gap-2 p-3 text-sm text-[var(--destructive-soft-foreground)]',\n 'bg-[color-mix(in_oklab,var(--destructive),transparent_92%)]',\n radius.control,\n )}\n >\n <TriangleAlert className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n {error}\n </p>\n )}\n\n {providers && (\n <>\n <div className=\"flex flex-col gap-2\">{providers}</div>\n <div className=\"flex items-center gap-3\">\n <Separator className=\"flex-1\" />\n <span className=\"text-muted-foreground text-xs\">{dividerLabel}</span>\n <Separator className=\"flex-1\" />\n </div>\n </>\n )}\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"login-email\">{emailLabel}</Label>\n <Input\n id=\"login-email\"\n name=\"username\"\n type=\"email\"\n autoComplete=\"username\"\n required\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2\">\n <Label htmlFor=\"login-password\">{passwordLabel}</Label>\n {onForgot && (\n <button\n type=\"button\"\n onClick={onForgot}\n className=\"text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline\"\n >\n {forgotLabel}\n </button>\n )}\n </div>\n <PasswordInput\n id=\"login-password\"\n name=\"password\"\n autoComplete=\"current-password\"\n required\n value={password}\n onChange={(event) => setPassword(event.target.value)}\n />\n </div>\n\n <Button type=\"submit\" disabled={loading}>\n {loading ? 'Signing in…' : 'Sign in'}\n </Button>\n\n {footer && <div className=\"text-muted-foreground text-center text-sm\">{footer}</div>}\n </form>\n )\n}\n\nexport { LoginForm }\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "map-embed",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Map Embed",
|
|
5
|
+
"description": "A map on CARTO's near-monochrome basemap. A full-colour street map fights everything drawn on top of it; a greyscale ground lets the marker be the only saturated thing on screen.",
|
|
6
|
+
"category": "Geo",
|
|
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/map-embed.tsx",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { ExternalLink, MapPin } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A map, drawn from raster tiles.\n *\n * The look is near-monochrome by default. A full-colour street map fights\n * everything drawn on top of it; a greyscale ground lets a marker, a route or a\n * heat layer be the only saturated thing on screen, which is why analytics\n * products use styles like CARTO's Dark Matter.\n *\n * Those styles need an API key, so the default source here is OpenStreetMap's\n * standard tiles — genuinely keyless — desaturated in CSS instead. `invert`\n * after `grayscale` turns the light basemap dark, which lands close to Dark\n * Matter with no token and no third-party account. It is a filter rather than a\n * real dark cartography, and it is worth being honest that it approximates one.\n *\n * OSM's Tile Usage Policy asks that their servers not carry a production\n * product's traffic. For anything past local work and internal tools, pass\n * `tiles` as a function pointing at your own or a keyed provider; `filter={null}`\n * then turns the CSS treatment off when that style is already monochrome.\n *\n * Tiles are plain `<img>` elements in a grid — no iframe, no mapping library,\n * so no third-party JavaScript or cookies. The remaining exposure is the tile\n * requests themselves, the same as any remote image. This is a static view by\n * design: anything needing pan and zoom wants MapLibre, not this.\n */\nexport type MapTheme = 'dark' | 'light' | 'auto'\n\nexport type TileSource = 'osm' | ((z: number, x: number, y: number) => string)\n\n/**\n * Desaturation applied to a full-colour raster. `invert` after `grayscale` is\n * what produces the dark ground; the brightness and contrast nudges stop roads\n * blowing out to white and keep labels legible.\n */\nconst FILTER = {\n dark: 'grayscale(1) invert(1) brightness(0.92) contrast(1.08)',\n light: 'grayscale(1) brightness(1.04) contrast(0.96)',\n} as const\n\nconst CREDIT = '© OpenStreetMap contributors'\n\nconst TILE = 256\n/** Tiles either side of centre. 2 gives a 5×5 grid — 1280px of cover. */\nconst RADIUS = 2\n/** Web Mercator cannot represent the poles; this is where the projection ends. */\nconst MAX_LAT = 85.05112878\n\n/** Longitude/latitude to fractional tile coordinates at a zoom level. */\nfunction project(lat: number, lng: number, zoom: number) {\n const scale = 2 ** zoom\n const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat))\n const radians = (clamped * Math.PI) / 180\n return {\n x: ((lng + 180) / 360) * scale,\n y:\n ((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2) *\n scale,\n }\n}\n\n/** OSM standard. Keyless; no retina variant and no dark style of its own. */\nfunction osmUrl(z: number, x: number, y: number) {\n return `https://tile.openstreetmap.org/${z}/${x}/${y}.png`\n}\n\nfunction MapEmbed({\n lat,\n lng,\n zoom = 14,\n tiles = 'osm',\n theme = 'dark',\n filter,\n marker = true,\n label,\n eager = true,\n height = 280,\n linkOut = true,\n loadLabel = 'Load map',\n consentNote,\n attribution,\n linkLabel = 'Open map',\n className,\n ...props\n}: Omit<ComponentProps<'figure'>, 'children'> & {\n lat: number\n lng: number\n zoom?: number\n /**\n * Where tiles come from. `'osm'` is the keyless default; pass\n * `(z, x, y) => url` for your own or a keyed provider.\n */\n tiles?: TileSource\n /** Which desaturation to apply. `auto` follows the page theme. */\n theme?: MapTheme\n /**\n * Overrides the CSS filter. Pass `null` when the tiles are already\n * monochrome and should be left alone.\n */\n filter?: string | null\n marker?: boolean\n /** Place name, shown in the caption and used as the image description. */\n label?: ReactNode\n /** Set false to hold the tiles behind a click, for a consent gate. */\n eager?: boolean\n height?: number | string\n linkOut?: boolean\n loadLabel?: ReactNode\n consentNote?: ReactNode\n /** Overrides the required OpenStreetMap credit. */\n attribution?: ReactNode\n linkLabel?: ReactNode\n}) {\n const [loaded, setLoaded] = useState(eager)\n\n const custom = typeof tiles === 'function' ? tiles : undefined\n const url = custom ?? osmUrl\n\n const centre = project(lat, lng, zoom)\n const originX = Math.floor(centre.x) - RADIUS\n const originY = Math.floor(centre.y) - RADIUS\n const span = RADIUS * 2 + 1\n\n // Where the target sits inside the rendered grid, in pixels. The grid is then\n // shifted by this much from the container's centre, putting the point dead\n // centre whatever size the container happens to be.\n const offsetX = (centre.x - originX) * TILE\n const offsetY = (centre.y - originY) * TILE\n\n const max = 2 ** zoom\n const wrap = (value: number) => ((value % max) + max) % max\n\n const external = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=${zoom}/${lat}/${lng}`\n const described = label\n ? `Map centred on ${typeof label === 'string' ? label : 'the selected location'}`\n : `Map centred on ${lat.toFixed(4)}, ${lng.toFixed(4)}`\n\n const layer = (css: string | undefined, extraClass?: string) => (\n <div\n aria-hidden=\"true\"\n className={cn('absolute top-1/2 left-1/2', extraClass)}\n style={{\n width: span * TILE,\n height: span * TILE,\n filter: css,\n // Grid is placed from the container centre, then pulled back by the\n // target's own offset within it.\n transform: `translate(${-offsetX}px, ${-offsetY}px)`,\n display: 'grid',\n gridTemplateColumns: `repeat(${span}, ${TILE}px)`,\n }}\n >\n {Array.from({ length: span * span }, (_, index) => {\n const column = index % span\n const row = Math.floor(index / span)\n const tileY = originY + row\n // Outside the vertical range there is no map, only empty projection.\n if (tileY < 0 || tileY >= max) {\n return <span key={index} style={{ width: TILE, height: TILE }} />\n }\n return (\n <img\n key={index}\n src={url(zoom, wrap(originX + column), tileY)}\n alt=\"\"\n loading=\"lazy\"\n draggable={false}\n width={TILE}\n height={TILE}\n className=\"block select-none\"\n />\n )\n })}\n </div>\n )\n\n const chosen = (mode: 'dark' | 'light') =>\n filter === null ? undefined : (filter ?? FILTER[mode])\n\n return (\n <figure\n data-slot=\"map-embed\"\n data-theme-mode={theme}\n className={cn('border-border overflow-hidden border', radius.surface, className)}\n {...props}\n >\n <div\n role=\"img\"\n aria-label={described}\n className=\"relative w-full overflow-hidden bg-[oklch(0.19_0_0)]\"\n style={{ height }}\n >\n {loaded ? (\n <>\n {/* `auto` renders both layers and lets CSS pick, so the theme\n toggle costs no tile requests. */}\n {theme === 'auto' ? (\n <>\n {layer(chosen('light'), 'dark:hidden')}\n {layer(chosen('dark'), 'hidden dark:grid')}\n </>\n ) : (\n layer(chosen(theme))\n )}\n\n {marker && (\n <MapPin\n aria-hidden=\"true\"\n className=\"absolute top-1/2 left-1/2 size-6 -translate-x-1/2 -translate-y-full fill-[var(--primary)] text-[var(--primary-foreground)] drop-shadow\"\n />\n )}\n </>\n ) : (\n <div className=\"flex size-full flex-col items-center justify-center gap-2 p-4 text-center\">\n <MapPin className=\"size-6 text-[oklch(0.65_0_0)]\" aria-hidden=\"true\" />\n <p className=\"max-w-xs text-xs text-[oklch(0.72_0_0)]\">\n {consentNote ??\n `This map loads tiles from ${custom ? 'your tile server' : 'OpenStreetMap'}, which will see your IP address.`}\n </p>\n <Button size=\"sm\" variant=\"secondary\" onClick={() => setLoaded(true)}>\n {loadLabel}\n </Button>\n </div>\n )}\n </div>\n\n <figcaption className=\"border-border text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 border-t p-3 text-xs\">\n <span className=\"min-w-0 flex-1 truncate\">\n {label ?? (\n <span className=\"tabular-nums\">\n {lat.toFixed(5)}, {lng.toFixed(5)}\n </span>\n )}\n </span>\n\n {/* Required by the tile provider. */}\n <span className=\"text-muted-foreground/60 shrink-0\">{attribution ?? CREDIT}</span>\n\n {linkOut && (\n <a\n href={external}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className=\"hover:text-foreground flex shrink-0 items-center gap-1 underline-offset-4 hover:underline\"\n >\n {linkLabel}\n <ExternalLink className=\"size-3\" aria-hidden=\"true\" />\n </a>\n )}\n </figcaption>\n </figure>\n )\n}\n\nexport { MapEmbed, project as projectMercator, osmUrl, FILTER as mapFilters }\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "market-table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Market Table",
|
|
5
|
+
"description": "A market list with price, change, volume and cap. Rank is the caller’s, never the row index — a re-sorted table that renumbers from one destroys the identifier people use to talk about a market.",
|
|
6
|
+
"category": "Crypto",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"input",
|
|
12
|
+
"lib-styles",
|
|
13
|
+
"lib-utils",
|
|
14
|
+
"sparkline"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "components/ui/market-table.tsx",
|
|
19
|
+
"type": "registry:ui",
|
|
20
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, Search, Star } from 'lucide-react'\nimport { Input } from '@/components/ui/input'\nimport { Sparkline } from '@/components/ui/sparkline'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A market list: price, change, volume, market cap.\n *\n * Rank is the caller's, not the row index. A filtered or re-sorted table whose\n * \"#\" column renumbers from one destroys the only stable identifier people use\n * to talk about a market — \"the number four coin\" has to keep meaning the same\n * asset when you sort by 24h change.\n *\n * Watchlist state is reported, never held: a starred asset belongs to an\n * account, not to a table that forgets on unmount.\n */\nexport type Market = {\n id: string\n rank?: number\n symbol: string\n name: ReactNode\n icon?: ReactNode\n price: number\n /** Percentage. */\n change24h?: number\n volume24h?: number\n marketCap?: number\n history?: number[]\n starred?: boolean\n}\n\ntype SortKey = 'rank' | 'price' | 'change24h' | 'volume24h' | 'marketCap'\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_STAR_LABEL: (symbol: string, starred: boolean) => string = (symbol, starred) => `${starred ? 'Unstar' : 'Star'} ${symbol}`\n\nfunction MarketTable({\n markets,\n currency = 'USD',\n locale = 'en-GB',\n searchable = true,\n onStar,\n onSelect,\n searchPlaceholder = 'Search markets',\n searchLabel = 'Search markets',\n rankHeader = '#',\n marketHeader = 'Market',\n priceHeader = 'Price',\n changeHeader = '24h',\n volumeHeader = 'Volume',\n capHeader = 'Cap',\n starLabel = DEFAULT_STAR_LABEL,\n emptyMessage = 'No markets match.',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & {\n markets: Market[]\n currency?: string\n locale?: string\n searchable?: boolean\n onStar?: (id: string, starred: boolean) => void\n onSelect?: (id: string) => void\n searchPlaceholder?: string\n /** Accessible name for the search field. */\n searchLabel?: string\n rankHeader?: ReactNode\n marketHeader?: ReactNode\n priceHeader?: string\n changeHeader?: string\n volumeHeader?: string\n capHeader?: string\n /** Accessible name for the star toggle. */\n starLabel?: (symbol: string, starred: boolean) => string\n emptyMessage?: ReactNode\n}) {\n const [query, setQuery] = useState('')\n const [sort, setSort] = useState<{ key: SortKey; desc: boolean }>({\n key: 'rank',\n desc: false,\n })\n\n const money = useMemo(\n () => (value: number, digits = 2) =>\n new Intl.NumberFormat(locale, {\n style: 'currency',\n currency,\n minimumFractionDigits: digits,\n maximumFractionDigits: digits,\n notation: value >= 1_000_000 ? 'compact' : 'standard',\n }).format(value),\n [locale, currency],\n )\n\n const rows = useMemo(() => {\n const term = query.toLowerCase()\n const filtered = markets.filter(\n (m) =>\n !term ||\n m.symbol.toLowerCase().includes(term) ||\n String(m.name).toLowerCase().includes(term),\n )\n return [...filtered].sort((a, b) => {\n const av = a[sort.key] ?? 0\n const bv = b[sort.key] ?? 0\n return sort.desc ? Number(bv) - Number(av) : Number(av) - Number(bv)\n })\n }, [markets, query, sort])\n\n const Header = ({ label, sortKey, className: cls }: { label: string; sortKey: SortKey; className?: string }) => (\n <th className={cn('px-3 py-2 text-end', cls)}>\n <button\n type=\"button\"\n onClick={() =>\n setSort((s) => ({ key: sortKey, desc: s.key === sortKey ? !s.desc : true }))\n }\n className={cn(\n 'text-muted-foreground hover:text-foreground -mx-1 inline-flex items-center gap-1 px-1 text-xs font-medium',\n radius.xs,\n focusRing,\n sort.key === sortKey && 'text-foreground',\n )}\n >\n {label}\n {sort.key === sortKey &&\n (sort.desc ? <ArrowDown className=\"size-3\" /> : <ArrowUp className=\"size-3\" />)}\n </button>\n </th>\n )\n\n return (\n <div\n data-slot=\"market-table\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <Input\n size=\"sm\"\n variant=\"secondary\"\n icon={<Search />}\n placeholder={searchPlaceholder}\n aria-label={searchLabel}\n value={query}\n clearable\n onChange={(event) => setQuery(event.target.value)}\n containerClassName=\"sm:w-56\"\n />\n </div>\n )}\n\n <div className=\"w-full overflow-x-auto\">\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n {onStar && <th className=\"w-8\" />}\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">\n {rankHeader}\n </th>\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">\n {marketHeader}\n </th>\n <Header label={priceHeader} sortKey=\"price\" />\n <Header label={changeHeader} sortKey=\"change24h\" />\n <Header label={volumeHeader} sortKey=\"volume24h\" className=\"hidden sm:table-cell\" />\n <Header label={capHeader} sortKey=\"marketCap\" className=\"hidden md:table-cell\" />\n <th className=\"hidden w-24 px-3 lg:table-cell\" />\n </tr>\n </thead>\n\n <tbody>\n {rows.map((market) => {\n const up = (market.change24h ?? 0) > 0\n const down = (market.change24h ?? 0) < 0\n\n return (\n <tr\n key={market.id}\n onClick={onSelect ? () => onSelect(market.id) : undefined}\n className={cn(\n 'border-border/60 border-b last:border-b-0',\n onSelect && 'hover:bg-accent/40 cursor-pointer',\n )}\n >\n {onStar && (\n <td className=\"ps-2\">\n <button\n type=\"button\"\n aria-label={starLabel(market.symbol, Boolean(market.starred))}\n aria-pressed={market.starred}\n onClick={(event) => {\n event.stopPropagation()\n onStar(market.id, !market.starred)\n }}\n className={cn(\n 'flex size-6 items-center justify-center',\n radius.xs,\n focusRing,\n market.starred\n ? 'text-[var(--amber-soft-foreground)]'\n : 'text-muted-foreground/40 hover:text-muted-foreground',\n )}\n >\n <Star className={cn('size-3.5', market.starred && 'fill-current')} />\n </button>\n </td>\n )}\n\n {/* The caller's rank, never the row index. */}\n <td className=\"text-muted-foreground px-3 py-2 text-xs tabular-nums\">\n {market.rank ?? '—'}\n </td>\n\n <td className=\"px-3 py-2\">\n <span className=\"flex items-center gap-2\">\n {market.icon}\n <span className=\"min-w-0\">\n <span className=\"block truncate font-medium\">{market.symbol}</span>\n <span className=\"text-muted-foreground block truncate text-xs\">\n {market.name}\n </span>\n </span>\n </span>\n </td>\n\n <td className=\"px-3 py-2 text-end tabular-nums\">\n {money(market.price, market.price < 1 ? 4 : 2)}\n </td>\n\n <td\n className={cn(\n 'px-3 py-2 text-end tabular-nums',\n up && 'text-[var(--green-soft-foreground)]',\n down && 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n {market.change24h === undefined\n ? '—'\n : `${up ? '+' : ''}${market.change24h.toFixed(2)}%`}\n </td>\n\n <td className=\"text-muted-foreground hidden px-3 py-2 text-end tabular-nums sm:table-cell\">\n {market.volume24h === undefined ? '—' : money(market.volume24h, 0)}\n </td>\n\n <td className=\"text-muted-foreground hidden px-3 py-2 text-end tabular-nums md:table-cell\">\n {market.marketCap === undefined ? '—' : money(market.marketCap, 0)}\n </td>\n\n <td className=\"hidden px-3 py-2 lg:table-cell\">\n {market.history && (\n <Sparkline\n values={market.history}\n color={up ? 'var(--green)' : down ? 'var(--destructive)' : 'var(--muted-foreground)'}\n className=\"h-7 w-20\"\n />\n )}\n </td>\n </tr>\n )\n })}\n\n {rows.length === 0 && (\n <tr>\n <td colSpan={8} className=\"text-muted-foreground p-8 text-center text-sm\">\n {emptyMessage}\n </td>\n </tr>\n )}\n </tbody>\n </table>\n </div>\n </div>\n )\n}\n\nexport { MarketTable }\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mask-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Mask Input",
|
|
5
|
+
"description": "Formats as you type — card numbers, phone numbers, dates. Hands back the raw characters alongside the formatted text, so a form does not submit \"(555) 010-9999\" to an API expecting digits.",
|
|
6
|
+
"category": "Forms",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/ui/mask-input.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"content": "import { useRef, useState, type ComponentProps } from 'react'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A text field that formats as you type — card numbers, phone numbers, dates.\n *\n * The mask is a pattern of placeholders: `#` for a digit, `A` for a letter, `*`\n * for either. Everything else is a literal that the component inserts.\n *\n * Two details make the difference between a mask that helps and one that\n * fights you. Deleting a literal deletes the character before it, so backspace\n * over the space in \"4242 4242\" removes a digit rather than sticking. And the\n * caller receives the raw value alongside the formatted one, because a form\n * that submits \"(555) 010-9999\" to an API expecting digits has just moved the\n * parsing problem downstream.\n */\nconst TOKENS: Record<string, RegExp> = {\n '#': /\\d/,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n}\n\n/** Apply the mask, returning the formatted text and the raw characters kept. */\nfunction applyMask(input: string, mask: string) {\n let formatted = ''\n let raw = ''\n let index = 0\n\n for (const slot of mask) {\n if (index >= input.length) break\n const token = TOKENS[slot]\n\n if (token) {\n // Skip anything that does not fit this slot rather than stopping, so\n // pasting \"4242-4242\" into a digits mask keeps the digits.\n while (index < input.length && !token.test(input[index])) index++\n if (index >= input.length) break\n formatted += input[index]\n raw += input[index]\n index++\n } else {\n formatted += slot\n // A literal typed by the user is consumed rather than duplicated.\n if (input[index] === slot) index++\n }\n }\n\n return { formatted, raw }\n}\n\nfunction MaskInput({\n mask,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n placeholder,\n className,\n ...props\n}: Omit<ComponentProps<'input'>, 'value' | 'defaultValue' | 'onChange' | 'size'> & {\n /** `#` digit, `A` letter, `*` either; anything else is a literal. */\n mask: string\n value?: string\n defaultValue?: string\n /** Receives both the formatted text and the raw characters. */\n onValueChange?: (formatted: string, raw: string) => void\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(\n () => applyMask(defaultValue, mask).formatted,\n )\n const value = controlled ? valueProp : uncontrolled\n const previous = useRef(value)\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\n data-slot=\"mask-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 className,\n )}\n >\n <input\n type=\"text\"\n inputMode={/^[#\\s\\-/()+.]+$/.test(mask) ? 'numeric' : 'text'}\n disabled={disabled}\n value={value}\n placeholder={placeholder ?? mask.replace(/[#A*]/g, '_')}\n onChange={(event) => {\n let next = event.target.value\n\n // Deleting into a literal removes the character before it too,\n // otherwise backspace appears to do nothing.\n if (next.length < previous.current.length) {\n const trimmed = next.replace(/[^#A*]$/, '')\n if (trimmed !== next && !TOKENS[mask[next.length]]) {\n next = next.slice(0, -1)\n }\n }\n\n const { formatted, raw } = applyMask(next, mask)\n previous.current = formatted\n if (!controlled) setUncontrolled(formatted)\n onValueChange?.(formatted, raw)\n }}\n className={cn(fieldInput, 'tabular-nums')}\n {...props}\n />\n </div>\n )\n}\n\nexport { MaskInput, applyMask }\n"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "media-gallery",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Media Gallery",
|
|
5
|
+
"description": "A grid that opens into a lightbox. `alt` is required by the type rather than optional, because a gallery is exactly where missing alt text accumulates.",
|
|
6
|
+
"category": "Media",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"aspect-ratio",
|
|
12
|
+
"button",
|
|
13
|
+
"lib-styles",
|
|
14
|
+
"lib-utils",
|
|
15
|
+
"primitive-focus-trap",
|
|
16
|
+
"primitive-portal"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "components/ui/media-gallery.tsx",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"content": "import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react'\nimport { AspectRatio } from '@/components/ui/aspect-ratio'\nimport { Button } from '@/components/ui/button'\nimport { Portal } from '@/components/primitives/portal'\nimport { useFocusTrap } from '@/components/primitives/focus-trap'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\nimport { useRef } from 'react'\n\n/**\n * A thumbnail grid with a lightbox.\n *\n * The lightbox traps focus and restores it to the thumbnail that opened it.\n * Returning focus to the top of the document is the standard failure and it\n * loses a keyboard user's place in a grid of fifty images.\n *\n * Arrow keys move between items while open, and Escape closes. These are the\n * bindings people try first; a lightbox that only responds to on-screen arrows\n * is one they will call broken.\n *\n * Every item needs `alt`. It is a required field rather than an optional one,\n * because a gallery is exactly where empty alt text accumulates.\n */\nexport type MediaItem = {\n id: string\n src: string\n /** Required — a gallery is where missing alt text accumulates. */\n alt: string\n thumbnail?: string\n caption?: ReactNode\n}\n\nfunction MediaGallery({\n items,\n columns = 3,\n ratio = 1,\n closeLabel = 'Close',\n previousLabel = 'Previous',\n nextLabel = 'Next',\n emptyLabel = 'Nothing to show',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n items: MediaItem[]\n columns?: 2 | 3 | 4 | 5\n ratio?: number\n /** Accessible names for the lightbox controls. */\n closeLabel?: string\n previousLabel?: string\n nextLabel?: string\n emptyLabel?: ReactNode\n}) {\n const [open, setOpen] = useState<number | null>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n const triggers = useRef<(HTMLButtonElement | null)[]>([])\n\n useFocusTrap(panelRef, open !== null)\n\n useEffect(() => {\n if (open === null) return\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') setOpen(null)\n if (event.key === 'ArrowRight') setOpen((i) => (i === null ? i : (i + 1) % items.length))\n if (event.key === 'ArrowLeft') setOpen((i) => (i === null ? i : (i - 1 + items.length) % items.length))\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [open, items.length])\n\n const grid = {\n 2: 'grid-cols-2',\n 3: 'grid-cols-2 sm:grid-cols-3',\n 4: 'grid-cols-2 sm:grid-cols-4',\n 5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',\n }[columns]\n\n const current = open === null ? undefined : items[open]\n\n return (\n <div data-slot=\"media-gallery\" className={className} {...props}>\n <ul className={cn('grid list-none gap-2', grid)}>\n {items.map((item, index) => (\n <li key={item.id}>\n <button\n type=\"button\"\n ref={(node) => {\n triggers.current[index] = node\n }}\n onClick={() => setOpen(index)}\n className={cn('block w-full overflow-hidden', radius.control, focusRing)}\n >\n <AspectRatio ratio={ratio} className=\"bg-muted\">\n <img\n src={item.thumbnail ?? item.src}\n alt={item.alt}\n loading=\"lazy\"\n className=\"size-full object-cover\"\n />\n </AspectRatio>\n </button>\n </li>\n ))}\n </ul>\n\n {current && (\n <Portal>\n <div\n className=\"fixed inset-0 z-50 flex flex-col bg-black/80 p-4\"\n onClick={(event) => {\n if (event.target === event.currentTarget) setOpen(null)\n }}\n >\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={current.alt}\n tabIndex={-1}\n className=\"flex min-h-0 flex-1 flex-col gap-3 outline-none\"\n >\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"text-sm text-white/80 tabular-nums\">\n {open! + 1} / {items.length}\n </span>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={closeLabel}\n className=\"text-white hover:bg-white/10\"\n onClick={() => {\n const index = open\n setOpen(null)\n // Back to the thumbnail that opened it, not the document top.\n if (index !== null) triggers.current[index]?.focus()\n }}\n >\n <X />\n </Button>\n </div>\n\n <div className=\"flex min-h-0 flex-1 items-center gap-2\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={previousLabel}\n className=\"shrink-0 text-white hover:bg-white/10\"\n onClick={() => setOpen((i) => (i === null ? i : (i - 1 + items.length) % items.length))}\n >\n <ChevronLeft />\n </Button>\n\n <img\n src={current.src}\n alt={current.alt}\n className=\"mx-auto max-h-full min-h-0 flex-1 object-contain\"\n />\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={nextLabel}\n className=\"shrink-0 text-white hover:bg-white/10\"\n onClick={() => setOpen((i) => (i === null ? i : (i + 1) % items.length))}\n >\n <ChevronRight />\n </Button>\n </div>\n\n {current.caption && (\n <p className=\"text-center text-sm text-white/80\">{current.caption}</p>\n )}\n </div>\n </div>\n </Portal>\n )}\n\n {items.length === 0 && (\n <p className=\"text-muted-foreground flex flex-col items-center gap-2 p-8 text-center text-sm\">\n <ImageOff className=\"size-6\" aria-hidden=\"true\" />\n {emptyLabel}\n </p>\n )}\n </div>\n )\n}\n\nexport { MediaGallery }\n"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mention-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Mention Input",
|
|
5
|
+
"description": "A textarea with @-mention autocomplete. The trigger only fires at a word boundary, so an email address does not open the menu halfway through every address anyone types.",
|
|
6
|
+
"category": "Product",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"avatar",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils",
|
|
12
|
+
"textarea"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/mention-input.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Textarea } from '@/components/ui/textarea'\nimport { menuItem, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A textarea with @-mention autocomplete.\n *\n * The trigger only fires at a word boundary — after a space or at the start of\n * the text. Without that, an email address opens the menu halfway through\n * every address anyone types.\n *\n * The query ends at whitespace, so the menu closes on its own once someone\n * types past a name rather than staying open with no matches.\n *\n * Positioning is deliberately anchored to the field, not to the caret.\n * Caret coordinates in a textarea require rendering a mirror element and\n * measuring it, which is a lot of machinery and breaks on wrap; a menu under\n * the field is understood immediately.\n */\nexport type Mentionable = {\n id: string\n label: string\n description?: string\n avatar?: ReactNode\n}\n\nfunction MentionInput({\n options,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n onMention,\n trigger = '@',\n listLabel = 'People',\n className,\n ...props\n}: Omit<ComponentProps<'textarea'>, 'value' | 'defaultValue' | 'onChange'> & {\n options: Mentionable[]\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n onMention?: (option: Mentionable) => void\n trigger?: string\n /** Accessible name for the suggestion list. */\n listLabel?: string\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultValue)\n const value = controlled ? valueProp : uncontrolled\n\n const [query, setQuery] = useState<string | null>(null)\n const [highlighted, setHighlighted] = useState(0)\n const ref = useRef<HTMLTextAreaElement>(null)\n\n const matches =\n query === null\n ? []\n : options\n .filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))\n .slice(0, 6)\n\n const open = query !== null && matches.length > 0\n\n function update(next: string, caret: number) {\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n\n const before = next.slice(0, caret)\n const at = before.lastIndexOf(trigger)\n\n // Only a trigger at a word boundary counts, and the query ends at the\n // first space after it.\n const boundary = at === 0 || /\\s/.test(before[at - 1] ?? '')\n const fragment = before.slice(at + trigger.length)\n\n setQuery(at !== -1 && boundary && !/\\s/.test(fragment) ? fragment : null)\n setHighlighted(0)\n }\n\n function insert(option: Mentionable) {\n const node = ref.current\n if (!node) return\n\n const caret = node.selectionStart\n const before = value.slice(0, caret)\n const at = before.lastIndexOf(trigger)\n const next = `${value.slice(0, at)}${trigger}${option.label} ${value.slice(caret)}`\n\n if (!controlled) setUncontrolled(next)\n onValueChange?.(next)\n onMention?.(option)\n setQuery(null)\n\n requestAnimationFrame(() => {\n const position = at + option.label.length + trigger.length + 1\n node.focus()\n node.setSelectionRange(position, position)\n })\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n if (!open) return\n\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setHighlighted((index) => (index + 1) % matches.length)\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setHighlighted((index) => (index - 1 + matches.length) % matches.length)\n } else if (event.key === 'Enter' || event.key === 'Tab') {\n event.preventDefault()\n insert(matches[highlighted])\n } else if (event.key === 'Escape') {\n event.preventDefault()\n setQuery(null)\n }\n }\n\n return (\n <div data-slot=\"mention-input\" className={cn('relative', className)}>\n <Textarea\n ref={ref}\n value={value}\n autoResize\n rows={3}\n aria-expanded={open}\n aria-autocomplete=\"list\"\n onKeyDown={onKeyDown}\n onChange={(event) => update(event.target.value, event.target.selectionStart)}\n onBlur={() => setQuery(null)}\n {...props}\n />\n\n {open && (\n <div\n className={cn(\n menuSurface,\n radius.surface,\n 'absolute inset-x-0 top-full z-50 mt-1 max-h-56',\n )}\n >\n <ul role=\"listbox\" aria-label={listLabel} className=\"list-none\">\n {matches.map((option, index) => (\n <li key={option.id}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={index === highlighted}\n data-highlighted={index === highlighted || undefined}\n onPointerEnter={() => setHighlighted(index)}\n // Pointer down, not click: blur would close the menu first.\n onPointerDown={(event) => {\n event.preventDefault()\n insert(option)\n }}\n className={cn(menuItem, radius.control, 'text-start')}\n >\n {option.avatar ?? <Avatar size=\"xs\" name={option.label} />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"text-muted-foreground block truncate text-xs\">\n {option.description}\n </span>\n )}\n </span>\n </button>\n </li>\n ))}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { MentionInput }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "menubar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Menubar",
|
|
5
|
+
"description": "An application menu bar. What makes it a menu bar rather than a row of dropdowns: once one menu is open, moving along the bar opens the next instead of merely focusing it.",
|
|
6
|
+
"category": "Navigation",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"lib-styles",
|
|
10
|
+
"lib-utils",
|
|
11
|
+
"primitive-dismissable",
|
|
12
|
+
"primitive-popper"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/ui/menubar.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactNode,\n} from 'react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport {\n focusRing,\n interactive,\n menuItem,\n menuSurface,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An application menu bar — File, Edit, View.\n *\n * The behaviour that makes it a menu bar rather than a row of dropdowns: once\n * one menu is open, moving along the bar opens the next one instead of merely\n * focusing it. That is what everyone expects from a desktop menu, and a row of\n * independent dropdowns feels broken by comparison.\n *\n * Arrow keys move between menus; the bar is one tab stop. Left and Right belong\n * to the bar, Up and Down to the open menu.\n */\nexport type MenubarItem = {\n id: string\n label: ReactNode\n shortcut?: string\n disabled?: boolean\n separatorBefore?: boolean\n onSelect?: () => void\n}\n\nexport type MenubarMenu = {\n id: string\n label: string\n items: MenubarItem[]\n}\n\nfunction Menubar({\n menus,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onSelect'> & { menus: MenubarMenu[] }) {\n const [open, setOpen] = useState<string | null>(null)\n const [focused, setFocused] = useState(0)\n const triggerRefs = useRef<(HTMLButtonElement | null)[]>([])\n const barRef = useRef<HTMLDivElement>(null)\n\n function focusMenu(index: number) {\n const next = (index + menus.length) % menus.length\n setFocused(next)\n triggerRefs.current[next]?.focus()\n // Already open? Move the open menu along with focus.\n if (open) setOpen(menus[next].id)\n }\n\n function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n if (event.key === 'ArrowRight') {\n event.preventDefault()\n focusMenu(focused + 1)\n } else if (event.key === 'ArrowLeft') {\n event.preventDefault()\n focusMenu(focused - 1)\n } else if (event.key === 'Home') {\n event.preventDefault()\n focusMenu(0)\n } else if (event.key === 'End') {\n event.preventDefault()\n focusMenu(menus.length - 1)\n } else if (event.key === 'Escape') {\n setOpen(null)\n }\n }\n\n return (\n <div\n ref={barRef}\n role=\"menubar\"\n data-slot=\"menubar\"\n onKeyDown={onKeyDown}\n className={cn('bg-card border-border flex items-center gap-0.5 border p-1', radius.control, className)}\n {...props}\n >\n {menus.map((menu, index) => (\n <MenubarTrigger\n key={menu.id}\n ref={(node) => {\n triggerRefs.current[index] = node\n }}\n menu={menu}\n open={open === menu.id}\n tabIndex={index === focused ? 0 : -1}\n onOpen={() => {\n setFocused(index)\n setOpen(open === menu.id ? null : menu.id)\n }}\n // Hovering a sibling while a menu is open switches to it, which is\n // what makes the bar feel like one control.\n onHover={() => {\n if (!open) return\n setFocused(index)\n setOpen(menu.id)\n }}\n onClose={() => {\n setOpen(null)\n triggerRefs.current[index]?.focus()\n }}\n />\n ))}\n </div>\n )\n}\n\nfunction MenubarTrigger({\n ref,\n menu,\n open,\n tabIndex,\n onOpen,\n onHover,\n onClose,\n}: {\n ref?: React.Ref<HTMLButtonElement>\n menu: MenubarMenu\n open: boolean\n tabIndex: number\n onOpen: () => void\n onHover: () => void\n onClose: () => void\n}) {\n const triggerRef = useRef<HTMLButtonElement>(null)\n const panelRef = useRef<HTMLDivElement>(null)\n const [highlighted, setHighlighted] = useState(0)\n\n const { style } = usePopper({\n open,\n anchorRef: triggerRef,\n floatingRef: panelRef,\n side: 'bottom',\n align: 'start',\n offset: 4,\n })\n\n useDismissable({\n open,\n onDismiss: onClose,\n refs: [triggerRef, panelRef],\n })\n\n const enabled = menu.items.filter((item) => !item.disabled)\n\n function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setHighlighted((index) => (index + 1) % enabled.length)\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n setHighlighted((index) => (index - 1 + enabled.length) % enabled.length)\n } else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n const item = enabled[highlighted]\n item?.onSelect?.()\n onClose()\n }\n // Left and Right deliberately bubble: they belong to the bar.\n }\n\n return (\n <>\n <button\n ref={(node) => {\n triggerRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) (ref as React.RefObject<HTMLButtonElement | null>).current = node\n }}\n type=\"button\"\n role=\"menuitem\"\n aria-haspopup=\"menu\"\n aria-expanded={open}\n tabIndex={tabIndex}\n onClick={onOpen}\n onPointerEnter={onHover}\n className={cn(\n 'text-muted-foreground hover:text-foreground hover:bg-accent h-7 px-2.5 text-sm',\n radius.xs,\n interactive,\n focusRing,\n open && 'bg-accent text-foreground',\n )}\n >\n {menu.label}\n </button>\n\n {open && (\n <div\n ref={panelRef}\n role=\"menu\"\n aria-label={menu.label}\n tabIndex={-1}\n style={style}\n onKeyDown={onPanelKeyDown}\n className={cn(menuSurface, radius.surface, 'min-w-48 outline-none')}\n >\n {menu.items.map((item) => (\n <div key={item.id}>\n {item.separatorBefore && (\n <div role=\"separator\" className=\"bg-border -mx-1 my-1 h-px\" />\n )}\n <button\n type=\"button\"\n role=\"menuitem\"\n disabled={item.disabled}\n data-disabled={item.disabled}\n data-highlighted={enabled.indexOf(item) === highlighted || undefined}\n onPointerEnter={() => setHighlighted(enabled.indexOf(item))}\n onClick={() => {\n item.onSelect?.()\n onClose()\n }}\n className={cn(menuItem, radius.control)}\n >\n <span className=\"flex-1\">{item.label}</span>\n {item.shortcut && (\n <span className=\"text-muted-foreground font-mono text-xs\">\n {item.shortcut}\n </span>\n )}\n </button>\n </div>\n ))}\n </div>\n )}\n </>\n )\n}\n\nexport { Menubar }\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "merge-conflict",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Merge Conflict",
|
|
5
|
+
"description": "Both sides of a conflict with a resolution per hunk. \"Keep both\" is offered because it is frequently right — two people adding a different import conflict textually and agree semantically.",
|
|
6
|
+
"category": "Code Tools",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"button",
|
|
10
|
+
"lib-styles",
|
|
11
|
+
"lib-utils"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/ui/merge-conflict.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A merge conflict, with a resolution per hunk.\n *\n * Both sides are shown in full, stacked, with the base labelled. Side-by-side\n * looks tidier and is worse: conflict hunks are rarely the same height, so the\n * columns desynchronise and you end up comparing line 4 against line 9.\n *\n * \"Both\" is offered as a resolution because it is frequently the right answer —\n * two people adding a different import to the same block conflict textually and\n * agree semantically. A two-button ours/theirs forces a manual re-edit for the\n * commonest case there is.\n *\n * Nothing resolves itself. Unresolved hunks are counted in the header and the\n * count does not go down until a choice is made, because a conflict UI that\n * quietly defaults to one side is how code silently disappears in a merge.\n */\nexport type ConflictSide = 'ours' | 'theirs' | 'both'\n\nexport type ConflictHunk = {\n id: string\n ours: string\n theirs: string\n /** Common ancestor, from a diff3-style conflict. */\n base?: string\n /** Unchanged lines before the hunk, for orientation. */\n context?: 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_UNRESOLVED_LABEL: (count: number) => ReactNode = (count) => `${count} unresolved`\n\nfunction MergeConflict({\n path,\n hunks,\n resolutions: resolutionsProp,\n onResolve,\n oursLabel = 'Ours',\n theirsLabel = 'Theirs',\n baseLabel = 'Base',\n bothLabel = 'Keep both',\n unresolvedLabel = DEFAULT_UNRESOLVED_LABEL,\n resolvedLabel = 'All conflicts resolved',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n path?: ReactNode\n hunks: ConflictHunk[]\n /** Controlled resolutions by hunk id. */\n resolutions?: Record<string, ConflictSide>\n onResolve?: (id: string, side: ConflictSide) => void\n oursLabel?: ReactNode\n theirsLabel?: ReactNode\n baseLabel?: ReactNode\n bothLabel?: ReactNode\n unresolvedLabel?: (count: number) => ReactNode\n resolvedLabel?: ReactNode\n}) {\n const [own, setOwn] = useState<Record<string, ConflictSide>>({})\n const resolutions = resolutionsProp ?? own\n\n const unresolved = useMemo(\n () => hunks.filter((hunk) => !resolutions[hunk.id]).length,\n [hunks, resolutions],\n )\n\n const choose = (id: string, side: ConflictSide) => {\n if (resolutionsProp === undefined) setOwn((current) => ({ ...current, [id]: side }))\n onResolve?.(id, side)\n }\n\n const pane = (label: ReactNode, text: string, active: boolean, tone: string) => (\n <div\n className={cn(\n 'border-s-2 ps-3',\n active ? 'opacity-100' : 'opacity-45',\n )}\n style={{ borderInlineStartColor: tone }}\n >\n <p className=\"text-muted-foreground mb-1 text-xs font-medium\">{label}</p>\n <pre className=\"overflow-x-auto font-mono text-xs whitespace-pre-wrap\">{text}</pre>\n </div>\n )\n\n return (\n <div\n data-slot=\"merge-conflict\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border flex flex-wrap items-center gap-2 border-b p-3\">\n {path && <code className=\"min-w-0 flex-1 truncate font-mono text-xs\">{path}</code>}\n <span\n className={cn(\n 'ms-auto text-xs',\n unresolved > 0 ? 'text-[var(--amber-soft-foreground)]' : 'text-[var(--green-soft-foreground)]',\n )}\n >\n {unresolved > 0 ? unresolvedLabel(unresolved) : resolvedLabel}\n </span>\n </div>\n\n <ul className=\"divide-border list-none divide-y\">\n {hunks.map((hunk) => {\n const choice = resolutions[hunk.id]\n return (\n <li key={hunk.id} className=\"flex flex-col gap-3 p-3\">\n {hunk.context && (\n <pre className=\"text-muted-foreground/60 overflow-x-auto font-mono text-xs whitespace-pre-wrap\">\n {hunk.context}\n </pre>\n )}\n\n {/* Stacked, not side by side: unequal heights desynchronise columns. */}\n <div className=\"flex flex-col gap-3\">\n {pane(oursLabel, hunk.ours, !choice || choice === 'ours' || choice === 'both', 'var(--blue)')}\n {hunk.base !== undefined &&\n pane(baseLabel, hunk.base, false, 'var(--muted-foreground)')}\n {pane(theirsLabel, hunk.theirs, !choice || choice === 'theirs' || choice === 'both', 'var(--green)')}\n </div>\n\n <div className=\"flex flex-wrap gap-2\">\n {(['ours', 'theirs', 'both'] as const).map((side) => (\n <Button\n key={side}\n size=\"xs\"\n variant={choice === side ? 'default' : 'secondary'}\n aria-pressed={choice === side}\n className={focusRing}\n onClick={() => choose(hunk.id, side)}\n >\n {side === 'ours' ? oursLabel : side === 'theirs' ? theirsLabel : bothLabel}\n </Button>\n ))}\n </div>\n </li>\n )\n })}\n </ul>\n </div>\n )\n}\n\nexport { MergeConflict }\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|