@xenide-io/the-old-ui-theme 0.9.18 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/{Chip-BzuNgJIa.d.mts → Chip-Dw2g2t8D.d.mts} +18 -4
  2. package/dist/{Chip-BzuNgJIa.d.ts → Chip-Dw2g2t8D.d.ts} +18 -4
  3. package/dist/{chunk-6SVJAU5K.mjs → chunk-RIPGN6FG.mjs} +1333 -526
  4. package/dist/index.d.mts +3 -2
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +490 -119
  7. package/dist/index.mjs +11 -3
  8. package/dist/suite-analytics--Mf1RZbw.d.mts +29 -0
  9. package/dist/suite-analytics--Mf1RZbw.d.ts +29 -0
  10. package/dist/suite.d.mts +36 -4
  11. package/dist/suite.d.ts +36 -4
  12. package/dist/suite.js +4617 -4001
  13. package/dist/suite.mjs +1456 -868
  14. package/dist/tailwind-preset.js +10 -1
  15. package/dist/tailwind-preset.mjs +10 -1
  16. package/dist/ui.d.mts +33 -4
  17. package/dist/ui.d.ts +33 -4
  18. package/dist/ui.js +1364 -571
  19. package/dist/ui.mjs +15 -1
  20. package/docs/macos-identity-audit.md +279 -0
  21. package/package.json +1 -1
  22. package/src/app/globals.css +390 -44
  23. package/src/components/ui/Accordion.tsx +4 -7
  24. package/src/components/ui/Avatar.tsx +5 -5
  25. package/src/components/ui/BrandSpinner.tsx +256 -0
  26. package/src/components/ui/Calendar.tsx +7 -7
  27. package/src/components/ui/CodeBlock.tsx +14 -11
  28. package/src/components/ui/CollapsibleSection.tsx +4 -4
  29. package/src/components/ui/CommandPalette.tsx +7 -10
  30. package/src/components/ui/EmptyState.tsx +2 -2
  31. package/src/components/ui/Input.tsx +1 -1
  32. package/src/components/ui/Marketing.tsx +144 -0
  33. package/src/components/ui/MarketingIllustrations.tsx +287 -0
  34. package/src/components/ui/Panel.tsx +1 -1
  35. package/src/components/ui/SegmentedControl.tsx +59 -13
  36. package/src/components/ui/SettingsLayout.tsx +4 -0
  37. package/src/components/ui/ShowcaseWrapper.tsx +3 -3
  38. package/src/components/ui/Table.tsx +45 -10
  39. package/src/components/ui/Typography.tsx +4 -4
  40. package/src/components/ui/avatar-stat.test.tsx +14 -1
  41. package/src/components/ui/brand-spinner.test.tsx +62 -0
  42. package/src/components/ui/index.ts +19 -11
  43. package/src/components/ui/interaction-primitives.test.tsx +12 -6
  44. package/src/components/ui/marketing.test.tsx +39 -0
  45. package/src/components/ui/table.test.tsx +47 -0
  46. package/src/styles/suite-skin.css +471 -74
  47. package/src/styles/themes.css +45 -45
  48. package/src/styles/typography.css +25 -1
  49. package/src/suite/components/ai-draft-editor.tsx +184 -0
  50. package/src/suite/components/ai-panel-workflows.test.tsx +179 -0
  51. package/src/suite/components/ai-panel.tsx +201 -31
  52. package/src/suite/components/app-switcher.tsx +22 -9
  53. package/src/suite/components/command-palette-host.tsx +20 -2
  54. package/src/suite/components/suite-app-layout.tsx +7 -4
  55. package/src/suite/components/suite-bottom-nav.tsx +1 -1
  56. package/src/suite/components/suite-clients-projects-directory.tsx +6 -6
  57. package/src/suite/components/suite-integration-picker.tsx +7 -7
  58. package/src/suite/components/suite-integration-rules.tsx +5 -5
  59. package/src/suite/components/suite-layout.test.tsx +32 -3
  60. package/src/suite/components/suite-layout.tsx +4 -4
  61. package/src/suite/components/suite-mobile-drawer.tsx +2 -2
  62. package/src/suite/components/suite-mobile-header.tsx +1 -1
  63. package/src/suite/components/suite-notification-bell.tsx +27 -6
  64. package/src/suite/components/suite-notification-settings.tsx +182 -0
  65. package/src/suite/components/suite-palette-library.test.tsx +46 -0
  66. package/src/suite/components/suite-palette-library.tsx +82 -15
  67. package/src/suite/components/suite-settings-layout.tsx +2 -2
  68. package/src/suite/components/suite-settings-mobile-nav.tsx +10 -10
  69. package/src/suite/components/suite-sidebar.test.tsx +39 -0
  70. package/src/suite/components/suite-sidebar.tsx +41 -19
  71. package/src/suite/components/suite-skeleton.tsx +6 -6
  72. package/src/suite/components/suite-user-menu.tsx +2 -2
  73. package/src/suite/components/theme-provider.tsx +2 -0
  74. package/src/suite/components/today-calibrating.tsx +3 -3
  75. package/src/suite/components/today-ui.tsx +42 -37
  76. package/src/suite/components/workspace-switcher.tsx +3 -3
  77. package/src/suite/index.ts +14 -0
  78. package/src/suite/lib/suite-notifications.tsx +5 -0
  79. package/tailwind-preset.ts +9 -0
