astralyx-ui 0.2.1 → 0.3.1

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.1",
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.1",
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",
@@ -1820,6 +1964,19 @@
1820
1964
  "lib-utils"
1821
1965
  ]
1822
1966
  },
1967
+ {
1968
+ "name": "knowledge-graph",
1969
+ "type": "registry:ui",
1970
+ "title": "Knowledge Graph",
1971
+ "description": "Entities and the named relations between them. Where a note graph answers what is connected, this answers how — and the edge label carries as much meaning as the nodes, so relations are labelled and directed.",
1972
+ "category": "Knowledge",
1973
+ "dependencies": [],
1974
+ "registryDependencies": [
1975
+ "lib-styles",
1976
+ "lib-use-force-graph",
1977
+ "lib-utils"
1978
+ ]
1979
+ },
1823
1980
  {
1824
1981
  "name": "label-picker",
1825
1982
  "type": "registry:ui",
@@ -1986,6 +2143,23 @@
1986
2143
  "lib-utils"
1987
2144
  ]
1988
2145
  },
2146
+ {
2147
+ "name": "markdown",
2148
+ "type": "registry:ui",
2149
+ "title": "Markdown",
2150
+ "description": "Rendered markdown with a switch to the source. It builds React nodes and never sets innerHTML, so a document you did not write cannot execute — and the RAW toggle is there because rendered output hides exactly what you look at when it renders wrongly.",
2151
+ "category": "Knowledge",
2152
+ "dependencies": [
2153
+ "lucide-react@^1.39.0"
2154
+ ],
2155
+ "registryDependencies": [
2156
+ "button",
2157
+ "code-block",
2158
+ "copy-button",
2159
+ "lib-styles",
2160
+ "lib-utils"
2161
+ ]
2162
+ },
1989
2163
  {
1990
2164
  "name": "market-table",
1991
2165
  "type": "registry:ui",
@@ -2431,6 +2605,19 @@
2431
2605
  "lib-utils"
2432
2606
  ]
2433
2607
  },
2608
+ {
2609
+ "name": "note-graph",
2610
+ "type": "registry:ui",
2611
+ "title": "Note Graph",
2612
+ "description": "A vault of linked notes laid out by force — the Obsidian-shaped view. Nodes are sized by how many links touch them, hovering dims everything outside the neighbourhood, and orphans are drawn hollow rather than hidden.",
2613
+ "category": "Knowledge",
2614
+ "dependencies": [],
2615
+ "registryDependencies": [
2616
+ "lib-styles",
2617
+ "lib-use-force-graph",
2618
+ "lib-utils"
2619
+ ]
2620
+ },
2434
2621
  {
2435
2622
  "name": "notification-inbox",
2436
2623
  "type": "registry:ui",
@@ -2463,6 +2650,24 @@
2463
2650
  "lib-utils"
2464
2651
  ]
2465
2652
  },
2653
+ {
2654
+ "name": "object-list",
2655
+ "type": "registry:ui",
2656
+ "title": "Object List",
2657
+ "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.",
2658
+ "category": "Storage",
2659
+ "dependencies": [
2660
+ "lucide-react@^1.39.0"
2661
+ ],
2662
+ "registryDependencies": [
2663
+ "badge",
2664
+ "empty",
2665
+ "input",
2666
+ "lib-styles",
2667
+ "lib-utils",
2668
+ "storage-usage"
2669
+ ]
2670
+ },
2466
2671
  {
2467
2672
  "name": "odds-display",
2468
2673
  "type": "registry:ui",
@@ -2981,6 +3186,21 @@
2981
3186
  "lib-utils"
2982
3187
  ]
2983
3188
  },
3189
+ {
3190
+ "name": "replication-status",
3191
+ "type": "registry:ui",
3192
+ "title": "Replication Status",
3193
+ "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.",
3194
+ "category": "Data",
3195
+ "dependencies": [
3196
+ "lucide-react@^1.39.0"
3197
+ ],
3198
+ "registryDependencies": [
3199
+ "badge",
3200
+ "lib-styles",
3201
+ "lib-utils"
3202
+ ]
3203
+ },
2984
3204
  {
2985
3205
  "name": "request-builder",
2986
3206
  "type": "registry:ui",
@@ -3444,6 +3664,22 @@
3444
3664
  "lib-utils"
3445
3665
  ]
3446
3666
  },
3667
+ {
3668
+ "name": "slow-query-log",
3669
+ "type": "registry:ui",
3670
+ "title": "Slow Query Log",
3671
+ "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.",
3672
+ "category": "Data",
3673
+ "dependencies": [
3674
+ "lucide-react@^1.39.0"
3675
+ ],
3676
+ "registryDependencies": [
3677
+ "badge",
3678
+ "code-block",
3679
+ "lib-styles",
3680
+ "lib-utils"
3681
+ ]
3682
+ },
3447
3683
  {
3448
3684
  "name": "sparkline",
3449
3685
  "type": "registry:ui",
@@ -3546,6 +3782,37 @@
3546
3782
  "spinner"
3547
3783
  ]
3548
3784
  },
