@hilum/ui 3.8.5 → 3.10.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.
Files changed (90) hide show
  1. package/README.md +32 -1
  2. package/dist/{chunk-RI6QJR2L.js → chunk-R576DV2A.js} +120 -14
  3. package/dist/chunk-R576DV2A.js.map +1 -0
  4. package/dist/create-theme.js +2 -1
  5. package/dist/create-theme.js.map +1 -1
  6. package/dist/index.d.ts +733 -68
  7. package/dist/index.js +3097 -1619
  8. package/dist/index.js.map +1 -1
  9. package/dist/tokens.css +163 -19
  10. package/dist/tokens.d.ts +69 -5
  11. package/dist/tokens.js +1 -1
  12. package/llms.txt +11 -4
  13. package/package.json +11 -10
  14. package/src/components/accordion.tsx +2 -2
  15. package/src/components/account-menu.tsx +1 -1
  16. package/src/components/alert-dialog.tsx +107 -0
  17. package/src/components/ask-user-questions.tsx +24 -18
  18. package/src/components/badge.tsx +36 -3
  19. package/src/components/breadcrumb.tsx +4 -2
  20. package/src/components/button-group.tsx +1 -1
  21. package/src/components/button.tsx +31 -23
  22. package/src/components/calendar.tsx +1 -1
  23. package/src/components/card.tsx +4 -2
  24. package/src/components/checkbox-card.tsx +1 -1
  25. package/src/components/checkbox-group.tsx +2 -1
  26. package/src/components/code-block.tsx +99 -0
  27. package/src/components/color-input.tsx +9 -6
  28. package/src/components/color-picker.tsx +38 -26
  29. package/src/components/combobox.tsx +59 -12
  30. package/src/components/command-palette.tsx +150 -42
  31. package/src/components/command.tsx +5 -3
  32. package/src/components/context-menu.tsx +18 -15
  33. package/src/components/contextual-save-bar.tsx +156 -0
  34. package/src/components/data-table.tsx +34 -5
  35. package/src/components/date-picker.tsx +275 -28
  36. package/src/components/date-text.tsx +90 -0
  37. package/src/components/dropdown-menu.tsx +11 -8
  38. package/src/components/dropdown.tsx +6 -3
  39. package/src/components/empty-state.tsx +85 -20
  40. package/src/components/file-dropzone.tsx +2 -1
  41. package/src/components/file-thumbnail.tsx +84 -20
  42. package/src/components/filter-bar.tsx +91 -0
  43. package/src/components/grid-list.tsx +2 -2
  44. package/src/components/help-tooltip.tsx +1 -1
  45. package/src/components/input-copy.tsx +1 -1
  46. package/src/components/input-group.tsx +1 -1
  47. package/src/components/input-message.tsx +5 -4
  48. package/src/components/input-number.tsx +103 -11
  49. package/src/components/input.tsx +5 -3
  50. package/src/components/label.tsx +1 -0
  51. package/src/components/media-asset-card.tsx +1 -1
  52. package/src/components/menu-item.tsx +37 -3
  53. package/src/components/menubar.tsx +21 -18
  54. package/src/components/native-select.tsx +1 -1
  55. package/src/components/navigation-menu.tsx +2 -2
  56. package/src/components/notification.tsx +1 -1
  57. package/src/components/pagination.tsx +97 -3
  58. package/src/components/popover.tsx +2 -0
  59. package/src/components/property-row.tsx +7 -4
  60. package/src/components/radio-card.tsx +1 -1
  61. package/src/components/radio-group.tsx +2 -1
  62. package/src/components/rating.tsx +136 -0
  63. package/src/components/resizable.tsx +1 -1
  64. package/src/components/resource-item.tsx +133 -0
  65. package/src/components/rich-text-editor.tsx +1 -0
  66. package/src/components/search-input.tsx +98 -0
  67. package/src/components/searchable-table.tsx +10 -11
  68. package/src/components/select.tsx +28 -9
  69. package/src/components/sidebar.tsx +4 -4
  70. package/src/components/slider.tsx +78 -36
  71. package/src/components/sonner.tsx +18 -4
  72. package/src/components/stat-card.tsx +121 -37
  73. package/src/components/status-badge.tsx +142 -47
  74. package/src/components/status-tile.tsx +9 -0
  75. package/src/components/switch.tsx +6 -5
  76. package/src/components/tabs-subtle.tsx +10 -2
  77. package/src/components/tabs.tsx +23 -3
  78. package/src/components/textarea.tsx +2 -3
  79. package/src/components/thinking-steps.tsx +3 -3
  80. package/src/components/toggle.tsx +14 -13
  81. package/src/components/tooltip.tsx +11 -8
  82. package/src/components/url-redirect-prompt.tsx +0 -1
  83. package/src/index.ts +15 -0
  84. package/src/lib/density-context.tsx +65 -0
  85. package/src/lib/format.tsx +301 -0
  86. package/src/lib/interaction.ts +12 -4
  87. package/src/lib/scroll-fade.tsx +39 -0
  88. package/src/tokens/tokens.test.ts +77 -0
  89. package/src/tokens/tokens.ts +114 -5
  90. package/dist/chunk-RI6QJR2L.js.map +0 -1
