@pylonsync/client 0.3.267

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.
@@ -0,0 +1,515 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import {
5
+ type FormEvent,
6
+ type ReactNode,
7
+ useEffect,
8
+ useState,
9
+ } from "react";
10
+ import {
11
+ ApiError,
12
+ type AuthProvider,
13
+ listAuthProviders,
14
+ passwordLogin,
15
+ persistSession,
16
+ sendMagicLink,
17
+ verifyMagicLink,
18
+ } from "../lib/api";
19
+ import { cn } from "../lib/cn";
20
+
21
+ export interface SignInProps {
22
+ /** Auth method to lead with. Default: "magic" (most secure default). */
23
+ method?: "magic" | "password";
24
+ /** Where the dashboard sends the user after sign-in. */
25
+ afterSignInUrl?: string;
26
+ /** Optional callback once a session is minted. */
27
+ onSignedIn?: () => void;
28
+ /** Forgot-password landing route shown when the password tab is active. */
29
+ forgotPasswordUrl?: string;
30
+ /** Text shown above the form. */
31
+ title?: ReactNode;
32
+ /** Subtitle / call-to-action. */
33
+ subtitle?: ReactNode;
34
+ /** Tailwind class merged onto the card. */
35
+ className?: string;
36
+ }
37
+
38
+ export function SignIn({
39
+ method = "magic",
40
+ afterSignInUrl,
41
+ onSignedIn,
42
+ forgotPasswordUrl = "/forgot-password",
43
+ title = "Sign in",
44
+ subtitle,
45
+ className,
46
+ }: SignInProps) {
47
+ const [tab, setTab] = useState<"magic" | "password">(method);
48
+
49
+ return (
50
+ <Card className={className}>
51
+ <Heading title={title} subtitle={subtitle} />
52
+ <TabBar value={tab} onChange={setTab} />
53
+ {tab === "magic" ? (
54
+ <MagicLinkPanel
55
+ afterSignInUrl={afterSignInUrl}
56
+ onSignedIn={onSignedIn}
57
+ />
58
+ ) : (
59
+ <PasswordPanel
60
+ mode="login"
61
+ afterSignInUrl={afterSignInUrl}
62
+ onSignedIn={onSignedIn}
63
+ forgotPasswordUrl={forgotPasswordUrl}
64
+ />
65
+ )}
66
+ <OAuthButtons />
67
+ <Switcher
68
+ prompt="No account?"
69
+ cta="Create one"
70
+ href={afterSignInUrl ? `?signup=1` : "#signup"}
71
+ />
72
+ </Card>
73
+ );
74
+ }
75
+
76
+ export interface SignUpProps extends SignInProps {}
77
+
78
+ export function SignUp({
79
+ afterSignInUrl,
80
+ onSignedIn,
81
+ title = "Create your account",
82
+ subtitle,
83
+ className,
84
+ }: SignUpProps) {
85
+ return (
86
+ <Card className={className}>
87
+ <Heading title={title} subtitle={subtitle} />
88
+ <PasswordPanel
89
+ mode="register"
90
+ afterSignInUrl={afterSignInUrl}
91
+ onSignedIn={onSignedIn}
92
+ />
93
+ <OAuthButtons />
94
+ <Switcher
95
+ prompt="Already have an account?"
96
+ cta="Sign in"
97
+ href={afterSignInUrl ? `?signin=1` : "#signin"}
98
+ />
99
+ </Card>
100
+ );
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Internals
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function Card({ className, children }: { className?: string; children: ReactNode }) {
108
+ return (
109
+ <div
110
+ className={cn(
111
+ "pylon-auth-card",
112
+ "mx-auto w-full max-w-sm space-y-5 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-7 shadow-sm",
113
+ className,
114
+ )}
115
+ >
116
+ {children}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function Heading({ title, subtitle }: { title: ReactNode; subtitle?: ReactNode }) {
122
+ return (
123
+ <div className="space-y-1.5 text-center">
124
+ <h2 className="text-lg font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
125
+ {title}
126
+ </h2>
127
+ {subtitle ? (
128
+ <p className="text-sm text-[var(--pylon-ink-2,#52525b)]">{subtitle}</p>
129
+ ) : null}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function TabBar({
135
+ value,
136
+ onChange,
137
+ }: {
138
+ value: "magic" | "password";
139
+ onChange: (v: "magic" | "password") => void;
140
+ }) {
141
+ return (
142
+ <div className="flex rounded-md border border-[var(--pylon-rule,#e5e7eb)] p-0.5 text-sm">
143
+ <TabButton active={value === "magic"} onClick={() => onChange("magic")}>
144
+ Magic link
145
+ </TabButton>
146
+ <TabButton
147
+ active={value === "password"}
148
+ onClick={() => onChange("password")}
149
+ >
150
+ Password
151
+ </TabButton>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function TabButton({
157
+ active,
158
+ onClick,
159
+ children,
160
+ }: {
161
+ active: boolean;
162
+ onClick: () => void;
163
+ children: ReactNode;
164
+ }) {
165
+ return (
166
+ <button
167
+ type="button"
168
+ onClick={onClick}
169
+ className={cn(
170
+ "flex-1 rounded-[5px] py-1.5 text-center font-medium transition-colors",
171
+ active
172
+ ? "bg-[var(--pylon-ink,#0a0a0a)] text-[var(--pylon-paper,#ffffff)]"
173
+ : "text-[var(--pylon-ink-2,#52525b)] hover:text-[var(--pylon-ink,#0a0a0a)]",
174
+ )}
175
+ >
176
+ {children}
177
+ </button>
178
+ );
179
+ }
180
+
181
+ function MagicLinkPanel({
182
+ afterSignInUrl,
183
+ onSignedIn,
184
+ }: {
185
+ afterSignInUrl?: string;
186
+ onSignedIn?: () => void;
187
+ }) {
188
+ const [email, setEmail] = useState("");
189
+ const [code, setCode] = useState("");
190
+ const [step, setStep] = useState<"email" | "code">("email");
191
+ const [error, setError] = useState<string | null>(null);
192
+ const [pending, setPending] = useState(false);
193
+
194
+ async function onRequest(e: FormEvent) {
195
+ e.preventDefault();
196
+ setError(null);
197
+ setPending(true);
198
+ try {
199
+ await sendMagicLink(email);
200
+ setStep("code");
201
+ } catch (err) {
202
+ setError(messageFromError(err));
203
+ } finally {
204
+ setPending(false);
205
+ }
206
+ }
207
+
208
+ async function onVerify(e: FormEvent) {
209
+ e.preventDefault();
210
+ setError(null);
211
+ setPending(true);
212
+ try {
213
+ const session = await verifyMagicLink(email, code);
214
+ persistSession(session);
215
+ onSignedIn?.();
216
+ if (afterSignInUrl && typeof window !== "undefined") {
217
+ window.location.assign(afterSignInUrl);
218
+ }
219
+ } catch (err) {
220
+ setError(messageFromError(err));
221
+ } finally {
222
+ setPending(false);
223
+ }
224
+ }
225
+
226
+ if (step === "email") {
227
+ return (
228
+ <form onSubmit={onRequest} className="space-y-3">
229
+ <Field
230
+ label="Email"
231
+ type="email"
232
+ value={email}
233
+ onChange={setEmail}
234
+ required
235
+ autoComplete="email"
236
+ placeholder="you@example.com"
237
+ />
238
+ <SubmitButton pending={pending} label="Send code" />
239
+ <ErrorText message={error} />
240
+ </form>
241
+ );
242
+ }
243
+
244
+ return (
245
+ <form onSubmit={onVerify} className="space-y-3">
246
+ <p className="text-center text-sm text-[var(--pylon-ink-2,#52525b)]">
247
+ We sent a code to <span className="font-medium">{email}</span>.
248
+ </p>
249
+ <Field
250
+ label="Code"
251
+ value={code}
252
+ onChange={setCode}
253
+ required
254
+ autoComplete="one-time-code"
255
+ inputMode="numeric"
256
+ placeholder="123456"
257
+ />
258
+ <SubmitButton pending={pending} label="Sign in" />
259
+ <ErrorText message={error} />
260
+ <button
261
+ type="button"
262
+ onClick={() => {
263
+ setStep("email");
264
+ setCode("");
265
+ setError(null);
266
+ }}
267
+ className="block w-full text-center text-xs text-[var(--pylon-ink-3,#71717a)] hover:underline"
268
+ >
269
+ Use a different email
270
+ </button>
271
+ </form>
272
+ );
273
+ }
274
+
275
+ function PasswordPanel({
276
+ mode,
277
+ afterSignInUrl,
278
+ onSignedIn,
279
+ forgotPasswordUrl,
280
+ }: {
281
+ mode: "login" | "register";
282
+ afterSignInUrl?: string;
283
+ onSignedIn?: () => void;
284
+ forgotPasswordUrl?: string;
285
+ }) {
286
+ const [email, setEmail] = useState("");
287
+ const [password, setPassword] = useState("");
288
+ const [displayName, setDisplayName] = useState("");
289
+ const [error, setError] = useState<string | null>(null);
290
+ const [pending, setPending] = useState(false);
291
+
292
+ async function onSubmit(e: FormEvent) {
293
+ e.preventDefault();
294
+ setError(null);
295
+ setPending(true);
296
+ try {
297
+ const session =
298
+ mode === "login"
299
+ ? await passwordLogin({ email, password })
300
+ : await (
301
+ await import("../lib/api")
302
+ ).passwordRegister({
303
+ email,
304
+ password,
305
+ displayName: displayName || undefined,
306
+ });
307
+ persistSession(session);
308
+ onSignedIn?.();
309
+ if (afterSignInUrl && typeof window !== "undefined") {
310
+ window.location.assign(afterSignInUrl);
311
+ }
312
+ } catch (err) {
313
+ setError(messageFromError(err));
314
+ } finally {
315
+ setPending(false);
316
+ }
317
+ }
318
+
319
+ return (
320
+ <form onSubmit={onSubmit} className="space-y-3">
321
+ {mode === "register" ? (
322
+ <Field
323
+ label="Name"
324
+ value={displayName}
325
+ onChange={setDisplayName}
326
+ autoComplete="name"
327
+ placeholder="optional"
328
+ />
329
+ ) : null}
330
+ <Field
331
+ label="Email"
332
+ type="email"
333
+ value={email}
334
+ onChange={setEmail}
335
+ required
336
+ autoComplete="email"
337
+ placeholder="you@example.com"
338
+ />
339
+ <Field
340
+ label="Password"
341
+ type="password"
342
+ value={password}
343
+ onChange={setPassword}
344
+ required
345
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
346
+ />
347
+ <SubmitButton
348
+ pending={pending}
349
+ label={mode === "login" ? "Sign in" : "Create account"}
350
+ />
351
+ {mode === "login" && forgotPasswordUrl ? (
352
+ <a
353
+ href={forgotPasswordUrl}
354
+ className="block text-center text-xs text-[var(--pylon-ink-3,#71717a)] hover:underline"
355
+ >
356
+ Forgot password?
357
+ </a>
358
+ ) : null}
359
+ <ErrorText message={error} />
360
+ </form>
361
+ );
362
+ }
363
+
364
+ function OAuthButtons() {
365
+ const [providers, setProviders] = useState<AuthProvider[] | null>(null);
366
+ useEffect(() => {
367
+ let cancelled = false;
368
+ void listAuthProviders().then((p) => {
369
+ if (!cancelled) setProviders(p);
370
+ });
371
+ return () => {
372
+ cancelled = true;
373
+ };
374
+ }, []);
375
+ if (!providers || providers.length === 0) return null;
376
+ return (
377
+ <div className="space-y-2">
378
+ <Divider label="or" />
379
+ {providers.map((p) => (
380
+ <button
381
+ key={p.provider}
382
+ type="button"
383
+ onClick={() => {
384
+ if (typeof window !== "undefined") {
385
+ const callback = encodeURIComponent(window.location.href);
386
+ window.location.assign(`${p.auth_url}?callback=${callback}`);
387
+ }
388
+ }}
389
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm font-medium text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]"
390
+ >
391
+ Continue with {labelFor(p.provider)}
392
+ </button>
393
+ ))}
394
+ </div>
395
+ );
396
+ }
397
+
398
+ function Switcher({
399
+ prompt,
400
+ cta,
401
+ href,
402
+ }: {
403
+ prompt: string;
404
+ cta: string;
405
+ href: string;
406
+ }) {
407
+ return (
408
+ <p className="text-center text-xs text-[var(--pylon-ink-2,#52525b)]">
409
+ {prompt}{" "}
410
+ <a
411
+ href={href}
412
+ className="font-medium text-[var(--pylon-ink,#0a0a0a)] hover:underline"
413
+ >
414
+ {cta}
415
+ </a>
416
+ </p>
417
+ );
418
+ }
419
+
420
+ function Field({
421
+ label,
422
+ value,
423
+ onChange,
424
+ type = "text",
425
+ required,
426
+ autoComplete,
427
+ placeholder,
428
+ inputMode,
429
+ }: {
430
+ label: string;
431
+ value: string;
432
+ onChange: (v: string) => void;
433
+ type?: string;
434
+ required?: boolean;
435
+ autoComplete?: string;
436
+ placeholder?: string;
437
+ inputMode?: "text" | "numeric";
438
+ }) {
439
+ return (
440
+ <label className="block space-y-1.5">
441
+ <span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
442
+ {label}
443
+ </span>
444
+ <input
445
+ type={type}
446
+ value={value}
447
+ onChange={(e) => onChange(e.target.value)}
448
+ required={required}
449
+ autoComplete={autoComplete}
450
+ placeholder={placeholder}
451
+ inputMode={inputMode}
452
+ className="w-full rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
453
+ />
454
+ </label>
455
+ );
456
+ }
457
+
458
+ function SubmitButton({ pending, label }: { pending: boolean; label: string }) {
459
+ return (
460
+ <button
461
+ type="submit"
462
+ disabled={pending}
463
+ className="w-full rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
464
+ >
465
+ {pending ? "…" : label}
466
+ </button>
467
+ );
468
+ }
469
+
470
+ function ErrorText({ message }: { message: string | null }) {
471
+ if (!message) return null;
472
+ return (
473
+ <p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
474
+ {message}
475
+ </p>
476
+ );
477
+ }
478
+
479
+ function Divider({ label }: { label: string }) {
480
+ return (
481
+ <div className="flex items-center gap-3 text-[10px] uppercase tracking-wider text-[var(--pylon-ink-3,#a1a1aa)]">
482
+ <span className="h-px flex-1 bg-[var(--pylon-rule,#e5e7eb)]" />
483
+ {label}
484
+ <span className="h-px flex-1 bg-[var(--pylon-rule,#e5e7eb)]" />
485
+ </div>
486
+ );
487
+ }
488
+
489
+ function messageFromError(err: unknown): string {
490
+ if (err instanceof ApiError) {
491
+ switch (err.code) {
492
+ case "INVALID_CREDENTIALS":
493
+ return "Wrong email or password.";
494
+ case "USER_EXISTS":
495
+ return "That email is already in use.";
496
+ case "WEAK_PASSWORD":
497
+ return "Pick a stronger password.";
498
+ case "RATE_LIMITED":
499
+ return "Too many attempts — try again in a minute.";
500
+ case "CAPTCHA_REQUIRED":
501
+ return "Captcha verification failed.";
502
+ case "INVALID_CODE":
503
+ return "That code didn't match. Try again.";
504
+ default:
505
+ return err.message;
506
+ }
507
+ }
508
+ if (err instanceof Error) return err.message;
509
+ return "Something went wrong.";
510
+ }
511
+
512
+ function labelFor(provider: string): string {
513
+ if (!provider) return "provider";
514
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
515
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import { type ReactNode } from "react";
5
+ import { useAuth } from "../hooks/useAuth";
6
+ import { cn } from "../lib/cn";
7
+
8
+ export interface SignOutButtonProps {
9
+ afterSignOutUrl?: string;
10
+ className?: string;
11
+ children?: ReactNode;
12
+ }
13
+
14
+ /**
15
+ * Headless-friendly sign-out button. Pass children to fully customize
16
+ * the rendered label/UI; default renders a plain "Sign out" button.
17
+ */
18
+ export function SignOutButton({
19
+ afterSignOutUrl,
20
+ className,
21
+ children,
22
+ }: SignOutButtonProps) {
23
+ const { signOut } = useAuth();
24
+ async function onClick() {
25
+ await signOut();
26
+ if (afterSignOutUrl && typeof window !== "undefined") {
27
+ window.location.assign(afterSignOutUrl);
28
+ }
29
+ }
30
+ return (
31
+ <button
32
+ type="button"
33
+ onClick={onClick}
34
+ className={cn(
35
+ "inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium text-[var(--pylon-ink-2,#52525b)] transition-colors hover:text-[var(--pylon-ink,#0a0a0a)]",
36
+ className,
37
+ )}
38
+ >
39
+ {children ?? "Sign out"}
40
+ </button>
41
+ );
42
+ }
@@ -0,0 +1,163 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import {
5
+ type ReactNode,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { useAuth } from "../hooks/useAuth";
11
+ import { cn } from "../lib/cn";
12
+
13
+ export interface UserButtonProps {
14
+ /** Where to send the user after sign-out. Default: current page reload. */
15
+ afterSignOutUrl?: string;
16
+ /** Show the user's name next to the avatar (Clerk's `showName`). */
17
+ showName?: boolean;
18
+ /** Extra menu items rendered above the built-in entries. */
19
+ menuItems?: Array<{
20
+ label: ReactNode;
21
+ onClick?: () => void;
22
+ href?: string;
23
+ }>;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * Avatar dropdown with profile/sign-out, mirroring Clerk's `<UserButton />`.
29
+ * Renders nothing when the user isn't signed in — wrap the call site in
30
+ * `<SignedIn>` or check `useAuth()` if you want a sign-in CTA instead.
31
+ */
32
+ export function UserButton({
33
+ afterSignOutUrl,
34
+ showName,
35
+ menuItems,
36
+ className,
37
+ }: UserButtonProps) {
38
+ const { isSignedIn, userId, session, signOut } = useAuth();
39
+ const [open, setOpen] = useState(false);
40
+ const rootRef = useRef<HTMLDivElement | null>(null);
41
+
42
+ useEffect(() => {
43
+ if (!open) return;
44
+ function onDocClick(e: MouseEvent) {
45
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
46
+ }
47
+ function onKey(e: KeyboardEvent) {
48
+ if (e.key === "Escape") setOpen(false);
49
+ }
50
+ document.addEventListener("mousedown", onDocClick);
51
+ document.addEventListener("keydown", onKey);
52
+ return () => {
53
+ document.removeEventListener("mousedown", onDocClick);
54
+ document.removeEventListener("keydown", onKey);
55
+ };
56
+ }, [open]);
57
+
58
+ if (!isSignedIn) return null;
59
+
60
+ // ResolvedSession is intentionally minimal (userId / tenantId / roles).
61
+ // Apps that want a real name / email shown on the avatar pass it in
62
+ // via `menuItems` or wrap their own component over the User entity.
63
+ const label = userId ?? "Account";
64
+ const initials = initialsFor(label);
65
+ // session reserved for future extension hooks
66
+ void session;
67
+
68
+ async function onSignOut() {
69
+ setOpen(false);
70
+ await signOut();
71
+ if (afterSignOutUrl && typeof window !== "undefined") {
72
+ window.location.assign(afterSignOutUrl);
73
+ }
74
+ }
75
+
76
+ return (
77
+ <div ref={rootRef} className={cn("relative inline-flex", className)}>
78
+ <button
79
+ type="button"
80
+ onClick={() => setOpen((o) => !o)}
81
+ aria-haspopup="menu"
82
+ aria-expanded={open}
83
+ className="inline-flex items-center gap-2 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--pylon-ink,#0a0a0a)]"
84
+ >
85
+ <span
86
+ className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--pylon-ink,#0a0a0a)] text-xs font-semibold text-[var(--pylon-paper,#ffffff)]"
87
+ aria-hidden
88
+ >
89
+ {initials}
90
+ </span>
91
+ {showName ? (
92
+ <span className="text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
93
+ {label}
94
+ </span>
95
+ ) : null}
96
+ </button>
97
+ {open ? (
98
+ <div
99
+ role="menu"
100
+ className="absolute right-0 top-full z-50 mt-2 w-56 overflow-hidden rounded-lg border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] shadow-lg"
101
+ >
102
+ <div className="border-b border-[var(--pylon-rule,#e5e7eb)] px-3 py-2.5">
103
+ <p className="truncate text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
104
+ {label}
105
+ </p>
106
+ {userId ? (
107
+ <p className="truncate text-xs text-[var(--pylon-ink-3,#71717a)]">
108
+ {userId}
109
+ </p>
110
+ ) : null}
111
+ </div>
112
+ <div className="py-1">
113
+ {(menuItems ?? []).map((item, i) => (
114
+ <MenuItem
115
+ key={i}
116
+ onClick={() => {
117
+ item.onClick?.();
118
+ setOpen(false);
119
+ }}
120
+ href={item.href}
121
+ >
122
+ {item.label}
123
+ </MenuItem>
124
+ ))}
125
+ <MenuItem onClick={onSignOut}>Sign out</MenuItem>
126
+ </div>
127
+ </div>
128
+ ) : null}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function MenuItem({
134
+ onClick,
135
+ href,
136
+ children,
137
+ }: {
138
+ onClick?: () => void;
139
+ href?: string;
140
+ children: ReactNode;
141
+ }) {
142
+ const cls =
143
+ "block w-full px-3 py-2 text-left text-sm text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]";
144
+ if (href) {
145
+ return (
146
+ <a href={href} role="menuitem" className={cls} onClick={onClick}>
147
+ {children}
148
+ </a>
149
+ );
150
+ }
151
+ return (
152
+ <button type="button" role="menuitem" onClick={onClick} className={cls}>
153
+ {children}
154
+ </button>
155
+ );
156
+ }
157
+
158
+ function initialsFor(label: string): string {
159
+ const parts = label.split(/\s+/).filter(Boolean);
160
+ if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
161
+ const seed = parts[0] ?? label;
162
+ return seed.slice(0, 2).toUpperCase();
163
+ }