astralyx-ui 0.3.1 → 0.4.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/registry/index.json +412 -1
  3. package/registry/items/ab-test-results.json +21 -0
  4. package/registry/items/attribution.json +19 -0
  5. package/registry/items/back-to-top.json +21 -0
  6. package/registry/items/bottom-nav.json +19 -0
  7. package/registry/items/box-plot.json +19 -0
  8. package/registry/items/cascader.json +23 -0
  9. package/registry/items/cookie-consent.json +20 -0
  10. package/registry/items/currency-input.json +19 -0
  11. package/registry/items/emoji-picker.json +21 -0
  12. package/registry/items/event-stream.json +22 -0
  13. package/registry/items/gantt.json +19 -0
  14. package/registry/items/image-cropper.json +20 -0
  15. package/registry/items/knowledge-graph.json +1 -1
  16. package/registry/items/lib-qr.json +14 -0
  17. package/registry/items/lib-styles.json +1 -1
  18. package/registry/items/markdown-editor.json +23 -0
  19. package/registry/items/note-graph.json +1 -1
  20. package/registry/items/org-chart.json +21 -0
  21. package/registry/items/pdf-viewer.json +22 -0
  22. package/registry/items/phone-input.json +19 -0
  23. package/registry/items/popconfirm.json +24 -0
  24. package/registry/items/qr-code.json +20 -0
  25. package/registry/items/radar-chart.json +19 -0
  26. package/registry/items/result.json +21 -0
  27. package/registry/items/retention-curve.json +19 -0
  28. package/registry/items/sankey.json +19 -0
  29. package/registry/items/scatter-plot.json +19 -0
  30. package/registry/items/scheduler.json +19 -0
  31. package/registry/items/segment-builder.json +23 -0
  32. package/registry/items/segmented-control.json +19 -0
  33. package/registry/items/select.json +1 -1
  34. package/registry/items/signature-pad.json +20 -0
  35. package/registry/items/transfer.json +22 -0
  36. package/registry/items/tree-select.json +23 -0
  37. package/registry/items/treemap.json +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
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",
@@ -1,8 +1,22 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
+ {
7
+ "name": "ab-test-results",
8
+ "type": "registry:ui",
9
+ "title": "A/B Test Results",
10
+ "description": "Variants with lift, a confidence interval and a two-proportion z-test — plus the peeking warning, because stopping at the first significant reading is the failure that actually happens.",
11
+ "category": "Analytics",
12
+ "dependencies": [
13
+ "lucide-react@^1.39.0"
14
+ ],
15
+ "registryDependencies": [
16
+ "lib-styles",
17
+ "lib-utils"
18
+ ]
19
+ },
6
20
  {
7
21
  "name": "accordion",
8
22
  "type": "registry:ui",
@@ -216,6 +230,18 @@
216
230
  "progress"
217
231
  ]
218
232
  },
233
+ {
234
+ "name": "attribution",
235
+ "type": "registry:ui",
236
+ "title": "Attribution",
237
+ "description": "Credit for a conversion split across touchpoints, with the model as a control rather than a buried setting — because the disagreement between models is the whole finding.",
238
+ "category": "Analytics",
239
+ "dependencies": [],
240
+ "registryDependencies": [
241
+ "lib-styles",
242
+ "lib-utils"
243
+ ]
244
+ },
219
245
  {
220
246
  "name": "audio-player",
221
247
  "type": "registry:ui",
@@ -264,6 +290,20 @@
264
290
  "lib-utils"
265
291
  ]
266
292
  },
293
+ {
294
+ "name": "back-to-top",
295
+ "type": "registry:ui",
296
+ "title": "Back To Top",
297
+ "description": "Appears once you have scrolled, driven by an IntersectionObserver sentinel rather than a scroll handler — and it moves focus, not just the viewport.",
298
+ "category": "Navigation",
299
+ "dependencies": [
300
+ "lucide-react@^1.39.0"
301
+ ],
302
+ "registryDependencies": [
303
+ "lib-styles",
304
+ "lib-utils"
305
+ ]
306
+ },
267
307
  {
268
308
  "name": "backup-list",
269
309
  "type": "registry:ui",
@@ -355,6 +395,30 @@
355
395
  "tooltip"
356
396
  ]
357
397
  },
398
+ {
399
+ "name": "bottom-nav",
400
+ "type": "registry:ui",
401
+ "title": "Bottom Nav",
402
+ "description": "The mobile tab bar — three to five destinations, pinned to the bottom where thumbs are, with the home-indicator safe area accounted for.",
403
+ "category": "Navigation",
404
+ "dependencies": [],
405
+ "registryDependencies": [
406
+ "lib-styles",
407
+ "lib-utils"
408
+ ]
409
+ },
410
+ {
411
+ "name": "box-plot",
412
+ "type": "registry:ui",
413
+ "title": "Box Plot",
414
+ "description": "Quartiles, Tukey whiskers and outliers — the distribution an average hides. Quartiles use linear interpolation, so they agree with what your analytics stack reports.",
415
+ "category": "Charts",
416
+ "dependencies": [],
417
+ "registryDependencies": [
418
+ "lib-styles",
419
+ "lib-utils"
420
+ ]
421
+ },
358
422
  {
359
423
  "name": "branch-select",
360
424
  "type": "registry:ui",
@@ -622,6 +686,22 @@
622
686
  "lib-utils"
623
687
  ]
