astralyx-ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "343 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -15,7 +15,8 @@
15
15
  "registryDependencies": [
16
16
  "lib-motion",
17
17
  "lib-styles",
18
- "lib-utils"
18
+ "lib-utils",
19
+ "table"
19
20
  ]
20
21
  },
21
22
  {
@@ -252,7 +253,8 @@
252
253
  "registryDependencies": [
253
254
  "lib-motion",
254
255
  "lib-styles",
255
- "lib-utils"
256
+ "lib-utils",
257
+ "segmented-control"
256
258
  ]
257
259
  },
258
260
  {
@@ -268,7 +270,8 @@
268
270
  "button",
269
271
  "lib-motion",
270
272
  "lib-styles",
271
- "lib-utils"
273
+ "lib-utils",
274
+ "slider"
272
275
  ]
273
276
  },
274
277
  {
@@ -669,6 +672,7 @@
669
672
  "lucide-react@^1.39.0"
670
673
  ],
671
674
  "registryDependencies": [
675
+ "input",
672
676
  "lib-motion",
673
677
  "lib-styles",
674
678
  "lib-utils",
@@ -874,7 +878,8 @@
874
878
  "registryDependencies": [
875
879
  "lib-motion",
876
880
  "lib-styles",
877
- "lib-utils"
881
+ "lib-utils",
882
+ "table"
878
883
  ]
879
884
  },
880
885
  {
@@ -1652,7 +1657,8 @@
1652
1657
  "registryDependencies": [
1653
1658
  "lib-motion",
1654
1659
  "lib-styles",
1655
- "lib-utils"
1660
+ "lib-utils",
1661
+ "table"
1656
1662
  ]
1657
1663
  },
1658
1664
  {
@@ -1686,6 +1692,7 @@
1686
1692
  "lib-motion",
1687
1693
  "lib-styles",
1688
1694
  "lib-utils",
1695
+ "slider",
1689
1696
  "switch"
1690
1697
  ]
1691
1698
  },
@@ -2313,7 +2320,8 @@
2313
2320
  "fmt",
2314
2321
  "lib-motion",
2315
2322
  "lib-styles",
2316
- "lib-utils"
2323
+ "lib-utils",
2324
+ "table"
2317
2325
  ]
2318
2326
  },
2319
2327
  {
@@ -2476,7 +2484,8 @@
2476
2484
  "lib-motion",
2477
2485
  "lib-styles",
2478
2486
  "lib-utils",
2479
- "sparkline"
2487
+ "sparkline",
2488
+ "table"
2480
2489
  ]
2481
2490
  },
2482
2491
  {
@@ -3164,7 +3173,8 @@
3164
3173
  "checkbox",
3165
3174
  "lib-motion",
3166
3175
  "lib-styles",
3167
- "lib-utils"
3176
+ "lib-utils",
3177
+ "table"
3168
3178
  ]
3169
3179
  },
3170
3180
  {
@@ -3175,8 +3185,10 @@
3175
3185
  "category": "Forms",
3176
3186
  "dependencies": [],
3177
3187
  "registryDependencies": [
3178
- "lib-styles",
3179
- "lib-utils"
3188
+ "button-group",
3189
+ "input",
3190
+ "lib-utils",
3191
+ "select"
3180
3192
  ]
3181
3193
  },
3182
3194
  {
@@ -3722,7 +3734,8 @@
3722
3734
  "registryDependencies": [
3723
3735
  "fmt",
3724
3736
  "lib-motion",
3725
- "lib-utils"
3737
+ "lib-utils",
3738
+ "progress"
3726
3739
  ]
3727
3740
  },
3728
3741
  {
@@ -4269,7 +4282,8 @@
4269
4282
  "registryDependencies": [
4270
4283
  "fmt",
4271
4284
  "lib-motion",
4272
- "lib-utils"
4285
+ "lib-utils",
4286
+ "progress"
4273
4287
  ]
4274
4288
  },
4275
4289
  {
@@ -5161,6 +5175,7 @@
5161
5175
  ],
5162
5176
  "registryDependencies": [
5163
5177
  "button",
5178
+ "checkbox",
5164
5179
  "copy-button",
5165
5180
  "input-otp",
5166
5181
  "lib-motion",
@@ -5272,7 +5287,8 @@
5272
5287
  "badge",
5273
5288
  "lib-motion",
5274
5289
  "lib-styles",
5275
- "lib-utils"
5290
+ "lib-utils",
5291
+ "table"
5276
5292
  ]
5277
5293
  },
5278
5294
  {
@@ -5289,7 +5305,8 @@
5289
5305
  "dropdown-menu",
5290
5306
  "lib-motion",
5291
5307
  "lib-styles",
5292
- "lib-utils"
5308
+ "lib-utils",
5309
+ "slider"
5293
5310
  ]
5294
5311
  },
5295
5312
  {
@@ -10,13 +10,14 @@
10
10
  "registryDependencies": [
11
11
  "lib-motion",
12
12
  "lib-styles",
13
- "lib-utils"
13
+ "lib-utils",
14
+ "table"
14
15
  ],
15
16
  "files": [
16
17
  {
17
18
  "path": "components/ui/ab-test-results.tsx",
18
19
  "type": "registry:ui",
19
- "content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle } from 'lucide-react'\nimport { enterFade } from '@/lib/motion'\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(enterFade, 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"
20
+ "content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { AlertTriangle } from 'lucide-react'\nimport {\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { enterFade } from '@/lib/motion'\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(enterFade, 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 {/* The kit's table parts: the six column headings were six copies of\n the same recipe, and the rows now pick up the shared rule and hover\n for free. */}\n <table className=\"w-full text-start text-sm\">\n <TableHeader>\n <TableRow>\n <TableHead>Variant</TableHead>\n <TableHead className=\"text-end\">Visitors</TableHead>\n <TableHead className=\"text-end\">{metricLabel}</TableHead>\n <TableHead className=\"text-end\">Lift</TableHead>\n <TableHead className=\"text-end\">\n {Math.round((1 - alpha) * 100)}% CI on lift\n </TableHead>\n <TableHead className=\"text-end\">p</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => {\n const significant = row.p !== null && !row.thin && row.p < alpha\n return (\n <TableRow key={row.variant.id}>\n <TableCell>\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 </TableCell>\n <TableCell className=\"text-end tabular-nums\">\n {row.variant.visitors.toLocaleString()}\n </TableCell>\n <TableCell className=\"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 </TableCell>\n <TableCell\n className={cn(\n '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 </TableCell>\n <TableCell className=\"text-muted-foreground text-end text-xs tabular-nums\">\n {row.ci ? `[${signed(row.ci[0])}, ${signed(row.ci[1])}]` : '—'}\n </TableCell>\n <TableCell className=\"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 </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\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"
20
21
  }
21
22
  ]
22
23
  }
