astralyx-ui 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # astralyx-ui
2
2
 
3
- **295 accessible React components you copy into your repo** — with a CLI and a
3
+ **309 accessible React components you copy into your repo** — with a CLI and a
4
4
  registry that work out what each one needs and bring that too.
5
5
 
6
6
  Not a dependency. There is no package to upgrade and nothing to fight when a
@@ -14,7 +14,7 @@ npx astralyx-ui add button
14
14
 
15
15
  ## What you get
16
16
 
17
- - **295 components** across 31 categories — forms, data, overlays, commerce,
17
+ - **309 components** across 33 categories — forms, data, overlays, commerce,
18
18
  auth, AI, crypto, observability, developer tooling.
19
19
  - **12 primitives** underneath them. Slot, Popper, FocusTrap, Dismissable and
20
20
  friends — no headless-UI dependency.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "295 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
5
+ "description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
7
7
  "author": "Astralyx",
8
8
  "homepage": "https://ui.astralyx.dev",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -184,6 +184,22 @@
184
184
  "lib-utils"
185
185
  ]
186
186
  },
187
+ {
188
+ "name": "asset-grid",
189
+ "type": "registry:ui",
190
+ "title": "Asset Grid",
191
+ "description": "A media library as a selectable grid, built on this kit’s Image so every tile gets a placeholder, a reserved box and a failure state. A broken asset stays in the grid — it is exactly the one someone came to find and replace.",
192
+ "category": "Storage",
193
+ "dependencies": [
194
+ "lucide-react@^1.39.0"
195
+ ],
196
+ "registryDependencies": [
197
+ "image",
198
+ "lib-styles",
199
+ "lib-utils",
200
+ "storage-usage"
201
+ ]
202
+ },
187
203
  {
188
204
  "name": "attachment-preview",
189
205
  "type": "registry:ui",
@@ -200,6 +216,21 @@
200
216
  "progress"
201
217
  ]
202
218
  },
219
+ {
220
+ "name": "audio-player",
221
+ "type": "registry:ui",
222
+ "title": "Audio Player",
223
+ "description": "A player built on the real <audio> element, so buffering, codecs, media keys, the OS lock screen and Bluetooth controls all keep working. State is read back off the element rather than mirrored, so anything else touching playback stays in sync.",
224
+ "category": "Media",
225
+ "dependencies": [
226
+ "lucide-react@^1.39.0"
227
+ ],
228
+ "registryDependencies": [
229
+ "button",
230
+ "lib-styles",
231
+ "lib-utils"
232
+ ]
233
+ },
203
234
  {
204
235
  "name": "audit-log",
205
236
  "type": "registry:ui",
@@ -233,6 +264,23 @@
233
264
  "lib-utils"
234
265
  ]
235
266
  },
267
+ {
268
+ "name": "backup-list",
269
+ "type": "registry:ui",
270
+ "title": "Backup List",
271
+ "description": "Snapshots, and whether any of them would actually restore. A backup nobody has restored is a hypothesis, so an untested one is drawn as a warning rather than as a success.",
272
+ "category": "Storage",
273
+ "dependencies": [
274
+ "lucide-react@^1.39.0"
275
+ ],
276
+ "registryDependencies": [
277
+ "badge",
278
+ "button",
279
+ "lib-styles",
280
+ "lib-utils",
281
+ "storage-usage"
282
+ ]
283
+ },
236
284
  {
237
285
  "name": "badge",
238
286
  "type": "registry:ui",
@@ -353,6 +401,22 @@
353
401
  "spinner"
354
402
  ]
355
403
  },
404
+ {
405
+ "name": "bucket-list",
406
+ "type": "registry:ui",
407
+ "title": "Bucket List",
408
+ "description": "Object-storage buckets, with public access rendered as a warning rather than as one small grey tag among five. Every storage breach of the last decade is a bucket nobody realised was world-readable.",
409
+ "category": "Storage",
410
+ "dependencies": [
411
+ "lucide-react@^1.39.0"
412
+ ],
413
+ "registryDependencies": [
414
+ "badge",
415
+ "lib-styles",
416
+ "lib-utils",
417
+ "storage-usage"
418
+ ]
419
+ },
356
420
  {
357
421
  "name": "budget-guard",
358
422
  "type": "registry:ui",
@@ -830,6 +894,20 @@
830
894
  "lib-utils"
831
895
  ]
832
896
  },
897
+ {
898
+ "name": "connection-pool",
899
+ "type": "registry:ui",
900
+ "title": "Connection Pool",
901
+ "description": "What is checked out, idle and waiting. A pool at 100% with nothing queued is exactly the right size; the same pool with twelve waiting is an outage forming — so waiters are drawn beyond the bar rather than folded into it.",
902
+ "category": "Data",
903
+ "dependencies": [
904
+ "lucide-react@^1.39.0"
905
+ ],
906
+ "registryDependencies": [
907
+ "lib-styles",
908
+ "lib-utils"
909
+ ]
910
+ },
833
911
  {
834
912
  "name": "connection-string",
835
913
  "type": "registry:ui",
@@ -992,6 +1070,21 @@
992
1070
  "spinner"
993
1071
  ]
994
1072
  },
1073
+ {
1074
+ "name": "csv-preview",
1075
+ "type": "registry:ui",
1076
+ "title": "CSV Preview",
1077
+ "description": "A parsed CSV before you import it, showing the rows that will break the import. A preview that renders the first ten rows of a clean file has shown you nothing — mismatched column counts are what abort an import halfway.",
1078
+ "category": "Data",
1079
+ "dependencies": [
1080
+ "lucide-react@^1.39.0"
1081
+ ],
1082
+ "registryDependencies": [
1083
+ "badge",
1084
+ "lib-styles",
1085
+ "lib-utils"
1086
+ ]
1087
+ },
995
1088
  {
996
1089
  "name": "curl-command",
997
1090
  "type": "registry:ui",
@@ -1020,6 +1113,21 @@
1020
1113
  "table"
1021
1114
  ]
1022
1115
  },
1116
+ {
1117
+ "name": "data-quality",
1118
+ "type": "registry:ui",
1119
+ "title": "Data Quality",
1120
+ "description": "A column profile: how complete, how varied, and what the values look like. Sample values are the fastest way to spot that a “date” column holds three different formats — no summary statistic catches that.",
1121
+ "category": "Data",
1122
+ "dependencies": [
1123
+ "lucide-react@^1.39.0"
1124
+ ],
1125
+ "registryDependencies": [
1126
+ "badge",
1127
+ "lib-styles",
1128
+ "lib-utils"
1129
+ ]
1130
+ },
1023
1131
  {
1024
1132
  "name": "date-picker",
1025
1133
  "type": "registry:ui",
@@ -1404,6 +1512,26 @@
1404
1512
  "lib-utils"
1405
1513
  ]
1406
1514
  },
1515
+ {
1516
+ "name": "file-preview",
1517
+ "type": "registry:ui",
1518
+ "title": "File Preview",
1519
+ "description": "One file rendered as whatever it actually is — image, text, audio, or an honest “no preview” panel. The MIME type decides, never the extension: a .png served as text/html is the oldest upload attack there is.",
1520
+ "category": "Storage",
1521
+ "dependencies": [
1522
+ "lucide-react@^1.39.0"
1523
+ ],
1524
+ "registryDependencies": [
1525
+ "audio-player",
1526
+ "badge",
1527
+ "button",
1528
+ "code-block",
1529
+ "image",
1530
+ "lib-styles",
1531
+ "lib-utils",
1532
+ "storage-usage"
1533
+ ]
1534
+ },
1407
1535
  {
1408
1536
  "name": "file-tree",
1409
1537
  "type": "registry:ui",
@@ -1667,6 +1795,22 @@
1667
1795
  "lib-utils"
1668
1796
  ]
1669
1797
  },
1798
+ {
1799
+ "name": "index-list",
1800
+ "type": "registry:ui",
1801
+ "title": "Index List",
1802
+ "description": "The indexes on a table and which are dead weight. An unused index is written on every insert and makes the planner’s job harder — but a primary key with no scans is not flagged, because suggesting you drop one is advice that loses data.",
1803
+ "category": "Data",
1804
+ "dependencies": [
1805
+ "lucide-react@^1.39.0"
1806
+ ],
1807
+ "registryDependencies": [
1808
+ "badge",
1809
+ "lib-styles",
1810
+ "lib-utils",
1811
+ "storage-usage"
1812
+ ]
1813
+ },
1670
1814
  {
1671
1815
  "name": "input-file",
1672
1816
  "type": "registry:ui",
@@ -2463,6 +2607,24 @@
2463
2607
  "lib-utils"
2464
2608
  ]
2465
2609
  },
2610
+ {
2611
+ "name": "object-list",
2612
+ "type": "registry:ui",
2613
+ "title": "Object List",
2614
+ "description": "Objects in a bucket, with the prefix hierarchy object stores only pretend to have. There are no folders in S3-shaped storage — `a/b/c.png` is one flat key — so keys deeper than the current prefix collapse into a prefix row with a count.",
2615
+ "category": "Storage",
2616
+ "dependencies": [
2617
+ "lucide-react@^1.39.0"
2618
+ ],
2619
+ "registryDependencies": [
2620
+ "badge",
2621
+ "empty",
2622
+ "input",
2623
+ "lib-styles",
2624
+ "lib-utils",
2625
+ "storage-usage"
2626
+ ]
2627
+ },
2466
2628
  {
2467
2629
  "name": "odds-display",
2468
2630
  "type": "registry:ui",
@@ -2981,6 +3143,21 @@
2981
3143
  "lib-utils"
2982
3144
  ]
2983
3145
  },
3146
+ {
3147
+ "name": "replication-status",
3148
+ "type": "registry:ui",
3149
+ "title": "Replication Status",
3150
+ "description": "A primary and its replicas with how far behind each one is, in seconds and bytes. Seconds say how stale a read is; bytes say whether it is catching up or falling further behind — a replica idling on 200MB of unsent log shows 0s until traffic arrives.",
3151
+ "category": "Data",
3152
+ "dependencies": [
3153
+ "lucide-react@^1.39.0"
3154
+ ],
3155
+ "registryDependencies": [
3156
+ "badge",
3157
+ "lib-styles",
3158
+ "lib-utils"
3159
+ ]
3160
+ },
2984
3161
  {
2985
3162
  "name": "request-builder",
2986
3163
  "type": "registry:ui",
@@ -3444,6 +3621,22 @@
3444
3621
  "lib-utils"
3445
3622
  ]
3446
3623
  },
3624
+ {
3625
+ "name": "slow-query-log",
3626
+ "type": "registry:ui",
3627
+ "title": "Slow Query Log",
3628
+ "description": "The queries costing you the most, ranked by total time rather than mean. The 9-second report running twice a day is easy to find; the 40ms lookup running four thousand times a minute is the one saturating the database.",
3629
+ "category": "Data",
3630
+ "dependencies": [
3631
+ "lucide-react@^1.39.0"
3632
+ ],
3633
+ "registryDependencies": [
3634
+ "badge",
3635
+ "code-block",
3636
+ "lib-styles",
3637
+ "lib-utils"
3638
+ ]
3639
+ },
3447
3640
  {
3448
3641
  "name": "sparkline",
3449
3642
  "type": "registry:ui",
@@ -3546,6 +3739,37 @@
3546
3739
  "spinner"
3547
3740
  ]
3548
3741
  },