624
688
  },
689
+ {
690
+ "name": "cascader",
691
+ "type": "registry:ui",
692
+ "title": "Cascader",
693
+ "description": "A regular hierarchy picked one level at a time, in side-by-side columns — so the path stays readable and the siblings you passed are still visible.",
694
+ "category": "Forms",
695
+ "dependencies": [
696
+ "lucide-react@^1.39.0"
697
+ ],
698
+ "registryDependencies": [
699
+ "lib-styles",
700
+ "lib-utils",
701
+ "primitive-dismissable",
702
+ "primitive-popper"
703
+ ]
704
+ },
625
705
  {
626
706
  "name": "chain-select",
627
707
  "type": "registry:ui",
@@ -985,6 +1065,19 @@
985
1065
  "lib-utils"
986
1066
  ]
987
1067
  },
1068
+ {
1069
+ "name": "cookie-consent",
1070
+ "type": "registry:ui",
1071
+ "title": "Cookie Consent",
1072
+ "description": "A consent banner with reject as easy as accept, nothing pre-ticked, and no cookie written by the component itself — the arrangement regulators have repeatedly ruled is the compliant one.",
1073
+ "category": "Utility",
1074
+ "dependencies": [],
1075
+ "registryDependencies": [
1076
+ "button",
1077
+ "lib-styles",
1078
+ "lib-utils"
1079
+ ]
1080
+ },
988
1081
  {
989
1082
  "name": "coordinate-input",
990
1083
  "type": "registry:ui",
@@ -1096,6 +1189,18 @@
1096
1189
  "code-block"
1097
1190
  ]
1098
1191
  },
1192
+ {
1193
+ "name": "currency-input",
1194
+ "type": "registry:ui",
1195
+ "title": "Currency Input",
1196
+ "description": "A money field whose value is an integer number of minor units, formatted only while unfocused. Reads its decimal count from Intl, so zero-decimal currencies work without a special case.",
1197
+ "category": "Forms",
1198
+ "dependencies": [],
1199
+ "registryDependencies": [
1200
+ "lib-styles",
1201
+ "lib-utils"
1202
+ ]
1203
+ },
1099
1204
  {
1100
1205
  "name": "data-grid",
1101
1206
  "type": "registry:ui",
@@ -1345,6 +1450,20 @@
1345
1450
  "upload-list"
1346
1451
  ]
1347
1452
  },
1453
+ {
1454
+ "name": "emoji-picker",
1455
+ "type": "registry:ui",
1456
+ "title": "Emoji Picker",
1457
+ "description": "A grouped, searchable grid with recents, rendered as text rather than sprites. One tab stop with arrow-key movement, and it never touches localStorage.",
1458
+ "category": "Forms",
1459
+ "dependencies": [
1460
+ "lucide-react@^1.39.0"
1461
+ ],
1462
+ "registryDependencies": [
1463
+ "lib-styles",
1464
+ "lib-utils"
1465
+ ]
1466
+ },
1348
1467
  {
1349
1468
  "name": "empty",
1350
1469
  "type": "registry:ui",
@@ -1451,6 +1570,21 @@
1451
1570
  "lib-utils"
1452
1571
  ]
1453
1572
  },
1573
+ {
1574
+ "name": "event-stream",
1575
+ "type": "registry:ui",
1576
+ "title": "Event Stream",
1577
+ "description": "A live tail of analytics events with their payloads — the tool for debugging the tracking itself. Autoscroll pauses when you scroll away and the list is capped rather than unbounded.",
1578
+ "category": "Analytics",
1579
+ "dependencies": [
1580
+ "lucide-react@^1.39.0"
1581
+ ],
1582
+ "registryDependencies": [
1583
+ "button",
1584
+ "lib-styles",
1585
+ "lib-utils"
1586
+ ]
1587
+ },
1454
1588
  {
1455
1589
  "name": "feature-flag",
1456
1590
  "type": "registry:ui",
@@ -1625,6 +1759,18 @@
1625
1759
  "lib-utils"
1626
1760
  ]
1627
1761
  },
1762
+ {
1763
+ "name": "gantt",
1764
+ "type": "registry:ui",
1765
+ "title": "Gantt",
1766
+ "description": "Work on a timeline: bars for duration, a computed critical path, and dependency conflicts drawn as conflicts rather than quietly rendered backwards.",
1767
+ "category": "Views",
1768
+ "dependencies": [],
1769
+ "registryDependencies": [
1770
+ "lib-styles",
1771
+ "lib-utils"
1772
+ ]
1773
+ },
1628
1774
  {
1629
1775
  "name": "gas-tracker",
1630
1776
  "type": "registry:ui",
@@ -1764,6 +1910,19 @@
1764
1910
  "lib-utils"
1765
1911
  ]
1766
1912
  },