@@ -1,16 +1,39 @@
1
1
  import * as React from "react";
2
2
  import { TrendingUp, TrendingDown, Minus, Plus } from "lucide-react";
3
3
  import { cn } from "../lib/utils";
4
+ import { Skeleton } from "./skeleton";
5
+
6
+ type StatTrendTone = "positive" | "negative" | "neutral";
7
+
8
+ interface StatCardTrend {
9
+ value: string;
10
+ direction: "up" | "down" | "neutral";
11
+ /**
12
+ * Whether the change is good or bad. Defaults to up → positive,
13
+ * down → negative. Set explicitly for metrics where "up" is bad
14
+ * (refunds, churn, response time).
15
+ */
16
+ tone?: StatTrendTone;
17
+ /** Screen-reader / caption context, e.g. "vs last 30 days". */
18
+ label?: string;
19
+ }
4
20
 
5
21
  interface StatCardProps {
6
22
  label?: string;
7
23
  title?: string;
8
- value?: string | number;
9
- variant?: "default" | "responsive";
10
- trend?: {
11
- value: string;
12
- direction: "up" | "down" | "neutral";
13
- };
24
+ value?: React.ReactNode;
25
+ /**
26
+ * `default` — bordered card. `responsive` — flat on mobile, card from `sm`.
27
+ * `plain` — no surface; use inside an existing Card or a divided StatGrid.
28
+ */
29
+ variant?: "default" | "responsive" | "plain";
30
+ trend?: StatCardTrend;
31
+ /** Secondary line under the value (e.g. "32 orders", "Last 30 days"). */
32
+ description?: React.ReactNode;
33
+ /** Render skeletons for the value and trend. */
34
+ loading?: boolean;
35
+ /** Make the whole card a link (e.g. drill into the orders list). */
36
+ href?: string;
14
37
  icon?: React.ReactNode;
15
38
  children?: React.ReactNode;
16
39
  actionButtons?: React.ReactNode;
@@ -22,6 +45,11 @@ interface StatCardProps {
22
45
 
23
46
  interface StatCardGridProps {
24
47
  children: React.ReactNode;
48
+ /**
49
+ * Column count at the widest breakpoint. Always 2 columns on mobile (1 for
50
+ * `columns={1}`), stepping up through `sm`/`lg`. Default: 6-slot dashboard grid.
51
+ */
52
+ columns?: 1 | 2 | 3 | 4 | 5 | 6;
25
53
  maxSlots?: number;
26
54
  onAddCard?: () => void;
27
55
  className?: string;
@@ -40,6 +68,9 @@ function StatCard({
40
68
  value,
41
69
  variant = "default",
42
70
  trend,
71
+ description,
72
+ loading = false,
73
+ href,
43
74
  icon,
44
75
  children,
45
76
  actionButtons,
@@ -49,11 +80,10 @@ function StatCard({
49
80
  valueClassName,
50
81
  }: StatCardProps) {
51
82
  const displayLabel = title ?? label;
52
- const trendColor = {
53
- up: "text-muted-foreground bg-brand-secondary/30 rounded-full px-2 py-0.5 w-fit",
54
- down: "text-destructive",
55
- neutral: "text-muted-foreground",
56
- };
83
+ const trendTone: StatTrendTone | undefined = trend
84
+ ? (trend.tone ??
85
+ (trend.direction === "up" ? "positive" : trend.direction === "down" ? "negative" : "neutral"))
86
+ : undefined;
57
87
 
58
88
  const TrendIcon = {
59
89
  up: TrendingUp,
@@ -61,19 +91,14 @@ function StatCard({
61
91
  neutral: Minus,
62
92
  };
63
93
  const surfaceClass =
64
- variant === "responsive"
65
- ? "min-w-0 border-border bg-transparent p-4 sm:rounded-xl sm:border sm:bg-card sm:p-5 sm:shadow-natural"
66
- : "min-w-0 rounded-xl border border-border bg-card p-4 shadow-natural sm:p-5";
94
+ variant === "plain"
95
+ ? "min-w-0 p-4 sm:p-5"
96
+ : variant === "responsive"
97
+ ? "min-w-0 border-border bg-transparent p-4 sm:rounded-xl sm:border sm:bg-card sm:p-5 sm:shadow-natural"
98
+ : "min-w-0 rounded-xl border border-border bg-card p-4 shadow-natural sm:p-5";
67
99
 
68
- return (
69
- <div
70
- className={cn(
71
- surfaceClass,
72
- containerClassName,
73
- className,
74
- )}
75
- data-slot="stat-card"
76
- >
100
+ const body = (
101
+ <>
77
102
  <div className="flex items-start justify-between gap-3">
78
103
  {displayLabel && (
79
104
  <p className={cn("label min-w-0 text-muted-foreground", titleClassName)}>
@@ -87,23 +112,65 @@ function StatCard({
87
112
  )}
88
113
  {actionButtons && <div className="flex shrink-0 items-center gap-2">{actionButtons}</div>}
89
114
  </div>
90
- {value !== undefined ? (
91
- <p className={cn("heading-xl mt-2 tabular-nums text-foreground", valueClassName)}>
115
+ {loading ? (
116
+ <Skeleton className="mt-3 h-7 w-24" />
117
+ ) : value !== undefined ? (
118
+ <p className={cn("heading-xl mt-2 truncate tabular-nums text-foreground", valueClassName)}>
92
119
  {value}
93
120
  </p>
94
121
  ) : children ? (
95
122
  <div className="mt-2">{children}</div>
96
123
  ) : null}
97
- {trend &&
98
- (() => {
99
- const Icon = TrendIcon[trend.direction];
100
- return (
101
- <div className={cn("mt-2 flex items-center gap-1", trendColor[trend.direction])}>
102
- <Icon size={12} />
103
- <span className="caption tabular-nums font-medium">{trend.value}</span>
104
- </div>
105
- );
106
- })()}
124
+ {(description || (trend && !loading)) && (
125
+ <div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
126
+ {trend &&
127
+ !loading &&
128
+ (() => {
129
+ const Icon = TrendIcon[trend.direction];
130
+ return (
131
+ <span
132
+ data-tone={trendTone}
133
+ className={cn(
134
+ "inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5",
135
+ trendTone === "positive" && "bg-success/40 text-foreground",
136
+ trendTone === "negative" && "bg-destructive/10 text-destructive",
137
+ trendTone === "neutral" && "bg-muted text-muted-foreground",
138
+ )}
139
+ >
140
+ <Icon size={12} aria-hidden="true" />
141
+ <span className="caption tabular-nums font-medium">{trend.value}</span>
142
+ {trend.label && <span className="sr-only">{trend.label}</span>}
143
+ </span>
144
+ );
145
+ })()}
146
+ {description && (
147
+ <span className="caption min-w-0 truncate text-muted-foreground">{description}</span>
148
+ )}
149
+ </div>
150
+ )}
151
+ </>
152
+ );
153
+
154
+ const classes = cn(surfaceClass, containerClassName, className);
155
+
156
+ if (href) {
157
+ return (
158
+ <a
159
+ href={href}
160
+ className={cn(
161
+ classes,
162
+ "block transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
163
+ )}
164
+ data-slot="stat-card"
165
+ >
166
+ {body}
167
+ </a>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <div className={classes} data-slot="stat-card" aria-busy={loading || undefined}>
173
+ {body}
107
174
  </div>
108
175
  );
109
176
  }
@@ -117,7 +184,7 @@ function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlot
117
184
  className={cn(
118
185
  "flex min-h-28 w-full min-w-0 items-center justify-center rounded-xl border border-dashed border-border bg-card p-4 text-muted-foreground shadow-natural",
119
186
  "transition-[background-color,border-color,color,scale] duration-150 hover:border-ground-300 hover:bg-muted hover:text-foreground active:scale-[0.96]",
120
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
187
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
121
188
  className,
122
189
  )}
123
190
  onClick={onAddCard}
@@ -132,8 +199,18 @@ function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlot
132
199
 
133
200
  StatCardSlot.displayName = "StatCardSlot";
134
201
 
202
+ const GRID_COLUMNS: Record<NonNullable<StatCardGridProps["columns"]>, string> = {
203
+ 1: "grid-cols-1",
204
+ 2: "grid-cols-2",
205
+ 3: "grid-cols-2 lg:grid-cols-3",
206
+ 4: "grid-cols-2 lg:grid-cols-4",
207
+ 5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5",
208
+ 6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6",
209
+ };
210
+
135
211
  function StatCardGrid({
136
212
  children,
213
+ columns = 6,
137
214
  maxSlots = 6,
138
215
  onAddCard,
139
216
  className,
@@ -145,7 +222,8 @@ function StatCardGrid({
145
222
  return (
146
223
  <div
147
224
  className={cn(
148
- "grid min-w-0 grid-cols-2 items-stretch gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6",
225
+ "grid min-w-0 items-stretch gap-2 sm:gap-3 [&>*>[data-slot=stat-card]]:h-full",
226
+ GRID_COLUMNS[columns],
149
227
  className,
150
228
  )}
151
229
  data-slot="stat-card-grid"
@@ -166,11 +244,17 @@ function StatCardGrid({
166
244
 
167
245
  StatCardGrid.displayName = "StatCardGrid";
168
246
 
247
+ /** Alias — `StatGrid` reads better next to `StatCard` in new code. */
248
+ const StatGrid = StatCardGrid;
249
+
169
250
  export {
170
251
  StatCard,
171
252
  StatCardGrid,
172
253
  StatCardSlot,
254
+ StatGrid,
173
255
  type StatCardProps,
256
+ type StatCardTrend,
257
+ type StatTrendTone,
174
258
  type StatCardGridProps,
175
259
  type StatCardSlotProps,
176
260
  };
@@ -1,75 +1,131 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Badge } from "./badge";
4
+ import { Badge, STATUS_TONE_BADGE, type StatusTone } from "./badge";
5
5
  import { cn } from "../lib/utils";
6
6
 
7
7
  type StatusBadgeVariant = NonNullable<React.ComponentProps<typeof Badge>["variant"]>;
8
8
  type StatusBadgeIcon = React.ComponentType<{ className?: string }>;
9
9
 
10
- interface StatusBadgeProps extends Omit<
11
- React.ComponentProps<typeof Badge>,
12
- "children" | "variant"
13
- > {
14
- status?: string | null;
15
- label?: React.ReactNode;
16
- variant?: StatusBadgeVariant;
17
- variantMap?: Record<string, StatusBadgeVariant>;
18
- labelMap?: Record<string, React.ReactNode>;
19
- icon?: StatusBadgeIcon;
20
- iconMap?: Record<string, StatusBadgeIcon>;
21
- iconClassName?: string;
22
- showDot?: boolean;
23
- dotClassName?: string;
24
- }
25
-
26
- const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = {
10
+ const DEFAULT_STATUS_TONE: Record<string, StatusTone> = {
11
+ // success
27
12
  active: "success",
28
13
  approved: "success",
14
+ authorized: "success",
15
+ captured: "success",
29
16
  complete: "success",
30
17
  completed: "success",
31
18
  confirmed: "success",
19
+ connected: "success",
32
20
  delivered: "success",
33
21
  enabled: "success",
22
+ fulfilled: "success",
34
23
  healthy: "success",
24
+ live: "success",
35
25
  optimized: "success",
36
26
  paid: "success",
37
27
  published: "success",
38
28
  resolved: "success",
29
+ succeeded: "success",
30
+ success: "success",
39
31
  translated: "success",
40
32
  verified: "success",
41
-
33
+ // info
34
+ in_progress: "info",
35
+ in_transit: "info",
36
+ info: "info",
37
+ invited: "info",
38
+ new: "info",
39
+ open: "info",
40
+ out_for_delivery: "info",
41
+ processing: "info",
42
+ scheduled: "info",
43
+ shipped: "info",
44
+ trial: "info",
45
+ trialing: "info",
46
+ // attention
47
+ action_required: "attention",
48
+ attention: "attention",
49
+ flagged: "attention",
50
+ held_for_review: "attention",
51
+ on_hold: "attention",
52
+ partially_fulfilled: "attention",
53
+ partially_paid: "attention",
54
+ requires_action: "attention",
55
+ return_requested: "attention",
56
+ unfulfilled: "attention",
57
+ // warning
42
58
  degraded: "warning",
43
- flagged: "warning",
44
- held_for_review: "warning",
59
+ expiring: "warning",
60
+ overdue: "warning",
61
+ past_due: "warning",
45
62
  pending: "warning",
46
- processing: "warning",
47
63
  retrying: "warning",
48
- scheduled: "warning",
49
- trial: "warning",
50
64
  warning: "warning",
51
-
52
- archived: "secondary",
53
- disabled: "secondary",
54
- draft: "secondary",
55
- inactive: "secondary",
56
- missing: "secondary",
57
- neutral: "secondary",
58
- skipped: "secondary",
59
-
60
- blocked: "destructive",
61
- cancelled: "destructive",
62
- critical: "destructive",
63
- dead_letter: "destructive",
64
- deleted: "destructive",
65
- error: "destructive",
66
- failed: "destructive",
67
- rejected: "destructive",
68
- suspended: "destructive",
69
- unpaid: "destructive",
70
- outdated: "destructive",
65
+ // critical
66
+ blocked: "critical",
67
+ canceled: "critical",
68
+ cancelled: "critical",
69
+ chargeback: "critical",
70
+ critical: "critical",
71
+ dead_letter: "critical",
72
+ declined: "critical",
73
+ deleted: "critical",
74
+ disputed: "critical",
75
+ error: "critical",
76
+ expired: "critical",
77
+ failed: "critical",
78
+ outdated: "critical",
79
+ rejected: "critical",
80
+ suspended: "critical",
81
+ unpaid: "critical",
82
+ voided: "critical",
83
+ // neutral
84
+ archived: "neutral",
85
+ closed: "neutral",
86
+ disabled: "neutral",
87
+ draft: "neutral",
88
+ inactive: "neutral",
89
+ missing: "neutral",
90
+ neutral: "neutral",
91
+ partially_refunded: "neutral",
92
+ refunded: "neutral",
93
+ restocked: "neutral",
94
+ returned: "neutral",
95
+ skipped: "neutral",
96
+ unknown: "neutral",
71
97
  };
72
98
 
99
+ interface StatusBadgeProps extends Omit<
100
+ React.ComponentProps<typeof Badge>,
101
+ "children" | "variant"
102
+ > {
103
+ status?: string | null;
104
+ label?: React.ReactNode;
105
+ /**
106
+ * Semantic tone. Wins over `variant` and the built-in status map. Prefer
107
+ * `tone` / `toneMap` over raw `variant` so colours stay consistent.
108
+ */
109
+ tone?: StatusTone;
110
+ /** Per-app status → tone overrides, merged over the built-in convention. */
111
+ toneMap?: Record<string, StatusTone>;
112
+ variant?: StatusBadgeVariant;
113
+ variantMap?: Record<string, StatusBadgeVariant>;
114
+ labelMap?: Record<string, React.ReactNode>;
115
+ icon?: StatusBadgeIcon;
116
+ iconMap?: Record<string, StatusBadgeIcon>;
117
+ iconClassName?: string;
118
+ showDot?: boolean;
119
+ dotClassName?: string;
120
+ }
121
+
122
+ const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = Object.fromEntries(
123
+ Object.entries(DEFAULT_STATUS_TONE).map(([status, tone]) => [
124
+ status,
125
+ STATUS_TONE_BADGE[tone].variant,
126
+ ]),
127
+ );
128
+
73
129
  const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
74
130
  default: "bg-background",
75
131
  solid: "bg-background",
@@ -82,6 +138,15 @@ const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
82
138
  destructive: "bg-destructive",
83
139
  };
84
140
 
141
+ const TONE_DOT_CLASS: Record<StatusTone, string> = {
142
+ success: "bg-emerald-500",
143
+ info: "bg-blue-500",
144
+ attention: "bg-orange-500",
145
+ warning: "bg-amber-500",
146
+ critical: "bg-destructive",
147
+ neutral: "bg-muted-foreground",
148
+ };
149
+
85
150
  function normalizeStatus(status?: string | null) {
86
151
  return String(status ?? "unknown")
87
152
  .trim()
@@ -100,6 +165,16 @@ function statusLabel(status?: string | null) {
100
165
  .join(" ");
101
166
  }
102
167
 
168
+ /**
169
+ * Resolve a status string to its semantic tone. Unknown statuses resolve to
170
+ * `neutral`. Status strings are normalised ("Partially Fulfilled",
171
+ * "partially-fulfilled" and "PARTIALLY_FULFILLED" are equivalent).
172
+ */
173
+ function statusToneFor(status?: string | null, toneMap?: Record<string, StatusTone>): StatusTone {
174
+ const normalized = normalizeStatus(status);
175
+ return toneMap?.[normalized] ?? DEFAULT_STATUS_TONE[normalized] ?? "neutral";
176
+ }
177
+
103
178
  function statusBadgeVariantFor(
104
179
  status?: string | null,
105
180
  variantMap?: Record<string, StatusBadgeVariant>,
@@ -111,6 +186,8 @@ function statusBadgeVariantFor(
111
186
  function StatusBadge({
112
187
  status,
113
188
  label,
189
+ tone,
190
+ toneMap,
114
191
  variant,
115
192
  variantMap,
116
193
  labelMap,
@@ -123,13 +200,25 @@ function StatusBadge({
123
200
  ...props
124
201
  }: StatusBadgeProps) {
125
202
  const normalized = normalizeStatus(status);
126
- const resolvedVariant = variant ?? statusBadgeVariantFor(normalized, variantMap);
203
+ // Precedence: tone > toneMap > variant > variantMap > built-in tone map.
204
+ const resolvedTone: StatusTone | undefined =
205
+ tone ??
206
+ toneMap?.[normalized] ??
207
+ (variant === undefined && variantMap?.[normalized] === undefined
208
+ ? DEFAULT_STATUS_TONE[normalized]
209
+ : undefined);
210
+ const toneBadge = resolvedTone ? STATUS_TONE_BADGE[resolvedTone] : undefined;
211
+ const resolvedVariant =
212
+ toneBadge?.variant ?? variant ?? statusBadgeVariantFor(normalized, variantMap);
213
+ const resolvedColor = toneBadge?.color ?? props.color;
127
214
  const resolvedLabel = label ?? labelMap?.[normalized] ?? statusLabel(normalized);
128
215
  const Icon = icon ?? iconMap?.[normalized];
129
216
 
130
217
  return (
131
218
  <Badge
132
219
  variant={resolvedVariant}
220
+ {...(resolvedColor ? { color: resolvedColor } : {})}
221
+ data-tone={resolvedTone}
133
222
  className={cn("max-w-full whitespace-nowrap", (showDot || Icon) && "pl-2", className)}
134
223
  {...props}
135
224
  >
@@ -137,7 +226,7 @@ function StatusBadge({
137
226
  <span
138
227
  className={cn(
139
228
  "size-1.5 shrink-0 rounded-full",
140
- DEFAULT_DOT_CLASS[resolvedVariant],
229
+ resolvedTone ? TONE_DOT_CLASS[resolvedTone] : DEFAULT_DOT_CLASS[resolvedVariant],
141
230
  dotClassName,
142
231
  )}
143
232
  aria-hidden="true"
@@ -151,5 +240,11 @@ function StatusBadge({
151
240
 
152
241
  StatusBadge.displayName = "StatusBadge";
153
242
 
154
- export { StatusBadge, statusBadgeVariantFor, statusLabel };
243
+ export {
244
+ StatusBadge,
245
+ statusBadgeVariantFor,
246
+ statusLabel,
247
+ statusToneFor,
248
+ DEFAULT_STATUS_TONE,
249
+ };
155
250
  export type { StatusBadgeProps, StatusBadgeVariant };
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { cn } from "../lib/utils";
3
3
  import { StatusBadge, type StatusBadgeVariant } from "./status-badge";
4
+ import type { StatusTone } from "./badge";
4
5
 
5
6
  interface StatusTileGridProps extends React.HTMLAttributes<HTMLDivElement> {
6
7
  columns?: 2 | 3 | 4;
@@ -13,6 +14,10 @@ interface StatusTileProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "ti
13
14
  meta?: React.ReactNode;
14
15
  icon?: React.ComponentType<{ className?: string }>;
15
16
  labelMap?: Record<string, React.ReactNode>;
17
+ /** Semantic tone override for the badge. */
18
+ tone?: StatusTone;
19
+ /** Per-app status → tone overrides. */
20
+ toneMap?: Record<string, StatusTone>;
16
21
  variantMap?: Record<string, StatusBadgeVariant>;
17
22
  iconMap?: Record<string, React.ComponentType<{ className?: string }>>;
18
23
  }
@@ -44,6 +49,8 @@ function StatusTile({
44
49
  meta,
45
50
  icon: Icon,
46
51
  labelMap,
52
+ tone,
53
+ toneMap,
47
54
  variantMap,
48
55
  iconMap,
49
56
  className,
@@ -66,6 +73,8 @@ function StatusTile({
66
73
  <StatusBadge
67
74
  status={status}
68
75
  {...(labelMap ? { labelMap } : {})}
76
+ {...(tone ? { tone } : {})}
77
+ {...(toneMap ? { toneMap } : {})}
69
78
  {...(variantMap ? { variantMap } : {})}
70
79
  {...(iconMap ? { iconMap } : {})}
71
80
  className="shrink-0"
@@ -163,6 +163,7 @@ const Switch = forwardRef<HTMLDivElement, SwitchProps>(
163
163
  }, [checked, setChecked, motionX, thumbTransition]);
164
164
 
165
165
  return (
166
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- pointer/drag convenience on the row; the switch button inside is the keyboard-accessible control
166
167
  <div
167
168
  ref={ref}
168
169
  className={cn(
@@ -196,18 +197,18 @@ const Switch = forwardRef<HTMLDivElement, SwitchProps>(
196
197
  className={cn(
197
198
  "relative shrink-0 rounded-full outline-none cursor-pointer",
198
199
  "transition-colors duration-80",
199
- "focus-visible:ring-1 focus-visible:ring-[#6B97FF] focus-visible:ring-offset-2 focus-visible:ring-offset-background",
200
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
200
201
  )}
201
202
  style={{
202
203
  width: TRACK_WIDTH,
203
204
  height: TRACK_HEIGHT,
204
205
  backgroundColor: checked
205
206
  ? hovered
206
- ? "#5C89F2"
207
- : "#6B97FF"
207
+ ? "color-mix(in oklab, var(--primary), var(--foreground) 12%)"
208
+ : "var(--primary)"
208
209
  : hovered
209
- ? "color-mix(in oklab, var(--accent), rgb(var(--overlay)) 10%)"
210
- : "var(--accent)",
210
+ ? "color-mix(in oklab, var(--foreground) 22%, transparent)"
211
+ : "color-mix(in oklab, var(--foreground) 15%, transparent)",
211
212
  }}
212
213
  onClick={(e) => e.stopPropagation()}
213
214
  >
@@ -19,6 +19,7 @@ import { spring } from "../lib/springs";
19
19
  import { fontWeights } from "../lib/font-weight";
20
20
  import { useShape } from "../lib/shape-context";
21
21
  import { useProximityHover } from "../hooks/use-proximity-hover";
22
+ import { scrollStripItemIntoView, useHorizontalOverflowMask } from "../lib/scroll-fade";
22
23
 
23
24
  interface TabsSubtleContextValue {
24
25
  registerTab: (index: number, element: HTMLElement | null) => void;
@@ -55,11 +56,16 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
55
56
  idPrefix: idPrefixProp,
56
57
  activeLabel = false,
57
58
  className,
59
+ style,
58
60
  ...props
59
61
  },
60
62
  ref,
61
63
  ) => {
62
64
  const containerRef = useRef<HTMLDivElement>(null);
65
+ const maskStyle = useHorizontalOverflowMask(containerRef);
66
+ useEffect(() => {
67
+ scrollStripItemIntoView(containerRef.current, selectedIndex);
68
+ }, [selectedIndex]);
63
69
  const isMouseInside = useRef(false);
64
70
  const generatedId = useId();
65
71
  const shape = useShape();
@@ -127,6 +133,7 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
127
133
  <TabsSubtleContext.Provider
128
134
  value={{ registerTab, hoveredIndex, selectedIndex, onSelect, idPrefix, activeLabel }}
129
135
  >
136
+ {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus -- roving tabindex per WAI-ARIA APG: the tabs are focusable, the tablist container is not */}
130
137
  <div
131
138
  ref={(node) => {
132
139
  (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
@@ -173,9 +180,10 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
173
180
  }
174
181
  }}
175
182
  className={cn(
176
- "relative flex items-center gap-0.5 select-none overflow-x-auto max-w-full scrollbar-hide -my-1 py-1",
183
+ "relative flex items-center gap-0.5 select-none overflow-x-auto max-w-full overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden -my-1 py-1 [&>[role=tab]]:shrink-0",
177
184
  className,
178
185
  )}
186
+ style={maskStyle ? { ...maskStyle, ...style } : style}
179
187
  role="tablist"
180
188
  {...props}
181
189
  >
@@ -242,7 +250,7 @@ const TabsSubtle = forwardRef<HTMLDivElement, TabsSubtleProps>(
242
250
  {focusRect && (
243
251
  <motion.div
244
252
  className={cn(
245
- "absolute pointer-events-none z-20 border border-[#6B97FF]",
253
+ "absolute pointer-events-none z-20 border-2 border-ring",
246
254
  shape.focusRing,
247
255
  )}
248
256
  initial={false}