@hilum/ui 3.8.6 → 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
@@ -24,6 +24,7 @@ import { useShape } from "../lib/shape-context";
24
24
  import { useSurface } from "../lib/surface-context";
25
25
  import { surfaceClasses } from "../lib/surface-classes";
26
26
  import { useProximityHover } from "../hooks/use-proximity-hover";
27
+ import { scrollStripItemIntoView, useHorizontalOverflowMask } from "../lib/scroll-fade";
27
28
 
28
29
  /* ─────────────────────── Contexts ─────────────────────── */
29
30
 
@@ -128,10 +129,18 @@ Tabs.displayName = "Tabs";
128
129
 
129
130
  /* ─────────────────────── TabsList ─────────────────────── */
130
131
 
131
- type TabsListProps = ComponentPropsWithoutRef<typeof TabsPrimitive.List>;
132
+ interface TabsListProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
133
+ /**
134
+ * Let the strip scroll horizontally instead of clipping when the tabs are
135
+ * wider than their container (settings tabs on mobile, narrow panels).
136
+ * Edges fade while more tabs are hidden and the active tab is scrolled into
137
+ * view. Default: `true`.
138
+ */
139
+ scrollable?: boolean;
140
+ }
132
141
 
133
142
  const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
134
- ({ children, className, ...props }, ref) => {
143
+ ({ children, className, scrollable = true, style, ...props }, ref) => {
135
144
  const containerRef = useRef<HTMLDivElement>(null);
136
145
  const isMouseInside = useRef(false);
137
146
  const shape = useShape();
@@ -211,6 +220,13 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
211
220
  setOptimisticIdx(selectedIdx >= 0 ? selectedIdx : null);
212
221
  }, [selectedIdx]);
213
222
 
223
+ const maskStyle = useHorizontalOverflowMask(containerRef, scrollable);
224
+
225
+ // Keep the active tab visible when the strip overflows.
226
+ useEffect(() => {
227
+ if (scrollable) scrollStripItemIntoView(containerRef.current, selectedIdx);
228
+ }, [scrollable, selectedIdx]);
229
+
214
230
  const activeSelectedIdx = optimisticIdx;
215
231
  const selectedRect = activeSelectedIdx !== null ? itemRects[activeSelectedIdx] : null;
216
232
  const hoverRect = hoveredIndex !== null ? itemRects[hoveredIndex] : null;
@@ -259,11 +275,15 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
259
275
  if (isMouseInside.current) return;
260
276
  setHoveredIndex(null);
261
277
  }}
278
+ data-scrollable={scrollable ? "" : undefined}
262
279
  className={cn(
263
280
  "relative inline-flex items-center gap-0.5 p-1 select-none bg-muted",
281
+ scrollable &&
282
+ "max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[role=tab]]:shrink-0",
264
283
  shape.container,
265
284
  className,
266
285
  )}
286
+ style={maskStyle ? { ...maskStyle, ...style } : style}
267
287
  {...props}
268
288
  >
269
289
  {/* Active segment indicator */}