1913
+ {
1914
+ "name": "image-cropper",
1915
+ "type": "registry:ui",
1916
+ "title": "Image Cropper",
1917
+ "description": "The step between picking an image and saving it. The crop is stored in natural image coordinates and exported with toBlob, so it survives a resize and uploads directly.",
1918
+ "category": "Media",
1919
+ "dependencies": [],
1920
+ "registryDependencies": [
1921
+ "button",
1922
+ "lib-styles",
1923
+ "lib-utils"
1924
+ ]
1925
+ },
1767
1926
  {
1768
1927
  "name": "image",
1769
1928
  "type": "registry:ui",
@@ -2143,6 +2302,22 @@
2143
2302
  "lib-utils"
2144
2303
  ]
2145
2304
  },
2305
+ {
2306
+ "name": "markdown-editor",
2307
+ "type": "registry:ui",
2308
+ "title": "Markdown Editor",
2309
+ "description": "A real textarea, a toolbar that edits the selection through setRangeText — so the browser undo stack survives — and the Markdown component as its preview.",
2310
+ "category": "Knowledge",
2311
+ "dependencies": [
2312
+ "lucide-react@^1.39.0"
2313
+ ],
2314
+ "registryDependencies": [
2315
+ "button",
2316
+ "lib-styles",
2317
+ "lib-utils",
2318
+ "markdown"
2319
+ ]
2320
+ },
2146
2321
  {
2147
2322
  "name": "markdown",
2148
2323
  "type": "registry:ui",
@@ -2712,6 +2887,20 @@
2712
2887
  "lib-utils"
2713
2888
  ]
2714
2889
  },
2890
+ {
2891
+ "name": "org-chart",
2892
+ "type": "registry:ui",
2893
+ "title": "Org Chart",
2894
+ "description": "A reporting hierarchy drawn top-down with connectors, built from flat { id, managerId } rows. Orphans and multiple roots are rendered rather than dropped; cycles are reported.",
2895
+ "category": "Views",
2896
+ "dependencies": [
2897
+ "lucide-react@^1.39.0"
2898
+ ],
2899
+ "registryDependencies": [
2900
+ "lib-styles",
2901
+ "lib-utils"
2902
+ ]
2903
+ },
2715
2904
  {
2716
2905
  "name": "page-header",
2717
2906
  "type": "registry:ui",
@@ -2784,6 +2973,21 @@
2784
2973
  "stepper"
2785
2974
  ]
2786
2975
  },
2976
+ {
2977
+ "name": "pdf-viewer",
2978
+ "type": "registry:ui",
2979
+ "title": "PDF Viewer",
2980
+ "description": "A PDF in the browser’s own viewer with a toolbar around it. It delegates the rendering on purpose, and always shows a download link — because embedded PDFs fail routinely.",
2981
+ "category": "Media",
2982
+ "dependencies": [
2983
+ "lucide-react@^1.39.0"
2984
+ ],
2985
+ "registryDependencies": [
2986
+ "button",
2987
+ "lib-styles",
2988
+ "lib-utils"
2989
+ ]
2990
+ },
2787
2991
  {
2788
2992
  "name": "permission-matrix",
2789
2993
  "type": "registry:ui",
@@ -2799,6 +3003,18 @@
2799
3003
  "lib-utils"
2800
3004
  ]
2801
3005
  },
3006
+ {
3007
+ "name": "phone-input",
3008
+ "type": "registry:ui",
3009
+ "title": "Phone Input",
3010
+ "description": "A country selector and a national number that together produce E.164 — the shape every telephony API wants. Honest about not being a validator.",
3011
+ "category": "Forms",
3012
+ "dependencies": [],
3013
+ "registryDependencies": [
3014
+ "lib-styles",
3015
+ "lib-utils"
3016
+ ]
3017
+ },
2802
3018
  {
2803
3019
  "name": "pipeline",
2804
3020
  "type": "registry:ui",
@@ -2815,6 +3031,23 @@
2815
3031
  "spinner"
2816
3032
  ]
2817
3033
  },
3034
+ {
3035
+ "name": "popconfirm",
3036
+ "type": "registry:ui",
3037
+ "title": "Popconfirm",
3038
+ "description": "A confirmation anchored to the control that triggered it, for reversible or single-item actions — so the row you are asking about stays visible.",
3039
+ "category": "Overlays",
3040
+ "dependencies": [
3041
+ "lucide-react@^1.39.0"
3042
+ ],
3043
+ "registryDependencies": [
3044
+ "button",
3045
+ "lib-styles",
3046
+ "lib-utils",
3047
+ "primitive-dismissable",
3048
+ "primitive-popper"
3049
+ ]
3050
+ },
2818
3051
  {
2819
3052
  "name": "popover",
2820
3053
  "type": "registry:ui",
@@ -3034,6 +3267,19 @@
3034
3267
  "lib-utils"
3035
3268
  ]
3036
3269
  },
3270
+ {
3271
+ "name": "qr-code",
3272
+ "type": "registry:ui",
3273
+ "title": "QR Code",
3274
+ "description": "A QR code encoded in the browser and drawn as SVG. No dependency and no network round trip — which matters, because the two things people encode most are a TOTP secret and a wallet address.",
3275
+ "category": "Utility",
3276
+ "dependencies": [],
3277
+ "registryDependencies": [
3278
+ "lib-qr",
3279
+ "lib-styles",
3280
+ "lib-utils"
3281
+ ]
3282
+ },
3037
3283
  {
3038
3284
  "name": "query-editor",
3039
3285
  "type": "registry:ui",
@@ -3083,6 +3329,18 @@
3083
3329
  "sparkline"
3084
3330
  ]