3742
+ {
3743
+ "name": "storage-usage",
3744
+ "type": "registry:ui",
3745
+ "title": "Storage Usage",
3746
+ "description": "A quota and what is filling it. The bar is scaled to the quota, not to what is used, so free space reads as free space — a bar normalised to the total consumed is always full, which is backwards for a component answering “how much room is left”.",
3747
+ "category": "Storage",
3748
+ "dependencies": [
3749
+ "lucide-react@^1.39.0"
3750
+ ],
3751
+ "registryDependencies": [
3752
+ "lib-styles",
3753
+ "lib-utils"
3754
+ ]
3755
+ },
3756
+ {
3757
+ "name": "story",
3758
+ "type": "registry:ui",
3759
+ "title": "Story",
3760
+ "description": "A full-screen sequence of panels over a darkened page. A dialog underneath — same overlay, focus trap, Escape and scroll lock — but it advances on its own, and everything else follows from that.",
3761
+ "category": "Overlays",
3762
+ "dependencies": [
3763
+ "lucide-react@^1.39.0"
3764
+ ],
3765
+ "registryDependencies": [
3766
+ "button",
3767
+ "lib-styles",
3768
+ "lib-utils",
3769
+ "primitive-focus-trap",
3770
+ "primitive-portal"
3771
+ ]
3772
+ },
3549
3773
  {
3550
3774
  "name": "stream-inspector",
3551
3775
  "type": "registry:ui",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "asset-grid",
3
+ "type": "registry:ui",
4
+ "title": "Asset Grid",
5
+ "description": "A media library as a selectable grid, built on this kit’s Image so every tile gets a placeholder, a reserved box and a failure state. A broken asset stays in the grid — it is exactly the one someone came to find and replace.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "image",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "storage-usage"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/asset-grid.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, FileText, Film, Music } from 'lucide-react'\nimport { Image } from '@/components/ui/image'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A media library: images and files as a selectable grid.\n *\n * The picker half of an asset store. Built on this kit's `Image`, so every tile\n * gets the placeholder mask, the reserved box and the failure state for free —\n * a grid of raw `<img>` tags is the fastest way to make a library page jump\n * around while it loads.\n *\n * **A broken asset stays in the grid.** An image whose URL has rotted is\n * exactly the one someone needs to find and replace, and a library that hides\n * failures makes it invisible while its reference is still live in production.\n *\n * Selection is controlled and multi-capable, because the operations people\n * actually perform here — delete these six, move these to another folder — are\n * bulk ones.\n */\nexport type Asset = {\n id: string\n /** Image URL, or a thumbnail for a non-image file. */\n src?: string\n name: string\n /** MIME type. Drives the fallback glyph when there is no thumbnail. */\n type?: string\n /** Bytes. */\n size?: number\n /** Already formatted. */\n modified?: string\n /** Shown on the tile — dimensions, a duration, a tag. */\n meta?: ReactNode\n}\n\nfunction glyphFor(type: string | undefined) {\n if (!type) return FileText\n if (type.startsWith('video/')) return Film\n if (type.startsWith('audio/')) return Music\n return FileText\n}\n\ntype AssetGridProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n assets: Asset[]\n /** Selected ids. Omit both to render a read-only library. */\n value?: string[]\n onValueChange?: (next: string[]) => void\n /** One at a time. */\n single?: boolean\n onOpen?: (asset: Asset) => void\n /** Minimum tile width; the grid fills the rest. */\n minTile?: number\n ratio?: number\n emptyLabel?: string\n format?: (bytes: number) => string\n}\n\nfunction AssetGrid({\n assets,\n value,\n onValueChange,\n single = false,\n onOpen,\n minTile = 150,\n ratio = 1,\n emptyLabel = 'Nothing in this library yet.',\n format = formatBytes,\n className,\n ...props\n}: AssetGridProps) {\n const [failed, setFailed] = useState<Set<string>>(new Set())\n const selectable = value !== undefined && onValueChange !== undefined\n const selected = new Set(value ?? [])\n\n function toggle(id: string) {\n if (!selectable) return\n if (single) return onValueChange!(selected.has(id) ? [] : [id])\n onValueChange!(selected.has(id) ? (value ?? []).filter((item) => item !== id) : [...(value ?? []), id])\n }\n\n if (assets.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'p-6 text-center', className)} {...props}>\n <p className=\"text-muted-foreground text-xs\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"asset-grid\"\n className={cn('grid list-none gap-3', className)}\n style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${minTile}px, 1fr))` }}\n {...(props as ComponentProps<'ul'>)}\n >\n {assets.map((asset) => {\n const on = selected.has(asset.id)\n const Glyph = glyphFor(asset.type)\n const broken = failed.has(asset.id)\n\n return (\n <li key={asset.id}>\n <button\n type=\"button\"\n aria-pressed={selectable ? on : undefined}\n onClick={() => (selectable ? toggle(asset.id) : onOpen?.(asset))}\n onDoubleClick={() => onOpen?.(asset)}\n className={cn(\n 'group block w-full text-start',\n radius.surface,\n focusRing,\n 'border transition-colors motion-reduce:transition-none',\n on ? 'border-primary ring-ring/40 ring-2' : 'border-border hover:border-foreground/25',\n )}\n >\n <div className={cn('relative overflow-hidden', radius.surface)}>\n {asset.src && !broken ? (\n <Image\n src={asset.src}\n alt={asset.name}\n ratio={ratio}\n mask=\"skeleton\"\n className=\"rounded-none\"\n onStatusChange={(status) => {\n // Kept in the grid, marked. The rotted asset is the one\n // someone came here to find.\n if (status === 'error') {\n setFailed((current) => new Set(current).add(asset.id))\n }\n }}\n />\n ) : (\n <div\n className=\"bg-muted text-muted-foreground flex items-center justify-center\"\n style={{ aspectRatio: ratio }}\n >\n <Glyph className=\"size-6\" aria-hidden=\"true\" />\n </div>\n )}\n\n {selectable && on && (\n <span className=\"bg-primary text-primary-foreground absolute end-2 top-2 flex size-5 items-center justify-center rounded-full\">\n <Check className=\"size-3\" aria-hidden=\"true\" />\n </span>\n )}\n </div>\n\n <div className=\"min-w-0 px-2.5 py-2\">\n <p className=\"truncate text-xs font-medium\">{asset.name}</p>\n <p className=\"text-muted-foreground/60 truncate text-[11px] tabular-nums\">\n {[asset.size !== undefined ? format(asset.size) : null, asset.modified]\n .filter(Boolean)\n .join(' · ')}\n {asset.meta}\n </p>\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { AssetGrid }\nexport type { AssetGridProps }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "audio-player",
3
+ "type": "registry:ui",
4
+ "title": "Audio Player",
5
+ "description": "A player built on the real <audio> element, so buffering, codecs, media keys, the OS lock screen and Bluetooth controls all keep working. State is read back off the element rather than mirrored, so anything else touching playback stays in sync.",
6
+ "category": "Media",
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/audio-player.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An audio element with a scrubber, built on the real `<audio>` element.\n *\n * The element does the work — buffering, codecs, media keys, the OS lock\n * screen, Bluetooth controls — and this draws the surface over it. A player\n * built on `AudioContext` for the sake of a nicer waveform loses all of that,\n * and the waveform is the least important part.\n *\n * **State is read from the element, not mirrored beside it.** `timeupdate`,\n * `play` and `pause` all fire whether the change came from this UI, a media\n * key, or the OS — so mirroring into React state means the two disagree the\n * moment anything else touches playback.\n *\n * **The scrubber is an `<input type=\"range\">`.** Keyboard seeking, page-up\n * jumps, screen-reader announcement and touch drag are all native. It is\n * scrubbing *while dragging* that needs care: `dragging` suspends the\n * `timeupdate` handler, or every frame of playback yanks the thumb back out of\n * your hand.\n *\n * `peaks` draws a waveform behind the track when you have one. Computing it\n * here would mean decoding the whole file in the main thread before the first\n * frame — the caller either has it precomputed or does not want it.\n */\ntype AudioPlayerProps = Omit<ComponentProps<'div'>, 'title'> & {\n src: string\n title?: ReactNode\n artist?: ReactNode\n /** Precomputed amplitudes, 0–1. Drawn behind the scrubber. */\n peaks?: number[]\n /** Start muted — required for anything that also autoplays. */\n defaultMuted?: boolean\n /** Seconds a skip button moves. */\n skipBy?: number\n /** Hide the skip buttons for a short clip. */\n showSkip?: boolean\n /** Cover art. */\n artwork?: ReactNode\n onEnded?: () => void\n playLabel?: string\n pauseLabel?: string\n muteLabel?: string\n unmuteLabel?: string\n seekLabel?: string\n backLabel?: string\n forwardLabel?: string\n /** Formats the clock. Defaults to `m:ss`, and `h:mm:ss` past an hour. */\n formatTime?: (seconds: number) => string\n}\n\nfunction defaultTime(seconds: number) {\n if (!Number.isFinite(seconds)) return '0:00'\n const whole = Math.floor(seconds)\n const h = Math.floor(whole / 3600)\n const m = Math.floor((whole % 3600) / 60)\n const s = whole % 60\n const pad = (value: number) => String(value).padStart(2, '0')\n return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`\n}\n\nfunction AudioPlayer({\n src,\n title,\n artist,\n peaks,\n defaultMuted = false,\n skipBy = 15,\n showSkip = true,\n artwork,\n onEnded,\n playLabel = 'Play',\n pauseLabel = 'Pause',\n muteLabel = 'Mute',\n unmuteLabel = 'Unmute',\n seekLabel = 'Seek',\n backLabel = 'Back',\n forwardLabel = 'Forward',\n formatTime = defaultTime,\n className,\n ...props\n}: AudioPlayerProps) {\n const ref = useRef<HTMLAudioElement>(null)\n const id = useId()\n\n const [playing, setPlaying] = useState(false)\n const [muted, setMuted] = useState(defaultMuted)\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n const [dragging, setDragging] = useState(false)\n\n // Every listener reads back off the element, so a media key or the OS lock\n // screen keeps this UI correct without going through it.\n useEffect(() => {\n const audio = ref.current\n if (!audio) return\n\n const onTime = () => {\n // Suspended while scrubbing, or playback yanks the thumb out of your hand.\n if (!dragging) setTime(audio.currentTime)\n }\n const onMeta = () => setDuration(audio.duration)\n const onPlay = () => setPlaying(true)\n const onPause = () => setPlaying(false)\n const onVolume = () => setMuted(audio.muted)\n const onEnd = () => {\n setPlaying(false)\n onEnded?.()\n }\n\n audio.addEventListener('timeupdate', onTime)\n audio.addEventListener('loadedmetadata', onMeta)\n audio.addEventListener('durationchange', onMeta)\n audio.addEventListener('play', onPlay)\n audio.addEventListener('pause', onPause)\n audio.addEventListener('volumechange', onVolume)\n audio.addEventListener('ended', onEnd)\n\n return () => {\n audio.removeEventListener('timeupdate', onTime)\n audio.removeEventListener('loadedmetadata', onMeta)\n audio.removeEventListener('durationchange', onMeta)\n audio.removeEventListener('play', onPlay)\n audio.removeEventListener('pause', onPause)\n audio.removeEventListener('volumechange', onVolume)\n audio.removeEventListener('ended', onEnd)\n }\n }, [dragging, onEnded])\n\n const seek = useCallback((seconds: number) => {\n const audio = ref.current\n if (!audio) return\n audio.currentTime = Math.min(Math.max(0, seconds), audio.duration || 0)\n }, [])\n\n const progress = duration > 0 ? time / duration : 0\n\n return (\n <div\n data-slot=\"audio-player\"\n data-playing={playing || undefined}\n className={cn(surface, radius.surface, 'flex items-center gap-4 p-4', className)}\n {...props}\n >\n {/* The real element. Hidden, never removed — it is what actually plays. */}\n <audio ref={ref} src={src} muted={defaultMuted} preload=\"metadata\" className=\"sr-only\" />\n\n {artwork && (\n <div className={cn('size-14 shrink-0 overflow-hidden', radius.control)}>{artwork}</div>\n )}\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n {(title || artist) && (\n <div className=\"min-w-0\">\n {title && <p className=\"truncate text-sm font-medium\">{title}</p>}\n {artist && <p className=\"text-muted-foreground truncate text-xs\">{artist}</p>}\n </div>\n )}\n\n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={playing ? pauseLabel : playLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (!audio) return\n if (audio.paused) void audio.play()\n else audio.pause()\n }}\n >\n {playing ? <Pause /> : <Play />}\n </Button>\n\n {showSkip && (\n <>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${backLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time - skipBy)}\n >\n <SkipBack />\n </Button>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${forwardLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time + skipBy)}\n >\n <SkipForward />\n </Button>\n </>\n )}\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(time)}\n </span>\n\n <div className=\"relative min-w-0 flex-1\">\n {/* Behind the track, never in front: the range input has to stay\n the thing you actually grab. */}\n {peaks && peaks.length > 0 && (\n <div aria-hidden=\"true\" className=\"absolute inset-0 flex items-center gap-px\">\n {peaks.map((peak, index) => (\n <span\n key={index}\n className={cn(\n 'flex-1 rounded-full',\n index / peaks.length <= progress ? 'bg-foreground/70' : 'bg-muted-foreground/25',\n )}\n style={{ height: `${Math.max(8, peak * 100)}%` }}\n />\n ))}\n </div>\n )}\n\n <input\n id={id}\n type=\"range\"\n min={0}\n max={duration || 0}\n step={0.01}\n value={time}\n aria-label={seekLabel}\n aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n onPointerDown={() => setDragging(true)}\n onPointerUp={() => setDragging(false)}\n onKeyDown={() => setDragging(true)}\n onKeyUp={() => setDragging(false)}\n onChange={(event) => {\n const next = Number(event.target.value)\n setTime(next)\n seek(next)\n }}\n className={cn(\n 'relative h-6 w-full cursor-pointer appearance-none bg-transparent',\n focusRing,\n radius.control,\n // Track and thumb have to be styled per engine; there is no\n // cross-browser shorthand for either.\n '[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full',\n peaks?.length\n ? '[&::-webkit-slider-runnable-track]:bg-transparent'\n : '[&::-webkit-slider-runnable-track]:bg-muted',\n '[&::-webkit-slider-thumb]:mt-[-4px] [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none',\n '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground',\n '[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted',\n '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-foreground',\n )}\n />\n </div>\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(duration)}\n </span>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={muted ? unmuteLabel : muteLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (audio) audio.muted = !audio.muted\n }}\n >\n {muted ? <VolumeX /> : <Volume2 />}\n </Button>\n </div>\n </div>\n </div>\n )\n}\n\nexport { AudioPlayer, defaultTime as formatAudioTime }\nexport type { AudioPlayerProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "backup-list",
3
+ "type": "registry:ui",
4
+ "title": "Backup List",
5
+ "description": "Snapshots, and whether any of them would actually restore. A backup nobody has restored is a hypothesis, so an untested one is drawn as a warning rather than as a success.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "button",
13
+ "lib-styles",
14
+ "lib-utils",
15
+ "storage-usage"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/backup-list.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Archive, RotateCcw, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Snapshots, and whether any of them would actually restore.\n *\n * **A backup nobody has restored is a hypothesis.** The column this component\n * insists on is `verified` — an untested snapshot is drawn as a warning, not as\n * a success, because the failure mode of backups is discovering at the worst\n * possible moment that they have been silently empty for a month.\n *\n * The age of the newest snapshot is stated in the header rather than left to be\n * worked out from a list of timestamps, since \"how much data would we lose\" is\n * the only question anyone brings to this screen.\n */\nexport type Backup = {\n id: string\n /** Already formatted — this component does not own your locale. */\n createdAt: string\n /** Bytes. */\n size?: number\n kind?: 'full' | 'incremental' | 'snapshot'\n /** A restore has actually been tested from this backup. */\n verified?: boolean\n status?: 'complete' | 'running' | 'failed'\n meta?: ReactNode\n}\n\ntype BackupListProps = Omit<ComponentProps<'div'>, 'children'> & {\n backups: Backup[]\n onRestore?: (backup: Backup) => void\n /** How old the newest backup is, already formatted. */\n newestAge?: ReactNode\n restoreLabel?: string\n unverifiedLabel?: string\n verifiedLabel?: string\n emptyLabel?: string\n format?: (bytes: number) => string\n label?: string\n}\n\nfunction BackupList({\n backups,\n onRestore,\n newestAge,\n restoreLabel = 'Restore',\n unverifiedLabel = 'never test-restored',\n verifiedLabel = 'verified',\n emptyLabel = 'No backups exist for this database.',\n format = formatBytes,\n label = 'Backups',\n className,\n ...props\n}: BackupListProps) {\n const unverified = backups.filter((backup) => !backup.verified && backup.status !== 'failed').length\n\n return (\n <div\n data-slot=\"backup-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex flex-wrap items-center gap-2 border-b px-4 py-2\">\n <p className=\"text-muted-foreground/70 min-w-0 flex-1 text-[11px] font-medium tracking-[0.14em] uppercase\">\n {label}\n </p>\n {/* The question everyone actually brings here. */}\n {newestAge !== undefined && (\n <span className=\"text-muted-foreground font-mono text-[11px]\">newest {newestAge}</span>\n )}\n </div>\n\n {backups.length === 0 ? (\n <p className=\"flex items-start gap-2 px-4 py-3 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {emptyLabel}\n </p>\n ) : (\n <>\n {unverified > 0 && (\n <p className=\"border-border border-b px-4 py-2 text-xs text-[var(--amber-soft-foreground)]\">\n {unverified} of {backups.length} have never been test-restored.\n </p>\n )}\n\n <ul className=\"divide-border list-none divide-y\">\n {backups.map((backup) => (\n <li key={backup.id} className=\"flex items-center gap-3 px-4 py-3\">\n <Archive\n className={cn(\n 'size-4 shrink-0',\n backup.status === 'failed'\n ? 'text-[var(--destructive-soft-foreground)]'\n : 'text-muted-foreground/50',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-mono text-xs tabular-nums\">{backup.createdAt}</span>\n {backup.kind && (\n <Badge size=\"sm\" variant=\"outline\">{backup.kind}</Badge>\n )}\n {backup.status === 'failed' && (\n <Badge size=\"sm\" color=\"destructive\">failed</Badge>\n )}\n {backup.status === 'running' && (\n <Badge size=\"sm\" color=\"blue\">running</Badge>\n )}\n {/* A backup nobody has restored is a hypothesis. */}\n {backup.status !== 'failed' &&\n (backup.verified ? (\n <Badge size=\"sm\" color=\"green\">{verifiedLabel}</Badge>\n ) : (\n <Badge size=\"sm\" color=\"amber\">{unverifiedLabel}</Badge>\n ))}\n </div>\n {backup.meta && (\n <p className=\"text-muted-foreground/60 mt-0.5 text-[11px]\">{backup.meta}</p>\n )}\n </div>\n\n {backup.size !== undefined && (\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {format(backup.size)}\n </span>\n )}\n\n {onRestore && backup.status === 'complete' && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"-me-2 shrink-0\"\n onClick={() => onRestore(backup)}\n >\n <RotateCcw />\n {restoreLabel}\n </Button>\n )}\n </li>\n ))}\n </ul>\n </>\n )}\n </div>\n )\n}\n\nexport { BackupList }\nexport type { BackupListProps }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "bucket-list",
3
+ "type": "registry:ui",
4
+ "title": "Bucket List",
5
+ "description": "Object-storage buckets, with public access rendered as a warning rather than as one small grey tag among five. Every storage breach of the last decade is a bucket nobody realised was world-readable.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "storage-usage"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/bucket-list.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Globe, Lock, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Object-storage buckets, with the fact that matters first.\n *\n * That fact is **public or not**. Every storage breach of the last decade is\n * some version of a bucket nobody realised was world-readable, and a list that\n * renders access as a small grey tag among five other small grey tags is how it\n * stays unnoticed. Public buckets get a warning treatment, not a label.\n *\n * Region is shown because it is immutable and expensive to get wrong — a bucket\n * in the wrong region is a migration, not a setting — and because latency and\n * data residency both hang off it.\n */\nexport type Bucket = {\n name: string\n region?: string\n /** Bytes. */\n size?: number\n objects?: number\n /** World-readable. Rendered as a warning, not a tag. */\n public?: boolean\n /** Object-lock, versioning, whatever your provider calls it. */\n badges?: ReactNode[]\n /** Already formatted. */\n createdAt?: string\n}\n\ntype BucketListProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n buckets: Bucket[]\n onSelect?: (bucket: Bucket) => void\n selectedName?: string\n publicLabel?: string\n privateLabel?: string\n objectsLabel?: string\n emptyLabel?: string\n format?: (bytes: number) => string\n label?: string\n}\n\nfunction BucketList({\n buckets,\n onSelect,\n selectedName,\n publicLabel = 'Public',\n privateLabel = 'Private',\n objectsLabel = 'objects',\n emptyLabel = 'No buckets.',\n format = formatBytes,\n label = 'Buckets',\n className,\n ...props\n}: BucketListProps) {\n if (buckets.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'p-4', className)} {...props}>\n <p className=\"text-muted-foreground text-xs\">{emptyLabel}</p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"bucket-list\"\n aria-label={label}\n className={cn(surface, radius.surface, 'divide-border list-none divide-y overflow-hidden', className)}\n {...(props as ComponentProps<'ul'>)}\n >\n {buckets.map((bucket) => {\n const Row = onSelect ? 'button' : 'div'\n return (\n <li key={bucket.name}>\n <Row\n {...(onSelect ? { type: 'button' as const, onClick: () => onSelect(bucket) } : {})}\n className={cn(\n 'flex w-full items-center gap-3 px-4 py-3 text-start',\n onSelect && cn('hover:bg-accent/40', focusRing),\n bucket.name === selectedName && 'bg-accent/60',\n )}\n >\n {bucket.public ? (\n <Globe\n className=\"size-4 shrink-0 text-[var(--destructive-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n ) : (\n <Lock className=\"text-muted-foreground/50 size-4 shrink-0\" aria-hidden=\"true\" />\n )}\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-sm\">{bucket.name}</code>\n {/* A warning, not a tag — this is the fact that matters. */}\n {bucket.public ? (\n <Badge size=\"sm\" color=\"destructive\">\n <TriangleAlert className=\"size-3\" aria-hidden=\"true\" />\n {publicLabel}\n </Badge>\n ) : (\n <span className=\"text-muted-foreground/50 text-[11px]\">{privateLabel}</span>\n )}\n {bucket.badges?.map((badge, index) => (\n <Badge key={index} size=\"sm\" variant=\"outline\">\n {badge}\n </Badge>\n ))}\n </div>\n\n <p className=\"text-muted-foreground/60 mt-0.5 flex flex-wrap gap-x-3 text-[11px] tabular-nums\">\n {bucket.region && <span className=\"font-mono\">{bucket.region}</span>}\n {bucket.objects !== undefined && (\n <span>\n {bucket.objects.toLocaleString()} {objectsLabel}\n </span>\n )}\n {bucket.createdAt && <span>{bucket.createdAt}</span>}\n </p>\n </div>\n\n {bucket.size !== undefined && (\n <span className=\"shrink-0 font-mono text-xs tabular-nums\">\n {format(bucket.size)}\n </span>\n )}\n </Row>\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { BucketList }\nexport type { BucketListProps }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "connection-pool",
3
+ "type": "registry:ui",
4
+ "title": "Connection Pool",
5
+ "description": "What is checked out, idle and waiting. A pool at 100% with nothing queued is exactly the right size; the same pool with twelve waiting is an outage forming — so waiters are drawn beyond the bar rather than folded into it.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/connection-pool.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A database connection pool: what is in use, idle, and waiting.\n *\n * **Waiting is the number that matters, and it is not part of the pool.** A\n * pool at 100% utilisation is fine if nothing is queued — it means the pool is\n * exactly the right size. The same pool with twelve requests waiting is an\n * outage forming. So the bar shows the pool, and waiters are drawn beyond its\n * end rather than folded into it, because they are demand the pool cannot meet.\n *\n * Idle connections are shown rather than hidden. A pool sitting at two active\n * and thirty idle is paying to keep thirty sockets open against a database with\n * a connection limit, and that is a real finding.\n */\ntype ConnectionPoolProps = Omit<ComponentProps<'div'>, 'children'> & {\n /** Checked out and running a query. */\n active: number\n /** Open but unused. */\n idle: number\n /** Pool ceiling. */\n max: number\n /** Requests queued for a connection. The number that signals trouble. */\n waiting?: number\n /** Mean wait before a connection is handed over, in ms. */\n waitMs?: number\n label?: ReactNode\n activeLabel?: string\n idleLabel?: string\n waitingLabel?: string\n saturatedLabel?: (waiting: number) => ReactNode\n}\n\nfunction ConnectionPool({\n active,\n idle,\n max,\n waiting = 0,\n waitMs,\n label = 'Connection pool',\n activeLabel = 'active',\n idleLabel = 'idle',\n waitingLabel = 'waiting',\n saturatedLabel = (count) => `${count} request${count === 1 ? '' : 's'} queued for a connection`,\n className,\n ...props\n}: ConnectionPoolProps) {\n const open = active + idle\n const spare = Math.max(0, max - open)\n\n return (\n <div\n data-slot=\"connection-pool\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <p className=\"text-sm font-medium\">{label}</p>\n <p className=\"text-muted-foreground font-mono text-xs tabular-nums\">\n {open} / {max}\n </p>\n </div>\n\n <div className=\"flex items-center gap-2\">\n <div className=\"bg-muted flex h-2.5 min-w-0 flex-1 overflow-hidden rounded-full\">\n <span\n className=\"h-full bg-[var(--blue-soft-foreground)]\"\n style={{ width: `${(active / max) * 100}%`, minWidth: active > 0 ? 2 : 0 }}\n />\n <span\n className=\"h-full bg-muted-foreground/30\"\n style={{ width: `${(idle / max) * 100}%`, minWidth: idle > 0 ? 2 : 0 }}\n />\n <span style={{ width: `${(spare / max) * 100}%` }} />\n </div>\n\n {/* Beyond the pool's end, deliberately: waiters are demand the pool\n cannot meet, not a slice of it. */}\n {waiting > 0 && (\n <span\n className=\"flex h-2.5 shrink-0 items-center rounded-full bg-[var(--destructive-soft-foreground)] px-1.5 text-[9px] font-medium text-white tabular-nums\"\n title={`${waiting} ${waitingLabel}`}\n >\n +{waiting}\n </span>\n )}\n </div>\n\n <ul className=\"text-muted-foreground flex list-none flex-wrap gap-x-4 gap-y-1 text-xs\">\n <li className=\"flex items-center gap-1.5\">\n <span aria-hidden=\"true\" className=\"size-2 rounded-full bg-[var(--blue-soft-foreground)]\" />\n <span className=\"tabular-nums\">{active}</span> {activeLabel}\n </li>\n <li className=\"flex items-center gap-1.5\">\n <span aria-hidden=\"true\" className=\"bg-muted-foreground/30 size-2 rounded-full\" />\n <span className=\"tabular-nums\">{idle}</span> {idleLabel}\n </li>\n {waitMs !== undefined && (\n <li className=\"tabular-nums\">mean wait {Math.round(waitMs)}ms</li>\n )}\n </ul>\n\n {waiting > 0 && (\n <p className=\"flex items-center gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n {saturatedLabel(waiting)}\n </p>\n )}\n </div>\n )\n}\n\nexport { ConnectionPool }\nexport type { ConnectionPoolProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "csv-preview",
3
+ "type": "registry:ui",
4
+ "title": "CSV Preview",
5
+ "description": "A parsed CSV before you import it, showing the rows that will break the import. A preview that renders the first ten rows of a clean file has shown you nothing — mismatched column counts are what abort an import halfway.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/csv-preview.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A parsed CSV before you import it, with the rows that will break the import.\n *\n * The point of a preview is not to show the data — it is to show the **bad\n * rows**, and a preview that renders the first ten rows of a clean file has\n * shown you nothing. Rows whose column count does not match the header are\n * flagged and counted, because that is the failure that aborts an import\n * halfway and leaves a table half-populated.\n *\n * Type inference is per column and reported with a confidence, not asserted. A\n * column that is 998 integers and 2 empty strings is an integer column with\n * nulls; one that is 500 integers and 500 dates is a mess someone has to look\n * at. Both would render as \"mixed\" if inference were a single guess.\n *\n * It does not parse. Quoted fields containing commas and newlines are a real\n * parser's job, and a component that ships a naive `split(',')` will silently\n * mangle exactly the files that most need previewing.\n */\nexport type CsvColumn = {\n name: string\n /** What the caller's parser inferred. */\n type?: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'mixed'\n /** 0–1. Below 1 means some cells did not match. */\n confidence?: number\n /** Cells that were empty. */\n nulls?: number\n}\n\ntype CsvPreviewProps = Omit<ComponentProps<'div'>, 'children'> & {\n columns: CsvColumn[]\n /** Already parsed. This component renders; it does not parse. */\n rows: string[][]\n /** Total rows in the file, when more than are shown here. */\n totalRows?: number\n /** Indices into `rows` whose column count did not match the header. */\n malformed?: number[]\n emptyLabel?: string\n malformedLabel?: (count: number) => ReactNode\n showingLabel?: (shown: number, total: number) => ReactNode\n nullsLabel?: string\n}\n\nconst TYPE_TONE: Record<string, string> = {\n integer: 'text-[var(--blue-soft-foreground)]',\n number: 'text-[var(--blue-soft-foreground)]',\n date: 'text-[var(--violet-soft-foreground)]',\n boolean: 'text-[var(--cyan-soft-foreground)]',\n mixed: 'text-[var(--amber-soft-foreground)]',\n string: 'text-muted-foreground',\n}\n\nfunction CsvPreview({\n columns,\n rows,\n totalRows,\n malformed = [],\n emptyLabel = 'This file has no rows.',\n malformedLabel = (count) =>\n `${count} row${count === 1 ? '' : 's'} do not match the header and will fail the import.`,\n showingLabel = (shown, total) => `Showing ${shown} of ${total.toLocaleString()} rows`,\n nullsLabel = 'empty',\n className,\n ...props\n}: CsvPreviewProps) {\n const bad = useMemo(() => new Set(malformed), [malformed])\n\n return (\n <div\n data-slot=\"csv-preview\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {/* Before the table: the reason to open a preview at all. */}\n {malformed.length > 0 && (\n <p className=\"border-border flex items-center gap-2 border-b bg-[var(--destructive-soft)] px-4 py-2.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n {malformedLabel(malformed.length)}\n </p>\n )}\n\n {rows.length === 0 ? (\n <p className=\"text-muted-foreground px-4 py-3 text-xs\">{emptyLabel}</p>\n ) : (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full min-w-max border-collapse text-left\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground/40 w-10 px-3 py-2 text-[11px] font-normal tabular-nums\">\n #\n </th>\n {columns.map((column) => (\n <th key={column.name} className=\"px-3 py-2 align-bottom\">\n <span className=\"block font-mono text-xs font-medium\">{column.name}</span>\n <span className=\"text-muted-foreground/60 mt-0.5 flex flex-wrap items-baseline gap-1.5 text-[10px]\">\n <span className={cn('font-mono', TYPE_TONE[column.type ?? 'string'])}>\n {column.type ?? 'string'}\n </span>\n {/* Reported, not asserted: 998 ints and 2 blanks is an\n int column with nulls, not a mixed one. */}\n {column.confidence !== undefined && column.confidence < 1 && (\n <span className=\"tabular-nums\">{Math.round(column.confidence * 100)}%</span>\n )}\n {column.nulls ? (\n <span className=\"tabular-nums\">\n {column.nulls} {nullsLabel}\n </span>\n ) : null}\n </span>\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((row, index) => (\n <tr\n key={index}\n className={cn(\n 'border-border/60 border-b last:border-b-0',\n bad.has(index) && 'bg-[var(--destructive-soft)]',\n )}\n >\n <td className=\"text-muted-foreground/40 px-3 py-1.5 font-mono text-[11px] tabular-nums\">\n {index + 1}\n </td>\n {columns.map((column, cell) => (\n <td\n key={column.name}\n className={cn(\n 'px-3 py-1.5 font-mono text-[11px]',\n row[cell] === undefined || row[cell] === ''\n ? 'text-muted-foreground/30'\n : 'text-foreground/85',\n )}\n >\n {row[cell] === undefined || row[cell] === '' ? '∅' : row[cell]}\n </td>\n ))}\n {bad.has(index) && (\n <td className=\"px-3 py-1.5\">\n <Badge size=\"sm\" color=\"destructive\">\n {row.length} cols\n </Badge>\n </td>\n )}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n\n {totalRows !== undefined && totalRows > rows.length && (\n <p className=\"border-border text-muted-foreground/60 border-t px-4 py-2 text-[11px] tabular-nums\">\n {showingLabel(rows.length, totalRows)}\n </p>\n )}\n </div>\n )\n}\n\nexport { CsvPreview }\nexport type { CsvPreviewProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "data-quality",
3
+ "type": "registry:ui",
4
+ "title": "Data Quality",
5
+ "description": "A column profile: how complete, how varied, and what the values look like. Sample values are the fastest way to spot that a “date” column holds three different formats — no summary statistic catches that.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/data-quality.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A column profile: how complete, how varied, and what the values look like.\n *\n * The three numbers that decide whether a column is usable, in the order you\n * ask them:\n *\n * **Nulls** — an 80%-null column is not a column you can join on, and the\n * percentage is drawn as a bar because a number in a table is easy to skim past.\n *\n * **Distinct** — one distinct value across a million rows is a constant\n * masquerading as data; a million distinct values is an id, not a dimension.\n * Both are flagged, because both mean the column will not do what a chart\n * expects of it.\n *\n * **Samples** — the fastest way to spot that a \"date\" column holds\n * `01/02/2024`, `2024-02-01` and `Feb 1` in the same file. No summary statistic\n * catches that; three example values do immediately.\n */\nexport type ColumnProfile = {\n name: string\n type?: string\n /** Fraction 0–1. */\n nullFraction?: number\n distinct?: number\n /** Total rows profiled, for the distinct ratio. */\n total?: number\n min?: ReactNode\n max?: ReactNode\n /** A few real values. Worth more than any summary statistic. */\n samples?: string[]\n}\n\ntype DataQualityProps = Omit<ComponentProps<'div'>, 'children'> & {\n columns: ColumnProfile[]\n /** Null fraction above this is flagged. */\n nullWarnAt?: number\n constantLabel?: string\n uniqueLabel?: string\n sparseLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nfunction DataQuality({\n columns,\n nullWarnAt = 0.3,\n constantLabel = 'single value',\n uniqueLabel = 'unique per row',\n sparseLabel = 'mostly empty',\n emptyLabel = 'Nothing profiled.',\n label = 'Column profile',\n className,\n ...props\n}: DataQualityProps) {\n return (\n <div\n data-slot=\"data-quality\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <p className=\"border-border bg-muted/40 text-muted-foreground/70 border-b px-4 py-2 text-[11px] font-medium tracking-[0.14em] uppercase\">\n {label}\n </p>\n\n {columns.length === 0 ? (\n <p className=\"text-muted-foreground px-4 py-3 text-xs\">{emptyLabel}</p>\n ) : (\n <ul className=\"divide-border list-none divide-y\">\n {columns.map((column) => {\n const nulls = column.nullFraction ?? 0\n const sparse = nulls >= nullWarnAt\n // A constant and an id are both unusable as a dimension, for\n // opposite reasons.\n const constant = column.distinct === 1\n const unique =\n column.distinct !== undefined &&\n column.total !== undefined &&\n column.total > 0 &&\n column.distinct === column.total\n\n return (\n <li key={column.name} className=\"flex flex-col gap-2 px-4 py-3\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"font-mono text-xs font-medium\">{column.name}</code>\n {column.type && (\n <span className=\"text-muted-foreground/60 font-mono text-[11px]\">\n {column.type}\n </span>\n )}\n {sparse && (\n <Badge size=\"sm\" color=\"amber\">\n <TriangleAlert className=\"size-3\" aria-hidden=\"true\" />\n {sparseLabel}\n </Badge>\n )}\n {constant && <Badge size=\"sm\" color=\"amber\">{constantLabel}</Badge>}\n {unique && <Badge size=\"sm\" variant=\"outline\">{uniqueLabel}</Badge>}\n </div>\n\n {column.nullFraction !== undefined && (\n <div className=\"flex items-center gap-2\">\n <div className=\"bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full\">\n <div\n className={cn(\n 'h-full rounded-full',\n sparse\n ? 'bg-[var(--amber-soft-foreground)]'\n : 'bg-muted-foreground/40',\n )}\n style={{ width: `${nulls * 100}%`, minWidth: nulls > 0 ? 2 : 0 }}\n />\n </div>\n <span className=\"text-muted-foreground/60 w-24 shrink-0 text-end text-[11px] tabular-nums\">\n {Math.round(nulls * 100)}% empty\n </span>\n </div>\n )}\n\n <div className=\"text-muted-foreground/60 flex flex-wrap gap-x-4 gap-y-1 text-[11px] tabular-nums\">\n {column.distinct !== undefined && (\n <span>{column.distinct.toLocaleString()} distinct</span>\n )}\n {column.min !== undefined && <span>min {column.min}</span>}\n {column.max !== undefined && <span>max {column.max}</span>}\n </div>\n\n {column.samples && column.samples.length > 0 && (\n <div className=\"flex flex-wrap gap-1.5\">\n {column.samples.map((sample, index) => (\n <code\n key={index}\n className={cn(\n 'bg-secondary text-secondary-foreground max-w-full truncate px-2 py-0.5 font-mono text-[11px]',\n radius.xs,\n )}\n >\n {sample}\n </code>\n ))}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { DataQuality }\nexport type { DataQualityProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "file-preview",
3
+ "type": "registry:ui",
4
+ "title": "File Preview",
5
+ "description": "One file rendered as whatever it actually is — image, text, audio, or an honest “no preview” panel. The MIME type decides, never the extension: a .png served as text/html is the oldest upload attack there is.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "audio-player",
12
+ "badge",
13
+ "button",
14
+ "code-block",
15
+ "image",
16
+ "lib-styles",
17
+ "lib-utils",
18
+ "storage-usage"
19
+ ],
20
+ "files": [
21
+ {
22
+ "path": "components/ui/file-preview.tsx",
23
+ "type": "registry:ui",
24
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { Download, File, FileText, Film, Music, TriangleAlert } from 'lucide-react'\nimport { AudioPlayer } from '@/components/ui/audio-player'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { Image } from '@/components/ui/image'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * One file, rendered as whatever it actually is.\n *\n * The dispatcher a file store needs: an image previews, text and code render\n * highlighted, audio gets a player, and everything else gets an honest \"no\n * preview\" panel with a download rather than a broken `<img>`.\n *\n * **The MIME type decides, not the extension.** A `.png` served as\n * `text/html` is the oldest upload attack there is, and a previewer that trusts\n * the filename renders it as markup. Extension is only consulted when the store\n * reports no type at all.\n *\n * Text is capped. A 40MB log rendered into the DOM freezes the tab, and the\n * point of a preview is to decide whether you want the file — the first few\n * hundred lines answer that.\n */\nexport type PreviewFile = {\n name: string\n /** MIME type from the store. Trusted over the extension. */\n type?: string\n /** Bytes. */\n size?: number\n /** For images, audio and video. */\n url?: string\n /** Already-fetched contents, for text and code. */\n text?: string\n /** Highlighting hint for `text`. */\n language?: string\n}\n\ntype FilePreviewProps = Omit<ComponentProps<'div'>, 'children'> & {\n file: PreviewFile\n onDownload?: (file: PreviewFile) => void\n /** Characters of `text` to render. */\n maxChars?: number\n downloadLabel?: string\n noPreviewLabel?: string\n truncatedLabel?: (shown: number, total: number) => ReactNode\n actions?: ReactNode\n}\n\n/** MIME first; the extension is a fallback, never an override. */\nexport function previewKind(file: PreviewFile) {\n const type = file.type ?? ''\n if (type.startsWith('image/')) return 'image'\n if (type.startsWith('audio/')) return 'audio'\n if (type.startsWith('video/')) return 'video'\n if (type.startsWith('text/') || /json|xml|javascript|typescript|sql|yaml/.test(type)) return 'text'\n if (type) return 'binary'\n\n const extension = file.name.split('.').pop()?.toLowerCase() ?? ''\n if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'].includes(extension)) return 'image'\n if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(extension)) return 'audio'\n if (['mp4', 'webm', 'mov'].includes(extension)) return 'video'\n if (['txt', 'md', 'json', 'csv', 'log', 'yml', 'yaml', 'ts', 'tsx', 'js', 'sql'].includes(extension))\n return 'text'\n return 'binary'\n}\n\nfunction FilePreview({\n file,\n onDownload,\n maxChars = 20_000,\n downloadLabel = 'Download',\n noPreviewLabel = 'No preview for this file type.',\n truncatedLabel = (shown, total) =>\n `Showing the first ${shown.toLocaleString()} of ${total.toLocaleString()} characters.`,\n actions,\n className,\n ...props\n}: FilePreviewProps) {\n const [failed, setFailed] = useState(false)\n const kind = previewKind(file)\n\n const text = file.text ?? ''\n const truncated = text.length > maxChars\n\n const Glyph = kind === 'video' ? Film : kind === 'audio' ? Music : kind === 'text' ? FileText : File\n\n return (\n <div\n data-slot=\"file-preview\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border bg-muted/40 flex items-center gap-2.5 border-b px-4 py-2.5\">\n <Glyph className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate font-mono text-xs font-medium\">{file.name}</p>\n <p className=\"text-muted-foreground/60 text-[11px] tabular-nums\">\n {[file.type, file.size !== undefined ? formatBytes(file.size) : null]\n .filter(Boolean)\n .join(' · ')}\n </p>\n </div>\n {actions}\n {onDownload && (\n <Button variant=\"ghost\" size=\"sm\" className=\"-me-2 shrink-0\" onClick={() => onDownload(file)}>\n <Download />\n {downloadLabel}\n </Button>\n )}\n </div>\n\n <div className=\"min-h-0 flex-1\">\n {kind === 'image' && file.url && !failed && (\n <Image\n src={file.url}\n alt={file.name}\n ratio={16 / 10}\n fit=\"contain\"\n mask=\"skeleton\"\n className=\"rounded-none border-0\"\n onStatusChange={(status) => status === 'error' && setFailed(true)}\n />\n )}\n\n {kind === 'audio' && file.url && (\n <div className=\"p-4\">\n <AudioPlayer src={file.url} title={file.name} />\n </div>\n )}\n\n {kind === 'video' && file.url && (\n // The real element: it brings buffering, captions and the OS\n // controls with it.\n <video src={file.url} controls className=\"max-h-96 w-full bg-black\" />\n )}\n\n {kind === 'text' && file.text !== undefined && (\n <div className=\"p-3\">\n <CodeBlock\n code={truncated ? text.slice(0, maxChars) : text}\n language={(file.language ?? 'text') as never}\n header={false}\n lineNumbers\n />\n {truncated && (\n <p className=\"text-muted-foreground/60 mt-2 text-[11px]\">\n {truncatedLabel(maxChars, text.length)}\n </p>\n )}\n </div>\n )}\n\n {(kind === 'binary' || failed || (kind !== 'text' && !file.url)) && (\n <div className=\"text-muted-foreground flex flex-col items-center justify-center gap-2 p-8 text-center\">\n {failed ? (\n <TriangleAlert className=\"size-5 text-[var(--destructive-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <File className=\"size-5\" aria-hidden=\"true\" />\n )}\n <p className=\"text-xs\">{noPreviewLabel}</p>\n {file.type && (\n <Badge size=\"sm\" variant=\"outline\">\n {file.type}\n </Badge>\n )}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport { FilePreview }\nexport type { FilePreviewProps }\n"
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "index-list",
3
+ "type": "registry:ui",
4
+ "title": "Index List",
5
+ "description": "The indexes on a table and which are dead weight. An unused index is written on every insert and makes the planner’s job harder — but a primary key with no scans is not flagged, because suggesting you drop one is advice that loses data.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "storage-usage"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/index-list.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { KeyRound, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The indexes on a table, and which of them are dead weight.\n *\n * An unused index is not free: it is written on every insert, update and\n * delete, it takes space, and it makes the planner's job harder. They\n * accumulate because adding one is easy and nobody ever checks back — so the\n * scan count is the column this component exists to show, and zero is flagged.\n *\n * A primary key with no scans is *not* flagged. It enforces uniqueness whether\n * or not anything reads through it, and a component that suggests dropping one\n * is giving advice that loses data.\n *\n * Duplicate indexes — same columns in the same order — are called out\n * separately, because that is the other way this list quietly grows.\n */\nexport type DatabaseIndex = {\n name: string\n /** Columns, in index order. Order matters for a composite index. */\n columns: string[]\n unique?: boolean\n primary?: boolean\n /** Bytes on disk. */\n size?: number\n /** Times the planner used it. Zero is what makes an index dead weight. */\n scans?: number\n meta?: ReactNode\n}\n\n/** Indexes covering the same columns in the same order. */\nexport function duplicateIndexes(indexes: DatabaseIndex[]) {\n const seen = new Map<string, string[]>()\n for (const index of indexes) {\n const key = index.columns.join(',')\n seen.set(key, [...(seen.get(key) ?? []), index.name])\n }\n return new Set([...seen.values()].filter((names) => names.length > 1).flat())\n}\n\ntype IndexListProps = Omit<ComponentProps<'div'>, 'children'> & {\n indexes: DatabaseIndex[]\n unusedLabel?: string\n duplicateLabel?: string\n primaryLabel?: string\n uniqueLabel?: string\n scansLabel?: string\n emptyLabel?: string\n format?: (bytes: number) => string\n label?: string\n}\n\nfunction IndexList({\n indexes,\n unusedLabel = 'never scanned',\n duplicateLabel = 'duplicate',\n primaryLabel = 'primary',\n uniqueLabel = 'unique',\n scansLabel = 'scans',\n emptyLabel = 'No indexes on this table.',\n format = formatBytes,\n label = 'Indexes',\n className,\n ...props\n}: IndexListProps) {\n const duplicates = duplicateIndexes(indexes)\n\n return (\n <div\n data-slot=\"index-list\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <p className=\"border-border bg-muted/40 text-muted-foreground/70 border-b px-4 py-2 text-[11px] font-medium tracking-[0.14em] uppercase\">\n {label}\n </p>\n\n {indexes.length === 0 ? (\n <p className=\"text-muted-foreground px-4 py-3 text-xs\">{emptyLabel}</p>\n ) : (\n <ul className=\"divide-border list-none divide-y\">\n {indexes.map((index) => {\n // A primary key earns its keep by enforcing uniqueness, whether or\n // not anything reads through it. Flagging one is advice that loses\n // data.\n const unused = index.scans === 0 && !index.primary\n const duplicate = duplicates.has(index.name)\n\n return (\n <li key={index.name} className=\"flex items-start gap-3 px-4 py-3\">\n <KeyRound\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n unused || duplicate\n ? 'text-[var(--amber-soft-foreground)]'\n : 'text-muted-foreground/50',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-xs font-medium\">{index.name}</code>\n {index.primary && <Badge size=\"sm\" color=\"blue\">{primaryLabel}</Badge>}\n {index.unique && !index.primary && (\n <Badge size=\"sm\" variant=\"outline\">{uniqueLabel}</Badge>\n )}\n {unused && (\n <Badge size=\"sm\" color=\"amber\">\n <TriangleAlert className=\"size-3\" aria-hidden=\"true\" />\n {unusedLabel}\n </Badge>\n )}\n {duplicate && <Badge size=\"sm\" color=\"amber\">{duplicateLabel}</Badge>}\n </div>\n\n <p className=\"text-muted-foreground mt-1 font-mono text-[11px]\">\n ({index.columns.join(', ')})\n </p>\n {index.meta && (\n <p className=\"text-muted-foreground/60 mt-0.5 text-[11px]\">{index.meta}</p>\n )}\n </div>\n\n <div className=\"flex shrink-0 flex-col items-end gap-0.5\">\n {index.size !== undefined && (\n <span className=\"font-mono text-[11px] tabular-nums\">{format(index.size)}</span>\n )}\n {index.scans !== undefined && (\n <span\n className={cn(\n 'text-[11px] tabular-nums',\n unused ? 'text-[var(--amber-soft-foreground)]' : 'text-muted-foreground/60',\n )}\n >\n {index.scans.toLocaleString()} {scansLabel}\n </span>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { IndexList }\nexport type { IndexListProps }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "object-list",
3
+ "type": "registry:ui",
4
+ "title": "Object List",
5
+ "description": "Objects in a bucket, with the prefix hierarchy object stores only pretend to have. There are no folders in S3-shaped storage — `a/b/c.png` is one flat key — so keys deeper than the current prefix collapse into a prefix row with a count.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "empty",
13
+ "input",
14
+ "lib-styles",
15
+ "lib-utils",
16
+ "storage-usage"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "components/ui/object-list.tsx",
21
+ "type": "registry:ui",
22
+ "content": "import { useMemo, useState, type ComponentProps } from 'react'\nimport { ChevronRight, File, Folder, Search } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Empty } from '@/components/ui/empty'\nimport { Input } from '@/components/ui/input'\nimport { formatBytes } from '@/components/ui/storage-usage'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Objects in a bucket, with the prefix hierarchy object stores only pretend to\n * have.\n *\n * There are no folders in S3-shaped storage — `a/b/c.png` is one flat key with\n * slashes in it. Every console draws folders anyway, because a flat list of\n * fifty thousand keys is unusable, and this does the same grouping: keys under\n * the current prefix are files, anything deeper collapses into a prefix row\n * with a count.\n *\n * Storage class is shown because it is the difference between a file you can\n * read now and one that needs a restore request and several hours. A list that\n * shows only size and date makes an archived object look immediately available.\n */\nexport type StorageObject = {\n key: string\n /** Bytes. */\n size?: number\n /** Already formatted. */\n modified?: string\n /** `STANDARD`, `GLACIER`, whatever your provider calls it. */\n storageClass?: string\n /** Not immediately readable — needs a restore first. */\n archived?: boolean\n}\n\ntype ObjectListProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n objects: StorageObject[]\n /** Current prefix. Controlled when paired with `onPrefixChange`. */\n prefix?: string\n onPrefixChange?: (prefix: string) => void\n onSelect?: (object: StorageObject) => void\n searchable?: boolean\n searchPlaceholder?: string\n searchLabel?: string\n emptyLabel?: string\n emptyHint?: string\n archivedLabel?: string\n itemsLabel?: (count: number) => string\n format?: (bytes: number) => string\n}\n\n/** Split the keys under `prefix` into immediate files and deeper prefixes. */\nexport function groupByPrefix(objects: StorageObject[], prefix: string) {\n const files: StorageObject[] = []\n const folders = new Map<string, number>()\n\n for (const object of objects) {\n if (!object.key.startsWith(prefix)) continue\n const rest = object.key.slice(prefix.length)\n if (!rest) continue\n\n const slash = rest.indexOf('/')\n if (slash === -1) files.push(object)\n else {\n const name = rest.slice(0, slash + 1)\n folders.set(name, (folders.get(name) ?? 0) + 1)\n }\n }\n\n return { files, folders: [...folders.entries()].map(([name, count]) => ({ name, count })) }\n}\n\nfunction ObjectList({\n objects,\n prefix: prefixProp,\n onPrefixChange,\n onSelect,\n searchable = true,\n searchPlaceholder = 'Filter by key…',\n searchLabel = 'Filter objects',\n emptyLabel = 'Nothing here',\n emptyHint = 'This prefix has no objects.',\n archivedLabel = 'archived',\n itemsLabel = (count) => `${count} item${count === 1 ? '' : 's'}`,\n format = formatBytes,\n className,\n ...props\n}: ObjectListProps) {\n const controlled = prefixProp !== undefined\n const [uncontrolled, setUncontrolled] = useState('')\n const prefix = controlled ? prefixProp : uncontrolled\n const [query, setQuery] = useState('')\n\n function goTo(next: string) {\n if (!controlled) setUncontrolled(next)\n onPrefixChange?.(next)\n }\n\n const filtered = useMemo(() => {\n const needle = query.trim().toLowerCase()\n return needle ? objects.filter((o) => o.key.toLowerCase().includes(needle)) : objects\n }, [objects, query])\n\n // Searching goes flat: a filter that still hides matches behind folders is\n // not a search.\n const searching = query.trim().length > 0\n const { files, folders } = useMemo(\n () => (searching ? { files: filtered, folders: [] } : groupByPrefix(filtered, prefix)),\n [filtered, prefix, searching],\n )\n\n const crumbs = prefix.split('/').filter(Boolean)\n\n return (\n <div data-slot=\"object-list\" className={cn('flex flex-col gap-3', className)} {...props}>\n {searchable && (\n <Input\n size=\"sm\"\n value={query}\n aria-label={searchLabel}\n placeholder={searchPlaceholder}\n icon={<Search />}\n clearable\n onChange={(event) => setQuery(event.target.value)}\n />\n )}\n\n {!searching && (\n <nav aria-label=\"Prefix\" className=\"text-muted-foreground flex flex-wrap items-center gap-1 text-xs\">\n <button type=\"button\" className={cn('hover:text-foreground', focusRing)} onClick={() => goTo('')}>\n /\n </button>\n {crumbs.map((crumb, index) => (\n <span key={index} className=\"flex items-center gap-1\">\n <ChevronRight className=\"size-3\" aria-hidden=\"true\" />\n <button\n type=\"button\"\n className={cn('hover:text-foreground font-mono', focusRing)}\n onClick={() => goTo(`${crumbs.slice(0, index + 1).join('/')}/`)}\n >\n {crumb}\n </button>\n </span>\n ))}\n </nav>\n )}\n\n {files.length === 0 && folders.length === 0 ? (\n <Empty title={emptyLabel} description={emptyHint} />\n ) : (\n <ul className={cn(surface, radius.surface, 'divide-border list-none divide-y overflow-hidden')}>\n {folders.map((folder) => (\n <li key={folder.name}>\n <button\n type=\"button\"\n onClick={() => goTo(prefix + folder.name)}\n className={cn('hover:bg-accent/40 flex w-full items-center gap-3 px-4 py-2.5 text-start', focusRing)}\n >\n <Folder className=\"text-muted-foreground/60 size-4 shrink-0\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate font-mono text-xs\">{folder.name}</span>\n <span className=\"text-muted-foreground/60 shrink-0 text-[11px] tabular-nums\">\n {itemsLabel(folder.count)}\n </span>\n </button>\n </li>\n ))}\n\n {files.map((object) => {\n const Row = onSelect ? 'button' : 'div'\n return (\n <li key={object.key}>\n <Row\n {...(onSelect ? { type: 'button' as const, onClick: () => onSelect(object) } : {})}\n className={cn(\n 'flex w-full items-center gap-3 px-4 py-2.5 text-start',\n onSelect && cn('hover:bg-accent/40', focusRing),\n )}\n >\n <File className=\"text-muted-foreground/50 size-4 shrink-0\" aria-hidden=\"true\" />\n\n <span className=\"min-w-0 flex-1 truncate font-mono text-xs\" title={object.key}>\n {searching ? object.key : object.key.slice(prefix.length)}\n </span>\n\n {/* Not immediately readable, and that is not obvious from a\n size and a date. */}\n {object.archived && (\n <Badge size=\"sm\" color=\"amber\" className=\"shrink-0\">\n {archivedLabel}\n </Badge>\n )}\n {object.storageClass && !object.archived && (\n <span className=\"text-muted-foreground/50 shrink-0 font-mono text-[10px]\">\n {object.storageClass}\n </span>\n )}\n {object.modified && (\n <span className=\"text-muted-foreground/60 hidden shrink-0 text-[11px] tabular-nums sm:block\">\n {object.modified}\n </span>\n )}\n {object.size !== undefined && (\n <span className=\"w-16 shrink-0 text-end font-mono text-[11px] tabular-nums\">\n {format(object.size)}\n </span>\n )}\n </Row>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { ObjectList }\nexport type { ObjectListProps }\n"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "replication-status",
3
+ "type": "registry:ui",
4
+ "title": "Replication Status",
5
+ "description": "A primary and its replicas with how far behind each one is, in seconds and bytes. Seconds say how stale a read is; bytes say whether it is catching up or falling further behind — a replica idling on 200MB of unsent log shows 0s until traffic arrives.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "lib-styles",
13
+ "lib-utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "components/ui/replication-status.tsx",
18
+ "type": "registry:ui",
19
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Database, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A primary and its replicas, with how far behind each one is.\n *\n * **Lag is reported in seconds and bytes, because they answer different\n * questions.** Seconds tell you how stale a read is; bytes tell you whether the\n * replica is catching up or falling further behind. A replica idling at 200MB\n * of unsent WAL shows 0s lag until traffic arrives, and a dashboard with only\n * seconds calls that healthy right up to the moment it is not.\n *\n * A replica that is *disconnected* is drawn as a failure, not as very high lag.\n * They are different problems: one needs patience, the other needs someone.\n */\nexport type Replica = {\n id: string\n name: string\n /** Seconds behind the primary. */\n lagSeconds?: number\n /** Bytes of replication log not yet applied. */\n lagBytes?: number\n state?: 'streaming' | 'catchup' | 'disconnected'\n /** Serves reads. */\n readable?: boolean\n region?: string\n meta?: ReactNode\n}\n\ntype ReplicationStatusProps = Omit<ComponentProps<'div'>, 'children'> & {\n primary: { name: string; region?: string; writes?: ReactNode }\n replicas: Replica[]\n /** Lag above this many seconds is a problem. */\n warnSeconds?: number\n formatBytes?: (bytes: number) => string\n primaryLabel?: string\n readableLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nconst STATE: Record<NonNullable<Replica['state']>, { label: string; color: 'green' | 'amber' | 'destructive' }> = {\n streaming: { label: 'Streaming', color: 'green' },\n catchup: { label: 'Catching up', color: 'amber' },\n disconnected: { label: 'Disconnected', color: 'destructive' },\n}\n\nfunction ReplicationStatus({\n primary,\n replicas,\n warnSeconds = 10,\n formatBytes = (bytes) => `${(bytes / 1_048_576).toFixed(1)} MB`,\n primaryLabel = 'Primary',\n readableLabel = 'reads',\n emptyLabel = 'No replicas — this database has no read redundancy.',\n label = 'Replication',\n className,\n ...props\n}: ReplicationStatusProps) {\n return (\n <div\n data-slot=\"replication-status\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <p className=\"border-border bg-muted/40 text-muted-foreground/70 border-b px-4 py-2 text-[11px] font-medium tracking-[0.14em] uppercase\">\n {label}\n </p>\n\n <div className=\"border-border flex items-center gap-3 border-b px-4 py-3\">\n <Database className=\"text-muted-foreground size-4 shrink-0\" aria-hidden=\"true\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-sm font-medium\">{primary.name}</code>\n <Badge size=\"sm\" color=\"blue\">{primaryLabel}</Badge>\n </div>\n {primary.region && (\n <p className=\"text-muted-foreground/60 mt-0.5 font-mono text-[11px]\">{primary.region}</p>\n )}\n </div>\n {primary.writes && (\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {primary.writes}\n </span>\n )}\n </div>\n\n {replicas.length === 0 ? (\n <p className=\"flex items-start gap-2 px-4 py-3 text-xs text-[var(--amber-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {emptyLabel}\n </p>\n ) : (\n <ul className=\"divide-border list-none divide-y\">\n {replicas.map((replica) => {\n const state = STATE[replica.state ?? 'streaming']\n const lagging =\n replica.state !== 'disconnected' &&\n replica.lagSeconds !== undefined &&\n replica.lagSeconds > warnSeconds\n\n return (\n <li key={replica.id} className=\"flex items-center gap-3 px-4 py-3 ps-8\">\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <code className=\"truncate font-mono text-xs\">{replica.name}</code>\n <Badge size=\"sm\" color={state.color}>{state.label}</Badge>\n {replica.readable && (\n <span className=\"text-muted-foreground/50 text-[11px]\">{readableLabel}</span>\n )}\n </div>\n {(replica.region || replica.meta) && (\n <p className=\"text-muted-foreground/60 mt-0.5 flex gap-x-3 font-mono text-[11px]\">\n {replica.region}\n {replica.meta}\n </p>\n )}\n </div>\n\n {/* Both numbers: seconds say how stale a read is, bytes say\n whether it is catching up or falling further behind. */}\n <div className=\"flex shrink-0 flex-col items-end gap-0.5 font-mono text-[11px] tabular-nums\">\n {replica.lagSeconds !== undefined && (\n <span\n className={cn(\n lagging ? 'text-[var(--destructive-soft-foreground)]' : 'text-foreground',\n )}\n >\n {replica.lagSeconds.toFixed(1)}s\n </span>\n )}\n {replica.lagBytes !== undefined && (\n <span className=\"text-muted-foreground/60\">{formatBytes(replica.lagBytes)}</span>\n )}\n </div>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { ReplicationStatus }\nexport type { ReplicationStatusProps }\n"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "slow-query-log",
3
+ "type": "registry:ui",
4
+ "title": "Slow Query Log",
5
+ "description": "The queries costing you the most, ranked by total time rather than mean. The 9-second report running twice a day is easy to find; the 40ms lookup running four thousand times a minute is the one saturating the database.",
6
+ "category": "Data",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge",
12
+ "code-block",
13
+ "lib-styles",
14
+ "lib-utils"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "components/ui/slow-query-log.tsx",
19
+ "type": "registry:ui",
20
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronRight, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The queries costing you the most, ranked by total time.\n *\n * **Total time, not mean.** The query everyone finds first is the 9-second\n * report that runs twice a day; the one actually saturating the database is the\n * 40ms lookup running four thousand times a minute. Ranking by `mean × calls`\n * surfaces the second, which is the whole point of a slow query log and the\n * thing a naive \"slowest queries\" list gets backwards.\n *\n * Statements are normalised by the caller — `$1` rather than a literal — so the\n * same query with different parameters aggregates into one row instead of ten\n * thousand.\n *\n * A query with no index scan is flagged. It is the single most actionable\n * finding here and it is invisible in the timings alone.\n */\nexport type SlowQuery = {\n id: string\n /** Normalised statement — parameters replaced with placeholders. */\n statement: string\n /** Executions in the window. */\n calls: number\n /** Milliseconds. */\n meanMs: number\n p95Ms?: number\n /** Rows returned per call, on average. */\n rows?: number\n /** No index was used. */\n seqScan?: boolean\n meta?: ReactNode\n}\n\ntype SlowQueryLogProps = Omit<ComponentProps<'div'>, 'children'> & {\n queries: SlowQuery[]\n /** Rank by total time rather than by mean. */\n rankByTotal?: boolean\n seqScanLabel?: string\n emptyLabel?: string\n formatDuration?: (ms: number) => string\n headers?: { query?: string; calls?: string; mean?: string; total?: string }\n label?: string\n}\n\nfunction defaultDuration(ms: number) {\n if (ms < 1) return `${ms.toFixed(2)}ms`\n if (ms < 1000) return `${Math.round(ms)}ms`\n return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`\n}\n\nfunction SlowQueryLog({\n queries,\n rankByTotal = true,\n seqScanLabel = 'no index',\n emptyLabel = 'No queries in this window.',\n formatDuration = defaultDuration,\n headers,\n label = 'Slow queries',\n className,\n ...props\n}: SlowQueryLogProps) {\n const [open, setOpen] = useState<string | null>(null)\n\n const rows = useMemo(() => {\n const withTotal = queries.map((query) => ({ ...query, totalMs: query.meanMs * query.calls }))\n // A copy — sorting the caller's array in place is a side effect on a prop.\n return rankByTotal\n ? [...withTotal].sort((a, b) => b.totalMs - a.totalMs)\n : [...withTotal].sort((a, b) => b.meanMs - a.meanMs)\n }, [queries, rankByTotal])\n\n const worst = Math.max(1, ...rows.map((row) => row.totalMs))\n\n return (\n <div\n data-slot=\"slow-query-log\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n <div className=\"border-border text-muted-foreground/70 flex items-center gap-3 border-b px-4 py-2 text-[11px] tracking-wide uppercase\">\n <span className=\"min-w-0 flex-1\">{headers?.query ?? label}</span>\n <span className=\"w-16 shrink-0 text-end\">{headers?.calls ?? 'Calls'}</span>\n <span className=\"w-16 shrink-0 text-end\">{headers?.mean ?? 'Mean'}</span>\n <span className=\"w-20 shrink-0 text-end\">{headers?.total ?? 'Total'}</span>\n </div>\n\n {rows.length === 0 ? (\n <p className=\"text-muted-foreground px-4 py-3 text-xs\">{emptyLabel}</p>\n ) : (\n <ul className=\"divide-border/60 list-none divide-y\">\n {rows.map((row) => {\n const expanded = open === row.id\n return (\n <li key={row.id}>\n <button\n type=\"button\"\n aria-expanded={expanded}\n aria-label={`${expanded ? 'Hide' : 'Show'} the statement`}\n onClick={() => setOpen(expanded ? null : row.id)}\n className=\"hover:bg-accent/40 flex w-full items-center gap-3 px-4 py-2.5 text-start\"\n >\n <ChevronRight\n className={cn(\n 'text-muted-foreground size-3.5 shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none',\n expanded && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n\n <div className=\"min-w-0 flex-1\">\n <code className=\"block truncate font-mono text-xs\">{row.statement}</code>\n {/* Ranked bar: the row taking the most database time is the\n longest, which is the ordering that matters. */}\n <div className=\"bg-muted/60 mt-1.5 h-1 w-full overflow-hidden rounded-full\">\n <div\n className={cn(\n 'h-full rounded-full',\n row.seqScan\n ? 'bg-[var(--amber-soft-foreground)]'\n : 'bg-muted-foreground/50',\n )}\n style={{ width: `${Math.max(2, (row.totalMs / worst) * 100)}%` }}\n />\n </div>\n </div>\n\n {row.seqScan && (\n <Badge size=\"sm\" color=\"amber\" className=\"shrink-0\">\n <TriangleAlert className=\"size-3\" aria-hidden=\"true\" />\n {seqScanLabel}\n </Badge>\n )}\n\n <span className=\"text-muted-foreground w-16 shrink-0 text-end font-mono text-[11px] tabular-nums\">\n {row.calls.toLocaleString()}\n </span>\n <span className=\"w-16 shrink-0 text-end font-mono text-[11px] tabular-nums\">\n {formatDuration(row.meanMs)}\n </span>\n <span className=\"w-20 shrink-0 text-end font-mono text-[11px] font-medium tabular-nums\">\n {formatDuration(row.totalMs)}\n </span>\n </button>\n\n {expanded && (\n <div className=\"px-4 pb-3\">\n <CodeBlock code={row.statement} language=\"sql\" header={false} />\n {(row.rows !== undefined || row.p95Ms !== undefined || row.meta) && (\n <p className=\"text-muted-foreground/70 mt-2 flex flex-wrap gap-x-4 text-[11px] tabular-nums\">\n {row.p95Ms !== undefined && <span>p95 {formatDuration(row.p95Ms)}</span>}\n {row.rows !== undefined && <span>{row.rows.toLocaleString()} rows/call</span>}\n {row.meta}\n </p>\n )}\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n )\n}\n\nexport { SlowQueryLog }\nexport type { SlowQueryLogProps }\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "storage-usage",
3
+ "type": "registry:ui",
4
+ "title": "Storage Usage",
5
+ "description": "A quota and what is filling it. The bar is scaled to the quota, not to what is used, so free space reads as free space — a bar normalised to the total consumed is always full, which is backwards for a component answering “how much room is left”.",
6
+ "category": "Storage",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "lib-styles",
12
+ "lib-utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "components/ui/storage-usage.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A quota and what is filling it.\n *\n * The bar is scaled to the **quota**, not to what is used, so free space reads\n * as free space. A bar normalised to the total consumed is always full, which\n * is exactly backwards for a component whose job is \"how much room is left\".\n *\n * Segments below a pixel still get a sliver, for the reason every stacked bar\n * in this kit does: \"logs are tiny\" and \"there are no logs\" must not render\n * identically.\n *\n * Over quota is a real state, not a clamped bar. Object stores keep accepting\n * writes and bill for them, so a component that silently pins at 100% hides the\n * one number someone is about to be charged for.\n */\nexport type StorageSegment = {\n id: string\n label: ReactNode\n bytes: number\n color?: string\n}\n\nconst COLORS = [\n 'var(--blue-soft-foreground)',\n 'var(--violet-soft-foreground)',\n 'var(--cyan-soft-foreground)',\n 'var(--amber-soft-foreground)',\n 'var(--green-soft-foreground)',\n]\n\nexport function formatBytes(bytes: number) {\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']\n let value = Math.abs(bytes)\n let unit = 0\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024\n unit++\n }\n return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${units[unit]}`\n}\n\ntype StorageUsageProps = Omit<ComponentProps<'div'>, 'children'> & {\n segments: StorageSegment[]\n /** Bytes. Omit for an unmetered store — the bar then scales to the total. */\n quota?: number\n /** Warn past this fraction of the quota. */\n warnAt?: number\n label?: ReactNode\n freeLabel?: string\n overLabel?: (over: string) => ReactNode\n format?: (bytes: number) => string\n}\n\nfunction StorageUsage({\n segments,\n quota,\n warnAt = 0.85,\n label = 'Storage',\n freeLabel = 'Free',\n overLabel = (over) => `${over} over quota`,\n format = formatBytes,\n className,\n ...props\n}: StorageUsageProps) {\n const used = segments.reduce((total, segment) => total + segment.bytes, 0)\n const ceiling = quota ?? (used || 1)\n const free = quota === undefined ? undefined : Math.max(0, quota - used)\n const over = quota !== undefined && used > quota ? used - quota : 0\n const pressure = used / ceiling\n\n return (\n <div\n data-slot=\"storage-usage\"\n className={cn(surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <p className=\"text-sm font-medium\">{label}</p>\n <p\n className={cn(\n 'font-mono text-xs tabular-nums',\n over > 0\n ? 'text-[var(--destructive-soft-foreground)]'\n : pressure >= warnAt\n ? 'text-[var(--amber-soft-foreground)]'\n : 'text-muted-foreground',\n )}\n >\n {format(used)}\n {quota !== undefined && <> / {format(quota)}</>}\n </p>\n </div>\n\n <div className=\"bg-muted flex h-2.5 w-full overflow-hidden rounded-full\">\n {segments.map((segment, index) => (\n <span\n key={segment.id}\n className=\"h-full\"\n style={{\n width: `${Math.min(100, (segment.bytes / ceiling) * 100)}%`,\n minWidth: segment.bytes > 0 ? 2 : 0,\n background: segment.color ?? COLORS[index % COLORS.length],\n }}\n />\n ))}\n </div>\n\n {/* Not clamped: an object store keeps accepting writes and billing for\n them, and pinning the bar at 100% hides the overage. */}\n {over > 0 && (\n <p className=\"flex items-center gap-1.5 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n {overLabel(format(over))}\n </p>\n )}\n\n <ul className=\"flex list-none flex-wrap gap-x-4 gap-y-1.5\">\n {segments.map((segment, index) => (\n <li key={segment.id} className=\"flex items-center gap-1.5 text-xs\">\n <span\n aria-hidden=\"true\"\n className=\"size-2 shrink-0 rounded-full\"\n style={{ background: segment.color ?? COLORS[index % COLORS.length] }}\n />\n <span className=\"text-muted-foreground\">{segment.label}</span>\n <span className=\"tabular-nums\">{format(segment.bytes)}</span>\n </li>\n ))}\n {free !== undefined && (\n <li className=\"text-muted-foreground/60 flex items-center gap-1.5 text-xs\">\n {freeLabel}\n <span className=\"tabular-nums\">{format(free)}</span>\n </li>\n )}\n </ul>\n </div>\n )\n}\n\nexport { StorageUsage }\nexport type { StorageUsageProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "story",
3
+ "type": "registry:ui",
4
+ "title": "Story",
5
+ "description": "A full-screen sequence of panels over a darkened page. A dialog underneath — same overlay, focus trap, Escape and scroll lock — but it advances on its own, and everything else follows from that.",
6
+ "category": "Overlays",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "lib-styles",
13
+ "lib-utils",
14
+ "primitive-focus-trap",
15
+ "primitive-portal"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/story.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Pause, Play, X } from 'lucide-react'\nimport { Portal } from '@/components/primitives/portal'\nimport { FocusTrap } from '@/components/primitives/focus-trap'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A full-screen sequence of panels over a darkened page — the stories pattern.\n *\n * It is a dialog underneath: same overlay, same focus trap, same Escape, same\n * scroll lock. What makes it a story rather than a modal is that it **advances\n * on its own**, and everything below follows from that.\n *\n * **The timer is wall-clock, and it does not use `requestAnimationFrame`.**\n * rAF does not fire at all in a hidden tab, so a story in a background tab\n * simply stops — and a counter decremented on an interval drifts instead.\n * Elapsed time is recomputed from `Date.now()` on a coarse interval, which is\n * correct whatever the interval actually did, and a CSS transition on the bar\n * makes 20fps of state look continuous.\n *\n * **It pauses on press, and on focus.** Holding to read is the gesture everyone\n * already knows. Focus matters more: a panel containing a link or a button is\n * unusable if it advances while you are tabbing through it, so any focus inside\n * the content stops the clock.\n *\n * **`prefers-reduced-motion` removes the animation, not the story.** An earlier\n * version froze the timer outright, which meant anyone with that setting on\n * opened a story that silently never advanced and said nothing about why. Now\n * the bar jumps between steps instead of gliding, and the panels still turn.\n *\n * **There is a real pause button.** Holding to pause is the expected gesture\n * but it is neither discoverable nor reachable from a keyboard, and\n * auto-advancing content has to be pausable by everyone.\n *\n * Panels are `ReactNode`, so a story panel is not limited to an image — a chart,\n * a form, a changelog entry all work.\n */\nexport type StoryPanel = {\n id: string\n content: ReactNode\n /** Milliseconds. Falls back to the story-level `duration`. */\n duration?: number\n}\n\ntype StoryProps = Omit<ComponentProps<'div'>, 'content' | 'onSelect'> & {\n open: boolean\n onOpenChange: (open: boolean) => void\n panels: StoryPanel[]\n /** Panel to start on. Controlled when paired with `onIndexChange`. */\n index?: number\n onIndexChange?: (index: number) => void\n /** Default panel duration. */\n duration?: number\n /** Advance automatically. Off makes it a plain panel viewer. */\n autoPlay?: boolean\n /** Fires after the last panel finishes. Defaults to closing. */\n onFinish?: () => void\n /** Header slot — an avatar, a name, a timestamp. */\n header?: ReactNode\n closeLabel?: string\n previousLabel?: string\n nextLabel?: string\n pausedLabel?: string\n pauseLabel?: string\n resumeLabel?: string\n /**\n * Panel width. Stories are portrait by convention.\n *\n * Height follows from it at 9:16 unless you set one — a story is a fixed\n * shape, not a column that stretches to the window. Tying the height to the\n * viewport instead produced a 359x1163 sliver on a tall screen.\n */\n width?: number | string\n /** Panel height. Defaults to filling `maxHeight`. */\n height?: number | string\n /** Width over height. 9:16 — the shape the pattern is named for. */\n ratio?: number\n /** Caps for small windows, so the fixed box still fits. */\n maxWidth?: number | string\n maxHeight?: number | string\n}\n\nfunction Story({\n open,\n onOpenChange,\n panels,\n index: indexProp,\n onIndexChange,\n duration = 5000,\n autoPlay = true,\n onFinish,\n header,\n closeLabel = 'Close',\n previousLabel = 'Previous',\n nextLabel = 'Next',\n pausedLabel = 'Paused',\n pauseLabel = 'Pause',\n resumeLabel = 'Resume',\n width,\n height,\n ratio = 9 / 16,\n maxWidth = '92vw',\n maxHeight = '88vh',\n className,\n ...props\n}: StoryProps) {\n /**\n * One dimension plus an aspect ratio, never both dimensions.\n *\n * With both set, `max-height` biting on a short window clamps the height\n * alone and squashes the shape. With a ratio, the other side follows and the\n * story stays a story.\n *\n * The default fills the available height, because on a desktop a fixed 400px\n * box floats in the middle of a large screen looking like a dialog rather\n * than a story.\n */\n const box: React.CSSProperties =\n width !== undefined\n ? { width, aspectRatio: ratio, maxWidth, maxHeight }\n : { height: height ?? maxHeight, aspectRatio: ratio, maxWidth, maxHeight }\n\n const controlled = indexProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(0)\n const index = controlled ? indexProp : uncontrolled\n\n const [progress, setProgress] = useState(0)\n // How far into the current panel we are, in milliseconds. A ref because the\n // animation loop reads it to resume after a pause, and a state read there\n // would either restart the loop every frame or capture a stale value.\n const elapsedRef = useRef(0)\n const [held, setHeld] = useState(false)\n const [focusInside, setFocusInside] = useState(false)\n\n // The system setting is read once per open rather than at module scope, so a\n // viewer who changes it mid-session gets the new behaviour next time.\n const [reducedMotion, setReducedMotion] = useState(false)\n useEffect(() => {\n if (!open) return\n setReducedMotion(window.matchMedia('(prefers-reduced-motion: reduce)').matches)\n }, [open])\n\n const [manuallyPaused, setManuallyPaused] = useState(false)\n const paused = held || focusInside || manuallyPaused || !autoPlay\n\n /**\n * The current panel's length, as a number.\n *\n * The animation effect depends on this rather than on `panels`, because an\n * array literal built in the caller's render is a new identity every time —\n * and this effect sets state on every frame. Depending on the array meant\n * tearing the loop down and rebuilding it sixty times a second, which ran the\n * story at several times real speed and then wedged it.\n */\n const panelDuration = panels[index]?.duration ?? duration\n\n const go = useCallback(\n (next: number) => {\n if (next >= panels.length) {\n if (onFinish) onFinish()\n else onOpenChange(false)\n return\n }\n const clamped = Math.max(0, next)\n if (!controlled) setUncontrolled(clamped)\n onIndexChange?.(clamped)\n elapsedRef.current = 0\n setProgress(0)\n },\n [controlled, onFinish, onIndexChange, onOpenChange, panels.length],\n )\n\n // Reset to the first panel each time it opens, unless the caller drives it.\n useEffect(() => {\n if (!open || controlled) return\n setUncontrolled(0)\n elapsedRef.current = 0\n setProgress(0)\n }, [open, controlled])\n\n /**\n * Wall-clock progress.\n *\n * `elapsed` accumulates only while running, so a pause does not silently\n * consume the panel's time — the classic bug in a story that decrements a\n * counter on an interval.\n */\n useEffect(() => {\n if (!open || paused) return\n\n const total = panelDuration\n let last = Date.now()\n\n const id = setInterval(() => {\n const now = Date.now()\n // Accumulates only while running, so pausing does not silently consume\n // the panel's time. Recomputed from the clock, so an interval that fired\n // late — or a tab that was throttled — cannot make it drift.\n elapsedRef.current += now - last\n last = now\n\n const fraction = Math.min(1, elapsedRef.current / total)\n setProgress(fraction)\n\n if (fraction >= 1) go(index + 1)\n }, 50)\n\n return () => clearInterval(id)\n }, [open, paused, index, panelDuration, go])\n\n useEffect(() => {\n if (!open) return\n\n const onKey = (event: KeyboardEvent) => {\n if (event.key === 'Escape') onOpenChange(false)\n if (event.key === 'ArrowRight') go(index + 1)\n if (event.key === 'ArrowLeft') go(index - 1)\n }\n\n document.addEventListener('keydown', onKey)\n // The page behind must not scroll while a full-screen layer is up.\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n\n return () => {\n document.removeEventListener('keydown', onKey)\n document.body.style.overflow = previous\n }\n }, [open, index, go, onOpenChange])\n\n if (!open) return null\n\n const panel = panels[index]\n\n return (\n <Portal>\n <div\n data-slot=\"story\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Story\"\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-sm\"\n onPointerDown={(event) => {\n // A press on the backdrop closes; a press on the panel column does\n // not, and is handled by the tap zones inside it.\n if (event.target === event.currentTarget) onOpenChange(false)\n }}\n {...props}\n >\n <FocusTrap>\n <div\n className={cn('relative flex flex-col', className)}\n // Neither dimension can be a percentage — FocusTrap renders a\n // bare wrapper between this and the overlay, so a percentage would\n // resolve against a content-sized div and collapse the column.\n style={box}\n >\n {/* One segment per panel: filled behind, animating on the current\n one, empty ahead. The whole sequence has to be legible at a\n glance — how far in you are is the question a story answers. */}\n <div className=\"flex shrink-0 gap-1\" aria-hidden=\"true\">\n {panels.map((item, position) => (\n <span key={item.id} className=\"h-0.5 flex-1 overflow-hidden rounded-full bg-white/30\">\n <span\n className={cn(\n 'block h-full rounded-full bg-white',\n // 20fps of state looks continuous with a transition, and\n // reduced motion gets the steps instead of the glide.\n !reducedMotion && 'transition-[width] duration-100 ease-linear',\n )}\n style={{\n width:\n position < index ? '100%' : position === index ? `${progress * 100}%` : '0%',\n }}\n />\n </span>\n ))}\n </div>\n\n <div className=\"flex shrink-0 items-center gap-2 py-3 text-white\">\n <div className=\"min-w-0 flex-1\">{header}</div>\n {autoPlay && (\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={manuallyPaused ? resumeLabel : pauseLabel}\n className=\"text-white hover:bg-white/15 hover:text-white\"\n onClick={() => setManuallyPaused((current) => !current)}\n >\n {manuallyPaused ? <Play /> : <Pause />}\n </Button>\n )}\n {paused && autoPlay && (\n <span className=\"text-[11px] text-white/60\">{pausedLabel}</span>\n )}\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={closeLabel}\n className=\"text-white hover:bg-white/15 hover:text-white\"\n onClick={() => onOpenChange(false)}\n >\n <X />\n </Button>\n </div>\n\n <div\n className={cn(\n 'relative min-h-0 flex-1 overflow-hidden bg-neutral-950 text-white',\n radius.panel,\n )}\n // Hold to pause — the gesture everyone already knows from the\n // apps this pattern comes from.\n onPointerDown={() => setHeld(true)}\n onPointerUp={() => setHeld(false)}\n onPointerLeave={() => setHeld(false)}\n >\n {/* Focus-pausing is scoped to the content, not the whole\n column. On the column it also caught the tap zones and the\n close button — so one tap focused a zone, `focusInside`\n latched true, and the story never advanced again. */}\n <div\n className=\"h-full overflow-auto\"\n onFocusCapture={() => setFocusInside(true)}\n onBlurCapture={() => setFocusInside(false)}\n >\n {panel?.content}\n </div>\n\n {/* Tap zones sit above the content but below anything focusable\n in it, so a button inside a panel still takes its own click.\n They are real buttons, so the sequence is keyboard- and\n screen-reader-navigable rather than pointer-only. */}\n <button\n type=\"button\"\n aria-label={previousLabel}\n onClick={() => go(index - 1)}\n className={cn('absolute inset-y-0 start-0 w-1/3 cursor-default', focusRing)}\n />\n <button\n type=\"button\"\n aria-label={nextLabel}\n onClick={() => go(index + 1)}\n className={cn('absolute inset-y-0 end-0 w-1/3 cursor-default', focusRing)}\n />\n </div>\n </div>\n </FocusTrap>\n </div>\n </Portal>\n )\n}\n\nexport { Story }\nexport type { StoryProps }\n"
22
+ }
23
+ ]
24
+ }