@@ -8,13 +8,14 @@
8
8
  "registryDependencies": [
9
9
  "lib-motion",
10
10
  "lib-styles",
11
- "lib-utils"
11
+ "lib-utils",
12
+ "segmented-control"
12
13
  ],
13
14
  "files": [
14
15
  {
15
16
  "path": "components/ui/attribution.tsx",
16
17
  "type": "registry:ui",
17
- "content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { enterFade, growIn } from '@/lib/motion'\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(enterFade, '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={cn(growIn, '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"
18
+ "content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { SegmentedControl } from '@/components/ui/segmented-control'\nimport { enterFade, growIn } from '@/lib/motion'\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(enterFade, '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\n The kit's SegmentedControl rather than a row of pills: this picks a\n value from a handful of visible options, which is exactly what that\n component is, and a hand-rolled radiogroup here had none of its\n roving focus or its sliding indicator. */}\n <SegmentedControl\n size=\"sm\"\n label=\"Attribution model\"\n value={model}\n onValueChange={(next) => setModel(next as AttributionModel)}\n options={models.map((option) => ({\n value: option,\n label: MODEL_LABELS[option],\n }))}\n />\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={cn(growIn, '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"
18
19
  }
19
20
  ]
20
21
  }
@@ -11,13 +11,14 @@
11
11
  "button",
12
12
  "lib-motion",
13
13
  "lib-styles",
14
- "lib-utils"
14
+ "lib-utils",
15
+ "slider"
15
16
  ],
16
17
  "files": [
17
18
  {
18
19
  "path": "components/ui/audio-player.tsx",
19
20
  "type": "registry:ui",
20
- "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 { enterFade } from '@/lib/motion'\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 */\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype AudioPlayerProps = Omit<ComponentProps<'div'>, 'title' | 'onEnded'> & {\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(enterFade, 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"
21
+ "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 { Slider } from '@/components/ui/slider'\nimport { enterFade } from '@/lib/motion'\nimport { 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 the kit's `Slider`.** It is a range input underneath, so\n * keyboard seeking, page-up jumps, screen-reader announcement and touch drag\n * are all native — and it looks like every other slider in the product, which\n * a hand-styled one with its own vendor pseudo-element rules did not. 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, and the slider's\n * rail and fill go transparent so the peaks *are* the track rather than being\n * covered by a second progress bar. 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 */\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype AudioPlayerProps = Omit<ComponentProps<'div'>, 'title' | 'onEnded'> & {\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(enterFade, 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 h-6 min-w-0 flex-1\">\n {/* Behind the track, never in front: the slider has to stay the\n 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 <Slider\n id={id}\n size=\"sm\"\n min={0}\n max={duration || 0}\n step={0.01}\n value={time}\n 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 'absolute inset-0 h-full',\n // With a waveform, the peaks *are* the track: they already\n // carry the played/unplayed split, so painting a rail and a\n // fill over them would draw a second, disagreeing progress bar.\n peaks && peaks.length > 0 && '[--slider-fill:transparent] [--slider-rail:transparent]',\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"
21
22
  }
22
23
  ]
23
24
  }
@@ -8,6 +8,7 @@
8
8
  "lucide-react@^1.39.0"
9
9
  ],