3085
3331
  },
3332
+ {
3333
+ "name": "radar-chart",
3334
+ "type": "registry:ui",
3335
+ "title": "Radar Chart",
3336
+ "description": "Several measures for one subject on axes radiating from a centre. Good for recognising a profile’s shape; documented plainly as the wrong chart when the reader needs to compare values.",
3337
+ "category": "Charts",
3338
+ "dependencies": [],
3339
+ "registryDependencies": [
3340
+ "lib-styles",
3341
+ "lib-utils"
3342
+ ]
3343
+ },
3086
3344
  {
3087
3345
  "name": "radio-group",
3088
3346
  "type": "registry:ui",
@@ -3268,6 +3526,32 @@
3268
3526
  "lib-utils"
3269
3527
  ]
3270
3528
  },
3529
+ {
3530
+ "name": "result",
3531
+ "type": "registry:ui",
3532
+ "title": "Result",
3533
+ "description": "The whole-page outcome — not found, forbidden, server error, done. Announced on render, so a client-side route change to an error page is not silent for a screen reader.",
3534
+ "category": "Feedback",
3535
+ "dependencies": [
3536
+ "lucide-react@^1.39.0"
3537
+ ],
3538
+ "registryDependencies": [
3539
+ "lib-styles",
3540
+ "lib-utils"
3541
+ ]
3542
+ },
3543
+ {
3544
+ "name": "retention-curve",
3545
+ "type": "registry:ui",
3546
+ "title": "Retention Curve",
3547
+ "description": "What fraction of each cohort is still here, period by period — normalised so growth cannot hide churn. Later cohorts are drawn shorter, not padded with zeroes.",
3548
+ "category": "Analytics",
3549
+ "dependencies": [],
3550
+ "registryDependencies": [
3551
+ "lib-styles",
3552
+ "lib-utils"
3553
+ ]
3554
+ },
3271
3555
  {
3272
3556
  "name": "retrieval-results",
3273
3557
  "type": "registry:ui",
@@ -3431,6 +3715,42 @@
3431
3715
  "switch"
3432
3716
  ]
3433
3717
  },
3718
+ {
3719
+ "name": "sankey",
3720
+ "type": "registry:ui",
3721
+ "title": "Sankey",
3722
+ "description": "Flow between stages, where band width is the quantity moving. Nodes are layered by longest path so every link runs forward, and a cycle is reported rather than drawn as something plausible.",
3723
+ "category": "Charts",
3724
+ "dependencies": [],
3725
+ "registryDependencies": [
3726
+ "lib-styles",
3727
+ "lib-utils"
3728
+ ]
3729
+ },
3730
+ {
3731
+ "name": "scatter-plot",
3732
+ "type": "registry:ui",
3733
+ "title": "Scatter Plot",
3734
+ "description": "Two measured variables plotted against each other, with an optional least-squares trend line and Pearson’s r. Points can carry a third dimension as area and a fourth as colour.",
3735
+ "category": "Charts",
3736
+ "dependencies": [],
3737
+ "registryDependencies": [
3738
+ "lib-styles",
3739
+ "lib-utils"
3740
+ ]
3741
+ },
3742
+ {
3743
+ "name": "scheduler",
3744
+ "type": "registry:ui",
3745
+ "title": "Scheduler",
3746
+ "description": "A week of days with events laid out against the hours. Overlaps are split into columns rather than stacked, so a clash never hides the meeting you were about to miss.",
3747
+ "category": "Views",
3748
+ "dependencies": [],
3749
+ "registryDependencies": [
3750
+ "lib-styles",
3751
+ "lib-utils"
3752
+ ]
3753
+ },
3434
3754
  {
3435
3755
  "name": "schema-form",
3436
3756
  "type": "registry:ui",
@@ -3507,6 +3827,34 @@
3507
3827
  "lib-utils"
3508
3828
  ]
3509
3829
  },
3830
+ {
3831
+ "name": "segment-builder",
3832
+ "type": "registry:ui",
3833
+ "title": "Segment Builder",
3834
+ "description": "Build an audience from conditions, with precedence shown as nesting rather than implied by a word — and the output a structure, not a concatenated string.",
3835
+ "category": "Analytics",
3836
+ "dependencies": [
3837
+ "lucide-react@^1.39.0"
3838
+ ],
3839
+ "registryDependencies": [
3840
+ "button",
3841
+ "lib-styles",
3842
+ "lib-utils",
3843
+ "select"
3844
+ ]
3845
+ },
3846
+ {
3847
+ "name": "segmented-control",
3848
+ "type": "registry:ui",
3849
+ "title": "Segmented Control",
3850
+ "description": "A small set of mutually exclusive choices shown at once, built from real radio inputs in a fieldset — so arrow keys, form submission and screen-reader announcement all work without any script.",
3851
+ "category": "Forms",
3852
+ "dependencies": [],
3853
+ "registryDependencies": [
3854
+ "lib-styles",
3855
+ "lib-utils"
3856
+ ]
3857
+ },
3510
3858
  {
3511
3859
  "name": "select",
3512
3860
  "type": "registry:ui",
@@ -3612,6 +3960,19 @@
3612
3960
  "tooltip"
3613
3961
  ]
