@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.
- package/dist/{Chip-BzuNgJIa.d.mts → Chip-Dw2g2t8D.d.mts} +18 -4
- package/dist/{Chip-BzuNgJIa.d.ts → Chip-Dw2g2t8D.d.ts} +18 -4
- package/dist/{chunk-6SVJAU5K.mjs → chunk-RIPGN6FG.mjs} +1333 -526
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +490 -119
- package/dist/index.mjs +11 -3
- package/dist/suite-analytics--Mf1RZbw.d.mts +29 -0
- package/dist/suite-analytics--Mf1RZbw.d.ts +29 -0
- package/dist/suite.d.mts +36 -4
- package/dist/suite.d.ts +36 -4
- package/dist/suite.js +4617 -4001
- package/dist/suite.mjs +1456 -868
- package/dist/tailwind-preset.js +10 -1
- package/dist/tailwind-preset.mjs +10 -1
- package/dist/ui.d.mts +33 -4
- package/dist/ui.d.ts +33 -4
- package/dist/ui.js +1364 -571
- package/dist/ui.mjs +15 -1
- package/docs/macos-identity-audit.md +279 -0
- package/package.json +1 -1
- package/src/app/globals.css +390 -44
- package/src/components/ui/Accordion.tsx +4 -7
- package/src/components/ui/Avatar.tsx +5 -5
- package/src/components/ui/BrandSpinner.tsx +256 -0
- package/src/components/ui/Calendar.tsx +7 -7
- package/src/components/ui/CodeBlock.tsx +14 -11
- package/src/components/ui/CollapsibleSection.tsx +4 -4
- package/src/components/ui/CommandPalette.tsx +7 -10
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/Input.tsx +1 -1
- package/src/components/ui/Marketing.tsx +144 -0
- package/src/components/ui/MarketingIllustrations.tsx +287 -0
- package/src/components/ui/Panel.tsx +1 -1
- package/src/components/ui/SegmentedControl.tsx +59 -13
- package/src/components/ui/SettingsLayout.tsx +4 -0
- package/src/components/ui/ShowcaseWrapper.tsx +3 -3
- package/src/components/ui/Table.tsx +45 -10
- package/src/components/ui/Typography.tsx +4 -4
- package/src/components/ui/avatar-stat.test.tsx +14 -1
- package/src/components/ui/brand-spinner.test.tsx +62 -0
- package/src/components/ui/index.ts +19 -11
- package/src/components/ui/interaction-primitives.test.tsx +12 -6
- package/src/components/ui/marketing.test.tsx +39 -0
- package/src/components/ui/table.test.tsx +47 -0
- package/src/styles/suite-skin.css +471 -74
- package/src/styles/themes.css +45 -45
- package/src/styles/typography.css +25 -1
- package/src/suite/components/ai-draft-editor.tsx +184 -0
- package/src/suite/components/ai-panel-workflows.test.tsx +179 -0
- package/src/suite/components/ai-panel.tsx +201 -31
- package/src/suite/components/app-switcher.tsx +22 -9
- package/src/suite/components/command-palette-host.tsx +20 -2
- package/src/suite/components/suite-app-layout.tsx +7 -4
- package/src/suite/components/suite-bottom-nav.tsx +1 -1
- package/src/suite/components/suite-clients-projects-directory.tsx +6 -6
- package/src/suite/components/suite-integration-picker.tsx +7 -7
- package/src/suite/components/suite-integration-rules.tsx +5 -5
- package/src/suite/components/suite-layout.test.tsx +32 -3
- package/src/suite/components/suite-layout.tsx +4 -4
- package/src/suite/components/suite-mobile-drawer.tsx +2 -2
- package/src/suite/components/suite-mobile-header.tsx +1 -1
- package/src/suite/components/suite-notification-bell.tsx +27 -6
- package/src/suite/components/suite-notification-settings.tsx +182 -0
- package/src/suite/components/suite-palette-library.test.tsx +46 -0
- package/src/suite/components/suite-palette-library.tsx +82 -15
- package/src/suite/components/suite-settings-layout.tsx +2 -2
- package/src/suite/components/suite-settings-mobile-nav.tsx +10 -10
- package/src/suite/components/suite-sidebar.test.tsx +39 -0
- package/src/suite/components/suite-sidebar.tsx +41 -19
- package/src/suite/components/suite-skeleton.tsx +6 -6
- package/src/suite/components/suite-user-menu.tsx +2 -2
- package/src/suite/components/theme-provider.tsx +2 -0
- package/src/suite/components/today-calibrating.tsx +3 -3
- package/src/suite/components/today-ui.tsx +42 -37
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/index.ts +14 -0
- package/src/suite/lib/suite-notifications.tsx +5 -0
- 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-
|
|
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?:
|
|
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({
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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-
|
|
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-
|
|
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-
|
|
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(
|
|
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
|
|
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}>
|
|
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.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<td
|
|
55
|
-
|
|
56
|
-
|
|
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("
|
|
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-
|
|
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-
|
|
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-[
|
|
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
|
+
});
|