10
10
  "registryDependencies": [
11
+ "input",
11
12
  "lib-motion",
12
13
  "lib-styles",
13
14
  "lib-utils",
@@ -17,7 +18,7 @@
17
18
  {
18
19
  "path": "components/ui/card-input.tsx",
19
20
  "type": "registry:ui",
20
- "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { CreditCard, Lock } from 'lucide-react'\nimport { MaskInput } from '@/components/ui/mask-input'\nimport { enterFade } from '@/lib/motion'\nimport { fieldBase, fieldSize, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Card number, expiry and CVC as one control.\n *\n * The number is validated with the Luhn checksum, which catches a mistyped\n * digit before the request is made. That matters commercially, not just for\n * polish: failed authorisations count against a merchant's decline rate, and\n * card networks price on it.\n *\n * The brand is detected from the leading digits so the CVC length can follow —\n * American Express uses four, everyone else three, and a fixed `maxlength` of\n * three silently truncates an Amex code.\n *\n * Nothing here stores or transmits anything. In production the fields belong in\n * a PSP iframe so the page never touches a PAN; this is the shape for a demo,\n * a test harness, or a PSP that hands you styling control.\n */\nexport type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\n\n/** Brand from the issuer identification number. */\nfunction detectBrand(digits: string): CardBrand {\n if (/^4/.test(digits)) return 'visa'\n if (/^5[1-5]/.test(digits) || /^2(2[2-9]|[3-6]|7[01]|720)/.test(digits)) return 'mastercard'\n if (/^3[47]/.test(digits)) return 'amex'\n if (/^6(011|5|4[4-9])/.test(digits)) return 'discover'\n return 'unknown'\n}\n\n/** Luhn checksum — catches a single mistyped digit and most transpositions. */\nfunction luhnValid(digits: string) {\n if (digits.length < 12) return false\n let sum = 0\n let double = false\n for (let i = digits.length - 1; i >= 0; i--) {\n let d = Number(digits[i])\n if (double) {\n d *= 2\n if (d > 9) d -= 9\n }\n sum += d\n double = !double\n }\n return sum % 10 === 0\n}\n\nconst BRAND_LABEL: Record<CardBrand, string> = {\n visa: 'Visa',\n mastercard: 'Mastercard',\n amex: 'American Express',\n discover: 'Discover',\n unknown: 'Card',\n}\n\nfunction CardInput({\n onCardChange,\n size = 'md',\n numberLabel = 'Card number',\n expiryLabel = 'Expiry date',\n expiryPlaceholder = 'MM/YY',\n cvcLabel = 'Security code',\n invalidNote = 'That card number does not check out — one of the digits is wrong.',\n disabled = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n onCardChange?: (card: {\n number: string\n expiry: string\n cvc: string\n brand: CardBrand\n valid: boolean\n }) => void\n size?: keyof typeof fieldSize\n /** Accessible name for the number field. */\n numberLabel?: string\n expiryLabel?: string\n expiryPlaceholder?: string\n cvcLabel?: string\n /** Shown when the digits fail the Luhn check. */\n invalidNote?: ReactNode\n disabled?: boolean\n}) {\n const [number, setNumber] = useState('')\n const [expiry, setExpiry] = useState('')\n const [cvc, setCvc] = useState('')\n\n const brand = detectBrand(number)\n // Amex is 15 digits in 4-6-5 groups with a 4-digit code.\n const amex = brand === 'amex'\n const numberMask = amex ? '#### ###### #####' : '#### #### #### ####'\n const cvcLength = amex ? 4 : 3\n\n const numberValid = luhnValid(number)\n const expiryValid = /^(0[1-9]|1[0-2])\\d{2}$/.test(expiry)\n\n const emit = (next: Partial<{ number: string; expiry: string; cvc: string }>) => {\n const card = { number, expiry, cvc, ...next }\n onCardChange?.({\n ...card,\n brand: detectBrand(card.number),\n valid:\n luhnValid(card.number) &&\n /^(0[1-9]|1[0-2])\\d{2}$/.test(card.expiry) &&\n card.cvc.length === (detectBrand(card.number) === 'amex' ? 4 : 3),\n })\n }\n\n return (\n <div data-slot=\"card-input\" className={cn(enterFade, 'flex flex-col gap-2', className)} {...props}>\n <div className=\"relative\">\n <MaskInput\n // Remount when the mask changes, or the formatted value is stale.\n key={numberMask}\n mask={numberMask}\n size={size}\n disabled={disabled}\n aria-label={numberLabel}\n autoComplete=\"cc-number\"\n error={number.length > 0 && number.length >= 12 && !numberValid}\n onValueChange={(_, raw) => {\n setNumber(raw)\n emit({ number: raw })\n }}\n />\n <span className=\"text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-xs font-medium\">\n {number ? BRAND_LABEL[brand] : <CreditCard className=\"size-4\" aria-hidden=\"true\" />}\n </span>\n </div>\n\n <div className=\"flex gap-2\">\n <MaskInput\n mask=\"##/##\"\n size={size}\n disabled={disabled}\n aria-label={expiryLabel}\n autoComplete=\"cc-exp\"\n placeholder={expiryPlaceholder}\n error={expiry.length === 4 && !expiryValid}\n onValueChange={(_, raw) => {\n setExpiry(raw)\n emit({ expiry: raw })\n }}\n className=\"flex-1\"\n />\n\n <div\n className={cn(\n fieldBase,\n fieldSize[size],\n 'border-border bg-background flex-1 border',\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n )}\n >\n <Lock className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n <input\n inputMode=\"numeric\"\n maxLength={cvcLength}\n disabled={disabled}\n value={cvc}\n aria-label={cvcLabel}\n autoComplete=\"cc-csc\"\n placeholder={amex ? '4 digits' : 'CVC'}\n onChange={(event) => {\n const next = event.target.value.replace(/\\D/g, '').slice(0, cvcLength)\n setCvc(next)\n emit({ cvc: next })\n }}\n className=\"w-full min-w-0 bg-transparent tabular-nums outline-none\"\n />\n </div>\n </div>\n\n {number.length >= 12 && !numberValid && (\n <p className={cn('text-[var(--destructive-soft-foreground)] text-xs', radius.xs)}>\n {invalidNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { CardInput, detectBrand, luhnValid }\n"
21
+ "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { CreditCard, Lock } from 'lucide-react'\nimport { Input } from '@/components/ui/input'\nimport { MaskInput } from '@/components/ui/mask-input'\nimport { enterFade } from '@/lib/motion'\nimport { fieldSize, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Card number, expiry and CVC as one control.\n *\n * The number is validated with the Luhn checksum, which catches a mistyped\n * digit before the request is made. That matters commercially, not just for\n * polish: failed authorisations count against a merchant's decline rate, and\n * card networks price on it.\n *\n * The brand is detected from the leading digits so the CVC length can follow —\n * American Express uses four, everyone else three, and a fixed `maxlength` of\n * three silently truncates an Amex code.\n *\n * Nothing here stores or transmits anything. In production the fields belong in\n * a PSP iframe so the page never touches a PAN; this is the shape for a demo,\n * a test harness, or a PSP that hands you styling control.\n */\nexport type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\n\n/** Brand from the issuer identification number. */\nfunction detectBrand(digits: string): CardBrand {\n if (/^4/.test(digits)) return 'visa'\n if (/^5[1-5]/.test(digits) || /^2(2[2-9]|[3-6]|7[01]|720)/.test(digits)) return 'mastercard'\n if (/^3[47]/.test(digits)) return 'amex'\n if (/^6(011|5|4[4-9])/.test(digits)) return 'discover'\n return 'unknown'\n}\n\n/** Luhn checksum — catches a single mistyped digit and most transpositions. */\nfunction luhnValid(digits: string) {\n if (digits.length < 12) return false\n let sum = 0\n let double = false\n for (let i = digits.length - 1; i >= 0; i--) {\n let d = Number(digits[i])\n if (double) {\n d *= 2\n if (d > 9) d -= 9\n }\n sum += d\n double = !double\n }\n return sum % 10 === 0\n}\n\nconst BRAND_LABEL: Record<CardBrand, string> = {\n visa: 'Visa',\n mastercard: 'Mastercard',\n amex: 'American Express',\n discover: 'Discover',\n unknown: 'Card',\n}\n\nfunction CardInput({\n onCardChange,\n size = 'md',\n numberLabel = 'Card number',\n expiryLabel = 'Expiry date',\n expiryPlaceholder = 'MM/YY',\n cvcLabel = 'Security code',\n invalidNote = 'That card number does not check out — one of the digits is wrong.',\n disabled = false,\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n onCardChange?: (card: {\n number: string\n expiry: string\n cvc: string\n brand: CardBrand\n valid: boolean\n }) => void\n size?: keyof typeof fieldSize\n /** Accessible name for the number field. */\n numberLabel?: string\n expiryLabel?: string\n expiryPlaceholder?: string\n cvcLabel?: string\n /** Shown when the digits fail the Luhn check. */\n invalidNote?: ReactNode\n disabled?: boolean\n}) {\n const [number, setNumber] = useState('')\n const [expiry, setExpiry] = useState('')\n const [cvc, setCvc] = useState('')\n\n const brand = detectBrand(number)\n // Amex is 15 digits in 4-6-5 groups with a 4-digit code.\n const amex = brand === 'amex'\n const numberMask = amex ? '#### ###### #####' : '#### #### #### ####'\n const cvcLength = amex ? 4 : 3\n\n const numberValid = luhnValid(number)\n const expiryValid = /^(0[1-9]|1[0-2])\\d{2}$/.test(expiry)\n\n const emit = (next: Partial<{ number: string; expiry: string; cvc: string }>) => {\n const card = { number, expiry, cvc, ...next }\n onCardChange?.({\n ...card,\n brand: detectBrand(card.number),\n valid:\n luhnValid(card.number) &&\n /^(0[1-9]|1[0-2])\\d{2}$/.test(card.expiry) &&\n card.cvc.length === (detectBrand(card.number) === 'amex' ? 4 : 3),\n })\n }\n\n return (\n <div data-slot=\"card-input\" className={cn(enterFade, 'flex flex-col gap-2', className)} {...props}>\n <div className=\"relative\">\n <MaskInput\n // Remount when the mask changes, or the formatted value is stale.\n key={numberMask}\n mask={numberMask}\n size={size}\n disabled={disabled}\n aria-label={numberLabel}\n autoComplete=\"cc-number\"\n error={number.length > 0 && number.length >= 12 && !numberValid}\n onValueChange={(_, raw) => {\n setNumber(raw)\n emit({ number: raw })\n }}\n />\n <span className=\"text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-xs font-medium\">\n {number ? BRAND_LABEL[brand] : <CreditCard className=\"size-4\" aria-hidden=\"true\" />}\n </span>\n </div>\n\n <div className=\"flex gap-2\">\n <MaskInput\n mask=\"##/##\"\n size={size}\n disabled={disabled}\n aria-label={expiryLabel}\n autoComplete=\"cc-exp\"\n placeholder={expiryPlaceholder}\n error={expiry.length === 4 && !expiryValid}\n onValueChange={(_, raw) => {\n setExpiry(raw)\n emit({ expiry: raw })\n }}\n className=\"flex-1\"\n />\n\n {/* The kit's Input with a leading icon, not a hand-built field: this\n was `fieldBase` plus a bare input plus a re-spelled focus ring, all\n three of which Input already is. */}\n <Input\n // `fieldSize` calls the middle step 'md'; the control scale calls it\n // 'default'. Same height, different word.\n size={size === 'md' ? 'default' : size}\n icon={<Lock />}\n inputMode=\"numeric\"\n maxLength={cvcLength}\n disabled={disabled}\n value={cvc}\n aria-label={cvcLabel}\n autoComplete=\"cc-csc\"\n placeholder={amex ? '4 digits' : 'CVC'}\n onChange={(event) => {\n const next = event.target.value.replace(/\\D/g, '').slice(0, cvcLength)\n setCvc(next)\n emit({ cvc: next })\n }}\n containerClassName=\"flex-1\"\n className=\"tabular-nums\"\n />\n </div>\n\n {number.length >= 12 && !numberValid && (\n <p className={cn('text-[var(--destructive-soft-foreground)] text-xs', radius.xs)}>\n {invalidNote}\n </p>\n )}\n </div>\n )\n}\n\nexport { CardInput, detectBrand, luhnValid }\n"
21
22
  }
22
23
  ]