3614
3962
  },
3963
+ {
3964
+ "name": "signature-pad",
3965
+ "type": "registry:ui",
3966
+ "title": "Signature Pad",
3967
+ "description": "A canvas you sign with a finger, stylus or mouse. Strokes are kept as points and redrawn, so undo, resize and export all work — and stylus pressure varies the line.",
3968
+ "category": "Forms",
3969
+ "dependencies": [],
3970
+ "registryDependencies": [
3971
+ "button",
3972
+ "lib-styles",
3973
+ "lib-utils"
3974
+ ]
3975
+ },
3615
3976
  {
3616
3977
  "name": "skeleton",
3617
3978
  "type": "registry:ui",
@@ -4436,6 +4797,37 @@
4436
4797
  "wallet-address"
4437
4798
  ]
4438
4799
  },
4800
+ {
4801
+ "name": "transfer",
4802
+ "type": "registry:ui",
4803
+ "title": "Transfer",
4804
+ "description": "Two lists and the movement between them, for choosing a subset from a large pool. Both sides are searchable — filtering the target is what makes a 200-item selection usable.",
4805
+ "category": "Forms",
4806
+ "dependencies": [
4807
+ "lucide-react@^1.39.0"
4808
+ ],
4809
+ "registryDependencies": [
4810
+ "button",
4811
+ "lib-styles",
4812
+ "lib-utils"
4813
+ ]
4814
+ },
4815
+ {
4816
+ "name": "tree-select",
4817
+ "type": "registry:ui",
4818
+ "title": "Tree Select",
4819
+ "description": "A tree in a popover with real tri-state parents. Checking a branch checks its subtree, the value holds only leaves, and searching keeps the ancestors of every match.",
4820
+ "category": "Forms",
4821
+ "dependencies": [
4822
+ "lucide-react@^1.39.0"
4823
+ ],
4824
+ "registryDependencies": [
4825
+ "lib-styles",
4826
+ "lib-utils",
4827
+ "primitive-dismissable",
4828
+ "primitive-popper"
4829
+ ]
4830
+ },
4439
4831
  {
4440
4832
  "name": "tree",
4441
4833
  "type": "registry:ui",
@@ -4450,6 +4842,18 @@
4450
4842
  "lib-utils"
4451
4843
  ]
4452
4844
  },
4845
+ {
4846
+ "name": "treemap",
4847
+ "type": "registry:ui",
4848
+ "title": "Treemap",
4849
+ "description": "Part-to-whole as nested rectangles sized by area, laid out with the squarify algorithm so tiles stay close to square instead of degenerating into slivers.",
4850
+ "category": "Charts",
4851
+ "dependencies": [],
4852
+ "registryDependencies": [
4853
+ "lib-styles",
4854
+ "lib-utils"
4855
+ ]
4856
+ },
4453
4857
  {
4454
4858
  "name": "two-factor-setup",
4455
4859
  "type": "registry:ui",
@@ -4790,6 +5194,13 @@
4790
5194
  ],
4791
5195
  "registryDependencies": []
4792
5196
  },
