@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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 (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
@@ -37,7 +37,7 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
37
37
  <div className={cn("ph-settings-nav", className)} {...props}>
38
38
  {groups.map((group, index) => (
39
39
  <div className="ph-settings-nav__group" key={index}>
40
- <h3>{group.label}</h3>
40
+ <p className="ph-settings-nav__heading">{group.label}</p>
41
41
  <div className="ph-settings-nav__items">
42
42
  {group.items.map((item) => (
43
43
  <a
@@ -16,7 +16,7 @@ const toneMap: Record<StatTone, string> = {
16
16
  brand: "text-ph-brand",
17
17
  blue: "text-ph-blue",
18
18
  purple: "text-ph-purple",
19
- warning: "text-amber-900",
19
+ warning: "text-ph-warning",
20
20
  };
21
21
 
22
22
  const statMap: Record<StatTone, string> = {
@@ -24,7 +24,8 @@ const statMap: Record<StatTone, string> = {
24
24
  brand: "",
25
25
  blue: "",
26
26
  purple: "",
27
- warning: "border-amber-200 bg-amber-50/70",
27
+ warning:
28
+ "border-[color:color-mix(in_srgb,var(--ph-warning)_30%,var(--ph-border))] bg-[color:color-mix(in_srgb,var(--ph-warning)_10%,var(--ph-surface))]",
28
29
  };
29
30
 
30
31
  export function Stat({ icon, label, value, footer, tone = "default", className, ...props }: StatProps) {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import type { CSSProperties, ReactNode } from "react";
3
+ import type { CSSProperties, Key, ReactNode } from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
 
6
6
  export interface TableColumn<T> {
@@ -17,6 +17,7 @@ export interface TableProps<T> {
17
17
  compact?: boolean;
18
18
  ribbon?: boolean;
19
19
  getRowStyle?: (row: T) => CSSProperties | undefined;
20
+ getRowKey?: (row: T, index: number) => Key;
20
21
  className?: string;
21
22
  caption?: string;
22
23
  }
@@ -28,11 +29,15 @@ export function Table<T>({
28
29
  compact = false,
29
30
  ribbon = false,
30
31
  getRowStyle,
32
+ getRowKey,
31
33
  className,
32
34
  caption,
33
35
  }: TableProps<T>) {
34
36
  return (
35
- <div className={cn("ph-table-wrap", compact && "ph-table-compact", className)}>
37
+ <div
38
+ className={cn("ph-table-wrap", compact && "ph-table-compact", className)}
39
+ tabIndex={0}
40
+ >
36
41
  <table className={cn("ph-table", zebra && "ph-table-zebra", ribbon && "ph-table--ribbon")}>
37
42
  {caption && <caption className="sr-only">{caption}</caption>}
38
43
  <thead>
@@ -44,7 +49,7 @@ export function Table<T>({
44
49
  </thead>
45
50
  <tbody>
46
51
  {data.map((row, i) => (
47
- <tr key={i} style={getRowStyle?.(row)}>
52
+ <tr key={getRowKey?.(row, i) ?? i} style={getRowStyle?.(row)}>
48
53
  {columns.map((col) => (
49
54
  <td key={col.key} className={col.className}>{col.cell(row)}</td>
50
55
  ))}
@@ -1,6 +1,6 @@
1
1
  import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
- import { afterEach, describe, expect, it } from "vitest";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { Tooltip, TooltipProvider } from "@/components/ui/Tooltip";
5
5
 
6
6
  afterEach(cleanup);
@@ -55,4 +55,73 @@ describe("Tooltip", () => {
55
55
  await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
56
56
  expect(trigger).toHaveFocus();
57
57
  });
58
+
59
+ it("keeps hoverable content open while the pointer moves from its trigger", async () => {
60
+ const user = userEvent.setup();
61
+ render(
62
+ <Tooltip content="Hoverable details" delayDuration={0}>
63
+ <button type="button">Hover target</button>
64
+ </Tooltip>,
65
+ );
66
+
67
+ const trigger = screen.getByRole("button", { name: "Hover target" });
68
+ await user.hover(trigger);
69
+ const tooltip = await screen.findByRole("tooltip");
70
+ await user.unhover(trigger);
71
+ await user.hover(tooltip);
72
+
73
+ expect(screen.getByRole("tooltip")).toBeInTheDocument();
74
+ await user.unhover(tooltip);
75
+ await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
76
+ });
77
+
78
+ it("closes when the trigger is left if hoverable content is disabled", async () => {
79
+ const user = userEvent.setup();
80
+ render(
81
+ <Tooltip content="Non-hoverable details" delayDuration={0} disableHoverableContent>
82
+ <button type="button">Hover target</button>
83
+ </Tooltip>,
84
+ );
85
+
86
+ const trigger = screen.getByRole("button", { name: "Hover target" });
87
+ await user.hover(trigger);
88
+ expect(await screen.findByRole("tooltip")).toBeInTheDocument();
89
+ await user.unhover(trigger);
90
+
91
+ await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
92
+ });
93
+
94
+ it("keeps content within its collision padding", async () => {
95
+ const user = userEvent.setup();
96
+ const originalInnerWidth = window.innerWidth;
97
+ const originalInnerHeight = window.innerHeight;
98
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 200 });
99
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 100 });
100
+ const rect = vi
101
+ .spyOn(HTMLElement.prototype, "getBoundingClientRect")
102
+ .mockImplementation(function (this: HTMLElement) {
103
+ if (this.getAttribute("role") === "tooltip") {
104
+ return new DOMRect(0, 0, 100, 30);
105
+ }
106
+ return new DOMRect(2, 2, 20, 20);
107
+ });
108
+
109
+ try {
110
+ render(
111
+ <Tooltip content="Padded details" collisionPadding={16} delayDuration={0}>
112
+ <button type="button">Padded target</button>
113
+ </Tooltip>,
114
+ );
115
+
116
+ await user.hover(screen.getByRole("button", { name: "Padded target" }));
117
+ const tooltip = await screen.findByRole("tooltip");
118
+ await waitFor(() => {
119
+ expect(tooltip.parentElement).toHaveStyle({ left: "16px", top: "16px" });
120
+ });
121
+ } finally {
122
+ rect.mockRestore();
123
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
124
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
125
+ }
126
+ });
58
127
  });
@@ -3,9 +3,11 @@
3
3
  import {
4
4
  cloneElement,
5
5
  createContext,
6
+ useCallback,
6
7
  useContext,
7
8
  useEffect,
8
9
  useId,
10
+ useLayoutEffect,
9
11
  useRef,
10
12
  useState,
11
13
  type HTMLAttributes,
@@ -29,12 +31,14 @@ export interface TooltipProps {
29
31
  side?: TooltipSide;
30
32
  align?: TooltipAlign;
31
33
  sideOffset?: number;
34
+ /** Minimum distance between the tooltip and the viewport edge. */
32
35
  collisionPadding?: number;
33
36
  className?: string;
34
37
  open?: boolean;
35
38
  defaultOpen?: boolean;
36
39
  onOpenChange?: (open: boolean) => void;
37
40
  delayDuration?: number;
41
+ /** Prevents the pointer from keeping the tooltip open over its content. */
38
42
  disableHoverableContent?: boolean;
39
43
  }
40
44
 
@@ -53,9 +57,11 @@ export function TooltipProvider({
53
57
 
54
58
  function tooltipPosition(
55
59
  trigger: DOMRect,
60
+ content: DOMRect,
56
61
  side: TooltipSide,
57
62
  align: TooltipAlign,
58
63
  offset: number,
64
+ collisionPadding: number,
59
65
  ) {
60
66
  const horizontal =
61
67
  align === "start"
@@ -69,28 +75,20 @@ function tooltipPosition(
69
75
  : align === "end"
70
76
  ? trigger.bottom
71
77
  : trigger.top + trigger.height / 2;
72
- if (side === "top")
73
- return {
74
- left: horizontal,
75
- top: trigger.top - offset,
76
- transform: "translate(-50%, -100%)",
77
- };
78
- if (side === "bottom")
79
- return {
80
- left: horizontal,
81
- top: trigger.bottom + offset,
82
- transform: "translate(-50%, 0)",
83
- };
84
- if (side === "left")
85
- return {
86
- left: trigger.left - offset,
87
- top: vertical,
88
- transform: "translate(-100%, -50%)",
89
- };
78
+ const position =
79
+ side === "top"
80
+ ? { left: horizontal - content.width / 2, top: trigger.top - offset - content.height }
81
+ : side === "bottom"
82
+ ? { left: horizontal - content.width / 2, top: trigger.bottom + offset }
83
+ : side === "left"
84
+ ? { left: trigger.left - offset - content.width, top: vertical - content.height / 2 }
85
+ : { left: trigger.right + offset, top: vertical - content.height / 2 };
86
+ const maxLeft = Math.max(collisionPadding, window.innerWidth - content.width - collisionPadding);
87
+ const maxTop = Math.max(collisionPadding, window.innerHeight - content.height - collisionPadding);
88
+
90
89
  return {
91
- left: trigger.right + offset,
92
- top: vertical,
93
- transform: "translate(0, -50%)",
90
+ left: Math.min(Math.max(position.left, collisionPadding), maxLeft),
91
+ top: Math.min(Math.max(position.top, collisionPadding), maxTop),
94
92
  };
95
93
  }
96
94
 
@@ -100,15 +98,18 @@ export function Tooltip({
100
98
  side = "top",
101
99
  align = "center",
102
100
  sideOffset = 7,
101
+ collisionPadding = 8,
103
102
  className,
104
103
  open,
105
104
  defaultOpen = false,
106
105
  onOpenChange,
107
106
  delayDuration,
107
+ disableHoverableContent = false,
108
108
  }: TooltipProps) {
109
109
  const providerDelay = useContext(TooltipDelayContext);
110
110
  const generatedId = useId().replace(/:/g, "");
111
111
  const triggerRef = useRef<HTMLSpanElement>(null);
112
+ const contentRef = useRef<HTMLSpanElement>(null);
112
113
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
113
114
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
114
115
  const [position, setPosition] = useState<ReturnType<
@@ -130,17 +131,19 @@ export function Tooltip({
130
131
  timerRef.current = null;
131
132
  }
132
133
 
133
- function updatePosition() {
134
- if (!triggerRef.current) return;
134
+ const updatePosition = useCallback(() => {
135
+ if (!triggerRef.current || !contentRef.current) return;
135
136
  setPosition(
136
137
  tooltipPosition(
137
138
  triggerRef.current.getBoundingClientRect(),
139
+ contentRef.current.getBoundingClientRect(),
138
140
  side,
139
141
  align,
140
142
  sideOffset,
143
+ collisionPadding,
141
144
  ),
142
145
  );
143
- }
146
+ }, [align, collisionPadding, side, sideOffset]);
144
147
 
145
148
  function show(immediate: boolean) {
146
149
  clearTimer();
@@ -155,6 +158,20 @@ export function Tooltip({
155
158
  setOpen(false);
156
159
  }
157
160
 
161
+ function hideFromTrigger() {
162
+ if (disableHoverableContent) {
163
+ hide();
164
+ return;
165
+ }
166
+
167
+ clearTimer();
168
+ timerRef.current = setTimeout(() => setOpen(false), 100);
169
+ }
170
+
171
+ useLayoutEffect(() => {
172
+ if (isOpen) updatePosition();
173
+ }, [isOpen, updatePosition]);
174
+
158
175
  useEffect(() => {
159
176
  if (!isOpen) return;
160
177
  const reposition = () => updatePosition();
@@ -164,7 +181,7 @@ export function Tooltip({
164
181
  window.removeEventListener("resize", reposition);
165
182
  window.removeEventListener("scroll", reposition, true);
166
183
  };
167
- });
184
+ }, [isOpen, updatePosition]);
168
185
 
169
186
  useEffect(() => () => clearTimer(), []);
170
187
 
@@ -179,7 +196,7 @@ export function Tooltip({
179
196
  ref={triggerRef}
180
197
  className="inline-flex"
181
198
  onMouseEnter={() => show(false)}
182
- onMouseLeave={hide}
199
+ onMouseLeave={hideFromTrigger}
183
200
  onFocusCapture={() => show(true)}
184
201
  onBlurCapture={hide}
185
202
  onKeyDown={(event) => {
@@ -188,17 +205,24 @@ export function Tooltip({
188
205
  >
189
206
  {trigger}
190
207
  </span>
191
- {isOpen && position && typeof document !== "undefined"
208
+ {isOpen && typeof document !== "undefined"
192
209
  ? createPortal(
193
210
  <span
194
- style={{ position: "fixed", ...position }}
211
+ style={{ position: "fixed", ...position, visibility: position ? undefined : "hidden" }}
195
212
  className="pointer-events-none z-[200]"
196
213
  >
197
214
  <span
215
+ ref={contentRef}
198
216
  id={tooltipId}
199
217
  role="tooltip"
200
218
  data-side={side}
201
- className={cn("ph-tooltip-content", className)}
219
+ className={cn(
220
+ "ph-tooltip-content",
221
+ !disableHoverableContent && "pointer-events-auto",
222
+ className,
223
+ )}
224
+ onMouseEnter={disableHoverableContent ? undefined : clearTimer}
225
+ onMouseLeave={disableHoverableContent ? undefined : hide}
202
226
  >
203
227
  {content}
204
228
  </span>
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { forwardRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
2
2
  import { cn } from "@/lib/cn";
3
3
 
4
4
  export type TextTone =
@@ -27,6 +27,24 @@ export interface TextBaseProps {
27
27
  truncate?: boolean;
28
28
  }
29
29
 
30
+ type TypographyProps<T extends ElementType> = TextBaseProps &
31
+ Omit<ComponentPropsWithoutRef<T>, keyof TextBaseProps>;
32
+
33
+ export type DisplayProps = TypographyProps<"h1">;
34
+ export type SectionTitleProps = TypographyProps<"h2">;
35
+ export type H1Props = TypographyProps<"h1">;
36
+ export type H2Props = TypographyProps<"h2">;
37
+ export type H3Props = TypographyProps<"h3">;
38
+ export type H4Props = TypographyProps<"h4">;
39
+ export type H5Props = TypographyProps<"h5">;
40
+ export type PProps = TypographyProps<"p">;
41
+ export type SmallProps = TypographyProps<"span">;
42
+ export type CaptionProps = TypographyProps<"span">;
43
+ export type OverlineProps = TypographyProps<"span">;
44
+ export type LeadProps = TypographyProps<"p">;
45
+ export type MonoProps = TypographyProps<"code">;
46
+ export type LabelProps = TypographyProps<"label">;
47
+
30
48
  const toneMap: Record<TextTone, string> = {
31
49
  default: "text-ph-ink",
32
50
  muted: "text-ph-mutedtext",
@@ -61,77 +79,113 @@ function textClass(
61
79
  * Display — the marketing hero: fluid size, tight tracking, one per page.
62
80
  * Product page titles use H1/`SuitePageHeader` instead.
63
81
  */
64
- export function Display({
65
- children,
66
- tone = "default",
67
- weight,
68
- truncate,
69
- className,
70
- }: TextBaseProps) {
71
- return <h1 className={textClass("ph-hero-title", tone, weight, truncate, className)}>{children}</h1>;
72
- }
82
+ export const Display = forwardRef<HTMLHeadingElement, DisplayProps>(function Display(
83
+ { children, tone = "default", weight, truncate, className, ...props },
84
+ ref,
85
+ ) {
86
+ return <h1 ref={ref} {...props} className={textClass("ph-hero-title", tone, weight, truncate, className)}>{children}</h1>;
87
+ });
73
88
 
74
89
  /** Section title — fluid; pairs with Display on public pages. */
75
- export function SectionTitle({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
76
- return <h2 className={textClass("ph-section-title", tone, weight, truncate, className)}>{children}</h2>;
77
- }
90
+ export const SectionTitle = forwardRef<HTMLHeadingElement, SectionTitleProps>(function SectionTitle(
91
+ { children, tone = "default", weight, truncate, className, ...props },
92
+ ref,
93
+ ) {
94
+ return <h2 ref={ref} {...props} className={textClass("ph-section-title", tone, weight, truncate, className)}>{children}</h2>;
95
+ });
78
96
 
79
97
  /** H1 — page title */
80
- export function H1({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
81
- return <h1 className={textClass("text-3xl font-bold tracking-tight md:text-4xl", tone, weight, truncate, className)}>{children}</h1>;
82
- }
98
+ export const H1 = forwardRef<HTMLHeadingElement, H1Props>(function H1(
99
+ { children, tone = "default", weight, truncate, className, ...props },
100
+ ref,
101
+ ) {
102
+ return <h1 ref={ref} {...props} className={textClass("text-3xl font-bold tracking-tight md:text-4xl", tone, weight, truncate, className)}>{children}</h1>;
103
+ });
83
104
 
84
105
  /** H2 — section title */
85
- export function H2({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
86
- return <h2 className={textClass("text-2xl font-bold tracking-tight", tone, weight, truncate, className)}>{children}</h2>;
87
- }
106
+ export const H2 = forwardRef<HTMLHeadingElement, H2Props>(function H2(
107
+ { children, tone = "default", weight, truncate, className, ...props },
108
+ ref,
109
+ ) {
110
+ return <h2 ref={ref} {...props} className={textClass("text-2xl font-bold tracking-tight", tone, weight, truncate, className)}>{children}</h2>;
111
+ });
88
112
 
89
113
  /** H3 — subsection title */
90
- export function H3({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
91
- return <h3 className={textClass("text-xl font-semibold", tone, weight, truncate, className)}>{children}</h3>;
92
- }
114
+ export const H3 = forwardRef<HTMLHeadingElement, H3Props>(function H3(
115
+ { children, tone = "default", weight, truncate, className, ...props },
116
+ ref,
117
+ ) {
118
+ return <h3 ref={ref} {...props} className={textClass("text-xl font-semibold", tone, weight, truncate, className)}>{children}</h3>;
119
+ });
93
120
 
94
121
  /** H4 — card / panel title */
95
- export function H4({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
96
- return <h4 className={textClass("text-lg font-semibold", tone, weight, truncate, className)}>{children}</h4>;
97
- }
122
+ export const H4 = forwardRef<HTMLHeadingElement, H4Props>(function H4(
123
+ { children, tone = "default", weight, truncate, className, ...props },
124
+ ref,
125
+ ) {
126
+ return <h4 ref={ref} {...props} className={textClass("text-lg font-semibold", tone, weight, truncate, className)}>{children}</h4>;
127
+ });
98
128
 
99
129
  /** H5 — small heading */
100
- export function H5({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
101
- return <h5 className={textClass("text-base font-semibold", tone, weight, truncate, className)}>{children}</h5>;
102
- }
130
+ export const H5 = forwardRef<HTMLHeadingElement, H5Props>(function H5(
131
+ { children, tone = "default", weight, truncate, className, ...props },
132
+ ref,
133
+ ) {
134
+ return <h5 ref={ref} {...props} className={textClass("text-base font-semibold", tone, weight, truncate, className)}>{children}</h5>;
135
+ });
103
136
 
104
137
  /** Body — default paragraph text */
105
- export function P({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
106
- return <p className={textClass("text-base leading-relaxed", tone, weight, truncate, className)}>{children}</p>;
107
- }
138
+ export const P = forwardRef<HTMLParagraphElement, PProps>(function P(
139
+ { children, tone = "default", weight, truncate, className, ...props },
140
+ ref,
141
+ ) {
142
+ return <p ref={ref} {...props} className={textClass("text-base leading-relaxed", tone, weight, truncate, className)}>{children}</p>;
143
+ });
108
144
 
109
145
  /** Small body text */
110
- export function Small({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
111
- return <span className={textClass("text-sm leading-relaxed", tone, weight, truncate, className)}>{children}</span>;
112
- }
146
+ export const Small = forwardRef<HTMLSpanElement, SmallProps>(function Small(
147
+ { children, tone = "default", weight, truncate, className, ...props },
148
+ ref,
149
+ ) {
150
+ return <span ref={ref} {...props} className={textClass("text-sm leading-relaxed", tone, weight, truncate, className)}>{children}</span>;
151
+ });
113
152
 
114
153
  /** Extra small text — captions, meta */
115
- export function Caption({ children, tone = "muted", weight, truncate, className }: TextBaseProps) {
116
- return <span className={textClass("text-[11px] font-medium uppercase tracking-wider", tone, weight, truncate, className)}>{children}</span>;
117
- }
154
+ export const Caption = forwardRef<HTMLSpanElement, CaptionProps>(function Caption(
155
+ { children, tone = "muted", weight, truncate, className, ...props },
156
+ ref,
157
+ ) {
158
+ return <span ref={ref} {...props} className={textClass("text-[11px] font-medium uppercase tracking-wider", tone, weight, truncate, className)}>{children}</span>;
159
+ });
118
160
 
119
161
  /** Overline — tiny eyebrow text */
120
- export function Overline({ children, tone = "muted", weight, truncate, className }: TextBaseProps) {
121
- return <span className={textClass("text-[10px] font-bold uppercase tracking-[0.15em]", tone, weight, truncate, className)}>{children}</span>;
122
- }
162
+ export const Overline = forwardRef<HTMLSpanElement, OverlineProps>(function Overline(
163
+ { children, tone = "muted", weight, truncate, className, ...props },
164
+ ref,
165
+ ) {
166
+ return <span ref={ref} {...props} className={textClass("text-[10px] font-bold uppercase tracking-[0.15em]", tone, weight, truncate, className)}>{children}</span>;
167
+ });
123
168
 
124
169
  /** Lead — intro paragraph under a Display/SectionTitle; measure-capped for readability */
125
- export function Lead({ children, tone = "subtle", weight, truncate, className }: TextBaseProps) {
126
- return <p className={textClass("ph-lead", tone, weight, truncate, className)}>{children}</p>;
127
- }
170
+ export const Lead = forwardRef<HTMLParagraphElement, LeadProps>(function Lead(
171
+ { children, tone = "subtle", weight, truncate, className, ...props },
172
+ ref,
173
+ ) {
174
+ return <p ref={ref} {...props} className={textClass("ph-lead", tone, weight, truncate, className)}>{children}</p>;
175
+ });
128
176
 
129
177
  /** Mono — code, data, technical text */
130
- export function Mono({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
131
- return <code className={textClass("text-sm font-mono leading-relaxed", tone, weight, truncate, className)}>{children}</code>;
132
- }
178
+ export const Mono = forwardRef<HTMLElement, MonoProps>(function Mono(
179
+ { children, tone = "default", weight, truncate, className, ...props },
180
+ ref,
181
+ ) {
182
+ return <code ref={ref} {...props} className={textClass("text-sm font-mono leading-relaxed", tone, weight, truncate, className)}>{children}</code>;
183
+ });
133
184
 
134
185
  /** Label — form labels, tags */
135
- export function Label({ children, tone = "default", weight, truncate, className }: TextBaseProps) {
136
- return <label className={textClass("text-sm font-medium", tone, weight, truncate, className)}>{children}</label>;
137
- }
186
+ export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
187
+ { children, tone = "default", weight, truncate, className, ...props },
188
+ ref,
189
+ ) {
190
+ return <label ref={ref} {...props} className={textClass("text-sm font-medium", tone, weight, truncate, className)}>{children}</label>;
191
+ });
@@ -0,0 +1,23 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { Avatar } from "@/components/ui/Avatar";
4
+ import { Stat } from "@/components/ui/Stat";
5
+
6
+ describe("Avatar status", () => {
7
+ it("exposes its status to assistive technology with semantic theme tokens", () => {
8
+ render(<Avatar label="JD" status="online" />);
9
+
10
+ const status = screen.getByRole("img", { name: "online status" });
11
+ expect(status).toHaveClass("bg-ph-success", "border-ph-surface");
12
+ });
13
+ });
14
+
15
+ describe("Stat warning", () => {
16
+ it("uses warning theme tokens instead of fixed colours", () => {
17
+ render(<Stat label="Over budget" value="$120" tone="warning" />);
18
+
19
+ const stat = screen.getByRole("article");
20
+ expect(stat.className).not.toContain("amber");
21
+ expect(screen.getByText("$120")).toHaveClass("text-ph-warning");
22
+ });
23
+ });
@@ -0,0 +1,88 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { Calendar } from "./Calendar";
6
+
7
+ describe("Calendar", () => {
8
+ afterEach(() => {
9
+ cleanup();
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it("shows the month containing a controlled value update", () => {
14
+ const { rerender } = render(
15
+ <Calendar value={new Date(2026, 0, 15)} />,
16
+ );
17
+ expect(screen.getByText("January 2026")).toBeInTheDocument();
18
+
19
+ rerender(<Calendar value={new Date(2026, 1, 15)} />);
20
+ expect(screen.getByText("February 2026")).toBeInTheDocument();
21
+ });
22
+
23
+ it("provides full date names and marks today separately from selection", () => {
24
+ vi.useFakeTimers();
25
+ vi.setSystemTime(new Date(2026, 0, 15, 12));
26
+
27
+ render(<Calendar value={new Date(2026, 0, 14)} />);
28
+
29
+ expect(
30
+ screen.getByRole("button", { name: "Thursday, 15 January 2026" }),
31
+ ).toHaveAttribute("aria-current", "date");
32
+ expect(
33
+ screen.getByRole("button", { name: "Wednesday, 14 January 2026" })
34
+ .parentElement,
35
+ ).toHaveAttribute("aria-selected", "true");
36
+ });
37
+
38
+ it("uses date-grid keyboard navigation without changing controlled selection", async () => {
39
+ const user = userEvent.setup();
40
+ const onChange = vi.fn();
41
+
42
+ render(<Calendar value={new Date(2026, 0, 15)} onChange={onChange} />);
43
+
44
+ const selectedDate = screen.getByRole("button", {
45
+ name: "Thursday, 15 January 2026",
46
+ });
47
+ expect(selectedDate.parentElement).toHaveAttribute("aria-selected", "true");
48
+
49
+ await user.click(selectedDate);
50
+ onChange.mockClear();
51
+ await user.keyboard("{ArrowRight}");
52
+
53
+ const focusedDate = screen.getByRole("button", {
54
+ name: "Friday, 16 January 2026",
55
+ });
56
+ expect(focusedDate).toHaveFocus();
57
+ expect(selectedDate.parentElement).toHaveAttribute("aria-selected", "true");
58
+ expect(onChange).not.toHaveBeenCalled();
59
+
60
+ await user.keyboard("{Enter}");
61
+ expect(onChange).toHaveBeenCalledWith(new Date(2026, 0, 16));
62
+ });
63
+
64
+ it("moves focus by week boundaries and months", async () => {
65
+ const user = userEvent.setup();
66
+
67
+ render(<Calendar value={new Date(2026, 0, 15)} />);
68
+ await user.click(
69
+ screen.getByRole("button", { name: "Thursday, 15 January 2026" }),
70
+ );
71
+
72
+ await user.keyboard("{Home}");
73
+ expect(
74
+ screen.getByRole("button", { name: "Sunday, 11 January 2026" }),
75
+ ).toHaveFocus();
76
+
77
+ await user.keyboard("{End}");
78
+ expect(
79
+ screen.getByRole("button", { name: "Saturday, 17 January 2026" }),
80
+ ).toHaveFocus();
81
+
82
+ await user.keyboard("{PageDown}");
83
+ expect(screen.getByText("February 2026")).toBeInTheDocument();
84
+ expect(
85
+ screen.getByRole("button", { name: "Tuesday, 17 February 2026" }),
86
+ ).toHaveFocus();
87
+ });
88
+ });