@@ -336,7 +356,7 @@ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
336
356
  {focusRect && (
337
357
  <motion.div
338
358
  className={cn(
339
- "absolute pointer-events-none z-20 border border-[#6B97FF]",
359
+ "absolute pointer-events-none z-20 border-2 border-ring",
340
360
  shape.focusRing,
341
361
  )}
342
362
  initial={false}
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
- import { controlSurfaceClasses, focusRingClasses, motionClasses } from "../lib/interaction";
5
+ import { controlSurfaceClasses, inputFocusClasses, motionClasses } from "../lib/interaction";
6
6
  import { useShape } from "../lib/shape-context";
7
7
  import type { ControlDensity, ControlMobileSurface } from "./input";
8
8
 
@@ -49,9 +49,8 @@ function Textarea({
49
49
  "resize-none",
50
50
  controlSurfaceClasses,
51
51
  motionClasses,
52
- focusRingClasses,
52
+ inputFocusClasses,
53
53
  textareaMobileSurfaceClasses[mobileSurface],
54
- "focus-visible:border-brand-primary",
55
54
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted",
56
55
  className,
57
56
  )}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useRef, forwardRef, type ReactNode, type HTMLAttributes } from "react";
3
+ import { forwardRef, type ReactNode, type HTMLAttributes } from "react";
4
4
  import { motion } from "framer-motion";
5
5
  import { cn } from "../lib/utils";
6
6
  import { useIcon } from "../lib/icon-context";
@@ -152,8 +152,8 @@ function ThinkingStep({
152
152
  label,
153
153
  description,
154
154
  status = "complete",
155
- index,
156
- delay = 0,
155
+ // `index` / `delay` are accepted for API compatibility but no longer drive
156
+ // the entrance animation, so they aren't destructured here.
157
157
  isLast = false,
158
158
  children,
159
159
  className,
@@ -10,7 +10,8 @@ const toggleVariants = cva(
10
10
  "inline-flex shrink-0 items-center justify-center gap-2",
11
11
  "body font-medium whitespace-nowrap rounded-md",
12
12
  "transition-[background-color,border-color,box-shadow,color,opacity,scale] duration-150 outline-none active:scale-[0.96]",
13
- "focus-visible:ring-2 focus-visible:ring-brand-primary/30 focus-visible:ring-offset-1",
13
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
14
+ "compact:rounded-[5px] compact:text-[12px] compact:gap-1.5 [&_svg:not([class*='size-'])]:compact:size-3.5",
14
15
  "disabled:pointer-events-none disabled:opacity-50",
15
16
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
16
17
  ],
@@ -19,27 +20,27 @@ const toggleVariants = cva(
19
20
  variant: {
20
21
  default: [
21
22
  "bg-transparent text-muted-foreground",
22
- "hover:bg-muted hover:text-foreground",
23
- "data-[state=on]:bg-muted data-[state=on]:text-foreground",
23
+ "hover:bg-hover hover:text-foreground",
24
+ "data-[state=on]:bg-active data-[state=on]:text-foreground",
24
25
  ],
25
26
  outline: [
26
27
  "border border-border bg-card text-muted-foreground shadow-natural",
27
- "hover:bg-muted hover:text-foreground",
28
- "data-[state=on]:bg-muted data-[state=on]:text-foreground data-[state=on]:border-border",
28
+ "hover:bg-hover hover:text-foreground hover:border-border-strong",
29
+ "data-[state=on]:bg-active data-[state=on]:text-foreground data-[state=on]:border-border-strong",
29
30
  ],
30
31
  brand: [
31
32
  "bg-transparent text-muted-foreground",
32
- "hover:bg-muted hover:text-foreground",
33
- "data-[state=on]:bg-brand-primary data-[state=on]:text-background",
33
+ "hover:bg-hover hover:text-foreground",
34
+ "data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
34
35
  ],
35
36
  },
36
37
  size: {
37
- sm: "h-8 gap-1.5 px-3",
38
- default: "h-10 px-3",
39
- lg: "h-11 px-4",
40
- icon: "size-9",
41
- "icon-sm": "size-8",
42
- "icon-lg": "size-11",
38
+ sm: "h-8 gap-1.5 px-3 compact:h-6 compact:px-2",
39
+ default: "h-10 px-3 compact:h-6 compact:px-2",
40
+ lg: "h-11 px-4 compact:h-7 compact:px-2.5",
41
+ icon: "size-9 compact:size-6",
42
+ "icon-sm": "size-8 compact:size-6",
43
+ "icon-lg": "size-11 compact:size-7",
43
44
  },
44
45
  },
45
46
  defaultVariants: {
@@ -80,7 +80,18 @@ function Tooltip({
80
80
  forceOpen,
81
81
  onOpenChange: onOpenChangeProp,
82
82
  }: TooltipProps) {
83
+ // All hooks run before the `content === undefined` early return so the hook
84
+ // order is stable if `content` toggles between defined and undefined.
83
85
  const [internalOpen, setInternalOpen] = useState(false);
86
+ const open = forceOpen ?? openProp ?? internalOpen;
87
+ const [mounted, setMounted] = useState(false);
88
+ const shape = useShape();
89
+ const portalContainer = useContext(TooltipPortalContainerContext);
90
+
91
+ useEffect(() => {
92
+ if (open) setMounted(true);
93
+ }, [open]);
94
+
84
95
  if (content === undefined) {
85
96
  return (
86
97
  <TooltipPrimitive.Root
@@ -91,14 +102,6 @@ function Tooltip({
91
102
  </TooltipPrimitive.Root>
92
103
  );
93
104
  }
94
- const open = forceOpen ?? openProp ?? internalOpen;
95
- const [mounted, setMounted] = useState(false);
96
- const shape = useShape();
97
- const portalContainer = useContext(TooltipPortalContainerContext);
98
-
99
- useEffect(() => {
100
- if (open) setMounted(true);
101
- }, [open]);
102
105
 
103
106
  const handleExitComplete = () => {
104
107
  if (!open) setMounted(false);
@@ -2,7 +2,6 @@ import * as React from "react";
2
2
  import { ArrowRight } from "lucide-react";
3
3
  import { Callout } from "./callout";
4
4
  import { Switch } from "./switch";
5
- import { cn } from "../lib/utils";
6
5
 
7
6
  function normalizeUrlHandle(value: string): string {
8
7
  return String(value || "")
package/src/index.ts CHANGED
@@ -10,6 +10,14 @@ export { cn } from "./lib/utils";
10
10
  export * from "./lib/icon-context";
11
11
  export * from "./lib/shape-context";
12
12
  export * from "./lib/surface-context";
13
+ export * from "./lib/density-context";
14
+ export * from "./lib/format";
15
+ export {
16
+ useScrollEdges,
17
+ horizontalEdgeMask,
18
+ useHorizontalOverflowMask,
19
+ scrollStripItemIntoView,
20
+ } from "./lib/scroll-fade";
13
21
 
14
22
  // Components (alphabetical)
15
23
  export * from "./components/account-menu";
@@ -37,15 +45,18 @@ export * from "./components/checkbox";
37
45
  export * from "./components/checkbox-card";
38
46
  export * from "./components/checkbox-group";
39
47
  export * from "./components/collapsible";
48
+ export * from "./components/code-block";
40
49
  export * from "./components/color-input";
41
50
  export * from "./components/color-picker";
42
51
  export * from "./components/combobox";
43
52
  export * from "./components/command-palette";
44
53
  export * from "./components/command";
45
54
  export * from "./components/context-menu";
55
+ export * from "./components/contextual-save-bar";
46
56
  export * from "./components/data-table";
47
57
  export * from "./components/data-transfer-controls";
48
58
  export * from "./components/date-picker";
59
+ export * from "./components/date-text";
49
60
  export * from "./components/description-list";
50
61
  export * from "./components/dialog";
51
62
  export * from "./components/drawer";
@@ -54,6 +65,7 @@ export * from "./components/dropdown-menu";
54
65
  export * from "./components/empty-state";
55
66
  export * from "./components/field";
56
67
  export * from "./components/file-thumbnail";
68
+ export * from "./components/filter-bar";
57
69
  export * from "./components/file-dropzone";
58
70
  export * from "./components/grid-list";
59
71
  export * from "./components/help-tooltip";
@@ -83,11 +95,14 @@ export * from "./components/popover";
83
95
  export * from "./components/progress";
84
96
  export * from "./components/property-row";
85
97
  export * from "./components/radio-card";
98
+ export * from "./components/rating";
86
99
  export * from "./components/radio-group";
87
100
  export * from "./components/resizable";
101
+ export * from "./components/resource-item";
88
102
  export * from "./components/rich-text-editor";
89
103
  export * from "./components/scroll-area";
90
104
  export * from "./components/section-heading";
105
+ export * from "./components/search-input";
91
106
  export * from "./components/searchable-table";
92
107
  export * from "./components/select";
93
108
  export * from "./components/separator";
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+
5
+ /**
6
+ * Control density.
7
+ *
8
+ * - `default` — the standard Hilum sizing (40px inputs, 32px buttons, 40px
9
+ * menu rows). Used everywhere unless something opts in.
10
+ * - `compact` — editor-chrome sizing (Figma / Framer / Linear inspectors):
11
+ * 24px controls, 28px rows and menu items, 12px text, 11px labels, 8px
12
+ * horizontal padding, 4–6px radii.
13
+ *
14
+ * Density is carried two ways so it works for both CSS and portalled UI:
15
+ * 1. the `data-density="compact"` attribute on an ancestor element drives the
16
+ * `compact:` Tailwind variant and the `--density-*` CSS variables emitted
17
+ * in tokens.css;
18
+ * 2. React context, so portalled content (menus, popovers, select lists)
19
+ * can re-apply the attribute on its own root outside the DOM subtree.
20
+ */
21
+ type Density = "default" | "compact";
22
+
23
+ const DensityContext = createContext<Density>("default");
24
+
25
+ function useDensity(): Density {
26
+ return useContext(DensityContext);
27
+ }
28
+
29
+ interface DensityProviderProps {
30
+ density: Density;
31
+ children: ReactNode;
32
+ /**
33
+ * Render a `display: contents` wrapper carrying `data-density` (default).
34
+ * Pass `false` when you already set `data-density` on your own element and
35
+ * only need the React context (e.g. for portalled menus).
36
+ */
37
+ wrap?: boolean;
38
+ }
39
+
40
+ function DensityProvider({ density, children, wrap = true }: DensityProviderProps) {
41
+ return (
42
+ <DensityContext.Provider value={density}>
43
+ {wrap ? (
44
+ <div data-density={density} style={{ display: "contents" }}>
45
+ {children}
46
+ </div>
47
+ ) : (
48
+ children
49
+ )}
50
+ </DensityContext.Provider>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Props to spread on a portalled content root so it inherits the density of
56
+ * the tree that opened it. Returns `{}` in default density so markup is
57
+ * unchanged for non-editor consumers.
58
+ */
59
+ function useDensityAttributes(): { "data-density"?: Density } {
60
+ const density = useDensity();
61
+ return density === "default" ? {} : { "data-density": density };
62
+ }
63
+
64
+ export { DensityContext, DensityProvider, useDensity, useDensityAttributes };
65
+ export type { Density, DensityProviderProps };
@@ -0,0 +1,301 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Locale-aware formatting helpers
7
+ //
8
+ // Product UIs drift quickly when every screen formats dates, money and counts
9
+ // by hand ("Sep 26, 2026" next to "9/26/2026", "1 items", raw ISO strings).
10
+ // These helpers wrap `Intl` so every surface shares one convention:
11
+ //
12
+ // - dates: medium style ("Sep 26, 2026") unless a caller opts out
13
+ // - datetimes: medium date + short time ("Sep 26, 2026, 3:04 PM")
14
+ // - relative: "3 hours ago" / "in 2 days", falling back to a date after 7 days
15
+ // - money: ISO-4217 currency, minor units handled by Intl
16
+ // - counts: Intl.PluralRules-based ("1 item", "2 items")
17
+ //
18
+ // Every helper accepts an explicit `locale`; components read defaults from
19
+ // the nearest <FormatProvider>.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type DateInput = Date | string | number | null | undefined;
23
+
24
+ /**
25
+ * - `date` / `short` — "Sep 26, 2026" (default; `short` is an alias)
26
+ * - `long` — "September 26, 2026"
27
+ * - `datetime` — "Sep 26, 2026, 3:04 PM"
28
+ * - `time` — "3:04 PM"
29
+ * - `monthDay` — "Sep 26" (dense timelines, same-year lists)
30
+ * - `month` — "September 2026"
31
+ * - `iso` — "2026-09-26" (local calendar date, for APIs)
32
+ */
33
+ export type DateFormatStyle =
34
+ | "date"
35
+ | "short"
36
+ | "long"
37
+ | "datetime"
38
+ | "time"
39
+ | "monthDay"
40
+ | "month"
41
+ | "iso";
42
+
43
+ export interface FormatOptions {
44
+ /** BCP-47 locale. Defaults to the provider locale, then the runtime default. */
45
+ locale?: string | undefined;
46
+ /** IANA time zone, e.g. "America/Lima". Defaults to the runtime zone. */
47
+ timeZone?: string | undefined;
48
+ }
49
+
50
+ const DATE_STYLE_OPTIONS: Record<Exclude<DateFormatStyle, "iso">, Intl.DateTimeFormatOptions> = {
51
+ date: { year: "numeric", month: "short", day: "numeric" },
52
+ datetime: { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" },
53
+ time: { hour: "numeric", minute: "2-digit" },
54
+ short: { year: "numeric", month: "short", day: "numeric" },
55
+ monthDay: { month: "short", day: "numeric" },
56
+ long: { year: "numeric", month: "long", day: "numeric" },
57
+ month: { year: "numeric", month: "long" },
58
+ };
59
+
60
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
61
+
62
+ /**
63
+ * Parse a date-ish value. Date-only ISO strings ("2026-09-26") are read as
64
+ * local calendar dates instead of UTC midnight, so they never shift a day
65
+ * backwards in western time zones. Returns `null` for empty/invalid input.
66
+ */
67
+ export function toDate(value: DateInput): Date | null {
68
+ if (value === null || value === undefined || value === "") return null;
69
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
70
+ if (typeof value === "string" && DATE_ONLY_PATTERN.test(value)) {
71
+ const [y, m, d] = value.split("-").map(Number) as [number, number, number];
72
+ return new Date(y, m - 1, d);
73
+ }
74
+ const date = new Date(value);
75
+ return Number.isNaN(date.getTime()) ? null : date;
76
+ }
77
+
78
+ /** Format a Date as a local calendar date string ("YYYY-MM-DD"). */
79
+ export function toISODate(value: DateInput): string {
80
+ const date = toDate(value);
81
+ if (!date) return "";
82
+ const y = date.getFullYear();
83
+ const m = String(date.getMonth() + 1).padStart(2, "0");
84
+ const d = String(date.getDate()).padStart(2, "0");
85
+ return `${y}-${m}-${d}`;
86
+ }
87
+
88
+ export interface FormatDateOptions extends FormatOptions {
89
+ /** Preset style. Default: `"date"` ("Sep 26, 2026"). */
90
+ style?: DateFormatStyle;
91
+ /** Rendered for empty/invalid input. Default: "—". */
92
+ fallback?: string;
93
+ }
94
+
95
+ export function formatDate(value: DateInput, options: FormatDateOptions = {}): string {
96
+ const { style = "date", fallback = "—", locale, timeZone } = options;
97
+ const date = toDate(value);
98
+ if (!date) return fallback;
99
+ if (style === "iso") return toISODate(date);
100
+ return new Intl.DateTimeFormat(locale, {
101
+ ...DATE_STYLE_OPTIONS[style],
102
+ ...(timeZone ? { timeZone } : {}),
103
+ }).format(date);
104
+ }
105
+
106
+ export function formatDateTime(value: DateInput, options: Omit<FormatDateOptions, "style"> = {}) {
107
+ return formatDate(value, { ...options, style: "datetime" });
108
+ }
109
+
110
+ export interface FormatDateRangeOptions extends FormatOptions {
111
+ style?: Exclude<DateFormatStyle, "iso" | "time">;
112
+ fallback?: string;
113
+ }
114
+
115
+ /** "Sep 1 – 26, 2026" — collapses shared parts via Intl.DateTimeFormat#formatRange. */
116
+ export function formatDateRange(
117
+ start: DateInput,
118
+ end: DateInput,
119
+ options: FormatDateRangeOptions = {},
120
+ ): string {
121
+ const { style = "date", fallback = "—", locale, timeZone } = options;
122
+ const from = toDate(start);
123
+ const to = toDate(end);
124
+ if (!from && !to) return fallback;
125
+ if (!from || !to) return formatDate(from ?? to, { style, locale, timeZone, fallback });
126
+ const formatter = new Intl.DateTimeFormat(locale, {
127
+ ...DATE_STYLE_OPTIONS[style],
128
+ ...(timeZone ? { timeZone } : {}),
129
+ });
130
+ return typeof formatter.formatRange === "function"
131
+ ? formatter.formatRange(from, to)
132
+ : `${formatter.format(from)} – ${formatter.format(to)}`;
133
+ }
134
+
135
+ const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
136
+ ["year", 365 * 24 * 60 * 60],
137
+ ["month", 30 * 24 * 60 * 60],
138
+ ["week", 7 * 24 * 60 * 60],
139
+ ["day", 24 * 60 * 60],
140
+ ["hour", 60 * 60],
141
+ ["minute", 60],
142
+ ["second", 1],
143
+ ];
144
+
145
+ export interface FormatRelativeTimeOptions extends FormatOptions {
146
+ /** Reference time. Default: now. */
147
+ now?: DateInput;
148
+ /**
149
+ * After this many seconds the absolute date is shown instead
150
+ * ("Sep 26, 2026"). Default: 7 days. Pass `Infinity` to always be relative.
151
+ */
152
+ maxRelativeSeconds?: number;
153
+ fallback?: string;
154
+ }
155
+
156
+ /** "just now", "5 minutes ago", "in 2 days"; absolute date past `maxRelativeSeconds`. */
157
+ export function formatRelativeTime(
158
+ value: DateInput,
159
+ options: FormatRelativeTimeOptions = {},
160
+ ): string {
161
+ const {
162
+ locale,
163
+ timeZone,
164
+ now,
165
+ maxRelativeSeconds = 7 * 24 * 60 * 60,
166
+ fallback = "—",
167
+ } = options;
168
+ const date = toDate(value);
169
+ if (!date) return fallback;
170
+ const reference = toDate(now) ?? new Date();
171
+ const diffSeconds = Math.round((date.getTime() - reference.getTime()) / 1000);
172
+ const abs = Math.abs(diffSeconds);
173
+ if (abs > maxRelativeSeconds) return formatDate(date, { locale, timeZone });
174
+
175
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
176
+ if (abs < 45) return rtf.format(0, "second");
177
+ for (const [unit, seconds] of RELATIVE_UNITS) {
178
+ if (abs >= seconds) return rtf.format(Math.round(diffSeconds / seconds), unit);
179
+ }
180
+ return rtf.format(0, "second");
181
+ }
182
+
183
+ export interface FormatNumberOptions extends FormatOptions, Intl.NumberFormatOptions {
184
+ fallback?: string;
185
+ }
186
+
187
+ export function formatNumber(
188
+ value: number | string | null | undefined,
189
+ options: FormatNumberOptions = {},
190
+ ): string {
191
+ const { locale, timeZone: _timeZone, fallback = "—", ...intl } = options;
192
+ const num = typeof value === "string" ? Number(value) : value;
193
+ if (num === null || num === undefined || Number.isNaN(num)) return fallback;
194
+ return new Intl.NumberFormat(locale, intl).format(num);
195
+ }
196
+
197
+ export interface FormatCurrencyOptions extends FormatNumberOptions {
198
+ /** ISO-4217 code. Default: provider currency, then "USD". */
199
+ currency?: string;
200
+ /** When true, `value` is in minor units (cents) and divided by 10^fractionDigits. */
201
+ minorUnits?: boolean;
202
+ }
203
+
204
+ export function formatCurrency(
205
+ value: number | string | null | undefined,
206
+ options: FormatCurrencyOptions = {},
207
+ ): string {
208
+ const { currency = "USD", minorUnits = false, ...rest } = options;
209
+ const num = typeof value === "string" ? Number(value) : value;
210
+ if (num === null || num === undefined || Number.isNaN(num)) return rest.fallback ?? "—";
211
+ let amount = num;
212
+ if (minorUnits) {
213
+ const digits =
214
+ new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions()
215
+ .maximumFractionDigits ?? 2;
216
+ amount = num / 10 ** digits;
217
+ }
218
+ return formatNumber(amount, { style: "currency", currency, ...rest });
219
+ }
220
+
221
+ export function formatPercent(
222
+ value: number | null | undefined,
223
+ options: FormatNumberOptions = {},
224
+ ): string {
225
+ return formatNumber(value, { style: "percent", maximumFractionDigits: 1, ...options });
226
+ }
227
+
228
+ export interface PluralizeOptions extends FormatOptions {
229
+ /** Plural form. Default: `${singular}s`. */
230
+ plural?: string;
231
+ /** Omit the number ("items" instead of "2 items"). */
232
+ hideCount?: boolean;
233
+ }
234
+
235
+ /**
236
+ * "1 item", "2 items", "1,204 orders". Uses Intl.PluralRules to pick the form
237
+ * and Intl.NumberFormat for the count.
238
+ */
239
+ export function pluralize(count: number, singular: string, options: PluralizeOptions = {}) {
240
+ const { plural = `${singular}s`, hideCount = false, locale } = options;
241
+ const rule = new Intl.PluralRules(locale).select(count);
242
+ const word = rule === "one" ? singular : plural;
243
+ return hideCount ? word : `${new Intl.NumberFormat(locale).format(count)} ${word}`;
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // FormatProvider — app-wide locale/currency/timeZone defaults
248
+ // ---------------------------------------------------------------------------
249
+
250
+ export interface FormatContextValue {
251
+ locale?: string | undefined;
252
+ currency?: string | undefined;
253
+ timeZone?: string | undefined;
254
+ }
255
+
256
+ const FormatContext = createContext<FormatContextValue>({});
257
+
258
+ export interface FormatProviderProps extends FormatContextValue {
259
+ children: ReactNode;
260
+ }
261
+
262
+ export function FormatProvider({ locale, currency, timeZone, children }: FormatProviderProps) {
263
+ const value = useMemo(() => ({ locale, currency, timeZone }), [locale, currency, timeZone]);
264
+ return <FormatContext.Provider value={value}>{children}</FormatContext.Provider>;
265
+ }
266
+
267
+ /** Formatters bound to the nearest FormatProvider's locale/currency/timeZone. */
268
+ export function useFormatter() {
269
+ const ctx = useContext(FormatContext);
270
+ return useMemo(() => {
271
+ const base = {
272
+ ...(ctx.locale ? { locale: ctx.locale } : {}),
273
+ ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}),
274
+ };
275
+ return {
276
+ locale: ctx.locale,
277
+ currencyCode: ctx.currency,
278
+ timeZone: ctx.timeZone,
279
+ date: (value: DateInput, options: FormatDateOptions = {}) =>
280
+ formatDate(value, { ...base, ...options }),
281
+ dateTime: (value: DateInput, options: Omit<FormatDateOptions, "style"> = {}) =>
282
+ formatDateTime(value, { ...base, ...options }),
283
+ dateRange: (start: DateInput, end: DateInput, options: FormatDateRangeOptions = {}) =>
284
+ formatDateRange(start, end, { ...base, ...options }),
285
+ relative: (value: DateInput, options: FormatRelativeTimeOptions = {}) =>
286
+ formatRelativeTime(value, { ...base, ...options }),
287
+ number: (value: number | string | null | undefined, options: FormatNumberOptions = {}) =>
288
+ formatNumber(value, { ...base, ...options }),
289
+ currency: (value: number | string | null | undefined, options: FormatCurrencyOptions = {}) =>
290
+ formatCurrency(value, {
291
+ ...base,
292
+ ...(ctx.currency ? { currency: ctx.currency } : {}),
293
+ ...options,
294
+ }),
295
+ percent: (value: number | null | undefined, options: FormatNumberOptions = {}) =>
296
+ formatPercent(value, { ...base, ...options }),
297
+ pluralize: (count: number, singular: string, options: PluralizeOptions = {}) =>
298
+ pluralize(count, singular, { ...base, ...options }),
299
+ };
300
+ }, [ctx.locale, ctx.currency, ctx.timeZone]);
301
+ }
@@ -9,12 +9,20 @@ export const springMotionClasses =
9
9
  export const pressClasses = "active:scale-[0.97] motion-reduce:active:scale-100";
10
10
 
11
11
  export const focusRingClasses =
12
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30 focus-visible:ring-offset-1";
12
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background";
13
+
14
+ /** Focus treatment for text-entry fields: ring-token border + a soft 2px halo. */
15
+ export const inputFocusClasses =
16
+ "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35";
17
+
18
+ /** Same as inputFocusClasses for composite fields that wrap a native input. */
19
+ export const inputFocusWithinClasses =
20
+ "focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/35";
13
21
 
14
22
  export const iconStrokeClasses =
15
23
  "[&_svg]:transition-[stroke-width,transform,color] [&_svg]:duration-150 [&_svg]:ease-out group-hover:[&_svg]:stroke-[2]";
16
24
 
17
- export const controlSurfaceClasses = "border border-border bg-background hover:border-ground-200";
25
+ export const controlSurfaceClasses = "border border-border bg-background hover:border-border-strong";
18
26
 
19
27
  export const surfaceElevationClasses = {
20
28
  flat: "border border-border bg-card",
@@ -29,10 +37,10 @@ export const radiusClasses = {
29
37
  } as const;
30
38
 
31
39
  export const menuItemClasses =
32
- "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 body outline-none transition-[background-color,color,box-shadow] duration-150 ease-out";
40
+ "relative flex min-h-10 cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 body outline-none transition-[background-color,color,box-shadow] duration-150 ease-out compact:min-h-7 compact:px-2 compact:py-1 compact:text-[13px] compact:rounded-[4px]";
33
41
 
34
42
  export const menuItemActiveClasses =
35
- "focus:bg-muted focus:text-foreground data-[highlighted]:bg-muted";
43
+ "focus:bg-active focus:text-foreground data-[highlighted]:bg-active";
36
44
 
37
45
  export function useProximityIndex<T extends HTMLElement>(axis: "x" | "y" = "y") {
38
46
  const containerRef = React.useRef<T | null>(null);