5197
+ {
5198
+ "name": "lib-qr",
5199
+ "type": "registry:lib",
5200
+ "title": "qr",
5201
+ "dependencies": [],
5202
+ "registryDependencies": []
5203
+ },
4793
5204
  {
4794
5205
  "name": "lib-styles",
4795
5206
  "type": "registry:lib",
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "ab-test-results",
3
+ "type": "registry:ui",
4
+ "title": "A/B Test Results",
5
+ "description": "Variants with lift, a confidence interval and a two-proportion z-test — plus the peeking warning, because stopping at the first significant reading is the failure that actually happens.",
6
+ "category": "Analytics",
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/ab-test-results.tsx",
17
+ "type": "registry:ui",
18
+ "content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle } from 'lucide-react'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Experiment variants with lift, a confidence interval, and an honest verdict.\n *\n * **It shows the interval, not just the number.** \"+12% lift\" is not a result;\n * \"+12%, 95% CI [−3%, +27%]\" is, and it says the opposite — that the experiment\n * has not concluded. A dashboard that prints the point estimate alone\n * manufactures certainty, and people ship on it.\n *\n * **The maths.** A two-proportion z-test with a pooled standard error for the\n * p-value, and an unpooled standard error for the interval — the standard pair,\n * and the reason the interval can straddle zero while p sits just under 0.05.\n * The interval is then divided by the control rate so it is on the same scale\n * as the lift beside it; an absolute-difference interval printed next to a\n * relative lift is read as bounding that lift, and it does not.\n * The normal approximation needs roughly ten conversions in each arm, so below\n * that the verdict is withheld rather than computed on data too thin to carry\n * it.\n *\n * **It warns about peeking, because that is the real-world failure.** Checking\n * an experiment repeatedly and stopping at the first significant reading\n * inflates the false-positive rate far above the nominal 5% — a fixed-horizon\n * test read continuously is wrong roughly a third of the time. If a sample-size\n * target is given and has not been reached, this says so plainly.\n *\n * It reports a *statistical* result about one metric. Whether that metric is\n * the one that matters, whether the assignment was actually random, and whether\n * the effect is worth the change are not things any component can tell you.\n */\nexport type Variant = {\n id: string\n name: ReactNode\n /** Users in this arm. */\n visitors: number\n conversions: number\n /** Exactly one variant should be the baseline. */\n control?: boolean\n}\n\ntype AbTestResultsProps = Omit<ComponentProps<'div'>, 'title'> & {\n variants: Variant[]\n title?: ReactNode\n /** Two-sided significance threshold. */\n alpha?: number\n /** Per-arm sample size the test was planned for. Enables the peeking warning. */\n targetSample?: number\n metricLabel?: string\n emptyLabel?: string\n label?: string\n}\n\n/** Normal CDF via Abramowitz & Stegun 7.1.26 — accurate to ~1e-7. */\nfunction normalCdf(z: number): number {\n const sign = z < 0 ? -1 : 1\n const x = Math.abs(z) / Math.SQRT2\n const t = 1 / (1 + 0.3275911 * x)\n const y =\n 1 -\n ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *\n t *\n Math.exp(-x * x)\n return 0.5 * (1 + sign * y)\n}\n\n/** 95% ≈ 1.96. Inverse normal by bisection: exact enough, and tiny. */\nfunction zFor(confidence: number): number {\n let lo = 0\n let hi = 6\n const target = 1 - (1 - confidence) / 2\n for (let i = 0; i < 60; i++) {\n const mid = (lo + hi) / 2\n if (normalCdf(mid) < target) lo = mid\n else hi = mid\n }\n return (lo + hi) / 2\n}\n\nfunction AbTestResults({\n variants,\n title,\n alpha = 0.05,\n targetSample,\n metricLabel = 'Conversion',\n emptyLabel = 'No variants.',\n label = 'Experiment results',\n className,\n ...props\n}: AbTestResultsProps) {\n const titleId = useId()\n\n const rows = useMemo(() => {\n const control = variants.find((variant) => variant.control) ?? variants[0]\n if (!control) return []\n\n const z = zFor(1 - alpha)\n const controlRate = control.visitors > 0 ? control.conversions / control.visitors : 0\n\n return variants.map((variant) => {\n const rate = variant.visitors > 0 ? variant.conversions / variant.visitors : 0\n if (variant.id === control.id) {\n return { variant, rate, isControl: true, lift: null, ci: null, p: null, thin: false }\n }\n\n // The normal approximation needs enough successes and failures in both\n // arms; under that, no verdict is honest.\n const thin =\n Math.min(\n variant.conversions,\n variant.visitors - variant.conversions,\n control.conversions,\n control.visitors - control.conversions,\n ) < 10\n\n const pooled =\n (variant.conversions + control.conversions) / (variant.visitors + control.visitors)\n const pooledSe = Math.sqrt(\n pooled * (1 - pooled) * (1 / variant.visitors + 1 / control.visitors),\n )\n const statistic = pooledSe > 0 ? (rate - controlRate) / pooledSe : 0\n const p = 2 * (1 - normalCdf(Math.abs(statistic)))\n\n // Unpooled for the interval — pooling assumes the null it is testing.\n const se = Math.sqrt(\n (rate * (1 - rate)) / variant.visitors + (controlRate * (1 - controlRate)) / control.visitors,\n )\n const diff = rate - controlRate\n\n /**\n * The interval is put on the same scale as the lift beside it.\n *\n * The z-test gives an interval for the *absolute* difference in rates —\n * 0.75 percentage points. Printing that next to a *relative* lift of\n * +12.5% invites the reader to take it as bounding the lift, which it\n * does not: the two numbers are in different units and disagree by a\n * factor of the control rate. Dividing by the control rate is the\n * first-order (delta method) interval for relative lift, which is what\n * every experiment dashboard means by \"95% CI\" on a lift column.\n */\n const ci: [number, number] =\n controlRate > 0\n ? [(diff - z * se) / controlRate, (diff + z * se) / controlRate]\n : [diff - z * se, diff + z * se]\n\n return {\n variant,\n rate,\n isControl: false,\n lift: controlRate > 0 ? diff / controlRate : null,\n ci,\n p,\n thin,\n }\n })\n }, [variants, alpha])\n\n if (rows.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 const underpowered =\n targetSample !== undefined && variants.some((variant) => variant.visitors < targetSample)\n\n const percent = (value: number) => `${(value * 100).toFixed(2)}%`\n const signed = (value: number) => `${value >= 0 ? '+' : ''}${(value * 100).toFixed(1)}%`\n\n return (\n <div\n data-slot=\"ab-test-results\"\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n aria-labelledby={titleId}\n {...props}\n >\n {title && (\n <div className=\"border-border border-b px-4 py-3\">\n <p id={titleId} className=\"text-sm font-medium\">\n {title}\n </p>\n </div>\n )}\n {!title && (\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n )}\n\n <div className=\"overflow-x-auto\">\n <table className=\"w-full text-start text-sm\">\n <thead>\n <tr className=\"border-border text-muted-foreground border-b text-xs\">\n <th className=\"px-4 py-2 text-start font-medium\">Variant</th>\n <th className=\"px-4 py-2 text-end font-medium\">Visitors</th>\n <th className=\"px-4 py-2 text-end font-medium\">{metricLabel}</th>\n <th className=\"px-4 py-2 text-end font-medium\">Lift</th>\n <th className=\"px-4 py-2 text-end font-medium\">\n {Math.round((1 - alpha) * 100)}% CI on lift\n </th>\n <th className=\"px-4 py-2 text-end font-medium\">p</th>\n </tr>\n </thead>\n <tbody>\n {rows.map((row) => {\n const significant = row.p !== null && !row.thin && row.p < alpha\n return (\n <tr key={row.variant.id} className=\"border-border/60 border-b last:border-b-0\">\n <td className=\"px-4 py-2\">\n <span className=\"flex items-center gap-2\">\n {row.variant.name}\n {row.isControl && (\n <span className=\"text-muted-foreground text-[11px]\">control</span>\n )}\n </span>\n </td>\n <td className=\"px-4 py-2 text-end tabular-nums\">\n {row.variant.visitors.toLocaleString()}\n </td>\n <td className=\"px-4 py-2 text-end tabular-nums\">\n {percent(row.rate)}\n <span className=\"text-muted-foreground ms-1 text-[11px]\">\n ({row.variant.conversions})\n </span>\n </td>\n <td\n className={cn(\n 'px-4 py-2 text-end tabular-nums',\n significant && (row.lift ?? 0) > 0 && 'text-[var(--green-soft-foreground)]',\n significant && (row.lift ?? 0) < 0 && 'text-[var(--destructive)]',\n )}\n >\n {row.lift === null ? '—' : signed(row.lift)}\n </td>\n <td className=\"text-muted-foreground px-4 py-2 text-end text-xs tabular-nums\">\n {row.ci ? `[${signed(row.ci[0])}, ${signed(row.ci[1])}]` : '—'}\n </td>\n <td className=\"px-4 py-2 text-end tabular-nums\">\n {row.p === null ? (\n '—'\n ) : row.thin ? (\n <span className=\"text-muted-foreground text-xs\">too few</span>\n ) : (\n <span className={cn(significant && 'font-medium')}>\n {row.p < 0.001 ? '<0.001' : row.p.toFixed(3)}\n </span>\n )}\n </td>\n </tr>\n )\n })}\n </tbody>\n </table>\n </div>\n\n {/* The warning that matters more than the p-value. */}\n {underpowered && (\n <p\n role=\"status\"\n className=\"border-border text-muted-foreground flex items-start gap-2 border-t px-4 py-2 text-xs\"\n >\n <AlertTriangle aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n <span>\n Below the planned {targetSample?.toLocaleString()} per arm. Stopping at the first\n significant reading inflates the false-positive rate well past {Math.round(alpha * 100)}%\n — treat anything here as provisional.\n </span>\n </p>\n )}\n </div>\n )\n}\n\nexport { AbTestResults }\nexport type { AbTestResultsProps }\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "attribution",
3
+ "type": "registry:ui",
4
+ "title": "Attribution",
5
+ "description": "Credit for a conversion split across touchpoints, with the model as a control rather than a buried setting — because the disagreement between models is the whole finding.",
6
+ "category": "Analytics",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-utils"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "components/ui/attribution.tsx",
15
+ "type": "registry:ui",
16
+ "content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Credit for a conversion, split across the touchpoints that led to it.\n *\n * **The model is a control, not a setting buried in a config.** Last-touch says\n * paid search won; first-touch says the blog post did; linear says everyone\n * helped. These are the *same journeys* — the disagreement is entirely in the\n * rule, and any single number presented without its model is a claim\n * masquerading as a measurement. Switching models in place is the point of the\n * component: a channel whose share collapses when you change the rule was never\n * really driving conversions.\n *\n * **Five models, computed here from the raw journeys:**\n * - *First touch* — all credit to the first interaction.\n * - *Last touch* — all credit to the last. The default in most tools, and the\n * most flattering to bottom-of-funnel spend.\n * - *Linear* — split evenly. No assumption, which is its virtue and its flaw.\n * - *Time decay* — later touches earn more, on a configurable half-life.\n * - *Position based* — 40/20/40 across first, middle and last.\n *\n * **None of them are causal.** Every model here divides observed credit among\n * touchpoints that were present; none establishes that a touchpoint *caused*\n * anything, and none can see the channels that never got a click. Incrementality\n * testing answers that question; this one shows you how sensitive your answer is\n * to an arbitrary rule, which is the honest thing a chart can do.\n */\nexport type Touchpoint = {\n channel: string\n at: Date | string\n}\n\nexport type Journey = {\n id: string\n touchpoints: Touchpoint[]\n /** Revenue or count. Defaults to 1 conversion. */\n value?: number\n}\n\nexport type AttributionModel = 'first' | 'last' | 'linear' | 'decay' | 'position'\n\ntype AttributionProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n journeys: Journey[]\n model?: AttributionModel\n defaultModel?: AttributionModel\n onModelChange?: (model: AttributionModel) => void\n /** Models offered in the switcher. */\n models?: AttributionModel[]\n /** Half-life in days for time decay. */\n halfLife?: number\n valueFormat?: (value: number) => string\n emptyLabel?: string\n label?: string\n footnote?: ReactNode\n}\n\nconst MODEL_LABELS: Record<AttributionModel, string> = {\n first: 'First touch',\n last: 'Last touch',\n linear: 'Linear',\n decay: 'Time decay',\n position: 'Position based',\n}\n\nconst asTime = (value: Date | string) =>\n (value instanceof Date ? value : new Date(value)).getTime()\n\n/** Weights for one journey, summing to 1. */\nfunction weightsFor(journey: Journey, model: AttributionModel, halfLife: number): number[] {\n const points = journey.touchpoints\n const n = points.length\n if (n === 0) return []\n if (n === 1) return [1]\n\n switch (model) {\n case 'first':\n return points.map((_, index) => (index === 0 ? 1 : 0))\n case 'last':\n return points.map((_, index) => (index === n - 1 ? 1 : 0))\n case 'linear':\n return points.map(() => 1 / n)\n case 'position': {\n // 40% first, 40% last, 20% shared by the middle.\n if (n === 2) return [0.5, 0.5]\n const middle = 0.2 / (n - 2)\n return points.map((_, index) => (index === 0 || index === n - 1 ? 0.4 : middle))\n }\n case 'decay': {\n const last = asTime(points[n - 1].at)\n const dayMs = 86_400_000\n const raw = points.map((point) => {\n const daysBefore = (last - asTime(point.at)) / dayMs\n return Math.pow(2, -daysBefore / halfLife)\n })\n const sum = raw.reduce((total, weight) => total + weight, 0) || 1\n return raw.map((weight) => weight / sum)\n }\n }\n}\n\nfunction Attribution({\n journeys,\n model: modelProp,\n defaultModel = 'last',\n onModelChange,\n models = ['first', 'last', 'linear', 'decay', 'position'],\n halfLife = 7,\n valueFormat = (value) => value.toFixed(1),\n emptyLabel = 'No journeys.',\n label = 'Attribution',\n footnote,\n className,\n ...props\n}: AttributionProps) {\n const titleId = useId()\n const [internal, setInternal] = useState<AttributionModel>(defaultModel)\n const model = modelProp ?? internal\n\n const rows = useMemo(() => {\n const credit = new Map<string, number>()\n let total = 0\n\n for (const journey of journeys) {\n const value = journey.value ?? 1\n const weights = weightsFor(journey, model, halfLife)\n journey.touchpoints.forEach((point, index) => {\n credit.set(point.channel, (credit.get(point.channel) ?? 0) + value * (weights[index] ?? 0))\n })\n total += value\n }\n\n return {\n total,\n channels: [...credit.entries()]\n .map(([channel, amount]) => ({ channel, amount }))\n .sort((a, b) => b.amount - a.amount),\n }\n }, [journeys, model, halfLife])\n\n if (journeys.length === 0) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const max = Math.max(...rows.channels.map((row) => row.amount), 1)\n\n const setModel = (next: AttributionModel) => {\n if (modelProp === undefined) setInternal(next)\n onModelChange?.(next)\n }\n\n return (\n <div\n data-slot=\"attribution\"\n className={cn('flex flex-col gap-3', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <p id={titleId} className=\"text-sm font-medium\">\n {label}\n </p>\n\n {/* The model belongs on the chart, not in a settings panel: the number\n means nothing without it. */}\n <div role=\"radiogroup\" aria-label=\"Attribution model\" className=\"flex flex-wrap gap-1\">\n {models.map((option) => (\n <button\n key={option}\n type=\"button\"\n role=\"radio\"\n aria-checked={model === option}\n onClick={() => setModel(option)}\n className={cn(\n 'rounded-full px-2.5 py-1 text-xs',\n model === option\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted',\n )}\n >\n {MODEL_LABELS[option]}\n </button>\n ))}\n </div>\n </div>\n\n <ul className=\"flex list-none flex-col gap-2\">\n {rows.channels.map((row, index) => {\n const share = rows.total > 0 ? row.amount / rows.total : 0\n return (\n <li key={row.channel} className=\"flex items-center gap-3\">\n <span className=\"w-28 shrink-0 truncate text-xs\">{row.channel}</span>\n <span className=\"bg-muted relative h-5 min-w-0 flex-1 overflow-hidden rounded-[3px]\">\n <span\n className=\"absolute inset-y-0 start-0 rounded-[3px]\"\n style={{\n width: `${(row.amount / max) * 100}%`,\n background: dataPalette[index % dataPalette.length].fill,\n opacity: 0.75,\n }}\n />\n </span>\n <span className=\"text-muted-foreground w-24 shrink-0 text-end text-xs tabular-nums\">\n {valueFormat(row.amount)}\n <span className=\"ms-1 opacity-70\">({(share * 100).toFixed(0)}%)</span>\n </span>\n </li>\n )\n })}\n </ul>\n\n <p className=\"text-muted-foreground text-xs\">\n {footnote ?? (\n <>\n {journeys.length.toLocaleString()} journeys under {MODEL_LABELS[model].toLowerCase()}.\n Attribution divides observed credit; it does not establish cause.\n </>\n )}\n </p>\n </div>\n )\n}\n\nexport { Attribution }\nexport type { AttributionProps }\n"
17
+ }
18
+ ]
19
+ }