3785
+ {
3786
+ "name": "storage-usage",
3787
+ "type": "registry:ui",
3788
+ "title": "Storage Usage",
3789
+ "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”.",
3790
+ "category": "Storage",
3791
+ "dependencies": [
3792
+ "lucide-react@^1.39.0"
3793
+ ],
3794
+ "registryDependencies": [
3795
+ "lib-styles",
3796
+ "lib-utils"
3797
+ ]
3798
+ },
3799
+ {
3800
+ "name": "story",
3801
+ "type": "registry:ui",
3802
+ "title": "Story",
3803
+ "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.",
3804
+ "category": "Overlays",
3805
+ "dependencies": [
3806
+ "lucide-react@^1.39.0"
3807
+ ],
3808
+ "registryDependencies": [
3809
+ "button",
3810
+ "lib-styles",
3811
+ "lib-utils",
3812
+ "primitive-focus-trap",
3813
+ "primitive-portal"
3814
+ ]
3815
+ },
3549
3816
  {
3550
3817
  "name": "stream-inspector",
3551
3818
  "type": "registry:ui",
@@ -4537,6 +4804,13 @@
4537
4804
  "dependencies": [],
4538
4805
  "registryDependencies": []
4539
4806
  },
4807
+ {
4808
+ "name": "lib-use-force-graph",
4809
+ "type": "registry:lib",
4810
+ "title": "use-force-graph",
4811
+ "dependencies": [],
4812
+ "registryDependencies": []
4813
+ },
4540
4814
  {
4541
4815
  "name": "lib-use-uploads",
4542
4816
  "type": "registry:lib",
@@ -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,20 @@
1
+ {
2
+ "name": "knowledge-graph",
3
+ "type": "registry:ui",
4
+ "title": "Knowledge Graph",
5
+ "description": "Entities and the named relations between them. Where a note graph answers what is connected, this answers how — and the edge label carries as much meaning as the nodes, so relations are labelled and directed.",
6
+ "category": "Knowledge",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-use-force-graph",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/knowledge-graph.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import {\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { useForceGraph } from '@/lib/use-force-graph'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Entities and the named relations between them.\n *\n * The distinction from `NoteGraph`, which shares the same layout: a note graph\n * answers *what is connected*, and every edge means the same thing — \"links\n * to\". A knowledge graph answers *how* things are connected, and the edge label\n * carries as much meaning as the nodes. \"Ada — **founded** → Astralyx\" and\n * \"Ada — **left** → Astralyx\" are the same two circles and the same line.\n *\n * So relations are labelled and **directed**, drawn with an arrowhead, because\n * `employs` and `employed by` are not the same fact and a knowledge graph that\n * loses direction is a set of vague associations.\n *\n * Entities are drawn as labelled pills rather than circles. A knowledge graph\n * is read entity-first — you are looking for *Astralyx*, not for a hub — and a\n * circle with a caption underneath makes you match shapes to text. This is\n * denser and it is why these graphs stay smaller than note vaults.\n */\nexport type Entity = {\n id: string\n label: string\n /** 'person', 'company', 'concept' — colours the pill and the legend. */\n type?: string\n}\n\nexport type Relation = {\n source: string\n target: string\n /** The verb. The reason this component exists. */\n label?: string\n}\n\ntype KnowledgeGraphProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n entities: Entity[]\n relations: Relation[]\n onSelect?: (entity: Entity) => void\n selectedId?: string\n height?: number | string\n /** Hide relation labels when the graph is dense enough that they collide. */\n showRelationLabels?: boolean\n colorFor?: (type: string | undefined) => string\n emptyLabel?: string\n label?: string\n}\n\nconst TYPE_COLORS = [\n 'var(--violet-soft)',\n 'var(--blue-soft)',\n 'var(--green-soft)',\n 'var(--amber-soft)',\n 'var(--cyan-soft)',\n]\nconst TYPE_INK = [\n 'var(--violet-soft-foreground)',\n 'var(--blue-soft-foreground)',\n 'var(--green-soft-foreground)',\n 'var(--amber-soft-foreground)',\n 'var(--cyan-soft-foreground)',\n]\n\nfunction KnowledgeGraph({\n entities,\n relations,\n onSelect,\n selectedId,\n height = 460,\n showRelationLabels = true,\n colorFor,\n emptyLabel = 'Nothing in this graph yet.',\n label = 'Knowledge graph',\n className,\n ...props\n}: KnowledgeGraphProps) {\n const boxRef = useRef<HTMLDivElement>(null)\n const [size, setSize] = useState({ width: 720, height: 460 })\n const [hovered, setHovered] = useState<string | null>(null)\n const dragging = useRef<string | null>(null)\n // `useId`, not a random string: a random id differs between the server\n // render and the hydrated one, which is a mismatch React will replace the\n // whole subtree over.\n const arrowId = `arrow-${useId().replace(/:/g, '')}`\n\n const links = useMemo(\n () => relations.map((relation) => ({ source: relation.source, target: relation.target })),\n [relations],\n )\n // Longer links than a note graph: the labels sit on them and need the room.\n const { positions, drag, release } = useForceGraph(entities, links, {\n ...size,\n linkDistance: 130,\n charge: 4200,\n })\n\n const types = useMemo(\n () => [...new Set(entities.map((entity) => entity.type).filter(Boolean))] as string[],\n [entities],\n )\n\n const fill = (type: string | undefined) =>\n colorFor?.(type) ?? (type ? TYPE_COLORS[types.indexOf(type) % TYPE_COLORS.length] : 'var(--muted)')\n const ink = (type: string | undefined) =>\n type ? TYPE_INK[types.indexOf(type) % TYPE_INK.length] : 'var(--muted-foreground)'\n\n const at = useMemo(() => new Map(positions.map((p) => [p.id, p])), [positions])\n\n const neighbours = useMemo(() => {\n const map = new Map<string, Set<string>>()\n for (const entity of entities) map.set(entity.id, new Set())\n for (const relation of relations) {\n map.get(relation.source)?.add(relation.target)\n map.get(relation.target)?.add(relation.source)\n }\n return map\n }, [entities, relations])\n\n const lit = (id: string) =>\n !hovered || hovered === id || neighbours.get(hovered)?.has(id) === true\n\n function pointerToBox(event: ReactPointerEvent) {\n const box = boxRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return { x: event.clientX - box.left, y: event.clientY - box.top }\n }\n\n return (\n <div\n data-slot=\"knowledge-graph\"\n ref={(node) => {\n boxRef.current = node\n if (node) {\n const box = node.getBoundingClientRect()\n if (Math.abs(box.width - size.width) > 1 || Math.abs(box.height - size.height) > 1) {\n setSize({ width: box.width, height: box.height })\n }\n }\n }}\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n onPointerMove={(event) => {\n if (!dragging.current) return\n const point = pointerToBox(event)\n drag(dragging.current, point.x, point.y)\n }}\n onPointerUp={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n }}\n onPointerLeave={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n setHovered(null)\n }}\n {...props}\n >\n {entities.length === 0 ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : (\n <svg className=\"h-full w-full touch-none\" role=\"img\" aria-label={label}>\n <defs>\n {/* Scoped per instance: a document-wide id would be reused by a\n second graph on the page and inherit the first one's colour. */}\n <marker\n id={arrowId}\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"5\"\n markerHeight=\"5\"\n orient=\"auto-start-reverse\"\n >\n <path d=\"M 0 0 L 10 5 L 0 10 z\" className=\"fill-border\" />\n </marker>\n </defs>\n\n <g>\n {relations.map((relation, index) => {\n const a = at.get(relation.source)\n const b = at.get(relation.target)\n if (!a || !b) return null\n const shown = lit(relation.source) && lit(relation.target)\n\n // Stop the line short of the pill so the arrowhead is not buried\n // underneath it.\n const dx = b.x - a.x\n const dy = b.y - a.y\n const distance = Math.hypot(dx, dy) || 1\n const inset = 44\n const x2 = b.x - (dx / distance) * inset\n const y2 = b.y - (dy / distance) * inset\n\n return (\n <g key={index} opacity={shown ? 1 : 0.1}>\n <line\n x1={a.x}\n y1={a.y}\n x2={x2}\n y2={y2}\n className=\"stroke-border\"\n strokeWidth={1.2}\n markerEnd={`url(#${arrowId})`}\n />\n {showRelationLabels && relation.label && (\n <text\n x={(a.x + x2) / 2}\n y={(a.y + y2) / 2 - 4}\n textAnchor=\"middle\"\n className=\"fill-muted-foreground pointer-events-none text-[9px]\"\n >\n {relation.label}\n </text>\n )}\n </g>\n )\n })}\n </g>\n\n <g>\n {entities.map((entity) => {\n const position = at.get(entity.id)\n if (!position) return null\n\n const shown = lit(entity.id)\n const selected = entity.id === selectedId\n // Enough for the label; SVG cannot measure text before layout, so\n // it is estimated from the character count.\n const width = Math.max(56, entity.label.length * 6.6 + 18)\n\n return (\n <g\n key={entity.id}\n transform={`translate(${position.x} ${position.y})`}\n opacity={shown ? 1 : 0.12}\n className={cn(onSelect && 'cursor-pointer')}\n onPointerDown={(event) => {\n event.stopPropagation()\n dragging.current = entity.id\n }}\n onPointerEnter={() => setHovered(entity.id)}\n onClick={() => onSelect?.(entity)}\n >\n <rect\n x={-width / 2}\n y={-13}\n width={width}\n height={26}\n rx={13}\n fill={fill(entity.type)}\n stroke={selected ? 'var(--primary)' : 'transparent'}\n strokeWidth={selected ? 2 : 0}\n />\n <text\n textAnchor=\"middle\"\n dominantBaseline=\"central\"\n className=\"pointer-events-none text-[10px] font-medium\"\n fill={ink(entity.type)}\n >\n {entity.label}\n </text>\n <title>\n {entity.label}\n {entity.type ? ` — ${entity.type}` : ''}\n </title>\n </g>\n )\n })}\n </g>\n </svg>\n )}\n\n {types.length > 0 && (\n <ul className=\"absolute start-3 bottom-3 flex list-none flex-wrap gap-x-3 gap-y-1\">\n {types.map((type) => (\n <li key={type} className=\"text-muted-foreground flex items-center gap-1.5 text-[11px]\">\n <span\n aria-hidden=\"true\"\n className=\"size-2 shrink-0 rounded-full\"\n style={{ background: ink(type) }}\n />\n {type}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { KnowledgeGraph }\nexport type { KnowledgeGraphProps }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "lib-use-force-graph",
3
+ "type": "registry:lib",
4
+ "title": "use-force-graph",
5
+ "dependencies": [],
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "lib/use-force-graph.ts",
10
+ "type": "registry:lib",
11
+ "content": "import { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * A force-directed layout, shared by the graph components.\n *\n * Three forces, which is all a readable graph needs: every pair of nodes pushes\n * apart, every link pulls together, and a weak pull toward the centre stops\n * disconnected components drifting off screen forever.\n *\n * **It stops.** `alpha` decays and the loop ends once motion falls below a\n * threshold, because a graph that jitters permanently is unreadable, burns\n * battery, and never lets you click anything. Dragging or changing the data\n * reheats it.\n *\n * **Repulsion is O(n²)** — no quadtree. At the sizes a graph stays legible at,\n * a few hundred nodes, that is a fraction of a millisecond per tick, and a\n * Barnes–Hut tree is a lot of code to maintain for a component that becomes\n * unreadable long before it becomes slow.\n *\n * **Initial positions are deterministic**, placed on a phyllotaxis spiral\n * rather than at random. Random seeding makes a server-rendered graph and its\n * hydrated counterpart disagree, and makes every reload a different picture of\n * the same data.\n */\nexport type ForceNode = {\n id: string\n /** Pins the node. Dragged nodes are pinned while held. */\n fixed?: boolean\n}\n\nexport type ForceLink = { source: string; target: string }\n\nexport type Positioned = { id: string; x: number; y: number }\n\ntype Options = {\n /** Pixels. The layout is centred in this box. */\n width: number\n height: number\n /** Resting length of a link. Defaults to a value derived from the box. */\n linkDistance?: number\n /** How hard nodes push each other apart. Defaults to area per node. */\n charge?: number\n /** Pull toward the centre. Small, or everything collapses into a ball. */\n gravity?: number\n /** Below this average speed the simulation stops. */\n settleAt?: number\n /** Keep-out margin at the edges, in pixels. Room for radii and labels. */\n padding?: number\n}\n\ntype Body = { id: string; x: number; y: number; vx: number; vy: number; fixed: boolean }\n\n/** Deterministic, evenly spread starting positions — no RNG. */\nfunction seedPositions(count: number, width: number, height: number): Body[] {\n const golden = Math.PI * (3 - Math.sqrt(5))\n const radius = Math.min(width, height) * 0.35\n\n return Array.from({ length: count }, (_, index) => {\n const distance = radius * Math.sqrt((index + 0.5) / count)\n const angle = index * golden\n return {\n id: '',\n x: width / 2 + Math.cos(angle) * distance,\n y: height / 2 + Math.sin(angle) * distance,\n vx: 0,\n vy: 0,\n fixed: false,\n }\n })\n}\n\nexport function useForceGraph(\n nodes: ForceNode[],\n links: ForceLink[],\n { width, height, linkDistance, charge, gravity = 0.006, settleAt = 0.06, padding = 30 }: Options,\n) {\n /**\n * Both defaults are derived from the box, because a graph laid out with fixed\n * pixel constants is tuned for exactly one canvas size and wrong at every\n * other one — the same vault collapsed into the middle third of a small\n * preview and spilled off the edges of a large one.\n *\n * The **link distance** is what actually sets the size of the drawing. At the\n * separations these graphs settle at, the spring term is roughly fifty times\n * the repulsion term, so raising the charge to spread a cluster does almost\n * nothing; the resting length of a link is the lever. Dividing the shorter\n * side by √n is the usual area-per-node argument: n nodes at spacing d need\n * about n·d² of room.\n */\n const span = Math.min(width, height)\n const rest =\n linkDistance ?? Math.max(48, Math.min(170, (span / Math.sqrt(Math.max(2, nodes.length))) * 1.15))\n /** Repulsion, which matters at short range — it is what stops nodes piling up. */\n const push = charge ?? Math.max(600, ((width * height) / Math.max(1, nodes.length)) * 0.34)\n const [positions, setPositions] = useState<Positioned[]>([])\n const bodies = useRef<Map<string, Body>>(new Map())\n const alpha = useRef(1)\n const frame = useRef(0)\n\n // Identity of the graph, not of the arrays: a caller building `nodes` inline\n // would otherwise reseed the layout on every render.\n const shape = `${nodes.map((node) => node.id).join(',')}|${links\n .map((link) => `${link.source}>${link.target}`)\n .join(',')}`\n\n // Latest values, read inside the effects. `shape` is what actually decides\n // when the layout must restart; depending on the arrays themselves would\n // reseed on every render for any caller that builds them inline.\n const latest = useRef({ nodes, links })\n latest.current = { nodes, links }\n\n const reheat = useCallback(() => {\n alpha.current = 1\n }, [])\n\n useEffect(() => {\n const current = latest.current.nodes\n const seeds = seedPositions(current.length, width, height)\n const next = new Map<string, Body>()\n\n current.forEach((node, index) => {\n // Keep a node where it already was, so adding one does not scatter the\n // graph someone has just finished reading.\n const existing = bodies.current.get(node.id)\n next.set(node.id, existing ?? { ...seeds[index], id: node.id })\n })\n\n bodies.current = next\n alpha.current = 1\n setPositions([...next.values()].map(({ id, x, y }) => ({ id, x, y })))\n }, [shape, width, height])\n\n useEffect(() => {\n let running = true\n\n const tick = () => {\n if (!running) return\n\n const list = [...bodies.current.values()]\n if (list.length === 0) return\n\n // Repulsion, every pair once.\n for (let i = 0; i < list.length; i++) {\n for (let j = i + 1; j < list.length; j++) {\n const a = list[i]\n const b = list[j]\n let dx = b.x - a.x\n let dy = b.y - a.y\n let distanceSquared = dx * dx + dy * dy\n\n // Two nodes exactly on top of each other have no direction to\n // separate along, so nudge them apart deterministically.\n if (distanceSquared < 0.01) {\n dx = (i - j) * 0.5\n dy = 0.5\n distanceSquared = dx * dx + dy * dy\n }\n\n const force = push / distanceSquared\n const distance = Math.sqrt(distanceSquared)\n const fx = (dx / distance) * force\n const fy = (dy / distance) * force\n\n a.vx -= fx\n a.vy -= fy\n b.vx += fx\n b.vy += fy\n }\n }\n\n // Springs.\n for (const link of latest.current.links) {\n const a = bodies.current.get(link.source)\n const b = bodies.current.get(link.target)\n if (!a || !b) continue\n\n const dx = b.x - a.x\n const dy = b.y - a.y\n const distance = Math.sqrt(dx * dx + dy * dy) || 0.01\n const force = (distance - rest) * 0.05\n const fx = (dx / distance) * force\n const fy = (dy / distance) * force\n\n a.vx += fx\n a.vy += fy\n b.vx -= fx\n b.vy -= fy\n }\n\n // Gravity, then integrate.\n let motion = 0\n for (const body of list) {\n if (body.fixed) {\n body.vx = 0\n body.vy = 0\n continue\n }\n\n body.vx += (width / 2 - body.x) * gravity\n body.vy += (height / 2 - body.y) * gravity\n\n body.vx *= 0.82\n body.vy *= 0.82\n\n // Clamped to the box. The container clips, so a node pushed past the\n // edge is not merely off-centre — it is invisible, and so is every link\n // that ends there. Sizing the layout to the box gets it close; this is\n // what guarantees it, and it leaves room for the radius and the label.\n body.x = Math.min(Math.max(padding, body.x + body.vx * alpha.current), width - padding)\n body.y = Math.min(Math.max(padding, body.y + body.vy * alpha.current), height - padding)\n motion += Math.abs(body.vx) + Math.abs(body.vy)\n }\n\n alpha.current *= 0.985\n setPositions(list.map(({ id, x, y }) => ({ id, x, y })))\n\n // Stop when it has settled. A graph that never stops moving cannot be\n // read and cannot be clicked.\n if (motion / list.length > settleAt && alpha.current > 0.005) {\n frame.current = requestAnimationFrame(tick)\n }\n }\n\n frame.current = requestAnimationFrame(tick)\n return () => {\n running = false\n cancelAnimationFrame(frame.current)\n }\n }, [shape, width, height, rest, push, gravity, settleAt, padding])\n\n /** Move a node under the pointer and hold it there. */\n const drag = useCallback(\n (id: string, x: number, y: number) => {\n const body = bodies.current.get(id)\n if (!body) return\n body.x = Math.min(Math.max(padding, x), width - padding)\n body.y = Math.min(Math.max(padding, y), height - padding)\n body.fixed = true\n alpha.current = Math.max(alpha.current, 0.4)\n setPositions([...bodies.current.values()].map((b) => ({ id: b.id, x: b.x, y: b.y })))\n },\n [width, height, padding],\n )\n\n const release = useCallback((id: string) => {\n const body = bodies.current.get(id)\n if (body) body.fixed = false\n }, [])\n\n return { positions, drag, release, reheat }\n}\n"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "markdown",
3
+ "type": "registry:ui",
4
+ "title": "Markdown",
5
+ "description": "Rendered markdown with a switch to the source. It builds React nodes and never sets innerHTML, so a document you did not write cannot execute — and the RAW toggle is there because rendered output hides exactly what you look at when it renders wrongly.",
6
+ "category": "Knowledge",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "code-block",
13
+ "copy-button",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/markdown.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Code2, Eye } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Rendered markdown, with a switch to the source.\n *\n * **It builds React nodes. It never sets `innerHTML`.** Almost every small\n * markdown renderer converts to an HTML string and injects it, which turns any\n * document you did not write — a README from a registry, a model's output, a\n * comment — into script execution. Producing elements means there is no string\n * for a payload to survive in, and the one place raw HTML could appear in\n * markdown is passed through as text instead.\n *\n * **The RAW toggle is the point of the component.** Rendered markdown hides the\n * difference between a hard line break and a soft one, between `*` and `_`,\n * between a real table and an aligned one — and those are exactly what someone\n * is looking for when a document renders wrongly. Source is one click away and\n * copyable.\n *\n * The supported subset is what documents actually contain: ATX headings, bold,\n * italic, inline code, links, images, fenced and indented code, ordered and\n * unordered lists, blockquotes, horizontal rules, and tables. Setext headings,\n * reference links, footnotes and nested blockquotes are not handled — a\n * complete CommonMark implementation is a library, not a component, and this\n * one is honest about where it stops.\n */\ntype MarkdownProps = Omit<ComponentProps<'div'>, 'children'> & {\n children: string\n /** Start on the source rather than the preview. */\n defaultRaw?: boolean\n /** Hide the toggle entirely for a fixed, rendered-only view. */\n toggle?: boolean\n previewLabel?: string\n rawLabel?: string\n copyLabel?: string\n /** Trailing slot in the toolbar. */\n actions?: ReactNode\n /** Rendered when the source is empty. */\n emptyLabel?: string\n}\n\n/* ----------------------------------------------------------------- inline */\n\nconst INLINE = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(__[^_]+__)|(\\*[^*]+\\*)|(_[^_]+_)|(~~[^~]+~~)|(!?\\[[^\\]]*\\]\\([^)]+\\))/\n\n/**\n * Inline spans, as nodes.\n *\n * Split rather than replaced, so a link's text can never be re-parsed as\n * markup and a payload has no string to hide in.\n */\nfunction inline(text: string, keyPrefix = ''): ReactNode[] {\n const out: ReactNode[] = []\n let rest = text\n let key = 0\n\n while (rest) {\n const match = INLINE.exec(rest)\n if (!match || match.index === undefined) {\n out.push(rest)\n break\n }\n\n if (match.index > 0) out.push(rest.slice(0, match.index))\n const token = match[0]\n const id = `${keyPrefix}-${key++}`\n\n if (token.startsWith('`')) {\n out.push(\n <code key={id} className={cn('bg-muted px-1 py-0.5 font-mono text-[0.9em]', radius.xs)}>\n {token.slice(1, -1)}\n </code>,\n )\n } else if (token.startsWith('**') || token.startsWith('__')) {\n out.push(<strong key={id}>{token.slice(2, -2)}</strong>)\n } else if (token.startsWith('~~')) {\n out.push(\n <s key={id} className=\"text-muted-foreground\">\n {token.slice(2, -2)}\n </s>,\n )\n } else if (token.startsWith('![')) {\n const [, alt, src] = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/.exec(token) ?? []\n out.push(\n <img key={id} src={src} alt={alt ?? ''} className={cn('my-2 max-w-full', radius.control)} />,\n )\n } else if (token.startsWith('[')) {\n const [, label, href] = /\\[([^\\]]*)\\]\\(([^)]+)\\)/.exec(token) ?? []\n // `javascript:` in a link is the other half of the injection this\n // component refuses; only http(s), mailto and relative URLs survive.\n const safe = href && /^(https?:|mailto:|[./#])/i.test(href) ? href : undefined\n out.push(\n safe ? (\n <a\n key={id}\n href={safe}\n className={cn('underline underline-offset-2', focusRing)}\n rel=\"noreferrer noopener\"\n target={safe.startsWith('http') ? '_blank' : undefined}\n >\n {label}\n </a>\n ) : (\n <span key={id}>{label}</span>\n ),\n )\n } else {\n out.push(<em key={id}>{token.slice(1, -1)}</em>)\n }\n\n rest = rest.slice(match.index + token.length)\n }\n\n return out\n}\n\n/* ------------------------------------------------------------------ block */\n\nfunction render(source: string): ReactNode[] {\n const lines = source.replace(/\\r\\n/g, '\\n').split('\\n')\n const out: ReactNode[] = []\n let index = 0\n let key = 0\n\n while (index < lines.length) {\n const line = lines[index]\n\n if (!line.trim()) {\n index++\n continue\n }\n\n // Fenced code. Taken verbatim — nothing inside is parsed.\n const fence = /^```(\\w*)/.exec(line)\n if (fence) {\n const body: string[] = []\n index++\n while (index < lines.length && !/^```/.test(lines[index])) body.push(lines[index++])\n index++\n out.push(\n <CodeBlock\n key={key++}\n code={body.join('\\n')}\n language={(fence[1] || 'text') as never}\n header={false}\n className=\"my-3\"\n />,\n )\n continue\n }\n\n const heading = /^(#{1,6})\\s+(.*)$/.exec(line)\n if (heading) {\n const level = heading[1].length\n const Tag = `h${level}` as 'h1'\n const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm']\n out.push(\n <Tag\n key={key++}\n className={cn('mt-5 mb-2 font-semibold tracking-tight first:mt-0', sizes[level - 1])}\n >\n {inline(heading[2], `h${key}`)}\n </Tag>,\n )\n index++\n continue\n }\n\n if (/^(---|\\*\\*\\*|___)\\s*$/.test(line)) {\n out.push(<hr key={key++} className=\"border-border my-5\" />)\n index++\n continue\n }\n\n if (/^>\\s?/.test(line)) {\n const body: string[] = []\n while (index < lines.length && /^>\\s?/.test(lines[index])) {\n body.push(lines[index++].replace(/^>\\s?/, ''))\n }\n out.push(\n <blockquote\n key={key++}\n className=\"border-border text-muted-foreground my-3 border-s-2 ps-4 italic\"\n >\n {inline(body.join(' '), `q${key}`)}\n </blockquote>,\n )\n continue\n }\n\n // Tables: a header row, a delimiter row, then body rows.\n if (line.includes('|') && /^\\s*\\|?[\\s:-]+\\|[\\s:|-]*$/.test(lines[index + 1] ?? '')) {\n const cells = (row: string) =>\n row.replace(/^\\||\\|$/g, '').split('|').map((cell) => cell.trim())\n const head = cells(line)\n index += 2\n const rows: string[][] = []\n while (index < lines.length && lines[index].includes('|')) rows.push(cells(lines[index++]))\n\n out.push(\n <div key={key++} className=\"my-3 overflow-x-auto\">\n <table className=\"w-full border-collapse text-left text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n {head.map((cell, i) => (\n <th key={i} className=\"px-3 py-1.5 font-medium\">\n {inline(cell, `th${i}`)}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((row, r) => (\n <tr key={r} className=\"border-border/60 border-b last:border-b-0\">\n {row.map((cell, c) => (\n <td key={c} className=\"text-muted-foreground px-3 py-1.5\">\n {inline(cell, `td${r}-${c}`)}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>,\n )\n continue\n }\n\n const bullet = /^\\s*([-*+])\\s+/.test(line)\n const numbered = /^\\s*\\d+\\.\\s+/.test(line)\n if (bullet || numbered) {\n const items: string[] = []\n const matches = (row: string) =>\n bullet ? /^\\s*([-*+])\\s+/.test(row) : /^\\s*\\d+\\.\\s+/.test(row)\n\n while (index < lines.length && matches(lines[index])) {\n items.push(lines[index++].replace(/^\\s*([-*+]|\\d+\\.)\\s+/, ''))\n }\n\n const Tag = bullet ? 'ul' : 'ol'\n out.push(\n <Tag\n key={key++}\n className={cn('my-3 space-y-1 ps-5', bullet ? 'list-disc' : 'list-decimal')}\n >\n {items.map((item, i) => (\n <li key={i} className=\"text-sm leading-relaxed\">\n {inline(item, `li${i}`)}\n </li>\n ))}\n </Tag>,\n )\n continue\n }\n\n // Paragraph: consecutive non-blank lines that start nothing else.\n const body: string[] = []\n while (\n index < lines.length &&\n lines[index].trim() &&\n !/^(#{1,6}\\s|>|```|\\s*([-*+]|\\d+\\.)\\s|(---|\\*\\*\\*|___)\\s*$)/.test(lines[index])\n ) {\n body.push(lines[index++])\n }\n out.push(\n <p key={key++} className=\"my-3 text-sm leading-relaxed\">\n {inline(body.join(' '), `p${key}`)}\n </p>,\n )\n }\n\n return out\n}\n\nfunction Markdown({\n children,\n defaultRaw = false,\n toggle = true,\n previewLabel = 'Preview',\n rawLabel = 'Raw',\n copyLabel = 'Copy markdown',\n actions,\n emptyLabel = 'Nothing to show.',\n className,\n ...props\n}: MarkdownProps) {\n const [raw, setRaw] = useState(defaultRaw)\n const nodes = useMemo(() => render(children), [children])\n\n return (\n <div\n data-slot=\"markdown\"\n data-view={raw ? 'raw' : 'preview'}\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {(toggle || actions) && (\n <div className=\"border-border bg-muted/40 flex items-center gap-2 border-b px-3 py-2\">\n {toggle && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-pressed={raw}\n onClick={() => setRaw((current) => !current)}\n >\n {raw ? <Eye /> : <Code2 />}\n {raw ? previewLabel : rawLabel}\n </Button>\n )}\n <span className=\"flex-1\" />\n {actions}\n <CopyButton value={children} label={copyLabel} />\n </div>\n )}\n\n {!children.trim() ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : raw ? (\n // The source, exactly as given — the reason the toggle exists.\n <pre className=\"text-foreground/85 overflow-x-auto p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap\">\n {children}\n </pre>\n ) : (\n <div className=\"p-4\">{nodes}</div>\n )}\n </div>\n )\n}\n\nexport { Markdown, render as renderMarkdown }\nexport type { MarkdownProps }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "note-graph",
3
+ "type": "registry:ui",
4
+ "title": "Note Graph",
5
+ "description": "A vault of linked notes laid out by force — the Obsidian-shaped view. Nodes are sized by how many links touch them, hovering dims everything outside the neighbourhood, and orphans are drawn hollow rather than hidden.",
6
+ "category": "Knowledge",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-use-force-graph",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/note-graph.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import {\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { useForceGraph, type ForceLink } from '@/lib/use-force-graph'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A vault of linked notes, laid out by force.\n *\n * The Obsidian-shaped view: notes as circles, links as lines, and the thing you\n * are looking for found by shape rather than by name.\n *\n * **Size is degree, not recency or length.** A hub with thirty backlinks is the\n * note the vault is organised around, and it should be the one your eye lands\n * on. Sizing by word count or edit date produces a picture of your typing\n * habits instead of your structure.\n *\n * **Orphans are drawn, and drawn differently.** A note nothing links to is the\n * most actionable thing this view can surface — it is either miscategorised or\n * forgotten — so hiding unconnected nodes to tidy the picture removes its main\n * use. They sit in a ring at the edge, hollow.\n *\n * **Hovering dims everything except the neighbourhood.** At a few hundred nodes\n * the hairball is unreadable at rest; focus is what makes it legible, and it\n * costs one CSS class rather than a second layout.\n *\n * The layout stops once it settles — see `useForceGraph`. Dragging a note pins\n * it, which is how you pull a cluster apart to read it.\n */\nexport type Note = {\n id: string\n title: string\n /** Colours the node. A folder, a tag, whatever you group by. */\n group?: string\n /** Overrides the degree-derived size. */\n size?: number\n}\n\ntype NoteGraphProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n notes: Note[]\n links: ForceLink[]\n onSelect?: (note: Note) => void\n selectedId?: string\n height?: number | string\n /** Show every title, rather than only hubs and the hovered neighbourhood. */\n showAllLabels?: boolean\n /** Degree at or above which a title is always drawn. */\n labelFrom?: number\n /** Map a group to a CSS colour. */\n colorFor?: (group: string | undefined) => string\n orphanLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nconst GROUP_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\nfunction NoteGraph({\n notes,\n links,\n onSelect,\n selectedId,\n height = 460,\n showAllLabels = false,\n labelFrom = 4,\n colorFor,\n orphanLabel = 'no links',\n emptyLabel = 'This vault has no notes.',\n label = 'Note graph',\n className,\n ...props\n}: NoteGraphProps) {\n const boxRef = useRef<HTMLDivElement>(null)\n const [size, setSize] = useState({ width: 720, height: 460 })\n const [hovered, setHovered] = useState<string | null>(null)\n const dragging = useRef<string | null>(null)\n\n const { positions, drag, release } = useForceGraph(notes, links, size)\n\n /** How many links touch each note, and who its neighbours are. */\n const { degree, neighbours } = useMemo(() => {\n const degree = new Map<string, number>()\n const neighbours = new Map<string, Set<string>>()\n\n for (const note of notes) {\n degree.set(note.id, 0)\n neighbours.set(note.id, new Set())\n }\n for (const link of links) {\n degree.set(link.source, (degree.get(link.source) ?? 0) + 1)\n degree.set(link.target, (degree.get(link.target) ?? 0) + 1)\n neighbours.get(link.source)?.add(link.target)\n neighbours.get(link.target)?.add(link.source)\n }\n return { degree, neighbours }\n }, [notes, links])\n\n const groups = useMemo(\n () => [...new Set(notes.map((note) => note.group).filter(Boolean))] as string[],\n [notes],\n )\n\n const colour = (group: string | undefined) =>\n colorFor?.(group) ??\n (group ? GROUP_COLORS[groups.indexOf(group) % GROUP_COLORS.length] : 'var(--muted-foreground)')\n\n const at = useMemo(\n () => new Map(positions.map((position) => [position.id, position])),\n [positions],\n )\n\n /** Dimmed unless it is the hovered note or one of its neighbours. */\n const lit = (id: string) =>\n !hovered || hovered === id || neighbours.get(hovered)?.has(id) === true\n\n function pointerToBox(event: ReactPointerEvent) {\n const box = boxRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return { x: event.clientX - box.left, y: event.clientY - box.top }\n }\n\n return (\n <div\n data-slot=\"note-graph\"\n ref={(node) => {\n boxRef.current = node\n if (node) {\n const box = node.getBoundingClientRect()\n // Only on a real change, or this loops.\n if (Math.abs(box.width - size.width) > 1 || Math.abs(box.height - size.height) > 1) {\n setSize({ width: box.width, height: box.height })\n }\n }\n }}\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n onPointerMove={(event) => {\n if (!dragging.current) return\n const point = pointerToBox(event)\n drag(dragging.current, point.x, point.y)\n }}\n onPointerUp={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n }}\n onPointerLeave={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n setHovered(null)\n }}\n {...props}\n >\n {notes.length === 0 ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : (\n <svg className=\"h-full w-full touch-none\" role=\"img\" aria-label={label}>\n <g>\n {links.map((link, index) => {\n const a = at.get(link.source)\n const b = at.get(link.target)\n if (!a || !b) return null\n const shown = lit(link.source) && lit(link.target)\n\n return (\n <line\n key={index}\n x1={a.x}\n y1={a.y}\n x2={b.x}\n y2={b.y}\n className=\"stroke-border\"\n strokeWidth={hovered && shown ? 1.4 : 1}\n opacity={shown ? 0.9 : 0.12}\n />\n )\n })}\n </g>\n\n <g>\n {notes.map((note) => {\n const position = at.get(note.id)\n if (!position) return null\n\n const links = degree.get(note.id) ?? 0\n // Degree, damped: a hub with thirty backlinks should read as\n // bigger than one with three without swamping the canvas.\n const r = note.size ?? Math.min(18, 4 + Math.sqrt(links) * 2.6)\n const orphan = links === 0\n const shown = lit(note.id)\n const selected = note.id === selectedId\n const labelled =\n showAllLabels || selected || hovered === note.id || links >= labelFrom\n\n return (\n <g\n key={note.id}\n transform={`translate(${position.x} ${position.y})`}\n opacity={shown ? 1 : 0.15}\n className={cn(onSelect && 'cursor-pointer')}\n onPointerDown={(event) => {\n event.stopPropagation()\n dragging.current = note.id\n ;(event.target as Element).releasePointerCapture?.(event.pointerId)\n }}\n onPointerEnter={() => setHovered(note.id)}\n onClick={() => onSelect?.(note)}\n >\n <circle\n r={r}\n // Hollow for an orphan: the note nothing links to is the\n // most actionable thing here, so it must not blend in.\n fill={orphan ? 'transparent' : colour(note.group)}\n stroke={\n selected\n ? 'var(--primary)'\n : orphan\n ? colour(note.group)\n : 'transparent'\n }\n strokeWidth={selected ? 2.5 : orphan ? 1.5 : 0}\n strokeDasharray={orphan && !selected ? '3 3' : undefined}\n />\n {labelled && (\n <text\n y={r + 12}\n textAnchor=\"middle\"\n className=\"fill-foreground pointer-events-none text-[10px]\"\n >\n {note.title}\n </text>\n )}\n <title>\n {note.title}\n {orphan ? ` — ${orphanLabel}` : ` — ${links}`}\n </title>\n </g>\n )\n })}\n </g>\n </svg>\n )}\n\n {groups.length > 0 && (\n <ul className=\"absolute start-3 bottom-3 flex list-none flex-wrap gap-x-3 gap-y-1\">\n {groups.map((group) => (\n <li key={group} className=\"text-muted-foreground flex items-center gap-1.5 text-[11px]\">\n <span\n aria-hidden=\"true\"\n className=\"size-2 shrink-0 rounded-full\"\n style={{ background: colour(group) }}\n />\n {group}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { NoteGraph }\nexport type { NoteGraphProps }\n"
18
+ }
19
+ ]
20
+ }
@@ -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
+ }