@wibly/ui-kit 0.1.1 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # `@wibly/ui-kit` — Changelog
2
2
 
3
+ ## 0.1.3 — 2026-06-08
4
+
5
+ ## 0.1.2 — 2026-06-08
6
+
7
+ Version alignment release (no API changes).
8
+
3
9
  ## 0.1.1 — 2026-05-30
4
10
 
5
11
  Initial public npm release. Eight shared React components plus the Wibly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibly/ui-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Wibly @wibly/ui-kit",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,10 +18,11 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
+ "@platform/player-profile": "workspace:*",
21
22
  "clsx": "^2.1.1"
22
23
  },
23
24
  "peerDependencies": {
24
- "@wibly/sdk": "0.1.1",
25
+ "@wibly/sdk": "0.1.3",
25
26
  "react": "^19.0.0"
26
27
  },
27
28
  "peerDependenciesMeta": {
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ import type { ReactElement } from 'react';
4
+ import * as React from 'react';
5
+
6
+ import type { ProcessAvatarPhotoResult } from '@platform/player-profile';
7
+
8
+ export type CameraCaptureModalProps = {
9
+ readonly open: boolean;
10
+ readonly onClose: () => void;
11
+ readonly onCaptured: (dataUrl: string) => void;
12
+ readonly onProcessBytes: (args: {
13
+ readonly bytes: Uint8Array;
14
+ readonly mimeType: string;
15
+ readonly sizeBytes: number;
16
+ }) => ProcessAvatarPhotoResult;
17
+ };
18
+
19
+ export const CameraCaptureModal = ({
20
+ open,
21
+ onClose,
22
+ onCaptured,
23
+ onProcessBytes,
24
+ }: CameraCaptureModalProps): ReactElement | null => {
25
+ const videoRef = React.useRef<HTMLVideoElement>(null);
26
+ const streamRef = React.useRef<MediaStream | null>(null);
27
+ const [error, setError] = React.useState<string | null>(null);
28
+ const [ready, setReady] = React.useState(false);
29
+
30
+ const stopStream = React.useCallback((): void => {
31
+ for (const track of streamRef.current?.getTracks() ?? []) {
32
+ track.stop();
33
+ }
34
+ streamRef.current = null;
35
+ setReady(false);
36
+ }, []);
37
+
38
+ React.useEffect(() => {
39
+ if (!open) {
40
+ stopStream();
41
+ setError(null);
42
+ return;
43
+ }
44
+
45
+ let cancelled = false;
46
+
47
+ void (async () => {
48
+ if (
49
+ typeof navigator === 'undefined' ||
50
+ !navigator.mediaDevices?.getUserMedia
51
+ ) {
52
+ setError('Camera access is not available in this browser.');
53
+ return;
54
+ }
55
+ try {
56
+ const stream = await navigator.mediaDevices.getUserMedia({
57
+ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 640 } },
58
+ audio: false,
59
+ });
60
+ if (cancelled) {
61
+ for (const track of stream.getTracks()) track.stop();
62
+ return;
63
+ }
64
+ streamRef.current = stream;
65
+ const video = videoRef.current;
66
+ if (video) {
67
+ video.srcObject = stream;
68
+ await video.play();
69
+ setReady(true);
70
+ }
71
+ } catch {
72
+ setError(
73
+ 'Could not open the camera. Check permissions or try uploading a photo instead.',
74
+ );
75
+ }
76
+ })();
77
+
78
+ return (): void => {
79
+ cancelled = true;
80
+ stopStream();
81
+ };
82
+ }, [open, stopStream]);
83
+
84
+ const handleCapture = (): void => {
85
+ const video = videoRef.current;
86
+ if (!video || video.videoWidth === 0) return;
87
+ const canvas = document.createElement('canvas');
88
+ const size = Math.min(video.videoWidth, video.videoHeight);
89
+ canvas.width = size;
90
+ canvas.height = size;
91
+ const ctx = canvas.getContext('2d');
92
+ if (!ctx) return;
93
+ const offsetX = (video.videoWidth - size) / 2;
94
+ const offsetY = (video.videoHeight - size) / 2;
95
+ ctx.drawImage(video, offsetX, offsetY, size, size, 0, 0, size, size);
96
+ canvas.toBlob(
97
+ (blob) => {
98
+ if (!blob) {
99
+ setError('Could not capture a photo — try again.');
100
+ return;
101
+ }
102
+ void blob.arrayBuffer().then((buffer) => {
103
+ const result = onProcessBytes({
104
+ bytes: new Uint8Array(buffer),
105
+ mimeType: blob.type || 'image/jpeg',
106
+ sizeBytes: blob.size,
107
+ });
108
+ if (!result.ok) {
109
+ setError(result.message);
110
+ return;
111
+ }
112
+ stopStream();
113
+ onCaptured(result.dataUrl);
114
+ onClose();
115
+ });
116
+ },
117
+ 'image/jpeg',
118
+ 0.88,
119
+ );
120
+ };
121
+
122
+ if (!open) return null;
123
+
124
+ return (
125
+ <div
126
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
127
+ role="dialog"
128
+ aria-modal="true"
129
+ aria-label="Take a photo with your camera"
130
+ >
131
+ <div className="w-full max-w-md rounded-2xl bg-white p-4 text-neutral-900 shadow-xl dark:bg-neutral-900 dark:text-neutral-100">
132
+ <h2 className="text-lg font-semibold">Take a photo</h2>
133
+ <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-300">
134
+ Position your face in the frame, then capture.
135
+ </p>
136
+ <div className="relative mt-4 aspect-square overflow-hidden rounded-xl bg-black">
137
+ <video
138
+ ref={videoRef}
139
+ className="h-full w-full object-cover"
140
+ playsInline
141
+ muted
142
+ />
143
+ </div>
144
+ {error ? (
145
+ <p className="mt-3 text-sm text-red-600" role="alert">
146
+ {error}
147
+ </p>
148
+ ) : null}
149
+ <div className="mt-4 flex flex-wrap justify-end gap-2">
150
+ <button
151
+ type="button"
152
+ className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-semibold dark:border-neutral-600"
153
+ onClick={() => {
154
+ stopStream();
155
+ onClose();
156
+ }}
157
+ >
158
+ Cancel
159
+ </button>
160
+ <button
161
+ type="button"
162
+ className="rounded-full bg-indigo-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
163
+ disabled={!ready}
164
+ onClick={handleCapture}
165
+ >
166
+ Capture photo
167
+ </button>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ );
172
+ };
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Host TV display viewport — scale-to-fit 16:9 canvas with full-screen backdrop.
5
+ *
6
+ * Bundles set `HOST_BACKDROP_CSS_VAR` on the mount container (`data-host-mount`)
7
+ * so atmospheric backgrounds fill the physical screen outside letterboxing.
8
+ */
9
+
10
+ import * as React from 'react';
11
+
12
+ import {
13
+ computeHostCanvasScale,
14
+ HOST_BACKDROP_CSS_VAR,
15
+ HOST_CANVAS_HEIGHT,
16
+ HOST_CANVAS_WIDTH,
17
+ } from '../lib/host-canvas.js';
18
+ import { resolveHostMountElement } from '../lib/host-mount.js';
19
+ import { cn } from '../lib/cn.js';
20
+
21
+ export type HostDisplayViewportProps = {
22
+ readonly children: React.ReactNode;
23
+ /** When true, backdrop is contained to the preview card (Studio / ui-kit preview). */
24
+ readonly embedded?: boolean;
25
+ readonly className?: string;
26
+ readonly testId?: string;
27
+ };
28
+
29
+ const readHostBackdrop = (element: Element | null): string | null => {
30
+ if (!element) return null;
31
+ const value = getComputedStyle(element)
32
+ .getPropertyValue(HOST_BACKDROP_CSS_VAR)
33
+ .trim();
34
+ return value.length > 0 ? value : null;
35
+ };
36
+
37
+ export const HostDisplayViewport = ({
38
+ children,
39
+ embedded = false,
40
+ className,
41
+ testId = 'host-display-viewport',
42
+ }: HostDisplayViewportProps): React.ReactElement => {
43
+ const viewportRef = React.useRef<HTMLDivElement | null>(null);
44
+ const canvasRef = React.useRef<HTMLDivElement | null>(null);
45
+ const fitRef = React.useRef<HTMLDivElement | null>(null);
46
+ const [scale, setScale] = React.useState(1);
47
+
48
+ React.useLayoutEffect(() => {
49
+ const fit = fitRef.current;
50
+ const viewport = viewportRef.current;
51
+ if (!fit) return;
52
+
53
+ const updateScale = (): void => {
54
+ const { width, height } = fit.getBoundingClientRect();
55
+ const next = computeHostCanvasScale(width, height);
56
+ setScale(next > 0 ? next : 0);
57
+ };
58
+
59
+ updateScale();
60
+ const observer = new ResizeObserver(updateScale);
61
+ observer.observe(fit);
62
+ if (viewport) {
63
+ observer.observe(viewport);
64
+ }
65
+ return () => observer.disconnect();
66
+ }, []);
67
+
68
+ React.useLayoutEffect(() => {
69
+ const viewport = viewportRef.current;
70
+ const canvas = canvasRef.current;
71
+ if (!viewport || !canvas) return;
72
+
73
+ const syncBackdrop = (): void => {
74
+ const mount = resolveHostMountElement(canvas);
75
+ const backdrop = readHostBackdrop(mount);
76
+ if (backdrop) {
77
+ viewport.style.setProperty(HOST_BACKDROP_CSS_VAR, backdrop);
78
+ } else {
79
+ viewport.style.removeProperty(HOST_BACKDROP_CSS_VAR);
80
+ }
81
+ };
82
+
83
+ const observed: MutationObserver[] = [];
84
+
85
+ const observe = (target: Node): void => {
86
+ const observer = new MutationObserver(syncBackdrop);
87
+ observer.observe(target, {
88
+ childList: true,
89
+ subtree: true,
90
+ attributes: true,
91
+ attributeFilter: ['style', 'class'],
92
+ });
93
+ observed.push(observer);
94
+ };
95
+
96
+ const observeIframe = (iframe: HTMLIFrameElement): void => {
97
+ const doc = iframe.contentDocument;
98
+ if (!doc) return;
99
+ observe(doc.documentElement);
100
+ };
101
+
102
+ syncBackdrop();
103
+ observe(canvas);
104
+
105
+ const iframe = canvas.querySelector('iframe[data-host-mount]');
106
+ if (iframe instanceof HTMLIFrameElement) {
107
+ if (iframe.contentDocument) {
108
+ observeIframe(iframe);
109
+ }
110
+ iframe.addEventListener('load', () => {
111
+ observeIframe(iframe);
112
+ syncBackdrop();
113
+ });
114
+ }
115
+
116
+ return () => {
117
+ for (const observer of observed) {
118
+ observer.disconnect();
119
+ }
120
+ };
121
+ }, []);
122
+
123
+ const scaledWidth = HOST_CANVAS_WIDTH * scale;
124
+ const scaledHeight = HOST_CANVAS_HEIGHT * scale;
125
+
126
+ return (
127
+ <div
128
+ ref={viewportRef}
129
+ data-testid={testId}
130
+ className={cn(
131
+ 'host-display-viewport relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden',
132
+ embedded && 'host-display-viewport--embedded',
133
+ className,
134
+ )}
135
+ >
136
+ <div
137
+ aria-hidden
138
+ data-testid="host-display-backdrop"
139
+ className={cn(
140
+ 'host-display-backdrop pointer-events-none inset-0 z-0',
141
+ embedded ? 'absolute' : 'fixed',
142
+ )}
143
+ />
144
+ <div
145
+ ref={fitRef}
146
+ data-testid="host-display-fit"
147
+ className="relative z-10 flex min-h-0 min-w-0 flex-1 items-center justify-center overflow-hidden"
148
+ >
149
+ <div
150
+ data-testid="host-display-scaler"
151
+ className="host-display-scaler relative shrink-0 overflow-hidden"
152
+ style={{
153
+ width: scaledWidth,
154
+ height: scaledHeight,
155
+ }}
156
+ >
157
+ <div
158
+ ref={canvasRef}
159
+ data-testid="host-display-canvas"
160
+ className="host-display-canvas absolute top-0 left-0 overflow-hidden"
161
+ style={{
162
+ width: HOST_CANVAS_WIDTH,
163
+ height: HOST_CANVAS_HEIGHT,
164
+ transform: `scale(${scale})`,
165
+ transformOrigin: 'top left',
166
+ }}
167
+ >
168
+ {children}
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ };
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ import type { ReactElement } from 'react';
4
+ import * as React from 'react';
5
+
6
+ import {
7
+ letterFromDisplayName,
8
+ PRESET_AVATARS,
9
+ processAvatarPhotoBytes,
10
+ resolveAvatarPreviewUrl,
11
+ type PresetAvatarId,
12
+ type ProcessAvatarPhotoResult,
13
+ } from '@platform/player-profile';
14
+
15
+ import { CameraCaptureModal } from './camera-capture-modal.js';
16
+
17
+ export type PlayerAvatarPickerShape = 'rounded' | 'circle';
18
+
19
+ export type PlayerAvatarPickerProps = {
20
+ readonly displayName: string;
21
+ readonly selectedPresetId: PresetAvatarId;
22
+ readonly customAvatarUrl: string | null;
23
+ readonly onPresetChange: (id: PresetAvatarId) => void;
24
+ readonly onCustomAvatarChange: (dataUrl: string | null) => void;
25
+ readonly onProcessPhotoFile?: (
26
+ formData: FormData,
27
+ ) => Promise<ProcessAvatarPhotoResult>;
28
+ readonly shape?: PlayerAvatarPickerShape;
29
+ readonly previewClassName?: string;
30
+ readonly selectClassName?: string;
31
+ readonly primaryButtonClassName?: string;
32
+ readonly secondaryButtonClassName?: string;
33
+ readonly errorClassName?: string;
34
+ };
35
+
36
+ const defaultPreviewClass =
37
+ 'relative h-14 w-14 shrink-0 overflow-hidden border border-neutral-200 bg-neutral-50';
38
+
39
+ const defaultSelectClass =
40
+ 'h-10 w-full min-w-0 rounded-lg border border-neutral-200 bg-white px-3 text-sm font-medium text-neutral-900';
41
+
42
+ export const PlayerAvatarPicker = ({
43
+ displayName,
44
+ selectedPresetId,
45
+ customAvatarUrl,
46
+ onPresetChange,
47
+ onCustomAvatarChange,
48
+ onProcessPhotoFile,
49
+ shape = 'rounded',
50
+ previewClassName,
51
+ selectClassName,
52
+ primaryButtonClassName = 'text-xs font-semibold text-indigo-600 underline-offset-2 hover:underline',
53
+ secondaryButtonClassName = 'text-xs font-semibold text-neutral-500 underline-offset-2 hover:underline',
54
+ errorClassName = 'text-xs font-medium text-red-600',
55
+ }: PlayerAvatarPickerProps): ReactElement => {
56
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
57
+ const cameraInputRef = React.useRef<HTMLInputElement>(null);
58
+ const [photoError, setPhotoError] = React.useState<string | null>(null);
59
+ const [uploading, setUploading] = React.useState(false);
60
+ const [cameraOpen, setCameraOpen] = React.useState(false);
61
+
62
+ const previewRadius = shape === 'circle' ? 'rounded-full' : 'rounded-xl';
63
+ const letter = letterFromDisplayName(displayName);
64
+
65
+ const previewUrl =
66
+ customAvatarUrl ??
67
+ resolveAvatarPreviewUrl({
68
+ presetId: selectedPresetId,
69
+ displayName,
70
+ });
71
+
72
+ const applyPhotoResult = (result: ProcessAvatarPhotoResult): void => {
73
+ if (!result.ok) {
74
+ setPhotoError(result.message);
75
+ return;
76
+ }
77
+ onCustomAvatarChange(result.dataUrl);
78
+ };
79
+
80
+ const handlePhotoFile = async (file: File): Promise<void> => {
81
+ setUploading(true);
82
+ setPhotoError(null);
83
+ if (onProcessPhotoFile) {
84
+ const formData = new FormData();
85
+ formData.set('photo', file);
86
+ const result = await onProcessPhotoFile(formData);
87
+ setUploading(false);
88
+ applyPhotoResult(result);
89
+ return;
90
+ }
91
+ const buffer = await file.arrayBuffer();
92
+ const result = processAvatarPhotoBytes({
93
+ bytes: new Uint8Array(buffer),
94
+ mimeType: file.type,
95
+ sizeBytes: file.size,
96
+ });
97
+ setUploading(false);
98
+ applyPhotoResult(result);
99
+ };
100
+
101
+ const handleFileInput = (event: React.ChangeEvent<HTMLInputElement>): void => {
102
+ const file = event.target.files?.[0];
103
+ event.target.value = '';
104
+ if (!file) return;
105
+ void handlePhotoFile(file);
106
+ };
107
+
108
+ const handlePresetChange = (
109
+ event: React.ChangeEvent<HTMLSelectElement>,
110
+ ): void => {
111
+ onCustomAvatarChange(null);
112
+ onPresetChange(event.target.value as PresetAvatarId);
113
+ };
114
+
115
+ return (
116
+ <section className="flex flex-col gap-3">
117
+ <div className="flex items-center gap-3">
118
+ <div className={`${previewClassName ?? defaultPreviewClass} ${previewRadius}`}>
119
+ {/* eslint-disable-next-line @next/next/no-img-element */}
120
+ <img
121
+ src={previewUrl}
122
+ alt="Your avatar preview"
123
+ className="h-full w-full object-cover"
124
+ />
125
+ </div>
126
+ <div className="min-w-0 flex-1">
127
+ <label className="flex flex-col gap-1.5">
128
+ <span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
129
+ Avatar
130
+ </span>
131
+ <select
132
+ className={selectClassName ?? defaultSelectClass}
133
+ value={selectedPresetId}
134
+ disabled={Boolean(customAvatarUrl)}
135
+ aria-label="Choose an avatar"
136
+ onChange={handlePresetChange}
137
+ >
138
+ {PRESET_AVATARS.map((avatar) => (
139
+ <option key={avatar.id} value={avatar.id}>
140
+ {avatar.id === 'letter'
141
+ ? `${avatar.label} — ${letter}`
142
+ : avatar.label}
143
+ </option>
144
+ ))}
145
+ </select>
146
+ </label>
147
+ {customAvatarUrl ? (
148
+ <p className="mt-1 text-xs text-neutral-500">Using a custom photo</p>
149
+ ) : null}
150
+ </div>
151
+ </div>
152
+
153
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
154
+ <button
155
+ type="button"
156
+ className={primaryButtonClassName}
157
+ disabled={uploading}
158
+ onClick={() => setCameraOpen(true)}
159
+ >
160
+ Camera / webcam
161
+ </button>
162
+ <button
163
+ type="button"
164
+ className={primaryButtonClassName}
165
+ disabled={uploading}
166
+ onClick={() => cameraInputRef.current?.click()}
167
+ >
168
+ Take photo
169
+ </button>
170
+ <button
171
+ type="button"
172
+ className={primaryButtonClassName}
173
+ disabled={uploading}
174
+ onClick={() => fileInputRef.current?.click()}
175
+ >
176
+ {uploading ? 'Processing…' : 'Upload photo'}
177
+ </button>
178
+ {customAvatarUrl ? (
179
+ <button
180
+ type="button"
181
+ className={secondaryButtonClassName}
182
+ onClick={() => onCustomAvatarChange(null)}
183
+ >
184
+ Use preset instead
185
+ </button>
186
+ ) : null}
187
+ </div>
188
+
189
+ <input
190
+ ref={cameraInputRef}
191
+ type="file"
192
+ accept="image/jpeg,image/png,image/webp"
193
+ capture="user"
194
+ className="hidden"
195
+ onChange={handleFileInput}
196
+ />
197
+ <input
198
+ ref={fileInputRef}
199
+ type="file"
200
+ accept="image/jpeg,image/png,image/webp"
201
+ className="hidden"
202
+ onChange={handleFileInput}
203
+ />
204
+
205
+ <CameraCaptureModal
206
+ open={cameraOpen}
207
+ onClose={() => setCameraOpen(false)}
208
+ onCaptured={(dataUrl) => {
209
+ setPhotoError(null);
210
+ onCustomAvatarChange(dataUrl);
211
+ }}
212
+ onProcessBytes={processAvatarPhotoBytes}
213
+ />
214
+
215
+ {photoError ? (
216
+ <p className={errorClassName} role="alert">
217
+ {photoError}
218
+ </p>
219
+ ) : null}
220
+ </section>
221
+ );
222
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `PlayerAvatar` — single player chip for rosters and score strips.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { cn } from '../lib/cn.js';
8
+
9
+ export type PlayerAvatarProps = {
10
+ readonly playerId: string;
11
+ readonly displayName: string;
12
+ readonly imageUrl?: string | null;
13
+ readonly score?: number;
14
+ readonly connected?: boolean;
15
+ readonly highlighted?: boolean;
16
+ readonly size?: 'sm' | 'md';
17
+ readonly className?: string;
18
+ readonly testId?: string;
19
+ };
20
+
21
+ const initialsFromName = (name: string): string => {
22
+ const parts = name.trim().split(/\s+/).filter(Boolean);
23
+ if (parts.length === 0) return '?';
24
+ return parts[0]!.slice(0, 2).toUpperCase();
25
+ };
26
+
27
+ const SIZE_CLASS: Record<NonNullable<PlayerAvatarProps['size']>, string> = {
28
+ sm: 'size-10 text-[10px]',
29
+ md: 'size-14 text-xs',
30
+ };
31
+
32
+ export const PlayerAvatar = ({
33
+ playerId,
34
+ displayName,
35
+ imageUrl,
36
+ score,
37
+ connected,
38
+ highlighted = false,
39
+ size = 'md',
40
+ className,
41
+ testId,
42
+ }: PlayerAvatarProps): React.ReactElement => (
43
+ <div
44
+ data-testid={testId ?? `player-avatar-${playerId}`}
45
+ className={cn(
46
+ 'relative flex flex-col items-center gap-1',
47
+ highlighted ? 'opacity-100' : 'opacity-90',
48
+ className,
49
+ )}
50
+ title={displayName}
51
+ >
52
+ <div
53
+ className={cn(
54
+ 'relative flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-card font-bold text-card-foreground',
55
+ SIZE_CLASS[size],
56
+ highlighted ? 'border-primary shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]' : 'border-border',
57
+ connected === false ? 'opacity-50' : '',
58
+ )}
59
+ >
60
+ {imageUrl ? (
61
+ // eslint-disable-next-line @next/next/no-img-element
62
+ <img src={imageUrl} alt="" className="size-full object-cover" />
63
+ ) : (
64
+ <span>{initialsFromName(displayName)}</span>
65
+ )}
66
+ {connected === true ? (
67
+ <span
68
+ className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card bg-[color:var(--color-success,oklch(0.62_0.17_155))]"
69
+ aria-label="Connected"
70
+ />
71
+ ) : null}
72
+ </div>
73
+ <span className="max-w-[5.5rem] truncate text-center text-[10px] font-medium text-foreground">
74
+ {displayName}
75
+ </span>
76
+ {score !== undefined ? (
77
+ <span className="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] font-bold text-primary">
78
+ {score.toLocaleString()}
79
+ </span>
80
+ ) : null}
81
+ </div>
82
+ );