@usetheo/ui 0.6.3-next.0 → 0.8.0-next.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/CHANGELOG.md +200 -0
- package/README.md +18 -17
- package/dist/components.css +1 -1
- package/dist/index.d.ts +454 -8
- package/dist/index.js +898 -9
- package/dist/index.js.map +1 -1
- package/package.json +98 -48
- package/registry/index.json +72 -0
- package/registry/r/account-menu.json +24 -0
- package/registry/r/button.json +1 -1
- package/registry/r/code-block.json +21 -0
- package/registry/r/confirm-dialog.json +25 -0
- package/registry/r/copy-button.json +22 -0
- package/registry/r/danger-zone.json +20 -0
- package/registry/r/plan-badge.json +20 -0
- package/registry/r/progress.json +20 -0
- package/registry/r/stat-tile.json +22 -0
- package/registry/r/status-dot.json +20 -0
- package/registry/r/table.json +22 -0
- package/registry/r/theme-provider.json +1 -1
- package/registry/r/theme-script.json +1 -1
- package/registry/r/timestamp.json +20 -0
- package/registry/r/usage-meter.json +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "progress",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Progress",
|
|
6
|
+
"description": "Accessible progress bar primitive with intent variants (default, success, warning, destructive), 4 heights (h-1 / h-1.5 / h-2 / h-3), and an indeterminate animated state. Built on role=\"progressbar\" + ARIA semantics.",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
10
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/primitives/progress/progress.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": "components/ui/progress.tsx",
|
|
17
|
+
"content": "import { forwardRef } from \"react\";\nimport type { HTMLAttributes } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * Progress — accessible progress bar primitive.\n *\n * Built on `<div role=\"progressbar\">` (NOT native `<progress>`) so Tailwind\n * classes can style the track + fill cross-browser (Chrome/Safari/Firefox\n * shadow-DOM hooks for `<progress>` diverge). Matches Radix / shadcn /\n * Mantine convention.\n *\n * Variants:\n * - intent: `default` | `success` | `warning` | `destructive` — controls fill color\n * - height: `h-1` (4px, default) | `h-1.5` | `h-2` | `h-3`\n * - indeterminate: animated bar, no value (e.g. \"uploading…\", \"building…\")\n *\n * Composition:\n * <Progress value={42} max={100} intent=\"success\" aria-label=\"Upload\" />\n * <Progress indeterminate aria-label=\"Building\" />\n *\n * A11y:\n * - role=\"progressbar\"\n * - aria-valuenow / aria-valuemin / aria-valuemax (determinate)\n * - aria-busy=\"true\" when indeterminate\n * - Respects `prefers-reduced-motion` (no animation when set)\n *\n * Used by `<UsageMeter>` to render each metric's fill bar, but ships as a\n * standalone primitive for direct consumer use (deploy phase, file upload,\n * build progress, quota fill).\n */\n\nexport interface ProgressProps extends Omit<HTMLAttributes<HTMLDivElement>, \"role\"> {\n /** Current value (0..max). Values outside the range are clamped. */\n value?: number;\n /** Maximum value. Defaults to 100. */\n max?: number;\n /** Visual intent — controls fill color. */\n intent?: \"default\" | \"success\" | \"warning\" | \"destructive\";\n /** Bar height in tailwind units. Defaults to `\"h-1\"` (4px). */\n height?: \"h-1\" | \"h-1.5\" | \"h-2\" | \"h-3\";\n /** When true, animated bar with no value. Omits `aria-valuenow`, adds `aria-busy`. */\n indeterminate?: boolean;\n /** Accessible label. Required if not preceded by an `aria-labelledby` element. */\n \"aria-label\"?: string;\n}\n\nconst INTENT_FILL: Record<NonNullable<ProgressProps[\"intent\"]>, string> = {\n default: \"bg-primary\",\n success: \"bg-success\",\n warning: \"bg-warning\",\n destructive: \"bg-destructive\",\n};\n\nconst Progress = forwardRef<HTMLDivElement, ProgressProps>(\n (\n {\n className,\n value = 0,\n max = 100,\n intent = \"default\",\n height = \"h-1\",\n indeterminate = false,\n ...props\n },\n ref,\n ) => {\n const clampedMax = Math.max(0, max);\n const clampedValue = Math.min(clampedMax, Math.max(0, value));\n const percent = clampedMax > 0 ? (clampedValue / clampedMax) * 100 : 0;\n const fillClass = INTENT_FILL[intent];\n\n return (\n // biome-ignore lint/a11y/useFocusableInteractive: WAI-ARIA `progressbar` is a status role (https://www.w3.org/TR/wai-aria-1.2/#progressbar) — NOT supposed to be focusable; screen readers announce updates without keyboard navigation.\n <div\n ref={ref}\n role=\"progressbar\"\n aria-valuemin={0}\n aria-valuemax={clampedMax}\n aria-valuenow={indeterminate ? undefined : clampedValue}\n aria-busy={indeterminate ? true : undefined}\n className={cn(\"relative w-full overflow-hidden rounded-full bg-muted\", height, className)}\n {...props}\n >\n {indeterminate ? (\n <div\n className={cn(\n \"absolute inset-y-0 left-0 w-1/3 rounded-full\",\n \"animate-[progress-indeterminate_1.4s_ease-in-out_infinite] motion-reduce:animate-none\",\n \"motion-reduce:w-full motion-reduce:opacity-50\",\n fillClass,\n )}\n />\n ) : (\n <div\n className={cn(\n \"h-full rounded-full transition-[width] duration-base ease-out-soft\",\n \"motion-reduce:transition-none\",\n fillClass,\n )}\n style={{ width: `${percent}%` }}\n />\n )}\n </div>\n );\n },\n);\nProgress.displayName = \"Progress\";\n\nexport { Progress };\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "stat-tile",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "StatTile",
|
|
6
|
+
"description": "Big-number stat tile primitive for dashboard summaries. Renders value + label + optional icon + optional delta (trend up/down/flat with TrendingUp/TrendingDown/Minus icons and success/destructive/muted color). Dual mode: with onClick renders as button with hover state + trailing ArrowUpRight chevron; without, renders as static div. Value uses font-display tabular-nums whitespace-nowrap.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/primitives/stat-tile/stat-tile.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/stat-tile.tsx",
|
|
19
|
+
"content": "import { ArrowUpRight, Minus, TrendingDown, TrendingUp } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport type { ButtonHTMLAttributes, ElementType, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * StatTile — big number + label + optional delta + optional icon.\n *\n * Dual mode based on `onClick`:\n * - With onClick → renders as `<button>` with hover state + trailing\n * ArrowUpRight chevron (navigation affordance).\n * - Without onClick → renders as static `<div>`.\n *\n * Delta trend drives icon + color: up=success/TrendingUp, down=destructive/\n * TrendingDown, flat=muted/Minus. Big value uses font-display + tabular-nums.\n *\n * @example\n * <StatTile value=\"42\" label=\"Projects\" />\n * <StatTile value=\"$1,234\" label=\"MRR\" icon={DollarSign}\n * delta={{ value: \"+12%\", trend: \"up\" }} onClick={openBilling} />\n */\nexport interface StatTileProps\n extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"type\" | \"value\"> {\n value: ReactNode;\n label: ReactNode;\n icon?: ElementType;\n delta?: { value: ReactNode; trend: \"up\" | \"down\" | \"flat\" };\n}\n\nconst TREND: Record<\"up\" | \"down\" | \"flat\", { icon: ElementType; color: string }> = {\n up: { icon: TrendingUp, color: \"text-success\" },\n down: { icon: TrendingDown, color: \"text-destructive\" },\n flat: { icon: Minus, color: \"text-muted-foreground\" },\n};\n\nconst StatTile = forwardRef<HTMLElement, StatTileProps>(\n ({ className, value, label, icon: Icon, delta, onClick, children: _children, ...props }, ref) => {\n const isInteractive = onClick !== undefined;\n const TrendIcon = delta !== undefined ? TREND[delta.trend].icon : null;\n const trendColor = delta !== undefined ? TREND[delta.trend].color : \"\";\n\n const inner = (\n <>\n {(Icon !== undefined || isInteractive) && (\n <div className=\"mb-3 flex items-center justify-between\">\n {Icon !== undefined ? (\n <div className=\"flex size-8 items-center justify-center rounded-lg border border-border/40 bg-muted/40\">\n <Icon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n </div>\n ) : (\n <div />\n )}\n {isInteractive ? (\n <ArrowUpRight\n aria-hidden=\"true\"\n className=\"size-3.5 text-muted-foreground transition-colors group-hover:text-foreground\"\n />\n ) : null}\n </div>\n )}\n <div className=\"whitespace-nowrap font-bold font-display text-display-md text-foreground tabular-nums leading-none tracking-tight\">\n {value}\n </div>\n <div className=\"mt-1 font-sans text-body-sm text-muted-foreground\">{label}</div>\n {delta !== undefined && TrendIcon !== null ? (\n <div\n className={cn(\"mt-2 inline-flex items-center gap-1 font-mono text-label\", trendColor)}\n >\n <TrendIcon aria-hidden=\"true\" className=\"size-3\" />\n <span>{delta.value}</span>\n </div>\n ) : null}\n </>\n );\n\n if (isInteractive) {\n return (\n <button\n ref={ref as React.Ref<HTMLButtonElement>}\n type=\"button\"\n onClick={onClick}\n className={cn(\n \"group block w-full rounded-xl border border-border/40 bg-card p-5 text-left\",\n \"cursor-pointer transition-colors hover:border-primary/30\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n className,\n )}\n {...props}\n >\n {inner}\n </button>\n );\n }\n\n return (\n <div\n ref={ref as React.Ref<HTMLDivElement>}\n className={cn(\"rounded-xl border border-border/40 bg-card p-5\", className)}\n >\n {inner}\n </div>\n );\n },\n);\nStatTile.displayName = \"StatTile\";\n\nexport { StatTile };\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "status-dot",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "StatusDot",
|
|
6
|
+
"description": "Semantic status indicator (small colored circle + optional label). Five status kinds: live (success), building (warning, auto-pulses), failed (destructive), idle (muted), warning (warning, static). Three sizes (xs 6px / sm 8px default / md 10px). When neither label nor aria-label is provided, auto-applies aria-label=status and emits a dev warning.",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
10
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/primitives/status-dot/status-dot.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": "components/ui/status-dot.tsx",
|
|
17
|
+
"content": "import { forwardRef, useEffect } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * StatusDot — semantic status indicator (colored circle + optional label).\n *\n * Five status kinds:\n * - `live` — deployed / verified / healthy (success)\n * - `building` — in-progress / queued (warning, auto-pulses)\n * - `failed` — error / down / rejected (destructive)\n * - `idle` — pending / offline (muted)\n * - `warning` — degraded but functional (warning, static)\n *\n * Three sizes (xs 6px, sm 8px default, md 10px). `pulse` defaults to\n * `true` for `building` and `false` otherwise; passing `pulse` explicitly\n * overrides the auto behavior. When no visible `label` AND no `aria-label`\n * are provided, the component auto-applies `aria-label={status}` and\n * emits a dev-mode warning (a status communicated only by color is\n * invisible to screen readers).\n *\n * @example\n * <StatusDot status=\"live\" label=\"Production\" />\n * <StatusDot status=\"building\" /> // auto-pulses + auto-aria-label\n */\nexport type StatusKind = \"live\" | \"building\" | \"failed\" | \"idle\" | \"warning\";\n\nexport interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n status: StatusKind;\n label?: ReactNode;\n size?: \"xs\" | \"sm\" | \"md\";\n pulse?: boolean;\n}\n\nconst DOT_COLOR: Record<StatusKind, string> = {\n live: \"bg-success\",\n building: \"bg-warning\",\n failed: \"bg-destructive\",\n idle: \"bg-muted-foreground/40\",\n warning: \"bg-warning\",\n};\n\nconst LABEL_COLOR: Record<StatusKind, string> = {\n live: \"text-success\",\n building: \"text-warning\",\n failed: \"text-destructive\",\n idle: \"text-muted-foreground\",\n warning: \"text-warning\",\n};\n\nconst SIZE: Record<NonNullable<StatusDotProps[\"size\"]>, string> = {\n xs: \"size-1.5\",\n sm: \"size-2\",\n md: \"size-2.5\",\n};\n\nconst StatusDot = forwardRef<HTMLSpanElement, StatusDotProps>(\n ({ className, status, label, size = \"sm\", pulse, \"aria-label\": ariaLabel, ...props }, ref) => {\n const shouldPulse = pulse ?? status === \"building\";\n\n const hasVisibleLabel = label !== undefined && label !== null;\n const effectiveAriaLabel = ariaLabel ?? (hasVisibleLabel ? undefined : status);\n\n // EC-6: dev warning when neither label nor aria-label is provided.\n useEffect(() => {\n if (process.env.NODE_ENV !== \"production\" && !hasVisibleLabel && ariaLabel === undefined) {\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for a11y misconfiguration.\n console.warn(\n `<StatusDot status=\"${status}\" />: no \\`label\\` or \\`aria-label\\` provided. Color-only status is invisible to screen readers. Falling back to aria-label=\"${status}\".`,\n );\n }\n }, [hasVisibleLabel, ariaLabel, status]);\n\n const dot = (\n <span\n aria-hidden={hasVisibleLabel ? \"true\" : undefined}\n className={cn(\n \"inline-block shrink-0 rounded-full\",\n SIZE[size],\n DOT_COLOR[status],\n shouldPulse && \"animate-pulse\",\n )}\n />\n );\n\n if (!hasVisibleLabel) {\n return (\n <span\n ref={ref}\n // biome-ignore lint/a11y/useSemanticElements: StatusDot is a generic inline indicator; there is no HTML element with implicit role=\"status\" that is an inline span. The native <output> is block-level and form-bound, which doesn't fit this use case.\n role=\"status\"\n aria-label={effectiveAriaLabel}\n className={cn(\"inline-flex items-center\", className)}\n {...props}\n >\n {dot}\n </span>\n );\n }\n\n return (\n <span\n ref={ref}\n aria-label={effectiveAriaLabel}\n className={cn(\n \"inline-flex items-center gap-1.5 font-mono text-label\",\n LABEL_COLOR[status],\n className,\n )}\n {...props}\n >\n {dot}\n <span>{label}</span>\n </span>\n );\n },\n);\nStatusDot.displayName = \"StatusDot\";\n\nexport { StatusDot };\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "table",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Table",
|
|
6
|
+
"description": "Semantic data-table primitive with sub-components (Table.Header, Table.Body, Table.Row, Table.Cell, Table.HeaderCell). Supports density (default | compact via Context), per-cell align (left | center | right), numeric cells (font-mono tabular-nums), and sortable header cells (onSort + sortDirection with ChevronUp/ChevronDown affordance + aria-sort).",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
12
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "components/primitives/table/table.tsx",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": "components/ui/table.tsx",
|
|
19
|
+
"content": "import { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { createContext, forwardRef, useContext } from \"react\";\nimport type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * Table — semantic data table primitive with sub-components.\n *\n * Composition:\n * <Table density=\"default\">\n * <Table.Header>\n * <Table.Row>\n * <Table.HeaderCell>Date</Table.HeaderCell>\n * <Table.HeaderCell align=\"right\">Amount</Table.HeaderCell>\n * </Table.Row>\n * </Table.Header>\n * <Table.Body>\n * <Table.Row>\n * <Table.Cell>2026-05-23</Table.Cell>\n * <Table.Cell align=\"right\" numeric>$ 42.00</Table.Cell>\n * </Table.Row>\n * </Table.Body>\n * </Table>\n *\n * density propagates via Context so Cells pick up vertical padding without\n * prop drilling. Sub-components are attached as static properties on the\n * root (`Table.Header`, etc.) — single import surface.\n *\n * Sortable header: pass `onSort` + `sortDirection`. The HeaderCell renders\n * the sort affordance (ChevronUp/ChevronDown) and triggers the consumer\n * callback. `sortDirection` without `onSort` is a no-op (header stays\n * static); `sortDirection=\"none\"` with `onSort` shows a dimmed affordance.\n */\n\ntype TableDensity = \"default\" | \"compact\";\ntype AlignKind = \"left\" | \"center\" | \"right\";\ntype SortDirection = \"asc\" | \"desc\" | \"none\";\n\nconst TableDensityContext = createContext<TableDensity>(\"default\");\n\nconst alignClass: Record<AlignKind, string> = {\n left: \"text-left\",\n center: \"text-center\",\n right: \"text-right\",\n};\n\nexport interface TableProps extends HTMLAttributes<HTMLTableElement> {\n density?: TableDensity;\n}\n\nconst Root = forwardRef<HTMLTableElement, TableProps>(\n ({ className, density = \"default\", children, ...props }, ref) => (\n <TableDensityContext.Provider value={density}>\n <table\n ref={ref}\n className={cn(\"w-full border-collapse font-sans text-body-sm\", className)}\n {...props}\n >\n {children}\n </table>\n </TableDensityContext.Provider>\n ),\n);\nRoot.displayName = \"Table\";\n\nconst Header = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(\n ({ className, ...props }, ref) => (\n <thead\n ref={ref}\n className={cn(\n \"border-border/40 border-b text-label-caps text-muted-foreground uppercase tracking-wider\",\n className,\n )}\n {...props}\n />\n ),\n);\nHeader.displayName = \"Table.Header\";\n\nconst Body = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(\n ({ className, ...props }, ref) => (\n <tbody ref={ref} className={cn(\"text-foreground\", className)} {...props} />\n ),\n);\nBody.displayName = \"Table.Body\";\n\nconst Row = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(\n ({ className, ...props }, ref) => (\n <tr\n ref={ref}\n className={cn(\n \"border-border/20 border-b transition-colors last:border-0 hover:bg-muted/40\",\n className,\n )}\n {...props}\n />\n ),\n);\nRow.displayName = \"Table.Row\";\n\nexport interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {\n align?: AlignKind;\n numeric?: boolean;\n}\n\nconst Cell = forwardRef<HTMLTableCellElement, TableCellProps>(\n ({ className, align = \"left\", numeric, children, ...props }, ref) => {\n const density = useContext(TableDensityContext);\n return (\n <td\n ref={ref}\n className={cn(\n \"px-3\",\n density === \"compact\" ? \"py-1.5\" : \"py-3\",\n alignClass[align],\n numeric && \"font-mono tabular-nums\",\n className,\n )}\n {...props}\n >\n {children}\n </td>\n );\n },\n);\nCell.displayName = \"Table.Cell\";\n\nexport interface TableHeaderCellProps extends ThHTMLAttributes<HTMLTableCellElement> {\n align?: AlignKind;\n /** When provided, header becomes a sort trigger. */\n onSort?: () => void;\n /** Current sort state for this column. */\n sortDirection?: SortDirection;\n}\n\nconst HeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(\n ({ className, align = \"left\", onSort, sortDirection = \"none\", children, ...props }, ref) => {\n const sortAffordance =\n onSort !== undefined ? (\n <span className=\"ml-1 inline-flex flex-col\">\n <ChevronUp\n aria-hidden=\"true\"\n className={cn(\"-mb-1 size-3\", sortDirection === \"asc\" ? \"opacity-100\" : \"opacity-30\")}\n />\n <ChevronDown\n aria-hidden=\"true\"\n className={cn(\"size-3\", sortDirection === \"desc\" ? \"opacity-100\" : \"opacity-30\")}\n />\n </span>\n ) : null;\n\n const ariaSort: ThHTMLAttributes<HTMLTableCellElement>[\"aria-sort\"] =\n onSort === undefined\n ? undefined\n : sortDirection === \"asc\"\n ? \"ascending\"\n : sortDirection === \"desc\"\n ? \"descending\"\n : \"none\";\n\n return (\n <th\n ref={ref}\n scope=\"col\"\n aria-sort={ariaSort}\n className={cn(\n \"px-3 py-2.5 font-medium\",\n alignClass[align],\n align === \"right\" && \"[&_button]:justify-end\",\n className,\n )}\n {...props}\n >\n {onSort !== undefined ? (\n <button\n type=\"button\"\n onClick={onSort}\n className={cn(\n \"inline-flex items-center gap-1\",\n \"text-label-caps uppercase tracking-wider\",\n \"transition-colors hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n )}\n >\n {children}\n {sortAffordance}\n </button>\n ) : (\n children\n )}\n </th>\n );\n },\n);\nHeaderCell.displayName = \"Table.HeaderCell\";\n\ntype TableRoot = typeof Root & {\n Header: typeof Header;\n Body: typeof Body;\n Row: typeof Row;\n Cell: typeof Cell;\n HeaderCell: typeof HeaderCell;\n};\n\nconst Table: TableRoot = Object.assign(Root, { Header, Body, Row, Cell, HeaderCell });\n\nexport { Table };\n"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"path": "themes/theme-provider.tsx",
|
|
42
42
|
"type": "registry:lib",
|
|
43
43
|
"target": "themes/theme-provider.tsx",
|
|
44
|
-
"content": "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport { safeHref } from \"@/lib/safe-href\";\nimport { type Density, DensityContext, injectDensityCss } from \"./density\";\nimport type { ColorScale, Theme, ThemeMode } from \"@/themes/types\";\n\ninterface ThemeContextValue {\n /** Active theme (full descriptor). */\n theme: Theme;\n /** Active mode: light or dark. */\n mode: ThemeMode;\n /** All available themes. */\n themes: Theme[];\n /** Swap the active theme by name. */\n setTheme: (name: string) => void;\n /** Set light/dark explicitly. */\n setMode: (mode: ThemeMode) => void;\n /** Toggle light <> dark. */\n toggleMode: () => void;\n /** Register an additional theme at runtime. */\n registerTheme: (theme: Theme) => void;\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined);\n\nconst STYLE_ELEMENT_ID = \"theo-ui-theme-vars\";\n\n// T3.2 (SEC-001): allowlist validators for theme values. injectThemeCss\n// interpolates theme name + color values + font families into a <style>\n// textContent. Without validation, a theme object from an untrusted source\n// (e.g., a feature-flag service, a CMS) could inject arbitrary CSS via\n// closing the declaration with `}` or smuggling `url(...)` for exfiltration.\n// We reject rather than escape: themes are code, not user input. Invalid\n// values cause a dev-time throw (caller sees the problem); production\n// silently substitutes a safe fallback so a misconfigured theme can't\n// crash the app.\n\n// Color values. Multiple accepted shapes:\n// 1. Hex: `#fff`, `#0a0a0a`, `#0a0a0aff`.\n// 2. Fully-parenthesized CSS color functions: `oklch(...)`, `rgb(...)`,\n// `hsl(...)`, etc. Inner content restricted to digits/dots/spaces/\n// percent/slash/comma/dash/plus — no semicolons, no braces, no `url(`.\n// 3. HSL-component split (shadcn-ui convention used by the built-in\n// themes): `\"0 0% 100%\"`, `\"262 83% 58%\"` — space-separated numeric\n// components consumed via `hsl(var(--token))` in stylesheets.\n// 4. `var(--token)` references, optionally with a fallback value that\n// contains no parens/braces/semicolons.\n// 5. CSS keywords: `transparent`, `currentColor`, `inherit`, `initial`,\n// `unset`.\nconst COLOR_VALUE_PATTERN =\n /^(#[0-9a-fA-F]{3,8}|(?:oklch|oklab|rgb|rgba|hsl|hsla|lab|lch|color)\\(\\s*[\\d.\\s%,/+\\-]+\\s*\\)|-?\\d+(?:\\.\\d+)?%?(?:\\s+-?\\d+(?:\\.\\d+)?%?){1,3}|var\\(--[a-zA-Z0-9-]+(?:\\s*,\\s*[^();{}]+)?\\)|transparent|currentColor|inherit|initial|unset)$/;\n\n// Font family: word chars, spaces, commas, hyphens, dots, quotes. Excludes\n// parens (blocks `url(...)`) and semicolons (blocks declaration breakouts).\nconst FONT_FAMILY_PATTERN = /^[\\w\\s,\"'\\-.]+$/;\n\n// Theme name: kebab-case identifier. Excludes anything that could break out\n// of an attribute selector or inject additional rules.\nconst THEME_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;\n\nconst IS_DEV = typeof process === \"undefined\" || process.env.NODE_ENV !== \"production\";\n\nfunction rejectOrFallback(scope: string, value: string, fallback: string): string {\n if (IS_DEV) {\n throw new Error(\n `[@usetheo/ui] invalid ${scope} value: ${JSON.stringify(value)}. Theme values must match the allowlist (see src/themes/theme-provider.tsx). Refusing to inject potentially unsafe CSS.`,\n );\n }\n return fallback;\n}\n\nfunction validatedColor(token: string, value: string): string {\n if (COLOR_VALUE_PATTERN.test(value)) return value;\n return rejectOrFallback(`color \"${token}\"`, value, \"transparent\");\n}\n\nfunction validatedFontFamily(slot: string, value: string): string {\n if (FONT_FAMILY_PATTERN.test(value)) return value;\n return rejectOrFallback(`fontFamily \"${slot}\"`, value, \"inherit\");\n}\n\nfunction validatedThemeName(value: string): string {\n if (THEME_NAME_PATTERN.test(value)) return value;\n return rejectOrFallback(\"theme.name\", value, \"invalid-theme\");\n}\n\nfunction colorScaleToCss(name: string, mode: ThemeMode, colors: ColorScale): string {\n const safeName = validatedThemeName(name);\n const selector =\n mode === \"light\"\n ? `[data-theme=\"${safeName}\"]`\n : `[data-theme=\"${safeName}\"].dark, [data-theme=\"${safeName}\"][data-mode=\"dark\"]`;\n const decls = Object.entries(colors)\n .map(([token, value]) => ` --${token}: ${validatedColor(token, value)};`)\n .join(\"\\n\");\n return `${selector} {\\n${decls}\\n}`;\n}\n\nfunction fontsToCss(name: string, fonts: Theme[\"fonts\"]): string {\n const safeName = validatedThemeName(name);\n const display = validatedFontFamily(\"display\", fonts.display);\n const body = validatedFontFamily(\"body\", fonts.body);\n const mono = validatedFontFamily(\"mono\", fonts.mono);\n return `[data-theme=\"${safeName}\"] {\\n --font-display: ${display};\\n --font-body: ${body};\\n --font-mono: ${mono};\\n}`;\n}\n\nfunction injectThemeCss(themes: Theme[]): void {\n if (typeof document === \"undefined\") return;\n let style = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;\n if (!style) {\n style = document.createElement(\"style\");\n style.id = STYLE_ELEMENT_ID;\n document.head.appendChild(style);\n }\n const blocks: string[] = [];\n for (const theme of themes) {\n blocks.push(fontsToCss(theme.name, theme.fonts));\n blocks.push(colorScaleToCss(theme.name, \"light\", theme.light));\n blocks.push(colorScaleToCss(theme.name, \"dark\", theme.dark));\n }\n style.textContent = blocks.join(\"\\n\\n\");\n}\n\n/**\n * loadThemeFonts — idempotently inject `<link rel=\"stylesheet\">` for each\n * font URL declared by the theme.\n *\n * T4.2: the previous implementation kept a module-level `Set` to track\n * already-injected URLs. That singleton broke test isolation (state\n * leaked across renders) and silently skipped injection in micro-frontend\n * setups with multiple `<ThemeProvider>` mounts. Replaced with a DOM\n * check: we query `document.head` for an existing link with the same\n * `href` before appending a new one. The DOM is the single source of\n * truth; no shared state across instances.\n */\nfunction loadThemeFonts(theme: Theme): void {\n if (typeof document === \"undefined\") return;\n if (!theme.fontUrls) return;\n for (const url of theme.fontUrls) {\n // Re-audit NEW-001 (SSRF, LOW): defang dangerous protocols on\n // consumer-provided font URLs. Built-in themes use\n // fonts.googleapis.com/gstatic.com — safe. registerTheme accepts\n // arbitrary objects at runtime; a malicious theme could try to inject\n // javascript:/data:text/html via fontUrls. safeHref returns undefined\n // for dangerous protocols, which we skip silently.\n const safe = safeHref(url);\n if (!safe) continue;\n if (document.head.querySelector(`link[rel=\"stylesheet\"][href=\"${CSS.escape(safe)}\"]`)) {\n continue;\n }\n const link = document.createElement(\"link\");\n link.rel = \"stylesheet\";\n link.href = safe;\n document.head.appendChild(link);\n }\n}\n\ninterface ThemeProviderProps {\n children: ReactNode;\n /**\n * Theme to start with. Must match the `name` of an entry in `themes`.\n * Defaults to `\"violet-forge\"` for backward compat — if you don't pass\n * `violet-forge` in `themes`, set this prop explicitly.\n */\n defaultTheme?: string;\n /** Mode to start with. Defaults to `\"dark\"` (library is dark-first). */\n defaultMode?: ThemeMode;\n /**\n * Available themes. **Required**: ThemeProvider does not auto-include any\n * built-in theme since v0.1.0-next.0 — pass `builtinThemes` for all three\n * Violet Forge defaults, or your own array for a slimmer bundle.\n *\n * Migration: consumers previously calling `<ThemeProvider>` without this\n * prop now must pass `themes={builtinThemes}` (or use `<TheoUIProvider>`\n * which defaults to `builtinThemes` for you).\n */\n themes: Theme[];\n /**\n * Persist selection in localStorage under this key. Pass `null` to disable.\n * Default: \"theo-ui:theme\".\n */\n storageKey?: string | null;\n /**\n * Initial density. Drives `data-density` on `<html>` and the `--theo-control-h`\n * / `--theo-control-px` CSS vars consumed by form-control `md` variants.\n * Defaults to `\"comfortable\"` (36px controls — FAANG-tier modern density).\n * Plan: faang-density-tightening (D3).\n */\n defaultDensity?: Density;\n}\n\n/**\n * Storage failure diagnostic — dev-only one-line warn so engineers see\n * something when localStorage throws (Safari private mode, blocked\n * third-party cookies, sandboxed iframes). In production we stay silent;\n * runtime behavior is fail-safe (state still lives in memory).\n *\n * Per HIGH-006: silent catches diverge from the \"fail loud\" principle\n * declared in the global CLAUDE.md. We accept silence in prod because the\n * fallback is correct, but we surface a single warn per call site in dev.\n */\nfunction warnStorageFailure(scope: string, err: unknown): void {\n if (typeof process === \"undefined\" || process.env.NODE_ENV === \"production\") return;\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for storage failures (HIGH-006)\n console.warn(`[@usetheo/ui] theme storage failure (${scope}):`, err);\n}\n\n/**\n * ThemeProvider — central registry + runtime switcher for Theo themes.\n *\n * Behavior:\n * 1. On mount, injects a `<style id=\"theo-ui-theme-vars\">` element with\n * one CSS block per theme (`[data-theme=\"<name>\"] { --token: ... }`).\n * 2. Sets `data-theme` and `data-mode` on `<html>` so any element nested\n * below inherits the right tokens (the Tailwind config consumes them).\n * 3. Lazy-loads theme font URLs by injecting `<link rel=\"stylesheet\">`.\n * 4. Optionally persists choice in localStorage.\n */\nfunction ThemeProvider({\n children,\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n themes: themesProp,\n storageKey = \"theo-ui:theme\",\n defaultDensity = \"comfortable\",\n}: ThemeProviderProps): JSX.Element {\n // Themes prop is required since v0.1.0-next.0 — see migration note in\n // the JSDoc on ThemeProviderProps. Pass `builtinThemes` for the legacy\n // default behavior (violet-forge + classic-paper + aurora-terminal), or\n // an array of your own. Empty array is rejected: ThemeProvider has no\n // valid state without at least one registered theme.\n if (!themesProp || themesProp.length === 0) {\n throw new Error(\n \"<ThemeProvider> requires the `themes` prop with at least one Theme. \" +\n \"Pass `themes={builtinThemes}` for the Violet Forge defaults (importable \" +\n \"via the package barrel), or use <TheoUIProvider> which sets this for you.\",\n );\n }\n\n // T3.2 (SEC-001): eager validation. Calling validatedColor/FontFamily/\n // ThemeName here ensures CSS-injection attempts throw at construction\n // time rather than inside the deferred useEffect that injects the\n // <style>. Production-mode fallbacks keep the app rendering even if a\n // theme has bad values.\n //\n // Re-audit NEW-3: wrapped in useMemo so the validation cost (O(themes *\n // tokens), ~60 ops per built-in theme) only runs when themesProp's\n // reference changes — not on every parent re-render. Consumers passing\n // inline array literals (`themes={[violetForge, classicPaper]}`) would\n // otherwise pay this on every parent update.\n const mergedThemes = useMemo<Theme[]>(() => {\n for (const t of themesProp) {\n validatedThemeName(t.name);\n validatedFontFamily(\"display\", t.fonts.display);\n validatedFontFamily(\"body\", t.fonts.body);\n validatedFontFamily(\"mono\", t.fonts.mono);\n for (const [token, value] of Object.entries(t.light)) {\n validatedColor(token, value);\n }\n for (const [token, value] of Object.entries(t.dark)) {\n validatedColor(token, value);\n }\n }\n // Dedup by theme name; last writer wins (allows registerTheme override).\n const map = new Map<string, Theme>();\n for (const t of themesProp) map.set(t.name, t);\n return Array.from(map.values());\n }, [themesProp]);\n\n const [themes, setThemes] = useState<Theme[]>(mergedThemes);\n\n // Re-sync state when the `themes` prop changes between renders. Avoids the\n // common pitfall where the user passes a different array later and the\n // initial-state-only seed silently ignores the change.\n useEffect(() => {\n setThemes(mergedThemes);\n }, [mergedThemes]);\n\n const [themeName, setThemeName] = useState<string>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultTheme;\n try {\n return window.localStorage.getItem(`${storageKey}:name`) ?? defaultTheme;\n } catch (err) {\n warnStorageFailure(\"read theme name\", err);\n return defaultTheme;\n }\n });\n\n const [mode, setModeState] = useState<ThemeMode>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultMode;\n try {\n const stored = window.localStorage.getItem(`${storageKey}:mode`);\n return stored === \"dark\" || stored === \"light\" ? stored : defaultMode;\n } catch (err) {\n warnStorageFailure(\"read theme mode\", err);\n return defaultMode;\n }\n });\n\n const [density, setDensityState] = useState<Density>(() => {\n if (typeof window === \"undefined\" || !storageKey) return defaultDensity;\n try {\n const stored = window.localStorage.getItem(`${storageKey}:density`);\n return stored === \"compact\" || stored === \"comfortable\" || stored === \"spacious\"\n ? stored\n : defaultDensity;\n } catch (err) {\n warnStorageFailure(\"read density\", err);\n return defaultDensity;\n }\n });\n\n // Inject CSS vars whenever the themes list changes.\n useEffect(() => {\n injectThemeCss(themes);\n }, [themes]);\n\n // Apply data-theme + data-mode + data-density to <html>, load fonts,\n // inject density CSS vars.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n const active = themes.find((t) => t.name === themeName) ?? themes[0];\n if (!active) return;\n document.documentElement.setAttribute(\"data-theme\", active.name);\n document.documentElement.setAttribute(\"data-mode\", mode);\n document.documentElement.setAttribute(\"data-density\", density);\n document.documentElement.classList.toggle(\"dark\", mode === \"dark\");\n loadThemeFonts(active);\n injectDensityCss();\n }, [themeName, mode, density, themes]);\n\n // Persist on change.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n window.localStorage.setItem(`${storageKey}:name`, themeName);\n window.localStorage.setItem(`${storageKey}:mode`, mode);\n window.localStorage.setItem(`${storageKey}:density`, density);\n } catch (err) {\n // Storage may fail in private mode; behavior remains correct (state\n // lives in memory). Per HIGH-006 we surface a one-time dev warning so\n // the engineer sees something instead of complete silence.\n warnStorageFailure(\"persist theme + mode + density\", err);\n }\n }, [themeName, mode, density, storageKey]);\n\n const setTheme = useCallback((name: string) => setThemeName(name), []);\n const setMode = useCallback((next: ThemeMode) => setModeState(next), []);\n const toggleMode = useCallback(\n () => setModeState((cur) => (cur === \"light\" ? \"dark\" : \"light\")),\n [],\n );\n const setDensity = useCallback((next: Density) => setDensityState(next), []);\n const registerTheme = useCallback((theme: Theme) => {\n setThemes((cur) => {\n const idx = cur.findIndex((t) => t.name === theme.name);\n if (idx >= 0) {\n const next = cur.slice();\n next[idx] = theme;\n return next;\n }\n return [...cur, theme];\n });\n }, []);\n\n // themes[0] is guaranteed non-undefined by the constructor-time check\n // above (themesProp is non-empty); the non-null assert encodes that\n // invariant for TypeScript, which can't trace it through useState.\n // biome-ignore lint/style/noNonNullAssertion: T2.5 runtime invariant — themesProp non-empty validated at top of function\n const active = themes.find((t) => t.name === themeName) ?? themes[0]!;\n\n const value = useMemo<ThemeContextValue>(\n () => ({\n theme: active,\n mode,\n themes,\n setTheme,\n setMode,\n toggleMode,\n registerTheme,\n }),\n [active, mode, themes, setTheme, setMode, toggleMode, registerTheme],\n );\n\n const densityValue = useMemo(() => ({ density, setDensity }), [density, setDensity]);\n\n return (\n <ThemeContext.Provider value={value}>\n <DensityContext.Provider value={densityValue}>{children}</DensityContext.Provider>\n </ThemeContext.Provider>\n );\n}\n\n/**\n * useTheme — access theme state from any component inside <ThemeProvider>.\n * Throws if used outside the provider — fail-fast.\n */\nfunction useTheme(): ThemeContextValue {\n const ctx = useContext(ThemeContext);\n if (!ctx) {\n throw new Error(\"useTheme must be used inside <ThemeProvider>.\");\n }\n return ctx;\n}\n\nexport { ThemeProvider, useTheme };\n"
|
|
44
|
+
"content": "import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport { safeHref } from \"@/lib/safe-href\";\nimport { type Density, DensityContext, injectDensityCss } from \"./density\";\nimport type { ColorScale, Theme, ThemeMode } from \"@/themes/types\";\n\ninterface ThemeContextValue {\n /** Active theme (full descriptor). */\n theme: Theme;\n /** Active mode: light or dark. */\n mode: ThemeMode;\n /** All available themes. */\n themes: Theme[];\n /** Swap the active theme by name. */\n setTheme: (name: string) => void;\n /** Set light/dark explicitly. */\n setMode: (mode: ThemeMode) => void;\n /** Toggle light <> dark. */\n toggleMode: () => void;\n /** Register an additional theme at runtime. */\n registerTheme: (theme: Theme) => void;\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined);\n\nconst STYLE_ELEMENT_ID = \"theo-ui-theme-vars\";\n\n// T3.2 (SEC-001): allowlist validators for theme values. injectThemeCss\n// interpolates theme name + color values + font families into a <style>\n// textContent. Without validation, a theme object from an untrusted source\n// (e.g., a feature-flag service, a CMS) could inject arbitrary CSS via\n// closing the declaration with `}` or smuggling `url(...)` for exfiltration.\n// We reject rather than escape: themes are code, not user input. Invalid\n// values cause a dev-time throw (caller sees the problem); production\n// silently substitutes a safe fallback so a misconfigured theme can't\n// crash the app.\n\n// Color values. Multiple accepted shapes:\n// 1. Hex: `#fff`, `#0a0a0a`, `#0a0a0aff`.\n// 2. Fully-parenthesized CSS color functions: `oklch(...)`, `rgb(...)`,\n// `hsl(...)`, etc. Inner content restricted to digits/dots/spaces/\n// percent/slash/comma/dash/plus — no semicolons, no braces, no `url(`.\n// 3. HSL-component split (shadcn-ui convention used by the built-in\n// themes): `\"0 0% 100%\"`, `\"262 83% 58%\"` — space-separated numeric\n// components consumed via `hsl(var(--token))` in stylesheets.\n// 4. `var(--token)` references, optionally with a fallback value that\n// contains no parens/braces/semicolons.\n// 5. CSS keywords: `transparent`, `currentColor`, `inherit`, `initial`,\n// `unset`.\nconst COLOR_VALUE_PATTERN =\n /^(#[0-9a-fA-F]{3,8}|(?:oklch|oklab|rgb|rgba|hsl|hsla|lab|lch|color)\\(\\s*[\\d.\\s%,/+\\-]+\\s*\\)|-?\\d+(?:\\.\\d+)?%?(?:\\s+-?\\d+(?:\\.\\d+)?%?){1,3}|var\\(--[a-zA-Z0-9-]+(?:\\s*,\\s*[^();{}]+)?\\)|transparent|currentColor|inherit|initial|unset)$/;\n\n// Font family: word chars, spaces, commas, hyphens, dots, quotes. Excludes\n// parens (blocks `url(...)`) and semicolons (blocks declaration breakouts).\nconst FONT_FAMILY_PATTERN = /^[\\w\\s,\"'\\-.]+$/;\n\n// Theme name: kebab-case identifier. Excludes anything that could break out\n// of an attribute selector or inject additional rules.\nconst THEME_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;\n\nconst IS_DEV = typeof process === \"undefined\" || process.env.NODE_ENV !== \"production\";\n\nfunction rejectOrFallback(scope: string, value: string, fallback: string): string {\n if (IS_DEV) {\n throw new Error(\n `[@usetheo/ui] invalid ${scope} value: ${JSON.stringify(value)}. Theme values must match the allowlist (see src/themes/theme-provider.tsx). Refusing to inject potentially unsafe CSS.`,\n );\n }\n return fallback;\n}\n\nfunction validatedColor(token: string, value: string): string {\n if (COLOR_VALUE_PATTERN.test(value)) return value;\n return rejectOrFallback(`color \"${token}\"`, value, \"transparent\");\n}\n\nfunction validatedFontFamily(slot: string, value: string): string {\n if (FONT_FAMILY_PATTERN.test(value)) return value;\n return rejectOrFallback(`fontFamily \"${slot}\"`, value, \"inherit\");\n}\n\nfunction validatedThemeName(value: string): string {\n if (THEME_NAME_PATTERN.test(value)) return value;\n return rejectOrFallback(\"theme.name\", value, \"invalid-theme\");\n}\n\nfunction colorScaleToCss(name: string, mode: ThemeMode, colors: ColorScale): string {\n const safeName = validatedThemeName(name);\n const selector =\n mode === \"light\"\n ? `[data-theme=\"${safeName}\"]`\n : `[data-theme=\"${safeName}\"].dark, [data-theme=\"${safeName}\"][data-mode=\"dark\"]`;\n const decls = Object.entries(colors)\n .map(([token, value]) => ` --${token}: ${validatedColor(token, value)};`)\n .join(\"\\n\");\n return `${selector} {\\n${decls}\\n}`;\n}\n\nfunction fontsToCss(name: string, fonts: Theme[\"fonts\"]): string {\n const safeName = validatedThemeName(name);\n const display = validatedFontFamily(\"display\", fonts.display);\n const body = validatedFontFamily(\"body\", fonts.body);\n const mono = validatedFontFamily(\"mono\", fonts.mono);\n return `[data-theme=\"${safeName}\"] {\\n --font-display: ${display};\\n --font-body: ${body};\\n --font-mono: ${mono};\\n}`;\n}\n\nfunction injectThemeCss(themes: Theme[]): void {\n if (typeof document === \"undefined\") return;\n let style = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;\n if (!style) {\n style = document.createElement(\"style\");\n style.id = STYLE_ELEMENT_ID;\n document.head.appendChild(style);\n }\n const blocks: string[] = [];\n for (const theme of themes) {\n blocks.push(fontsToCss(theme.name, theme.fonts));\n blocks.push(colorScaleToCss(theme.name, \"light\", theme.light));\n blocks.push(colorScaleToCss(theme.name, \"dark\", theme.dark));\n }\n style.textContent = blocks.join(\"\\n\\n\");\n}\n\n/**\n * loadThemeFonts — idempotently inject `<link rel=\"stylesheet\">` for each\n * font URL declared by the theme.\n *\n * T4.2: the previous implementation kept a module-level `Set` to track\n * already-injected URLs. That singleton broke test isolation (state\n * leaked across renders) and silently skipped injection in micro-frontend\n * setups with multiple `<ThemeProvider>` mounts. Replaced with a DOM\n * check: we query `document.head` for an existing link with the same\n * `href` before appending a new one. The DOM is the single source of\n * truth; no shared state across instances.\n */\nfunction loadThemeFonts(theme: Theme): void {\n if (typeof document === \"undefined\") return;\n if (!theme.fontUrls) return;\n for (const url of theme.fontUrls) {\n // Re-audit NEW-001 (SSRF, LOW): defang dangerous protocols on\n // consumer-provided font URLs. Built-in themes use\n // fonts.googleapis.com/gstatic.com — safe. registerTheme accepts\n // arbitrary objects at runtime; a malicious theme could try to inject\n // javascript:/data:text/html via fontUrls. safeHref returns undefined\n // for dangerous protocols, which we skip silently.\n const safe = safeHref(url);\n if (!safe) continue;\n if (document.head.querySelector(`link[rel=\"stylesheet\"][href=\"${CSS.escape(safe)}\"]`)) {\n continue;\n }\n const link = document.createElement(\"link\");\n link.rel = \"stylesheet\";\n link.href = safe;\n document.head.appendChild(link);\n }\n}\n\ninterface ThemeProviderProps {\n children: ReactNode;\n /**\n * Theme to start with. Must match the `name` of an entry in `themes`.\n * Defaults to `\"violet-forge\"` for backward compat — if you don't pass\n * `violet-forge` in `themes`, set this prop explicitly.\n */\n defaultTheme?: string;\n /** Mode to start with. Defaults to `\"dark\"` (library is dark-first). */\n defaultMode?: ThemeMode;\n /**\n * Available themes. **Required**: ThemeProvider does not auto-include any\n * built-in theme since v0.1.0-next.0 — pass `builtinThemes` for all three\n * Violet Forge defaults, or your own array for a slimmer bundle.\n *\n * Migration: consumers previously calling `<ThemeProvider>` without this\n * prop now must pass `themes={builtinThemes}` (or use `<TheoUIProvider>`\n * which defaults to `builtinThemes` for you).\n */\n themes: Theme[];\n /**\n * Persist selection in localStorage under this key. Pass `null` to disable.\n * Default: \"theo-ui:theme\".\n */\n storageKey?: string | null;\n /**\n * Initial density. Drives `data-density` on `<html>` and the `--theo-control-h`\n * / `--theo-control-px` CSS vars consumed by form-control `md` variants.\n * Defaults to `\"comfortable\"` (36px controls — FAANG-tier modern density).\n * Plan: faang-density-tightening (D3).\n */\n defaultDensity?: Density;\n}\n\n/**\n * Storage failure diagnostic — dev-only one-line warn so engineers see\n * something when localStorage throws (Safari private mode, blocked\n * third-party cookies, sandboxed iframes). In production we stay silent;\n * runtime behavior is fail-safe (state still lives in memory).\n *\n * Per HIGH-006: silent catches diverge from the \"fail loud\" principle\n * declared in the global CLAUDE.md. We accept silence in prod because the\n * fallback is correct, but we surface a single warn per call site in dev.\n */\nfunction warnStorageFailure(scope: string, err: unknown): void {\n if (typeof process === \"undefined\" || process.env.NODE_ENV === \"production\") return;\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for storage failures (HIGH-006)\n console.warn(`[@usetheo/ui] theme storage failure (${scope}):`, err);\n}\n\n/**\n * ThemeProvider — central registry + runtime switcher for Theo themes.\n *\n * Behavior:\n * 1. On mount, injects a `<style id=\"theo-ui-theme-vars\">` element with\n * one CSS block per theme (`[data-theme=\"<name>\"] { --token: ... }`).\n * 2. Sets `data-theme` and `data-mode` on `<html>` so any element nested\n * below inherits the right tokens (the Tailwind config consumes them).\n * 3. Lazy-loads theme font URLs by injecting `<link rel=\"stylesheet\">`.\n * 4. Optionally persists choice in localStorage.\n */\nfunction ThemeProvider({\n children,\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n themes: themesProp,\n storageKey = \"theo-ui:theme\",\n defaultDensity = \"comfortable\",\n}: ThemeProviderProps): JSX.Element {\n // Themes prop is required since v0.1.0-next.0 — see migration note in\n // the JSDoc on ThemeProviderProps. Pass `builtinThemes` for the legacy\n // default behavior (violet-forge + classic-paper + aurora-terminal), or\n // an array of your own. Empty array is rejected: ThemeProvider has no\n // valid state without at least one registered theme.\n if (!themesProp || themesProp.length === 0) {\n throw new Error(\n \"<ThemeProvider> requires the `themes` prop with at least one Theme. \" +\n \"Pass `themes={builtinThemes}` for the Violet Forge defaults (importable \" +\n \"via the package barrel), or use <TheoUIProvider> which sets this for you.\",\n );\n }\n\n // T3.2 (SEC-001): eager validation. Calling validatedColor/FontFamily/\n // ThemeName here ensures CSS-injection attempts throw at construction\n // time rather than inside the deferred useEffect that injects the\n // <style>. Production-mode fallbacks keep the app rendering even if a\n // theme has bad values.\n //\n // Re-audit NEW-3: wrapped in useMemo so the validation cost (O(themes *\n // tokens), ~60 ops per built-in theme) only runs when themesProp's\n // reference changes — not on every parent re-render. Consumers passing\n // inline array literals (`themes={[violetForge, classicPaper]}`) would\n // otherwise pay this on every parent update.\n const mergedThemes = useMemo<Theme[]>(() => {\n for (const t of themesProp) {\n validatedThemeName(t.name);\n validatedFontFamily(\"display\", t.fonts.display);\n validatedFontFamily(\"body\", t.fonts.body);\n validatedFontFamily(\"mono\", t.fonts.mono);\n for (const [token, value] of Object.entries(t.light)) {\n validatedColor(token, value);\n }\n for (const [token, value] of Object.entries(t.dark)) {\n validatedColor(token, value);\n }\n }\n // Dedup by theme name; last writer wins (allows registerTheme override).\n const map = new Map<string, Theme>();\n for (const t of themesProp) map.set(t.name, t);\n return Array.from(map.values());\n }, [themesProp]);\n\n const [themes, setThemes] = useState<Theme[]>(mergedThemes);\n\n // Re-sync state when the `themes` prop changes between renders. Avoids the\n // common pitfall where the user passes a different array later and the\n // initial-state-only seed silently ignores the change.\n useEffect(() => {\n setThemes(mergedThemes);\n }, [mergedThemes]);\n\n // SSR-safe initialization (0.6.3-next.0 hydration-mismatch fix).\n //\n // Previously: `useState(() => localStorage.getItem(…))`. The initializer\n // ran on BOTH server (no `window`, returned default) AND client at\n // hydration time (with `window`, returned the stored value). The two\n // results disagreed → React threw a hydration error on every page load\n // for any user who had previously changed themes, and re-rendered the\n // entire tree from scratch — defeating SSR.\n //\n // Fix: initialize with the SSR default ALWAYS. Promote to the stored\n // value in a post-mount `useEffect` below. The 1-frame visual flicker\n // is mitigated by the optional `<ThemeScript>` component, which sets\n // `data-theme` / `data-mode` / `data-density` on `<html>` before React\n // hydrates — see `theme-script.tsx`.\n //\n // The `hydratedRef` flag below guards the persist effect so that\n // first-mount writes (with the SSR default values) don't clobber the\n // user's stored preference in the brief window between mount and the\n // post-mount hydration effect.\n const [themeName, setThemeName] = useState<string>(defaultTheme);\n const [mode, setModeState] = useState<ThemeMode>(defaultMode);\n const [density, setDensityState] = useState<Density>(defaultDensity);\n\n // First-run guard for the persist effect below. On initial mount the\n // state is the SSR-safe default; we MUST NOT clobber the user's stored\n // preference with that default before the post-mount hydration effect\n // can promote it. After the first persist-effect call returns early,\n // every subsequent change (post-hydration setState OR user-driven)\n // persists normally.\n const skipFirstPersistRef = useRef(true);\n\n // Post-mount hydration: read localStorage and promote stored values to\n // state. Runs ONCE on mount. If `storageKey` is null or no value is\n // stored, this is a no-op — state stays at the SSR defaults.\n useEffect(() => {\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n const storedName = window.localStorage.getItem(`${storageKey}:name`);\n const storedMode = window.localStorage.getItem(`${storageKey}:mode`);\n const storedDensity = window.localStorage.getItem(`${storageKey}:density`);\n if (storedName) setThemeName(storedName);\n if (storedMode === \"dark\" || storedMode === \"light\") setModeState(storedMode);\n if (\n storedDensity === \"compact\" ||\n storedDensity === \"comfortable\" ||\n storedDensity === \"spacious\"\n ) {\n setDensityState(storedDensity);\n }\n } catch (err) {\n warnStorageFailure(\"read theme + mode + density\", err);\n }\n }, [storageKey]);\n\n // Inject CSS vars whenever the themes list changes.\n useEffect(() => {\n injectThemeCss(themes);\n }, [themes]);\n\n // Apply data-theme + data-mode + data-density to <html>, load fonts,\n // inject density CSS vars.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n const active = themes.find((t) => t.name === themeName) ?? themes[0];\n if (!active) return;\n document.documentElement.setAttribute(\"data-theme\", active.name);\n document.documentElement.setAttribute(\"data-mode\", mode);\n document.documentElement.setAttribute(\"data-density\", density);\n document.documentElement.classList.toggle(\"dark\", mode === \"dark\");\n loadThemeFonts(active);\n injectDensityCss();\n }, [themeName, mode, density, themes]);\n\n // Persist on change.\n //\n // The first run is SKIPPED via `skipFirstPersistRef`: state at mount\n // is the SSR-safe default. If we wrote it to storage immediately,\n // we'd clobber the user's stored preference between mount and the\n // post-mount hydration effect that promotes the stored value. After\n // the first call, every subsequent run persists — whether the change\n // came from the hydration effect (no-op write back of the stored\n // value) or a user-driven `setTheme` / `toggleMode` / `setDensity`.\n useEffect(() => {\n if (skipFirstPersistRef.current) {\n skipFirstPersistRef.current = false;\n return;\n }\n if (typeof window === \"undefined\" || !storageKey) return;\n try {\n window.localStorage.setItem(`${storageKey}:name`, themeName);\n window.localStorage.setItem(`${storageKey}:mode`, mode);\n window.localStorage.setItem(`${storageKey}:density`, density);\n } catch (err) {\n // Storage may fail in private mode; behavior remains correct (state\n // lives in memory). Per HIGH-006 we surface a one-time dev warning so\n // the engineer sees something instead of complete silence.\n warnStorageFailure(\"persist theme + mode + density\", err);\n }\n }, [themeName, mode, density, storageKey]);\n\n const setTheme = useCallback((name: string) => setThemeName(name), []);\n const setMode = useCallback((next: ThemeMode) => setModeState(next), []);\n const toggleMode = useCallback(\n () => setModeState((cur) => (cur === \"light\" ? \"dark\" : \"light\")),\n [],\n );\n const setDensity = useCallback((next: Density) => setDensityState(next), []);\n const registerTheme = useCallback((theme: Theme) => {\n setThemes((cur) => {\n const idx = cur.findIndex((t) => t.name === theme.name);\n if (idx >= 0) {\n const next = cur.slice();\n next[idx] = theme;\n return next;\n }\n return [...cur, theme];\n });\n }, []);\n\n // themes[0] is guaranteed non-undefined by the constructor-time check\n // above (themesProp is non-empty); the non-null assert encodes that\n // invariant for TypeScript, which can't trace it through useState.\n // biome-ignore lint/style/noNonNullAssertion: T2.5 runtime invariant — themesProp non-empty validated at top of function\n const active = themes.find((t) => t.name === themeName) ?? themes[0]!;\n\n const value = useMemo<ThemeContextValue>(\n () => ({\n theme: active,\n mode,\n themes,\n setTheme,\n setMode,\n toggleMode,\n registerTheme,\n }),\n [active, mode, themes, setTheme, setMode, toggleMode, registerTheme],\n );\n\n const densityValue = useMemo(() => ({ density, setDensity }), [density, setDensity]);\n\n return (\n <ThemeContext.Provider value={value}>\n <DensityContext.Provider value={densityValue}>{children}</DensityContext.Provider>\n </ThemeContext.Provider>\n );\n}\n\n/**\n * useTheme — access theme state from any component inside <ThemeProvider>.\n * Throws if used outside the provider — fail-fast.\n */\nfunction useTheme(): ThemeContextValue {\n const ctx = useContext(ThemeContext);\n if (!ctx) {\n throw new Error(\"useTheme must be used inside <ThemeProvider>.\");\n }\n return ctx;\n}\n\nexport { ThemeProvider, useTheme };\n"
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"path": "themes/theme-switcher.tsx",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"path": "themes/theme-script.tsx",
|
|
13
13
|
"type": "registry:lib",
|
|
14
14
|
"target": "themes/theme-script.tsx",
|
|
15
|
-
"content": "/**\n * ThemeScript — inline `<script>` for SSR-safe theme initialization.\n *\n * Renders a synchronous script that runs BEFORE React hydration. It reads the\n * persisted theme + mode from localStorage (or falls back to the defaults) and\n * sets `data-theme` / `data-mode` on `<html>`, plus the `.dark` class when\n * mode is dark. This eliminates FOUC and avoids hydration mismatch warnings\n * when the user's persisted choice differs from the SSR defaults.\n *\n * Place this in `<head>` ABOVE `<body>`. The component does not need to live\n * inside `<ThemeProvider>`.\n *\n * Security: every interpolated value is passed through `safe()`, which both\n * `JSON.stringify`s the value AND escapes `<` to `<`. The `<` escape is\n * REQUIRED because `JSON.stringify` alone does NOT escape `/`, so a payload\n * like `\"</script><script>alert(1)</script>\"` would otherwise break out of\n * the inline `<script>` tag even though it stays inside a JS string literal.\n * (The browser tokenizes `</script>` at the HTML layer before JS parses.)\n *\n * Example (Next.js App Router): see docs/design-system.md → SSR section.\n * Pass `defaultTheme` and `defaultMode` to align with the consumer's\n * preferred initial state. Always wrap the root in `<html\n * suppressHydrationWarning>` to silence the expected one-render diff.\n */\nimport type { JSX } from \"react\";\nimport type { ThemeMode } from \"@/themes/types\";\n\ninterface ThemeScriptProps {\n /** Theme name to apply when no persisted value exists. Default `\"violet-forge\"`. */\n defaultTheme?: string;\n /** Mode to apply when no persisted value exists. Default `\"dark\"`. */\n defaultMode?: ThemeMode;\n /**\n * localStorage namespace. Must match the `storageKey` passed to\n * `<ThemeProvider>`. Default `\"theo-ui:theme\"`. Pass `null` to disable\n * persistence reads (the script will always apply defaults).\n */\n storageKey?: string | null;\n}\n\n/**\n * Encode a value for safe embedding inside an inline `<script>` block.\n *\n * `JSON.stringify` does NOT escape `/` by default, so `\"</script>\"` survives\n * as the literal three-character sequence inside the resulting string. When\n * that string is then rendered inside `<script>...</script>`, the browser's\n * HTML tokenizer sees `</script>` and ends the script tag — regardless of\n * whether the JS parser would have kept it inside a string. Escaping `<` to\n * its Unicode escape `<` preserves JS semantics (the JS parser still\n * resolves the escape to `<`) while making the HTML tokenizer happy.\n *\n * Reference: OWASP \"JSON-in-script\" guidance; React's own server-renderer\n * applies the same escape for inline JSON.\n */\nfunction safe(value: unknown): string {\n return JSON.stringify(value).replace(/</g, \"\\\\u003c\");\n}\n\nfunction buildScript(\n defaultTheme: string,\n defaultMode: ThemeMode,\n storageKey: string | null,\n): string {\n const k = safe(storageKey);\n const t = safe(defaultTheme);\n const m = safe(defaultMode);\n return `(function(){try{var k=${k};var d=document.documentElement;var t=null;var m=null;if(k){t=localStorage.getItem(k+\":name\");m=localStorage.getItem(k+\":mode\");}d.setAttribute(\"data-theme\",t||${t});d.setAttribute(\"data-mode\",m||${m});if((m||${m})===\"dark\"){d.classList.add(\"dark\");}}catch(e){}})();`;\n}\n\nfunction ThemeScript({\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n storageKey = \"theo-ui:theme\",\n}: ThemeScriptProps): JSX.Element {\n const code = buildScript(defaultTheme, defaultMode, storageKey);\n // biome-ignore lint/security/noDangerouslySetInnerHtml: payload is JSON.stringify-encoded literals (no user input); intentional for SSR theme bootstrap before React hydrates\n return <script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: code }} />;\n}\n\nexport { ThemeScript };\n"
|
|
15
|
+
"content": "/**\n * ThemeScript — inline `<script>` for SSR-safe theme initialization.\n *\n * Renders a synchronous script that runs BEFORE React hydration. It reads the\n * persisted theme + mode from localStorage (or falls back to the defaults) and\n * sets `data-theme` / `data-mode` on `<html>`, plus the `.dark` class when\n * mode is dark. This eliminates FOUC and avoids hydration mismatch warnings\n * when the user's persisted choice differs from the SSR defaults.\n *\n * Place this in `<head>` ABOVE `<body>`. The component does not need to live\n * inside `<ThemeProvider>`.\n *\n * Security: every interpolated value is passed through `safe()`, which both\n * `JSON.stringify`s the value AND escapes `<` to `<`. The `<` escape is\n * REQUIRED because `JSON.stringify` alone does NOT escape `/`, so a payload\n * like `\"</script><script>alert(1)</script>\"` would otherwise break out of\n * the inline `<script>` tag even though it stays inside a JS string literal.\n * (The browser tokenizes `</script>` at the HTML layer before JS parses.)\n *\n * Example (Next.js App Router): see docs/design-system.md → SSR section.\n * Pass `defaultTheme` and `defaultMode` to align with the consumer's\n * preferred initial state. Always wrap the root in `<html\n * suppressHydrationWarning>` to silence the expected one-render diff.\n */\nimport type { JSX } from \"react\";\nimport type { ThemeMode } from \"@/themes/types\";\n\ninterface ThemeScriptProps {\n /** Theme name to apply when no persisted value exists. Default `\"violet-forge\"`. */\n defaultTheme?: string;\n /** Mode to apply when no persisted value exists. Default `\"dark\"`. */\n defaultMode?: ThemeMode;\n /**\n * Density to apply when no persisted value exists. Default `\"comfortable\"`.\n * Mirrors `ThemeProvider`'s `defaultDensity` so the inline-script and\n * the React provider agree on the SSR-default density (and the\n * `data-density` attribute set by this script matches what\n * `ThemeProvider` promotes via its post-mount hydration effect).\n */\n defaultDensity?: \"compact\" | \"comfortable\" | \"spacious\";\n /**\n * localStorage namespace. Must match the `storageKey` passed to\n * `<ThemeProvider>`. Default `\"theo-ui:theme\"`. Pass `null` to disable\n * persistence reads (the script will always apply defaults).\n */\n storageKey?: string | null;\n}\n\n/**\n * Encode a value for safe embedding inside an inline `<script>` block.\n *\n * `JSON.stringify` does NOT escape `/` by default, so `\"</script>\"` survives\n * as the literal three-character sequence inside the resulting string. When\n * that string is then rendered inside `<script>...</script>`, the browser's\n * HTML tokenizer sees `</script>` and ends the script tag — regardless of\n * whether the JS parser would have kept it inside a string. Escaping `<` to\n * its Unicode escape `<` preserves JS semantics (the JS parser still\n * resolves the escape to `<`) while making the HTML tokenizer happy.\n *\n * Reference: OWASP \"JSON-in-script\" guidance; React's own server-renderer\n * applies the same escape for inline JSON.\n */\nfunction safe(value: unknown): string {\n return JSON.stringify(value).replace(/</g, \"\\\\u003c\");\n}\n\nfunction buildScript(\n defaultTheme: string,\n defaultMode: ThemeMode,\n defaultDensity: \"compact\" | \"comfortable\" | \"spacious\",\n storageKey: string | null,\n): string {\n const k = safe(storageKey);\n const t = safe(defaultTheme);\n const m = safe(defaultMode);\n const dn = safe(defaultDensity);\n return `(function(){try{var k=${k};var d=document.documentElement;var t=null;var m=null;var dn=null;if(k){t=localStorage.getItem(k+\":name\");m=localStorage.getItem(k+\":mode\");dn=localStorage.getItem(k+\":density\");}d.setAttribute(\"data-theme\",t||${t});d.setAttribute(\"data-mode\",m||${m});d.setAttribute(\"data-density\",dn||${dn});if((m||${m})===\"dark\"){d.classList.add(\"dark\");}}catch(e){}})();`;\n}\n\nfunction ThemeScript({\n defaultTheme = \"violet-forge\",\n defaultMode = \"dark\",\n defaultDensity = \"comfortable\",\n storageKey = \"theo-ui:theme\",\n}: ThemeScriptProps): JSX.Element {\n const code = buildScript(defaultTheme, defaultMode, defaultDensity, storageKey);\n // biome-ignore lint/security/noDangerouslySetInnerHtml: payload is JSON.stringify-encoded literals (no user input); intentional for SSR theme bootstrap before React hydrates\n return <script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: code }} />;\n}\n\nexport { ThemeScript };\n"
|
|
16
16
|
}
|
|
17
17
|
]
|
|
18
18
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "timestamp",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "Timestamp",
|
|
6
|
+
"description": "Accessible <time> primitive with relative/absolute/both formats, auto-refresh interval (default 60s, 0 disables), native title tooltip with absolute time, and aria-label that always carries the full date. Built on Intl.RelativeTimeFormat (zero deps). value accepts ISO string, Date, or Unix ms (NOT seconds). Invalid date renders empty element; invalid locale falls back to default with dev warning.",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
10
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "components/primitives/timestamp/timestamp.tsx",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": "components/ui/timestamp.tsx",
|
|
17
|
+
"content": "import { forwardRef, useEffect, useState } from \"react\";\nimport type { TimeHTMLAttributes } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/**\n * Timestamp — accessible relative/absolute time primitive.\n *\n * Renders a semantic `<time datetime>` element. Format modes:\n * - `relative` (default) — \"just now\", \"5 minutes ago\", \"2 hours ago\",\n * \"Dec 5\" (>7d), \"Dec 5, 2024\" (different year)\n * - `absolute` — full localized date+time\n * - `both` — \"Dec 5, 2026 (2 hours ago)\"\n *\n * Uses zero-dep `Intl.RelativeTimeFormat`. The tooltip on hover is the\n * HTML `title` attribute (not the `<Tooltip>` component) — keeps this\n * file a true primitive without sibling-primitive imports.\n *\n * Auto-refreshes via `setInterval` (default 60_000ms); pass\n * `refreshInterval={0}` to disable. `aria-label` always carries the\n * absolute time so screen readers read \"May 23, 2026 14:32 — posted\n * 2 hours ago\".\n *\n * @param value Source date — ISO string, Date object, or **Unix ms**\n * (NOT seconds). Passing seconds renders ~1970.\n */\nexport interface TimestampProps extends Omit<TimeHTMLAttributes<HTMLTimeElement>, \"children\"> {\n value: string | Date | number;\n format?: \"relative\" | \"absolute\" | \"both\";\n /** Auto-refresh interval when format=relative. Default 60000. 0 disables. */\n refreshInterval?: number;\n /** Locale for absolute formatting + Intl.RelativeTimeFormat. Default browser locale. */\n locale?: string;\n /** When true, omit the `title` tooltip. */\n noTooltip?: boolean;\n}\n\nfunction toDate(value: string | Date | number): Date | null {\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\nconst SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;\nconst UNITS: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\nfunction safeRelativeFormatter(locale: string | undefined): Intl.RelativeTimeFormat | null {\n try {\n return new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" });\n } catch {\n // EC-8: invalid locale tag — fall back to default locale.\n if (process.env.NODE_ENV !== \"production\") {\n // biome-ignore lint/suspicious/noConsole: dev-only diagnostic.\n console.warn(`<Timestamp locale=\"${locale}\">: invalid locale tag, falling back to default.`);\n }\n try {\n return new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n } catch {\n return null;\n }\n }\n}\n\nfunction safeAbsoluteFormat(date: Date, locale: string | undefined, withYear: boolean): string {\n try {\n return date.toLocaleDateString(locale, {\n month: \"short\",\n day: \"numeric\",\n ...(withYear ? { year: \"numeric\" } : {}),\n });\n } catch {\n return date.toLocaleDateString(undefined, {\n month: \"short\",\n day: \"numeric\",\n ...(withYear ? { year: \"numeric\" } : {}),\n });\n }\n}\n\nfunction formatRelative(date: Date, now: Date, locale: string | undefined): string {\n const diffMs = date.getTime() - now.getTime();\n const absMs = Math.abs(diffMs);\n\n if (absMs < 60_000) return \"just now\";\n\n if (diffMs < 0 && absMs > SEVEN_DAYS_MS) {\n const sameYear = date.getFullYear() === now.getFullYear();\n return safeAbsoluteFormat(date, locale, !sameYear);\n }\n\n const rtf = safeRelativeFormatter(locale);\n if (rtf === null) {\n return safeAbsoluteFormat(date, locale, date.getFullYear() !== now.getFullYear());\n }\n\n for (const { unit, ms } of UNITS) {\n if (absMs >= ms) {\n return rtf.format(Math.round(diffMs / ms), unit);\n }\n }\n return \"just now\";\n}\n\nfunction formatAbsolute(date: Date, locale: string | undefined): string {\n try {\n return date.toLocaleString(locale, {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n });\n } catch {\n return date.toLocaleString(undefined, {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n });\n }\n}\n\nconst Timestamp = forwardRef<HTMLTimeElement, TimestampProps>(\n (\n {\n className,\n value,\n format = \"relative\",\n refreshInterval = 60_000,\n locale,\n noTooltip,\n ...props\n },\n ref,\n ) => {\n const date = toDate(value);\n const [now, setNow] = useState<Date>(() => new Date());\n\n useEffect(() => {\n if (format !== \"relative\" || refreshInterval === 0 || date === null) return;\n const id = setInterval(() => setNow(new Date()), refreshInterval);\n return () => clearInterval(id);\n }, [format, refreshInterval, date]);\n\n if (date === null) {\n return <time ref={ref} className={cn(className)} suppressHydrationWarning {...props} />;\n }\n\n const iso = date.toISOString();\n const absolute = formatAbsolute(date, locale);\n const relative = formatRelative(date, now, locale);\n const visibleText =\n format === \"absolute\" ? absolute : format === \"both\" ? `${absolute} (${relative})` : relative;\n\n return (\n <time\n ref={ref}\n dateTime={iso}\n title={noTooltip ? undefined : absolute}\n aria-label={absolute}\n suppressHydrationWarning\n className={cn(className)}\n {...props}\n >\n {visibleText}\n </time>\n );\n },\n);\nTimestamp.displayName = \"Timestamp\";\n\nexport { Timestamp };\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "usage-meter",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"title": "UsageMeter",
|
|
6
|
+
"description": "Multi-metric stacked usage card for PaaS dashboards. Renders N metrics (data transfer, requests, build minutes, seats, …) each with label + value/max + Progress bar. Supports custom per-metric formatter, over-quota warning, and a compact bars-only mode. PaaS-shape sibling of CostMeter.",
|
|
7
|
+
"dependencies": [],
|
|
8
|
+
"registryDependencies": [
|
|
9
|
+
"https://usetheodev.github.io/theo-ui/r/cn.json",
|
|
10
|
+
"https://usetheodev.github.io/theo-ui/r/progress.json",
|
|
11
|
+
"https://usetheodev.github.io/theo-ui/r/tailwind-preset.json"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "components/composites/usage-meter/usage-meter.tsx",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": "components/ui/usage-meter.tsx",
|
|
18
|
+
"content": "import { forwardRef } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Progress } from \"@/components/ui/progress\";\n\n/**\n * UsageMeter — multi-metric stacked usage card.\n *\n * PaaS-shape sibling of `<CostMeter>` (which is mono-USD). Displays N\n * metrics with arbitrary units (GB, requests, build-minutes, seats) per\n * the reference TheoCloud dashboard mockup. Each row = label + value/max\n * + `<Progress>` fill bar.\n *\n * Composition:\n *\n * <UsageMeter\n * title=\"Last 30 days\"\n * action={<Badge variant=\"outline\">Upgrade</Badge>}\n * metrics={[\n * { label: \"Fast Data Transfer\", value: 0, max: 100, unit: \"GB\" },\n * { label: \"Edge Requests\", value: 0, max: 1_000_000, unit: \"req\",\n * formatter: (v, m) => `${v} / ${m >= 1e6 ? `${m / 1e6}M` : m}` },\n * ]}\n * />\n *\n * Over-quota detection: when `value > max`, the value text gets the\n * `text-warning` class and the underlying `<Progress>` uses\n * `intent=\"warning\"` (bar fills 100% via Progress's own clamping).\n *\n * `compact` mode renders only the bars (no label/value text) — useful in\n * narrow nav-bar slots.\n *\n * Imports the sibling `<Progress>` primitive via relative path (per RFC\n * dashboard-paas-primitives D3) — primitives must not depend on the\n * `@usetheo/ui` barrel.\n */\n\nexport interface UsageMetric {\n /** Display label (e.g. \"Fast Data Transfer\"). */\n label: ReactNode;\n /** Current consumption. */\n value: number;\n /** Maximum allowed in the current period. */\n max: number;\n /** Unit string (e.g. \"GB\", \"req\", \"min\"). Rendered after value/max. */\n unit?: string;\n /** Optional custom formatter — overrides default `${value} / ${max} ${unit}`. */\n formatter?: (value: number, max: number, unit?: string) => string;\n}\n\nexport interface UsageMeterProps extends Omit<HTMLAttributes<HTMLDivElement>, \"title\"> {\n /** Card title (e.g. \"Last 30 days\", \"This billing period\"). */\n title?: ReactNode;\n /** Optional right-aligned action slot (e.g. an Upgrade Badge or Button). */\n action?: ReactNode;\n /** Array of metrics to display. Order preserved. */\n metrics: UsageMetric[];\n /** When true, show only the bars without label/value text (sparkline mode). */\n compact?: boolean;\n}\n\nfunction defaultFormat(value: number, max: number, unit?: string): string {\n return `${value} / ${max}${unit ? ` ${unit}` : \"\"}`;\n}\n\nfunction metricAriaLabel(metric: UsageMetric, formatted: string): string {\n // Label may be a ReactNode; coerce to string for aria-label.\n const label = typeof metric.label === \"string\" ? metric.label : \"metric\";\n return `${label}: ${formatted}`;\n}\n\nconst UsageMeter = forwardRef<HTMLDivElement, UsageMeterProps>(\n ({ className, title, action, metrics, compact = false, ...props }, ref) => {\n const hasHeader = Boolean(title) || Boolean(action);\n return (\n <div\n ref={ref}\n className={cn(\n \"grid gap-3 rounded-xl border border-border bg-card p-4\",\n compact && \"gap-2\",\n className,\n )}\n data-theo-usage-meter=\"\"\n {...props}\n >\n {hasHeader ? (\n <header className=\"flex items-baseline justify-between gap-3\">\n {title ? (\n <span\n className={cn(\n \"font-mono text-label-caps text-muted-foreground uppercase tracking-wider\",\n )}\n >\n {title}\n </span>\n ) : (\n <span />\n )}\n {action ? <div className=\"shrink-0\">{action}</div> : null}\n </header>\n ) : null}\n\n {metrics.map((metric, idx) => {\n const overQuota = metric.value > metric.max;\n const fmt = metric.formatter ?? defaultFormat;\n const formatted = fmt(metric.value, metric.max, metric.unit);\n const intent = overQuota ? \"warning\" : \"default\";\n // Each metric needs a stable key. Without a unique id field we\n // synthesize one from label + idx — duplicates resolve via the\n // index suffix.\n const key = `${typeof metric.label === \"string\" ? metric.label : \"metric\"}-${idx}`;\n\n if (compact) {\n return (\n <Progress\n key={key}\n value={metric.value}\n max={metric.max}\n intent={intent}\n aria-label={metricAriaLabel(metric, formatted)}\n />\n );\n }\n\n return (\n <div key={key} className=\"grid gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-3 text-body-sm\">\n <span className=\"truncate text-muted-foreground\">{metric.label}</span>\n <span\n className={cn(\n \"shrink-0 font-mono tabular-nums\",\n overQuota ? \"text-warning\" : \"text-foreground\",\n )}\n >\n {formatted}\n </span>\n </div>\n <Progress\n value={metric.value}\n max={metric.max}\n intent={intent}\n aria-label={metricAriaLabel(metric, formatted)}\n />\n </div>\n );\n })}\n </div>\n );\n },\n);\nUsageMeter.displayName = \"UsageMeter\";\n\nexport { UsageMeter };\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|