@@ -0,0 +1,287 @@
1
+ "use client";
2
+
3
+ import { useId } from "react";
4
+
5
+ export function AiWritingIllustration({
6
+ title,
7
+ prompt,
8
+ before,
9
+ after,
10
+ }: {
11
+ title: string;
12
+ prompt: string;
13
+ before: string[];
14
+ after: string[];
15
+ }) {
16
+ const id = useId().replaceAll(":", "");
17
+ return (
18
+ <svg
19
+ viewBox="0 0 560 370"
20
+ className="h-auto w-full"
21
+ role="img"
22
+ aria-label={`${title}: use /ai and Shelly AI to draft and polish writing`}
23
+ data-marketing-art
24
+ >
25
+ <defs>
26
+ <radialGradient id={`${id}-glow`}>
27
+ <stop stopColor="var(--ph-accent)" stopOpacity=".2" />
28
+ <stop offset="1" stopColor="var(--ph-accent)" stopOpacity="0" />
29
+ </radialGradient>
30
+ <linearGradient id={`${id}-paper`} x2="1" y2="1">
31
+ <stop stopColor="var(--ph-surface)" />
32
+ <stop offset="1" stopColor="var(--ph-muted)" />
33
+ </linearGradient>
34
+ </defs>
35
+ <ellipse cx="280" cy="198" rx="260" ry="168" fill={`url(#${id}-glow)`} />
36
+ <g className="marketing-svg-float">
37
+ <rect
38
+ x="18"
39
+ y="28"
40
+ width="524"
41
+ height="300"
42
+ rx="20"
43
+ fill={`url(#${id}-paper)`}
44
+ stroke="var(--ph-border)"
45
+ />
46
+ <path d="M18 76h524" stroke="var(--ph-border)" />
47
+ <image href="/shelly-icon.svg" x="36" y="40" width="24" height="24" />
48
+ <text
49
+ x="70"
50
+ y="57"
51
+ fontSize="16"
52
+ fontWeight="700"
53
+ fill="var(--ph-text-primary)"
54
+ >
55
+ {title}
56
+ </text>
57
+ <text x="434" y="57" fontSize="12" fill="var(--ph-text-secondary)">
58
+ Shelly AI
59
+ </text>
60
+ <rect
61
+ x="38"
62
+ y="94"
63
+ width="484"
64
+ height="42"
65
+ rx="10"
66
+ fill="var(--ph-canvas)"
67
+ stroke="var(--ph-selected-border)"
68
+ />
69
+ <text x="54" y="121" fontSize="16" fill="var(--ph-text-primary)">
70
+ {prompt}
71
+ </text>
72
+ <g className="marketing-svg-draft">
73
+ <text
74
+ x="46"
75
+ y="171"
76
+ fontSize="12"
77
+ fontWeight="700"
78
+ fill="var(--ph-text-secondary)"
79
+ >
80
+ YOUR DRAFT
81
+ </text>
82
+ {before.map((line, index) => (
83
+ <text
84
+ key={index}
85
+ x="46"
86
+ y={205 + index * 26}
87
+ fontSize="16"
88
+ fill="var(--ph-text-secondary)"
89
+ >
90
+ {line}
91
+ </text>
92
+ ))}
93
+ </g>
94
+ <path
95
+ className="marketing-svg-thread"
96
+ d="M265 175v102"
97
+ stroke="var(--ph-accent)"
98
+ strokeWidth="2"
99
+ strokeDasharray="4 7"
100
+ />
101
+ <g className="marketing-svg-polished">
102
+ <text
103
+ x="294"
104
+ y="171"
105
+ fontSize="12"
106
+ fontWeight="700"
107
+ fill="var(--ph-accent)"
108
+ >
109
+ POLISHED WITH SHELLY
110
+ </text>
111
+ {after.map((line, index) => (
112
+ <text
113
+ key={index}
114
+ x="294"
115
+ y={205 + index * 26}
116
+ fontSize="16"
117
+ fill="var(--ph-text-primary)"
118
+ >
119
+ {line}
120
+ </text>
121
+ ))}
122
+ <path
123
+ d="m302 294 5 5 10-11"
124
+ fill="none"
125
+ stroke="var(--ph-success)"
126
+ strokeWidth="3"
127
+ strokeLinecap="round"
128
+ />
129
+ <text x="328" y="300" fontSize="12" fill="var(--ph-text-secondary)">
130
+ Ready to keep working
131
+ </text>
132
+ </g>
133
+ </g>
134
+ <g className="marketing-svg-spark" fill="var(--ph-accent)">
135
+ <path d="m520 12 3 10 10 3-10 3-3 10-3-10-10-3 10-3z" />
136
+ <path d="m25 310 2 7 7 2-7 2-2 7-2-7-7-2 7-2z" />
137
+ </g>
138
+ </svg>
139
+ );
140
+ }
141
+
142
+ export function IntegrationBridgeIllustration({
143
+ appName,
144
+ appIcon,
145
+ recordLabel,
146
+ calendar = false,
147
+ }: {
148
+ appName: string;
149
+ appIcon: string;
150
+ recordLabel: string;
151
+ calendar?: boolean;
152
+ }) {
153
+ const id = useId().replaceAll(":", "");
154
+ const rows = calendar
155
+ ? ["Client catch-up", "10:00 – 11:00", "Meeting notes"]
156
+ : ["Client", "Project", recordLabel];
157
+ return (
158
+ <svg
159
+ viewBox="0 0 600 350"
160
+ className="h-auto w-full"
161
+ role="img"
162
+ aria-label={
163
+ calendar
164
+ ? "Choose a calendar event to prefill a TurtleTime entry"
165
+ : `Shelly Bridge matches connected records with ${appName}`
166
+ }
167
+ data-marketing-art
168
+ >
169
+ <defs>
170
+ <radialGradient id={`${id}-bridge`}>
171
+ <stop stopColor="var(--ph-blue)" stopOpacity=".17" />
172
+ <stop offset="1" stopColor="var(--ph-blue)" stopOpacity="0" />
173
+ </radialGradient>
174
+ </defs>
175
+ <ellipse
176
+ cx="300"
177
+ cy="185"
178
+ rx="280"
179
+ ry="160"
180
+ fill={`url(#${id}-bridge)`}
181
+ />
182
+ <g className="marketing-svg-float">
183
+ <rect
184
+ x="18"
185
+ y="55"
186
+ width="230"
187
+ height="240"
188
+ rx="18"
189
+ fill="var(--ph-surface)"
190
+ stroke="var(--ph-border)"
191
+ />
192
+ <rect
193
+ x="352"
194
+ y="55"
195
+ width="230"
196
+ height="240"
197
+ rx="18"
198
+ fill="var(--ph-surface)"
199
+ stroke="var(--ph-border)"
200
+ />
201
+ <text
202
+ x="38"
203
+ y="92"
204
+ fontSize="17"
205
+ fontWeight="700"
206
+ fill="var(--ph-text-primary)"
207
+ >
208
+ {calendar ? "Calendar events" : "Connected tools"}
209
+ </text>
210
+ <image href={appIcon} x="370" y="72" width="28" height="28" />
211
+ <text
212
+ x="410"
213
+ y="93"
214
+ fontSize="17"
215
+ fontWeight="700"
216
+ fill="var(--ph-text-primary)"
217
+ >
218
+ {appName}
219
+ </text>
220
+ {rows.map((row, index) => (
221
+ <g key={row}>
222
+ <rect
223
+ x="36"
224
+ y={119 + index * 48}
225
+ width="194"
226
+ height="36"
227
+ rx="8"
228
+ fill="var(--ph-muted)"
229
+ />
230
+ <text
231
+ x="50"
232
+ y={142 + index * 48}
233
+ fontSize="14"
234
+ fill="var(--ph-text-secondary)"
235
+ >
236
+ {row}
237
+ </text>
238
+ <rect
239
+ x="370"
240
+ y={119 + index * 48}
241
+ width="194"
242
+ height="36"
243
+ rx="8"
244
+ fill="var(--ph-muted)"
245
+ />
246
+ <text
247
+ x="384"
248
+ y={142 + index * 48}
249
+ fontSize="14"
250
+ fill="var(--ph-text-primary)"
251
+ >
252
+ {calendar && index === 1 ? "1 hour, ready to review" : row}
253
+ </text>
254
+ <path
255
+ className="marketing-svg-thread"
256
+ d={`M248 ${137 + index * 48}h104`}
257
+ stroke="var(--ph-accent)"
258
+ strokeWidth="2"
259
+ strokeDasharray="5 8"
260
+ />
261
+ </g>
262
+ ))}
263
+ </g>
264
+ <g className="marketing-svg-polished">
265
+ <circle
266
+ cx="300"
267
+ cy="185"
268
+ r="27"
269
+ fill="var(--ph-canvas)"
270
+ stroke="var(--ph-selected-border)"
271
+ />
272
+ <image href="/shelly-icon.svg" x="280" y="165" width="40" height="40" />
273
+ <text
274
+ x="300"
275
+ y="331"
276
+ textAnchor="middle"
277
+ fontSize="13"
278
+ fill="var(--ph-text-secondary)"
279
+ >
280
+ {calendar
281
+ ? "Prefill details · review · save the entry"
282
+ : "Shelly Bridge · match the records you want to connect"}
283
+ </text>
284
+ </g>
285
+ </svg>
286
+ );
287
+ }
@@ -10,7 +10,7 @@ export interface PanelProps extends Omit<ComponentPropsWithoutRef<"div">, "title
10
10
  export function Panel({ title, description, children, className, ...props }: PanelProps) {
11
11
  return (
12
12
  <div className={cn("ph-panel", className)} {...props}>
13
- {title && <h3 className="text-sm font-semibold uppercase tracking-wider text-ph-mutedtext">{title}</h3>}
13
+ {title && <h3 className="text-lg font-semibold tracking-tight text-ph-ink">{title}</h3>}
14
14
  {description && <p className="text-sm text-ph-subtle">{description}</p>}
15
15
  {children}
16
16
  </div>
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
+ import { useRef, type KeyboardEvent, type ReactNode } from "react";
3
4
  import { cn } from "@/lib/cn";
4
5
 
5
6
  export interface SegmentedControlOption {
6
7
  value: string;
7
8
  label: string;
8
- icon?: React.ReactNode;
9
+ icon?: ReactNode;
9
10
  }
10
11
 
11
12
  export interface SegmentedControlProps {
@@ -13,30 +14,75 @@ export interface SegmentedControlProps {
13
14
  value: string;
14
15
  onChange: (value: string) => void;
15
16
  className?: string;
17
+ "aria-label"?: string;
16
18
  }
17
19
 
18
- export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) {
20
+ export function SegmentedControl({
21
+ options,
22
+ value,
23
+ onChange,
24
+ className,
25
+ "aria-label": ariaLabel,
26
+ }: SegmentedControlProps) {
27
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
28
+ const selectedIndex = options.findIndex((option) => option.value === value);
29
+
30
+ const handleKeyDown = (
31
+ event: KeyboardEvent<HTMLButtonElement>,
32
+ index: number,
33
+ ) => {
34
+ let nextIndex: number;
35
+
36
+ switch (event.key) {
37
+ case "ArrowLeft":
38
+ case "ArrowUp":
39
+ nextIndex = (index - 1 + options.length) % options.length;
40
+ break;
41
+ case "ArrowRight":
42
+ case "ArrowDown":
43
+ nextIndex = (index + 1) % options.length;
44
+ break;
45
+ case "Home":
46
+ nextIndex = 0;
47
+ break;
48
+ case "End":
49
+ nextIndex = options.length - 1;
50
+ break;
51
+ default:
52
+ return;
53
+ }
54
+
55
+ event.preventDefault();
56
+ const nextOption = options[nextIndex];
57
+ if (!nextOption) return;
58
+ onChange(nextOption.value);
59
+ itemRefs.current[nextIndex]?.focus();
60
+ };
61
+
19
62
  return (
20
63
  <div
21
- className={cn(
22
- "inline-flex rounded-lg bg-ph-muted p-1",
23
- className
24
- )}
64
+ role="radiogroup"
65
+ aria-label={ariaLabel}
66
+ className={cn("ph-segmented-control", className)}
25
67
  >
26
- {options.map((option) => (
68
+ {options.map((option, index) => (
27
69
  <button
28
70
  key={option.value}
71
+ ref={(node) => {
72
+ itemRefs.current[index] = node;
73
+ }}
29
74
  type="button"
30
- aria-pressed={value === option.value}
75
+ role="radio"
76
+ aria-checked={value === option.value}
77
+ tabIndex={selectedIndex === index || (selectedIndex < 0 && index === 0) ? 0 : -1}
31
78
  onClick={() => onChange(option.value)}
79
+ onKeyDown={(event) => handleKeyDown(event, index)}
32
80
  className={cn(
33
- "relative flex min-h-11 items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all sm:min-h-9",
34
- value === option.value
35
- ? "bg-ph-surface text-ph-ink shadow-ph"
36
- : "text-ph-subtle hover:text-ph-ink"
81
+ "ph-segmented-control__item",
82
+ value === option.value && "ph-segmented-control__item--selected",
37
83
  )}
38
84
  >
39
- {option.icon && <span className="h-4 w-4">{option.icon}</span>}
85
+ {option.icon && <span className="h-4 w-4" aria-hidden>{option.icon}</span>}
40
86
  {option.label}
41
87
  </button>
42
88
  ))}
@@ -1,6 +1,9 @@
1
+ "use client";
2
+
1
3
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
4
  import { IconTuning } from "@/components/icons";
3
5
  import { cn } from "@/lib/cn";
6
+ import { trackSuiteEvent } from "@/lib/suite-analytics";
4
7
 
5
8
  export interface SettingsNavItem {
6
9
  id: string;
@@ -50,6 +53,7 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
50
53
  item.active && "ph-settings-nav__item--active",
51
54
  )}
52
55
  aria-current={item.active ? "page" : undefined}
56
+ onClick={() => trackSuiteEvent("Tab Viewed", { tab: item.id })}
53
57
  >
54
58
  {item.icon ? (
55
59
  <span className="ph-settings-nav__icon">{item.icon}</span>
@@ -38,9 +38,9 @@ export function ShowcaseWrapper({
38
38
  type="button"
39
39
  onClick={() => setShowCode(!showCode)}
40
40
  className={cn(
41
- "flex h-10 w-10 items-center justify-center rounded-full border transition-all",
41
+ "ph-showcase-toggle flex h-10 w-10 items-center justify-center rounded-full border transition-colors",
42
42
  showCode
43
- ? "border-ph-brand bg-ph-brand text-[var(--ph-on-accent)] shadow-lg shadow-ph-brand/25"
43
+ ? "border-ph-brand bg-ph-brand text-[var(--ph-on-accent)] shadow-[var(--ph-shadow-control)]"
44
44
  : "border-ph-border bg-ph-surface text-ph-subtle hover:border-ph-brand hover:text-ph-brand"
45
45
  )}
46
46
  title={showCode ? "Hide code" : "Show code"}
@@ -68,7 +68,7 @@ export function ShowcaseWrapper({
68
68
 
69
69
  <div
70
70
  className={cn(
71
- "transition-all duration-300",
71
+ "transition-opacity duration-200",
72
72
  showCode
73
73
  ? "block opacity-100"
74
74
  : "hidden opacity-0 lg:hidden"
@@ -3,6 +3,8 @@
3
3
  import type { CSSProperties, Key, ReactNode } from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
 
6
+ export type TableVariant = "default" | "report";
7
+
6
8
  export interface TableColumn<T> {
7
9
  key: string;
8
10
  header: ReactNode;
@@ -19,7 +21,10 @@ export interface TableProps<T> {
19
21
  getRowStyle?: (row: T) => CSSProperties | undefined;
20
22
  getRowKey?: (row: T, index: number) => Key;
21
23
  className?: string;
24
+ tableClassName?: string;
25
+ variant?: TableVariant;
22
26
  caption?: string;
27
+ emptyState?: ReactNode;
23
28
  }
24
29
 
25
30
  export function Table<T>({
@@ -31,30 +36,60 @@ export function Table<T>({
31
36
  getRowStyle,
32
37
  getRowKey,
33
38
  className,
39
+ tableClassName,
40
+ variant = "default",
34
41
  caption,
42
+ emptyState = "No data available.",
35
43
  }: TableProps<T>) {
36
44
  return (
37
45
  <div
38
- className={cn("ph-table-wrap", compact && "ph-table-compact", className)}
46
+ className={cn(
47
+ variant === "report" ? "ph-table-report-wrap" : "ph-table-wrap",
48
+ compact && "ph-table-compact",
49
+ className,
50
+ )}
51
+ role="region"
52
+ aria-label={caption ?? "Data table"}
39
53
  tabIndex={0}
40
54
  >
41
- <table className={cn("ph-table", zebra && "ph-table-zebra", ribbon && "ph-table--ribbon")}>
55
+ <table
56
+ className={cn(
57
+ variant === "report" ? "ph-table-report" : "ph-table",
58
+ zebra && "ph-table-zebra",
59
+ ribbon && "ph-table--ribbon",
60
+ tableClassName,
61
+ )}
62
+ >
42
63
  {caption && <caption className="sr-only">{caption}</caption>}
43
64
  <thead>
44
65
  <tr>
45
66
  {columns.map((col) => (
46
- <th key={col.key} className={col.className}>{col.header}</th>
67
+ <th key={col.key} scope="col" className={col.className}>
68
+ {col.header}
69
+ </th>
47
70
  ))}
48
71
  </tr>
49
72
  </thead>
50
73
  <tbody>
51
- {data.map((row, i) => (
52
- <tr key={getRowKey?.(row, i) ?? i} style={getRowStyle?.(row)}>
53
- {columns.map((col) => (
54
- <td key={col.key} className={col.className}>{col.cell(row)}</td>
55
- ))}
56
- </tr>
57
- ))}
74
+ {data.length === 0 ? (
75
+ emptyState === null ? null : (
76
+ <tr>
77
+ <td colSpan={columns.length} className="ph-table-empty">
78
+ {emptyState}
79
+ </td>
80
+ </tr>
81
+ )
82
+ ) : (
83
+ data.map((row, i) => (
84
+ <tr key={getRowKey?.(row, i) ?? i} style={getRowStyle?.(row)}>
85
+ {columns.map((col) => (
86
+ <td key={col.key} className={col.className}>
87
+ {col.cell(row)}
88
+ </td>
89
+ ))}
90
+ </tr>
91
+ ))
92
+ )}
58
93
  </tbody>
59
94
  </table>
60
95
  </div>
@@ -99,7 +99,7 @@ export const H1 = forwardRef<HTMLHeadingElement, H1Props>(function H1(
99
99
  { children, tone = "default", weight, truncate, className, ...props },
100
100
  ref,
101
101
  ) {
102
- return <h1 ref={ref} {...props} className={textClass("text-3xl font-bold tracking-tight md:text-4xl", tone, weight, truncate, className)}>{children}</h1>;
102
+ return <h1 ref={ref} {...props} className={textClass("ph-page-title font-bold", tone, weight, truncate, className)}>{children}</h1>;
103
103
  });
104
104
 
105
105
  /** H2 — section title */
@@ -107,7 +107,7 @@ export const H2 = forwardRef<HTMLHeadingElement, H2Props>(function H2(
107
107
  { children, tone = "default", weight, truncate, className, ...props },
108
108
  ref,
109
109
  ) {
110
- return <h2 ref={ref} {...props} className={textClass("text-2xl font-bold tracking-tight", tone, weight, truncate, className)}>{children}</h2>;
110
+ return <h2 ref={ref} {...props} className={textClass("text-2xl font-semibold tracking-tight", tone, weight, truncate, className)}>{children}</h2>;
111
111
  });
112
112
 
113
113
  /** H3 — subsection title */
@@ -155,7 +155,7 @@ export const Caption = forwardRef<HTMLSpanElement, CaptionProps>(function Captio
155
155
  { children, tone = "muted", weight, truncate, className, ...props },
156
156
  ref,
157
157
  ) {
158
- return <span ref={ref} {...props} className={textClass("text-[11px] font-medium uppercase tracking-wider", tone, weight, truncate, className)}>{children}</span>;
158
+ return <span ref={ref} {...props} className={textClass("text-xs font-medium", tone, weight, truncate, className)}>{children}</span>;
159
159
  });
160
160
 
161
161
  /** Overline — tiny eyebrow text */
@@ -163,7 +163,7 @@ export const Overline = forwardRef<HTMLSpanElement, OverlineProps>(function Over
163
163
  { children, tone = "muted", weight, truncate, className, ...props },
164
164
  ref,
165
165
  ) {
166
- return <span ref={ref} {...props} className={textClass("text-[10px] font-bold uppercase tracking-[0.15em]", tone, weight, truncate, className)}>{children}</span>;
166
+ return <span ref={ref} {...props} className={textClass("text-[length:var(--ph-type-overline)] font-semibold uppercase tracking-[0.08em]", tone, weight, truncate, className)}>{children}</span>;
167
167
  });
168
168
 
169
169
  /** Lead — intro paragraph under a Display/SectionTitle; measure-capped for readability */
@@ -1,6 +1,6 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it } from "vitest";
3
- import { Avatar } from "@/components/ui/Avatar";
3
+ import { Avatar, AvatarGroup } from "@/components/ui/Avatar";
4
4
  import { Stat } from "@/components/ui/Stat";
5
5
 
6
6
  describe("Avatar status", () => {
@@ -10,6 +10,19 @@ describe("Avatar status", () => {
10
10
  const status = screen.getByRole("img", { name: "online status" });
11
11
  expect(status).toHaveClass("bg-ph-success", "border-ph-surface");
12
12
  });
13
+
14
+ it("uses semantic accent contrast and counts grouped children", () => {
15
+ const { container } = render(
16
+ <AvatarGroup max={1}>
17
+ <Avatar label="JD" />
18
+ <Avatar label="AR" />
19
+ </AvatarGroup>,
20
+ );
21
+
22
+ expect(container.querySelector(".ph-avatar")).toHaveClass("text-[var(--ph-on-accent)]");
23
+ expect(screen.getByText("+1")).toBeInTheDocument();
24
+ expect(container.querySelectorAll(".ph-avatar")).toHaveLength(2);
25
+ });
13
26
  });
14
27
 
15
28
  describe("Stat warning", () => {
@@ -0,0 +1,62 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { BrandSpinner } from "./BrandSpinner";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("BrandSpinner", () => {
8
+ it("keeps orbit gradient references unique when multiple loaders render", () => {
9
+ const { container } = render(
10
+ <>
11
+ <BrandSpinner variant="orbit" size="sm" label="Loading team" />
12
+ <BrandSpinner variant="orbit" size="lg" label="Loading projects" />
13
+ </>,
14
+ );
15
+ const gradients = container.querySelectorAll("linearGradient");
16
+ expect(gradients[0].id).not.toBe(gradients[1].id);
17
+ expect(
18
+ screen.getByRole("status", { name: "Loading team" }).querySelector("svg"),
19
+ ).toHaveAttribute("width", "20");
20
+ expect(
21
+ screen
22
+ .getByRole("status", { name: "Loading projects" })
23
+ .querySelector("svg"),
24
+ ).toHaveAttribute("width", "56");
25
+ expect(
26
+ container.querySelector(`[stroke="url(#${gradients[1].id})"]`),
27
+ ).not.toBeNull();
28
+ });
29
+
30
+ it("stops SVG animation when reduced-motion preference changes", () => {
31
+ const original = window.matchMedia;
32
+ let notify: (() => void) | undefined;
33
+ const media = {
34
+ matches: false,
35
+ addEventListener: vi.fn((_event: string, listener: () => void) => {
36
+ notify = listener;
37
+ }),
38
+ removeEventListener: vi.fn(),
39
+ };
40
+ Object.defineProperty(window, "matchMedia", {
41
+ configurable: true,
42
+ value: vi.fn(() => media),
43
+ });
44
+
45
+ try {
46
+ const { container, unmount } = render(<BrandSpinner variant="ink" />);
47
+ expect(container.querySelectorAll("animateTransform")).toHaveLength(2);
48
+ act(() => {
49
+ media.matches = true;
50
+ notify?.();
51
+ });
52
+ expect(container.querySelectorAll("animateTransform")).toHaveLength(0);
53
+ unmount();
54
+ expect(media.removeEventListener).toHaveBeenCalled();
55
+ } finally {
56
+ Object.defineProperty(window, "matchMedia", {
57
+ configurable: true,
58
+ value: original,
59
+ });
60
+ }
61
+ });
62
+ });