23
24
  }
@@ -8,13 +8,14 @@
8
8
  "registryDependencies": [
9
9
  "lib-motion",
10
10
  "lib-styles",
11
- "lib-utils"
11
+ "lib-utils",
12
+ "table"
12
13
  ],
13
14
  "files": [
14
15
  {
15
16
  "path": "components/ui/cohort-table.tsx",
16
17
  "type": "registry:ui",
17
- "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A retention cohort grid — signup period down, periods since across.\n *\n * Incomplete cohorts are marked rather than plotted. The newest cohort has not\n * lived long enough to have a week-4 number, and rendering an empty cell as 0%\n * makes retention look like it fell off a cliff. This is the single most common\n * way a cohort chart lies.\n *\n * Period 0 is always 100% by definition, so it is rendered as the cohort size\n * instead — that cell is the only place the absolute number appears, and\n * without it every percentage is unanchored.\n */\nexport type Cohort = {\n /** Period label — \"Jan 2026\", \"Week 12\". */\n label: ReactNode\n /** People in the cohort at period 0. */\n size: number\n /** Retained counts per period, index 0 being the cohort period. */\n values: (number | null)[]\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_PERIOD_LABEL: (index: number) => ReactNode = (index) => `${index}`\n\nfunction CohortTable({\n cohorts,\n periods,\n periodLabel = DEFAULT_PERIOD_LABEL,\n locale = 'en-GB',\n cohortHeader = 'Cohort',\n sizeHeader = 'Size',\n averageLabel = 'Average',\n notReachedLabel = 'Not yet reached',\n footnote = 'A dot means the cohort has not reached that period yet — not zero retention. Averages count only cohorts that have.',\n className,\n ...props\n}: ComponentProps<'div'> & {\n cohorts: Cohort[]\n /** How many period columns to render. */\n periods?: number\n periodLabel?: (index: number) => ReactNode\n locale?: string\n cohortHeader?: ReactNode\n sizeHeader?: ReactNode\n /** Label on the averages row. */\n averageLabel?: ReactNode\n /** Accessible name for a period a cohort has not reached. */\n notReachedLabel?: string\n /** Note under the table. Pass `null` to drop it. */\n footnote?: ReactNode\n}) {\n const columns = periods ?? Math.max(...cohorts.map((c) => c.values.length), 1)\n const num = new Intl.NumberFormat(locale)\n\n const averages = useMemo(\n () =>\n Array.from({ length: columns }, (_, index) => {\n // Only complete cohorts contribute — otherwise the average drifts\n // downward as young cohorts are counted as zero.\n const present = cohorts\n .map((cohort) => {\n const value = cohort.values[index]\n return value === null || value === undefined ? null : value / cohort.size\n })\n .filter((v): v is number => v !== null)\n return present.length\n ? present.reduce((sum, v) => sum + v, 0) / present.length\n : null\n }),\n [cohorts, columns],\n )\n\n return (\n <div\n data-slot=\"cohort-table\"\n className={cn(enterFade, surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-xs font-medium\">\n {cohortHeader}\n </th>\n <th className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium\">{sizeHeader}</th>\n {Array.from({ length: columns }, (_, index) => (\n <th\n key={index}\n className=\"text-muted-foreground px-3 py-2 text-center text-xs font-medium whitespace-nowrap\"\n >\n {periodLabel(index)}\n </th>\n ))}\n </tr>\n </thead>\n\n <tbody>\n {cohorts.map((cohort, rowIndex) => (\n <tr key={rowIndex} className=\"border-border/60 border-b\">\n <th className=\"sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-sm font-medium whitespace-nowrap\">\n {cohort.label}\n </th>\n <td className=\"text-muted-foreground px-3 py-2 text-end tabular-nums\">\n {num.format(cohort.size)}\n </td>\n\n {Array.from({ length: columns }, (_, index) => {\n const raw = cohort.values[index]\n // Not yet reached — never rendered as zero.\n if (raw === null || raw === undefined) {\n return (\n <td key={index} className=\"px-3 py-2 text-center\">\n <span className=\"text-muted-foreground/25\" aria-label={notReachedLabel}>\n ·\n </span>\n </td>\n )\n }\n\n const share = cohort.size > 0 ? raw / cohort.size : 0\n return (\n <td\n key={index}\n style={{\n backgroundColor: `color-mix(in oklab, var(--blue), transparent ${\n 100 - Math.round(share * 55)\n }%)`,\n }}\n className=\"px-3 py-2 text-center tabular-nums\"\n title={`${num.format(raw)} of ${num.format(cohort.size)}`}\n >\n {Math.round(share * 100)}%\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n\n <tfoot>\n <tr className=\"border-border border-t-2\">\n <th className=\"sticky start-0 bg-[var(--card)] px-3 py-2 text-start text-xs font-medium\">\n {averageLabel}\n </th>\n <td />\n {averages.map((average, index) => (\n <td key={index} className=\"px-3 py-2 text-center text-xs font-medium tabular-nums\">\n {average === null ? '—' : `${Math.round(average * 100)}%`}\n </td>\n ))}\n </tr>\n </tfoot>\n </table>\n\n <p className=\"border-border text-muted-foreground border-t p-3 text-xs\">\n A dot means the cohort has not reached that period yet — not zero\n retention. Averages count only cohorts that have.\n </p>\n </div>\n )\n}\n\nexport { CohortTable }\n"
18
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport {\n TableBody,\n TableCell,\n TableFooter,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A retention cohort grid — signup period down, periods since across.\n *\n * Incomplete cohorts are marked rather than plotted. The newest cohort has not\n * lived long enough to have a week-4 number, and rendering an empty cell as 0%\n * makes retention look like it fell off a cliff. This is the single most common\n * way a cohort chart lies.\n *\n * Period 0 is always 100% by definition, so it is rendered as the cohort size\n * instead — that cell is the only place the absolute number appears, and\n * without it every percentage is unanchored.\n */\nexport type Cohort = {\n /** Period label — \"Jan 2026\", \"Week 12\". */\n label: ReactNode\n /** People in the cohort at period 0. */\n size: number\n /** Retained counts per period, index 0 being the cohort period. */\n values: (number | null)[]\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_PERIOD_LABEL: (index: number) => ReactNode = (index) => `${index}`\n\nfunction CohortTable({\n cohorts,\n periods,\n periodLabel = DEFAULT_PERIOD_LABEL,\n locale = 'en-GB',\n cohortHeader = 'Cohort',\n sizeHeader = 'Size',\n averageLabel = 'Average',\n notReachedLabel = 'Not yet reached',\n footnote = 'A dot means the cohort has not reached that period yet — not zero retention. Averages count only cohorts that have.',\n className,\n ...props\n}: ComponentProps<'div'> & {\n cohorts: Cohort[]\n /** How many period columns to render. */\n periods?: number\n periodLabel?: (index: number) => ReactNode\n locale?: string\n cohortHeader?: ReactNode\n sizeHeader?: ReactNode\n /** Label on the averages row. */\n averageLabel?: ReactNode\n /** Accessible name for a period a cohort has not reached. */\n notReachedLabel?: string\n /** Note under the table. Pass `null` to drop it. */\n footnote?: ReactNode\n}) {\n const columns = periods ?? Math.max(...cohorts.map((c) => c.values.length), 1)\n const num = new Intl.NumberFormat(locale)\n\n const averages = useMemo(\n () =>\n Array.from({ length: columns }, (_, index) => {\n // Only complete cohorts contribute — otherwise the average drifts\n // downward as young cohorts are counted as zero.\n const present = cohorts\n .map((cohort) => {\n const value = cohort.values[index]\n return value === null || value === undefined ? null : value / cohort.size\n })\n .filter((v): v is number => v !== null)\n return present.length\n ? present.reduce((sum, v) => sum + v, 0) / present.length\n : null\n }),\n [cohorts, columns],\n )\n\n return (\n <div\n data-slot=\"cohort-table\"\n className={cn(enterFade, surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n {/* The kit's table parts. The heat map keeps its own per-cell fill;\n everything around it — heading ink, row rules, the totals band — is\n the shared table, and the density is stated once. */}\n <table className=\"w-full text-sm\">\n <TableHeader>\n <TableRow>\n <TableHead className=\"sticky start-0 bg-[var(--card)]\">{cohortHeader}</TableHead>\n <TableHead className=\"text-end\">{sizeHeader}</TableHead>\n {Array.from({ length: columns }, (_, index) => (\n <TableHead key={index} className=\"text-center\">\n {periodLabel(index)}\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {cohorts.map((cohort, rowIndex) => (\n <TableRow key={rowIndex}>\n {/* A row header, not a column one: body type, body ink. */}\n <th\n scope=\"row\"\n className=\"sticky start-0 bg-[var(--card)] text-start text-sm font-medium whitespace-nowrap\"\n >\n {cohort.label}\n </th>\n <TableCell className=\"text-muted-foreground text-end tabular-nums\">\n {num.format(cohort.size)}\n </TableCell>\n\n {Array.from({ length: columns }, (_, index) => {\n const raw = cohort.values[index]\n // Not yet reached — never rendered as zero.\n if (raw === null || raw === undefined) {\n return (\n <TableCell key={index} className=\"text-center\">\n <span className=\"text-muted-foreground/25\" aria-label={notReachedLabel}>\n ·\n </span>\n </TableCell>\n )\n }\n\n const share = cohort.size > 0 ? raw / cohort.size : 0\n return (\n <TableCell\n key={index}\n style={{\n backgroundColor: `color-mix(in oklab, var(--blue), transparent ${\n 100 - Math.round(share * 55)\n }%)`,\n }}\n className=\"text-center tabular-nums\"\n title={`${num.format(raw)} of ${num.format(cohort.size)}`}\n >\n {Math.round(share * 100)}%\n </TableCell>\n )\n })}\n </TableRow>\n ))}\n </TableBody>\n\n <TableFooter>\n <TableRow>\n <th\n scope=\"row\"\n className=\"text-muted-foreground sticky start-0 bg-[var(--card)] text-start text-xs font-medium\"\n >\n {averageLabel}\n </th>\n <TableCell />\n {averages.map((average, index) => (\n <TableCell key={index} className=\"text-center text-xs tabular-nums\">\n {average === null ? '—' : `${Math.round(average * 100)}%`}\n </TableCell>\n ))}\n </TableRow>\n </TableFooter>\n </table>\n\n <p className=\"border-border text-muted-foreground border-t p-3 text-xs\">\n A dot means the cohort has not reached that period yet — not zero\n retention. Averages count only cohorts that have.\n </p>\n </div>\n )\n}\n\nexport { CohortTable }\n"
18
19
  }
19
20
  ]
20
21
  }
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "path": "components/ui/csv-preview.tsx",
19
19
  "type": "registry:ui",
20
- "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { enterFade } from '@/lib/motion'\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(enterFade, 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
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { enterFade } from '@/lib/motion'\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(enterFade, 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 p-3 text-[11px] font-normal tabular-nums\">\n #\n </th>\n {columns.map((column) => (\n <th key={column.name} className=\"p-3 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 p-3 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 'p-3 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=\"p-3\">\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"
21
21
  }
22
22
  ]
23
23
  }
@@ -10,13 +10,14 @@
10
10
  "registryDependencies": [
11
11
  "lib-motion",
12
12
  "lib-styles",
13
- "lib-utils"
13
+ "lib-utils",
14
+ "table"
14
15
  ],
15
16
  "files": [
16
17
  {
17
18
  "path": "components/ui/eval-results.tsx",
18
19
  "type": "registry:ui",
19
- "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Minus, TrendingDown, TrendingUp } from 'lucide-react'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A model-versus-benchmark score matrix.\n *\n * Cells are tinted by score, and the best result in each row is marked. The\n * mark is per benchmark rather than overall, because \"which model wins\" is\n * rarely one answer — a model can lead on reasoning and trail on latency, and a\n * single winner badge hides exactly that.\n *\n * A missing score renders as an explicit dash. Blank cells read as zero, which\n * is a very different claim from \"not run\".\n */\nexport type EvalScore = {\n /** 0–1. Undefined means the benchmark was not run. */\n value?: number\n /** Change against the previous run, in points. */\n delta?: number\n}\n\nexport type EvalBenchmark = {\n id: string\n label: ReactNode\n /** Lower is better — latency, cost, error rate. */\n lowerIsBetter?: boolean\n scores: Record<string, EvalScore>\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_FORMAT: (value: number) => string = (value: number) => `${Math.round(value * 100)}`\n\nfunction EvalResults({\n models,\n benchmarks,\n format = DEFAULT_FORMAT,\n benchmarkHeader = 'Benchmark',\n lowerIsBetterLabel = 'lower is better',\n notRunLabel = 'Not run',\n bestLabel = 'best for this benchmark',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n models: string[]\n benchmarks: EvalBenchmark[]\n format?: (value: number) => string\n benchmarkHeader?: ReactNode\n /** Note on a benchmark where a lower score wins. */\n lowerIsBetterLabel?: ReactNode\n /** Accessible name for a benchmark a model was not run on. */\n notRunLabel?: string\n /** Screen-reader note on the winning cell. */\n bestLabel?: ReactNode\n}) {\n const best = useMemo(() => {\n const map = new Map<string, string | undefined>()\n for (const benchmark of benchmarks) {\n let winner: string | undefined\n let bestValue = benchmark.lowerIsBetter ? Infinity : -Infinity\n\n for (const model of models) {\n const score = benchmark.scores[model]?.value\n if (score === undefined) continue\n const better = benchmark.lowerIsBetter ? score < bestValue : score > bestValue\n if (better) {\n bestValue = score\n winner = model\n }\n }\n map.set(benchmark.id, winner)\n }\n return map\n }, [models, benchmarks])\n\n return (\n <div\n data-slot=\"eval-results\"\n className={cn(enterFade, surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n <th className=\"text-muted-foreground px-3 py-2 text-start text-xs font-medium\">\n {benchmarkHeader}\n </th>\n {models.map((model) => (\n <th\n key={model}\n className=\"text-muted-foreground px-3 py-2 text-end text-xs font-medium whitespace-nowrap\"\n >\n {model}\n </th>\n ))}\n </tr>\n </thead>\n\n <tbody>\n {benchmarks.map((benchmark) => (\n <tr key={benchmark.id} className=\"border-border/60 border-b last:border-b-0\">\n <th className=\"px-3 py-2 text-start text-sm font-medium whitespace-nowrap\">\n {benchmark.label}\n {benchmark.lowerIsBetter && (\n <span className=\"text-muted-foreground/70 ms-1.5 text-xs font-normal\">\n {lowerIsBetterLabel}\n </span>\n )}\n </th>\n\n {models.map((model) => {\n const score = benchmark.scores[model]\n const isBest = best.get(benchmark.id) === model\n\n if (score?.value === undefined) {\n return (\n <td\n key={model}\n className=\"text-muted-foreground/40 px-3 py-2 text-end tabular-nums\"\n >\n {/* Not run — deliberately not blank, which reads as zero. */}\n <Minus className=\"ms-auto size-3.5\" aria-label={notRunLabel} />\n </td>\n )\n }\n\n const Trend =\n score.delta === undefined || score.delta === 0\n ? null\n : score.delta > 0\n ? TrendingUp\n : TrendingDown\n const good =\n score.delta === undefined || score.delta === 0\n ? undefined\n : benchmark.lowerIsBetter\n ? score.delta < 0\n : score.delta > 0\n\n return (\n <td\n key={model}\n style={{\n backgroundColor: `color-mix(in oklab, var(--green), transparent ${\n 100 - Math.round(score.value * 22)\n }%)`,\n }}\n className=\"px-3 py-2 text-end tabular-nums\"\n >\n <span className=\"inline-flex items-center justify-end gap-1.5\">\n <span className={cn(isBest && 'font-semibold')}>\n {format(score.value)}\n </span>\n {isBest && (\n <span className=\"sr-only\">{bestLabel}</span>\n )}\n {Trend && (\n <span\n className={cn(\n 'inline-flex items-center text-xs',\n good ? 'text-[var(--green-soft-foreground)]' : 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n <Trend className=\"size-3\" aria-hidden=\"true\" />\n {Math.abs(score.delta!)}\n </span>\n )}\n </span>\n </td>\n )\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nexport { EvalResults }\n"
20
+ "content": "import { useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { Minus, TrendingDown, TrendingUp } from 'lucide-react'\nimport {\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/ui/table'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A model-versus-benchmark score matrix.\n *\n * Cells are tinted by score, and the best result in each row is marked. The\n * mark is per benchmark rather than overall, because \"which model wins\" is\n * rarely one answer — a model can lead on reasoning and trail on latency, and a\n * single winner badge hides exactly that.\n *\n * A missing score renders as an explicit dash. Blank cells read as zero, which\n * is a very different claim from \"not run\".\n */\nexport type EvalScore = {\n /** 0–1. Undefined means the benchmark was not run. */\n value?: number\n /** Change against the previous run, in points. */\n delta?: number\n}\n\nexport type EvalBenchmark = {\n id: string\n label: ReactNode\n /** Lower is better — latency, cost, error rate. */\n lowerIsBetter?: boolean\n scores: Record<string, EvalScore>\n}\n\n/**\n * Default label formatters, hoisted out of the parameter list.\n *\n * An arrow function written inline as a default parameter is a value the\n * React Compiler cannot safely reorder, so it bails on the whole component\n * and none of it gets auto-memoised. At module scope it is a stable\n * reference and the component compiles.\n */\nconst DEFAULT_FORMAT: (value: number) => string = (value: number) => `${Math.round(value * 100)}`\n\nfunction EvalResults({\n models,\n benchmarks,\n format = DEFAULT_FORMAT,\n benchmarkHeader = 'Benchmark',\n lowerIsBetterLabel = 'lower is better',\n notRunLabel = 'Not run',\n bestLabel = 'best for this benchmark',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'children'> & {\n models: string[]\n benchmarks: EvalBenchmark[]\n format?: (value: number) => string\n benchmarkHeader?: ReactNode\n /** Note on a benchmark where a lower score wins. */\n lowerIsBetterLabel?: ReactNode\n /** Accessible name for a benchmark a model was not run on. */\n notRunLabel?: string\n /** Screen-reader note on the winning cell. */\n bestLabel?: ReactNode\n}) {\n const best = useMemo(() => {\n const map = new Map<string, string | undefined>()\n for (const benchmark of benchmarks) {\n let winner: string | undefined\n let bestValue = benchmark.lowerIsBetter ? Infinity : -Infinity\n\n for (const model of models) {\n const score = benchmark.scores[model]?.value\n if (score === undefined) continue\n const better = benchmark.lowerIsBetter ? score < bestValue : score > bestValue\n if (better) {\n bestValue = score\n winner = model\n }\n }\n map.set(benchmark.id, winner)\n }\n return map\n }, [models, benchmarks])\n\n return (\n <div\n data-slot=\"eval-results\"\n className={cn(enterFade, surface, radius.surface, 'w-full overflow-x-auto', className)}\n {...props}\n >\n {/* The kit's table parts. Every column heading here used to spell out\n the same muted, extra-small, medium recipe by hand; the density this\n board runs at is the only thing it actually needs to say. */}\n <table className=\"w-full text-sm\">\n <TableHeader>\n <TableRow>\n <TableHead>{benchmarkHeader}</TableHead>\n {models.map((model) => (\n <TableHead key={model} className=\"text-end\">\n {model}\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {benchmarks.map((benchmark) => (\n <TableRow key={benchmark.id}>\n {/* A row header, not a column one: it keeps body type and body\n ink, so it stays a `th` rather than a `TableHead`. */}\n <th scope=\"row\" className=\"text-start text-sm font-medium whitespace-nowrap\">\n {benchmark.label}\n {benchmark.lowerIsBetter && (\n <span className=\"text-muted-foreground/70 ms-1.5 text-xs font-normal\">\n {lowerIsBetterLabel}\n </span>\n )}\n </th>\n\n {models.map((model) => {\n const score = benchmark.scores[model]\n const isBest = best.get(benchmark.id) === model\n\n if (score?.value === undefined) {\n return (\n <TableCell\n key={model}\n className=\"text-muted-foreground/40 text-end tabular-nums\"\n >\n {/* Not run — deliberately not blank, which reads as zero. */}\n <Minus className=\"ms-auto size-3.5\" aria-label={notRunLabel} />\n </TableCell>\n )\n }\n\n const Trend =\n score.delta === undefined || score.delta === 0\n ? null\n : score.delta > 0\n ? TrendingUp\n : TrendingDown\n const good =\n score.delta === undefined || score.delta === 0\n ? undefined\n : benchmark.lowerIsBetter\n ? score.delta < 0\n : score.delta > 0\n\n return (\n <TableCell\n key={model}\n style={{\n backgroundColor: `color-mix(in oklab, var(--green), transparent ${\n 100 - Math.round(score.value * 22)\n }%)`,\n }}\n className=\"text-end tabular-nums\"\n >\n <span className=\"inline-flex items-center justify-end gap-1.5\">\n <span className={cn(isBest && 'font-semibold')}>\n {format(score.value)}\n </span>\n {isBest && (\n <span className=\"sr-only\">{bestLabel}</span>\n )}\n {Trend && (\n <span\n className={cn(\n 'inline-flex items-center text-xs',\n good ? 'text-[var(--green-soft-foreground)]' : 'text-[var(--destructive-soft-foreground)]',\n )}\n >\n <Trend className=\"size-3\" aria-hidden=\"true\" />\n {Math.abs(score.delta!)}\n </span>\n )}\n </span>\n </TableCell>\n )\n })}\n </TableRow>\n ))}\n </TableBody>\n </table>\n </div>\n )\n}\n\nexport { EvalResults }\n"
20
21
  }
21
22
  ]
22
23
  }
@@ -12,13 +12,14 @@
12
12
  "lib-motion",
13
13
  "lib-styles",
14
14
  "lib-utils",
15
+ "slider",
15
16
  "switch"
16
17
  ],
17
18
  "files": [
18
19
  {
19
20
  "path": "components/ui/feature-flag.tsx",
20
21
  "type": "registry:ui",
21
- "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Flag } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Switch } from '@/components/ui/switch'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A feature flag with its rollout percentage and targeting rules.\n *\n * The kill switch and the rollout are separate controls on purpose. Turning a\n * flag off has to be one unambiguous action during an incident — folding it\n * into \"set rollout to 0%\" means the emergency path is a slider drag, and a\n * slider is the wrong instrument when something is on fire.\n *\n * The rollout bar is shown even at 100%, so \"fully rolled out\" and \"off\" are\n * visually distinct states rather than both being an empty control.\n */\nexport type FlagRule = {\n id: string\n label: ReactNode\n /** e.g. \"plan is enterprise\" */\n condition: string\n enabled?: boolean\n}\n\nfunction FeatureFlag({\n name,\n description,\n enabled,\n onEnabledChange,\n rollout,\n onRolloutChange,\n rules,\n environment,\n rolloutLabel = 'Rollout',\n targetingLabel = 'Targeting',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n name: string\n description?: ReactNode\n enabled: boolean\n onEnabledChange?: (enabled: boolean) => void\n /** 0–100. Omit for a flag with no gradual rollout. */\n rollout?: number\n onRolloutChange?: (rollout: number) => void\n rules?: FlagRule[]\n environment?: string\n rolloutLabel?: ReactNode\n targetingLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"feature-flag\"\n data-enabled={enabled || undefined}\n className={cn(enterFade, surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-3\">\n <Flag\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n enabled ? 'text-[var(--green-soft-foreground)]' : '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-sm font-medium\">{name}</code>\n {environment && <Badge size=\"sm\">{environment}</Badge>}\n <Badge size=\"sm\" color={enabled ? 'green' : 'neutral'}>\n {enabled ? 'On' : 'Off'}\n </Badge>\n </div>\n {description && (\n <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n )}\n </div>\n\n {/* The kill switch, kept separate from the rollout. */}\n <Switch\n checked={enabled}\n onChange={(event) => onEnabledChange?.(event.target.checked)}\n aria-label={`${name} enabled`}\n className=\"shrink-0\"\n />\n </div>\n\n {rollout !== undefined && (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-muted-foreground text-xs font-medium whitespace-nowrap\">\n {rolloutLabel}\n </span>\n <span\n className={cn(\n 'text-xs tabular-nums whitespace-nowrap',\n enabled ? 'text-foreground' : 'text-muted-foreground/50',\n )}\n >\n {rollout}% of users\n </span>\n </div>\n\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n value={rollout}\n disabled={!enabled || !onRolloutChange}\n aria-label={`${name} rollout percentage`}\n onChange={(event) => onRolloutChange?.(Number(event.target.value))}\n className=\"w-full accent-[var(--primary)] disabled:opacity-40\"\n />\n </div>\n )}\n\n {rules && rules.length > 0 && (\n <div className=\"border-border flex flex-col gap-1.5 border-t pt-3\">\n <span className=\"text-muted-foreground text-xs font-medium\">{targetingLabel}</span>\n <ul className=\"flex list-none flex-col gap-1\">\n {rules.map((rule) => (\n <li\n key={rule.id}\n className={cn(\n 'flex items-center gap-2 text-xs',\n rule.enabled === false && 'opacity-50',\n )}\n >\n <span className=\"bg-secondary rounded-sm px-1.5 py-0.5 font-mono\">\n {rule.condition}\n </span>\n <span className=\"text-muted-foreground min-w-0 flex-1 truncate\">\n {rule.label}\n </span>\n </li>\n ))}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { FeatureFlag }\n"
22
+ "content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Flag } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Slider } from '@/components/ui/slider'\nimport { Switch } from '@/components/ui/switch'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A feature flag with its rollout percentage and targeting rules.\n *\n * The kill switch and the rollout are separate controls on purpose. Turning a\n * flag off has to be one unambiguous action during an incident — folding it\n * into \"set rollout to 0%\" means the emergency path is a slider drag, and a\n * slider is the wrong instrument when something is on fire.\n *\n * The rollout bar is shown even at 100%, so \"fully rolled out\" and \"off\" are\n * visually distinct states rather than both being an empty control.\n */\nexport type FlagRule = {\n id: string\n label: ReactNode\n /** e.g. \"plan is enterprise\" */\n condition: string\n enabled?: boolean\n}\n\nfunction FeatureFlag({\n name,\n description,\n enabled,\n onEnabledChange,\n rollout,\n onRolloutChange,\n rules,\n environment,\n rolloutLabel = 'Rollout',\n targetingLabel = 'Targeting',\n className,\n ...props\n}: Omit<ComponentProps<'div'>, 'onChange'> & {\n name: string\n description?: ReactNode\n enabled: boolean\n onEnabledChange?: (enabled: boolean) => void\n /** 0–100. Omit for a flag with no gradual rollout. */\n rollout?: number\n onRolloutChange?: (rollout: number) => void\n rules?: FlagRule[]\n environment?: string\n rolloutLabel?: ReactNode\n targetingLabel?: ReactNode\n}) {\n return (\n <div\n data-slot=\"feature-flag\"\n data-enabled={enabled || undefined}\n className={cn(enterFade, surface, radius.surface, 'flex flex-col gap-3 p-4', className)}\n {...props}\n >\n <div className=\"flex items-start gap-3\">\n <Flag\n className={cn(\n 'mt-0.5 size-4 shrink-0',\n enabled ? 'text-[var(--green-soft-foreground)]' : '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-sm font-medium\">{name}</code>\n {environment && <Badge size=\"sm\">{environment}</Badge>}\n <Badge size=\"sm\" color={enabled ? 'green' : 'neutral'}>\n {enabled ? 'On' : 'Off'}\n </Badge>\n </div>\n {description && (\n <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n )}\n </div>\n\n {/* The kill switch, kept separate from the rollout. */}\n <Switch\n checked={enabled}\n onChange={(event) => onEnabledChange?.(event.target.checked)}\n aria-label={`${name} enabled`}\n className=\"shrink-0\"\n />\n </div>\n\n {rollout !== undefined && (\n <div className=\"flex flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"text-muted-foreground text-xs font-medium whitespace-nowrap\">\n {rolloutLabel}\n </span>\n <span\n className={cn(\n 'text-xs tabular-nums whitespace-nowrap',\n enabled ? 'text-foreground' : 'text-muted-foreground/50',\n )}\n >\n {rollout}% of users\n </span>\n </div>\n\n <Slider\n size=\"sm\"\n min={0}\n max={100}\n step={5}\n value={rollout}\n disabled={!enabled || !onRolloutChange}\n label={`${name} rollout percentage`}\n onChange={(event) => onRolloutChange?.(Number(event.target.value))}\n />\n </div>\n )}\n\n {rules && rules.length > 0 && (\n <div className=\"border-border flex flex-col gap-1.5 border-t pt-3\">\n <span className=\"text-muted-foreground text-xs font-medium\">{targetingLabel}</span>\n <ul className=\"flex list-none flex-col gap-1\">\n {rules.map((rule) => (\n <li\n key={rule.id}\n className={cn(\n 'flex items-center gap-2 text-xs',\n rule.enabled === false && 'opacity-50',\n )}\n >\n <span className=\"bg-secondary rounded-sm px-1.5 py-0.5 font-mono\">\n {rule.condition}\n </span>\n <span className=\"text-muted-foreground min-w-0 flex-1 truncate\">\n {rule.label}\n </span>\n </li>\n ))}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { FeatureFlag }\n"
22
23
  }
23
24
  ]
24
25
  }