@adea-ai/ui 0.10.8

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 (40) hide show
  1. package/README.md +5 -0
  2. package/components.json +19 -0
  3. package/package.json +56 -0
  4. package/src/components/account-drawer.tsx +133 -0
  5. package/src/components/on-screen-controls.tsx +156 -0
  6. package/src/components/prop-catalog.tsx +583 -0
  7. package/src/components/scene-settings.tsx +202 -0
  8. package/src/components/theme-provider.tsx +30 -0
  9. package/src/components/theme-toggle.tsx +73 -0
  10. package/src/components/ui/badge.tsx +49 -0
  11. package/src/components/ui/button.tsx +60 -0
  12. package/src/components/ui/card.tsx +18 -0
  13. package/src/components/ui/dialog.tsx +129 -0
  14. package/src/components/ui/drawer.tsx +209 -0
  15. package/src/components/ui/dropdown-menu.tsx +258 -0
  16. package/src/components/ui/empty.tsx +94 -0
  17. package/src/components/ui/field.tsx +224 -0
  18. package/src/components/ui/input.tsx +20 -0
  19. package/src/components/ui/label.tsx +20 -0
  20. package/src/components/ui/radio-group.tsx +38 -0
  21. package/src/components/ui/separator.tsx +21 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/spinner.tsx +16 -0
  24. package/src/components/ui/switch.tsx +32 -0
  25. package/src/components/ui/tabs.tsx +75 -0
  26. package/src/components/ui/toggle-group.tsx +87 -0
  27. package/src/components/ui/toggle.tsx +45 -0
  28. package/src/components/ui/tooltip.tsx +56 -0
  29. package/src/components/version-dialog.tsx +366 -0
  30. package/src/components/workspace-brand.tsx +18 -0
  31. package/src/components/workspace-logo.tsx +17 -0
  32. package/src/index.tsx +55 -0
  33. package/src/lib/utils.ts +6 -0
  34. package/src/lib/version-notes.ts +30 -0
  35. package/src/styles/auth-shell.css +67 -0
  36. package/src/styles/conventional-workspace.css +2846 -0
  37. package/src/styles/globals.css +93 -0
  38. package/src/styles/theme.css +94 -0
  39. package/src/styles/workspace-shell.css +145 -0
  40. package/tsconfig.json +10 -0
@@ -0,0 +1,583 @@
1
+ "use client";
2
+
3
+ import {
4
+ memo,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type PointerEvent as ReactPointerEvent,
10
+ } from "react";
11
+ import {
12
+ Archive,
13
+ Armchair,
14
+ Bath,
15
+ Baby,
16
+ BedDouble,
17
+ ChefHat,
18
+ ChevronLeft,
19
+ ChevronRight,
20
+ Dumbbell,
21
+ Gamepad2,
22
+ Image,
23
+ LampDesk,
24
+ Leaf,
25
+ PackageOpen,
26
+ PanelTop,
27
+ SquareDashed,
28
+ Sprout,
29
+ Store,
30
+ Tv,
31
+ Utensils,
32
+ type LucideIcon,
33
+ } from "lucide-react";
34
+ import * as THREE from "three";
35
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
36
+ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
37
+ import { Card } from "./ui/card";
38
+ import { cn } from "../lib/utils";
39
+
40
+ export type PropCatalogItem = {
41
+ id: string;
42
+ label: string;
43
+ assetUrl: string;
44
+ category: string;
45
+ /** Restrict placement to a floor or a wall surface. */
46
+ placementSurface?: "floor" | "wall";
47
+ /** Authored wall-mount height for wall-only props. */
48
+ wallMountHeight?: number;
49
+ /** Render the placement footprint as a circle instead of an AABB. */
50
+ footprintShape?: "rectangle" | "circle";
51
+ /** Small authored lift used to keep floor props off coplanar surfaces. */
52
+ floorLift?: number;
53
+ /** Allow other floor props to be placed on this surface. */
54
+ allowItemsOnTop?: boolean;
55
+ /** Allow this floor prop to be placed underneath existing furniture. */
56
+ canOverlapFurniture?: boolean;
57
+ /** Keep rugs from covering this floor decoration. */
58
+ blocksRugOverlap?: boolean;
59
+ /** Top surface height in authored units (tables, counters). */
60
+ surfaceHeight?: number;
61
+ /** Small item that stacks on surfaces with surfaceHeight. */
62
+ placeableOnTop?: boolean;
63
+ /** Yaw that presents the asset's authored front to the viewer. */
64
+ frontYaw?: number;
65
+ };
66
+
67
+ export type PropCatalogCategory = {
68
+ id: string;
69
+ label: string;
70
+ icon: LucideIcon;
71
+ };
72
+
73
+ export const defaultPropCatalogCategories: readonly PropCatalogCategory[] = [
74
+ { id: "seating", label: "Seating", icon: Armchair },
75
+ { id: "tables", label: "Tables", icon: PanelTop },
76
+ { id: "bedroom", label: "Bedroom", icon: BedDouble },
77
+ { id: "storage", label: "Storage", icon: Archive },
78
+ { id: "lighting", label: "Lighting", icon: LampDesk },
79
+ { id: "electronics", label: "Electronics", icon: Tv },
80
+ { id: "entertainment", label: "Entertainment", icon: Gamepad2 },
81
+ { id: "recreation", label: "Recreation", icon: Dumbbell },
82
+ { id: "kitchen", label: "Kitchen", icon: ChefHat },
83
+ { id: "bathroom", label: "Bathroom", icon: Bath },
84
+ { id: "rugs", label: "Rugs", icon: SquareDashed },
85
+ { id: "retail", label: "Retail", icon: Store },
86
+ { id: "fitness", label: "Fitness", icon: Dumbbell },
87
+ { id: "kids", label: "Kids", icon: Baby },
88
+ { id: "wall-art", label: "Wall Art", icon: Image },
89
+ { id: "plants", label: "Plants", icon: Sprout },
90
+ { id: "food-and-drinks", label: "Food & Drinks", icon: Utensils },
91
+ { id: "other", label: "Other", icon: PackageOpen },
92
+ ];
93
+
94
+ function disposeObject(object: THREE.Object3D): void {
95
+ object.traverse((child) => {
96
+ if (!(child instanceof THREE.Mesh)) return;
97
+ child.geometry.dispose();
98
+ const materials = Array.isArray(child.material) ? child.material : [child.material];
99
+ for (const material of materials) material.dispose();
100
+ });
101
+ }
102
+
103
+ // --- Thumbnail infrastructure ------------------------------------------------
104
+
105
+ // Shared singleton renderer + canvas for all thumbnails.
106
+ let thumbnailRenderer: THREE.WebGLRenderer | null = null;
107
+ let thumbnailRendererCanvas: HTMLCanvasElement | null = null;
108
+ // Shared singleton GLTFLoader so the browser can reuse connections and
109
+ // the loader can cache parsed resources internally. Exported so the
110
+ // room designer can reuse the same loader instance and avoid duplicate
111
+ // GLB fetches/parse work.
112
+ let sharedLoader: GLTFLoader | null = null;
113
+ export function getSharedLoader(): GLTFLoader {
114
+ if (!sharedLoader) {
115
+ sharedLoader = new GLTFLoader();
116
+ // Interior catalog GLBs are Meshopt-compressed. Configure the shared
117
+ // loader before thumbnails or the room designer can request any asset.
118
+ sharedLoader.setMeshoptDecoder(MeshoptDecoder);
119
+ }
120
+ return sharedLoader;
121
+ }
122
+
123
+ // Cache of rendered thumbnail data URLs keyed by asset URL + yaw.
124
+ // Survives category switches and drawer re-opens so thumbnails only
125
+ // render once per session.
126
+ const thumbnailDataCache = new Map<string, string>();
127
+ // Track in-flight load+render promises so concurrent mounts share one.
128
+ const thumbnailPromiseCache = new Map<string, Promise<string | null>>();
129
+ // Count mounted consumers so category switches can abandon queued thumbnail
130
+ // renders instead of keeping the main thread busy with an invisible catalog.
131
+ const thumbnailConsumerCounts = new Map<string, number>();
132
+
133
+ function thumbnailCacheKey(item: PropCatalogItem): string {
134
+ return `${item.assetUrl}|${item.frontYaw ?? 0}`;
135
+ }
136
+
137
+ type ThumbnailRenderJob = {
138
+ item: PropCatalogItem;
139
+ source: THREE.Object3D;
140
+ };
141
+
142
+ function renderThumbnailToDataURL(job: ThumbnailRenderJob): string {
143
+ if (!thumbnailRendererCanvas) thumbnailRendererCanvas = document.createElement("canvas");
144
+ if (!thumbnailRenderer) {
145
+ thumbnailRenderer = new THREE.WebGLRenderer({
146
+ canvas: thumbnailRendererCanvas,
147
+ alpha: true,
148
+ antialias: true,
149
+ powerPreference: "low-power",
150
+ });
151
+ thumbnailRenderer.setPixelRatio(1);
152
+ thumbnailRenderer.setSize(96, 96, false);
153
+ thumbnailRenderer.outputColorSpace = THREE.SRGBColorSpace;
154
+ }
155
+ const scene = new THREE.Scene();
156
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 2.4));
157
+ const key = new THREE.DirectionalLight(0xffffff, 2.8);
158
+ key.position.set(2, 4, 3);
159
+ scene.add(key);
160
+ const camera = new THREE.PerspectiveCamera(28, 1, 0.01, 100);
161
+ const model = job.source;
162
+ model.rotation.y = job.item.frontYaw ?? 0;
163
+ model.updateMatrixWorld(true);
164
+ const bounds = new THREE.Box3().setFromObject(model);
165
+ const size = bounds.getSize(new THREE.Vector3());
166
+ const center = bounds.getCenter(new THREE.Vector3());
167
+ model.position.sub(center);
168
+ const extent = Math.max(size.x, size.y, size.z, 0.25);
169
+ camera.position.set(extent * 2.25, extent * 1.55, -extent * 2.25);
170
+ camera.lookAt(0, Math.max(size.y * 0.12, 0), 0);
171
+ scene.add(model);
172
+ thumbnailRenderer.render(scene, camera);
173
+ // JPEG is ~5x faster to encode than PNG and produces smaller data URLs,
174
+ // which speeds up both the toDataURL call and the subsequent img.src load.
175
+ const dataUrl = thumbnailRendererCanvas.toDataURL("image/jpeg", 0.85);
176
+ scene.remove(model);
177
+ disposeObject(model);
178
+ return dataUrl;
179
+ }
180
+
181
+ /**
182
+ * Yield to the event loop so the browser can process user input between
183
+ * thumbnail renders. Without this, a queue of N renders executes as one
184
+ * synchronous batch (microtask drain) and blocks the main thread.
185
+ */
186
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
187
+
188
+ /**
189
+ * Sequential render queue. Each render waits for one animation frame before
190
+ * executing, keeping the UI responsive even when many thumbnails are queued.
191
+ */
192
+ let renderQueue: Promise<unknown> = Promise.resolve();
193
+
194
+ function scheduleRender(render: () => string | null): Promise<string | null> {
195
+ const result = renderQueue.then(async () => {
196
+ await nextFrame();
197
+ return render();
198
+ });
199
+ renderQueue = result.catch(() => undefined);
200
+ return result;
201
+ }
202
+
203
+ /**
204
+ * Load a GLB and render a thumbnail, returning a data URL.
205
+ * Results are cached so repeated mounts (category switches, drawer re-opens)
206
+ * skip the fetch + render entirely. Concurrent mounts for the same asset
207
+ * share a single in-flight promise.
208
+ *
209
+ * GLB loads are concurrency-limited to avoid firing 50+ simultaneous HTTP
210
+ * requests when a category with many items becomes visible. The render
211
+ * queue (scheduleRender) already serializes the sync WebGL renders, but
212
+ * the async GLB fetches should still be bounded. Four concurrent loads fill
213
+ * the visible catalog quickly without starting dozens of requests at once.
214
+ */
215
+ const MAX_CONCURRENT_THUMBNAIL_LOADS = 4;
216
+ let activeThumbnailLoads = 0;
217
+ const pendingThumbnailLoads: Array<() => void> = [];
218
+
219
+ function dequeueThumbnailLoad(): void {
220
+ if (activeThumbnailLoads >= MAX_CONCURRENT_THUMBNAIL_LOADS) return;
221
+ const next = pendingThumbnailLoads.shift();
222
+ if (!next) return;
223
+ activeThumbnailLoads++;
224
+ next();
225
+ }
226
+
227
+ type ThumbnailHandle = {
228
+ promise: Promise<string | null>;
229
+ release: () => void;
230
+ };
231
+
232
+ function acquireThumbnail(item: PropCatalogItem): ThumbnailHandle {
233
+ const key = thumbnailCacheKey(item);
234
+ const cached = thumbnailDataCache.get(key);
235
+ if (cached) return { promise: Promise.resolve(cached), release: () => undefined };
236
+
237
+ thumbnailConsumerCounts.set(key, (thumbnailConsumerCounts.get(key) ?? 0) + 1);
238
+ let promise = thumbnailPromiseCache.get(key);
239
+ if (!promise) {
240
+ promise = new Promise<string | null>((resolve, reject) => {
241
+ const run = () => {
242
+ if (!thumbnailConsumerCounts.has(key)) {
243
+ activeThumbnailLoads--;
244
+ dequeueThumbnailLoad();
245
+ thumbnailPromiseCache.delete(key);
246
+ resolve(null);
247
+ return;
248
+ }
249
+ const finishLoad = () => {
250
+ activeThumbnailLoads--;
251
+ dequeueThumbnailLoad();
252
+ };
253
+ getSharedLoader()
254
+ .loadAsync(item.assetUrl)
255
+ .then(
256
+ ({ scene: source }) => {
257
+ finishLoad();
258
+ // The GLB parse is async, but the WebGL render + toDataURL is sync.
259
+ // Schedule the sync part in an animation frame so it doesn't block.
260
+ return scheduleRender(() => {
261
+ if (!thumbnailConsumerCounts.has(key)) {
262
+ thumbnailPromiseCache.delete(key);
263
+ disposeObject(source);
264
+ return null;
265
+ }
266
+ const dataUrl = renderThumbnailToDataURL({ item, source });
267
+ thumbnailDataCache.set(key, dataUrl);
268
+ thumbnailPromiseCache.delete(key);
269
+ return dataUrl;
270
+ });
271
+ },
272
+ (error) => {
273
+ finishLoad();
274
+ throw error;
275
+ }
276
+ )
277
+ .then(resolve, reject);
278
+ };
279
+ pendingThumbnailLoads.push(run);
280
+ dequeueThumbnailLoad();
281
+ });
282
+ thumbnailPromiseCache.set(key, promise);
283
+ }
284
+
285
+ let released = false;
286
+ return {
287
+ promise,
288
+ release: () => {
289
+ if (released) return;
290
+ released = true;
291
+ const consumers = thumbnailConsumerCounts.get(key) ?? 0;
292
+ if (consumers <= 1) thumbnailConsumerCounts.delete(key);
293
+ else thumbnailConsumerCounts.set(key, consumers - 1);
294
+ },
295
+ };
296
+ }
297
+
298
+ export const ModelThumbnail = memo(function ModelThumbnail({
299
+ item,
300
+ deferMs = 0,
301
+ }: {
302
+ item: PropCatalogItem;
303
+ /** Delay expensive GLB preview work so nearby controls remain responsive. */
304
+ deferMs?: number;
305
+ }) {
306
+ const wrapperRef = useRef<HTMLDivElement>(null);
307
+ const canvasRef = useRef<HTMLCanvasElement>(null);
308
+ const [visible, setVisible] = useState(false);
309
+ const [failed, setFailed] = useState(false);
310
+
311
+ useEffect(() => {
312
+ const wrapper = wrapperRef.current;
313
+ if (!wrapper || typeof IntersectionObserver === "undefined") {
314
+ setVisible(true);
315
+ return;
316
+ }
317
+ // Use a rootMargin so items slightly below the fold start loading
318
+ // before the user scrolls to them, making scroll feel instant.
319
+ const scrollRoot = wrapper.closest("[data-model-thumbnail-root]");
320
+ const observer = new IntersectionObserver(
321
+ ([entry]) => {
322
+ setVisible(Boolean(entry?.isIntersecting));
323
+ },
324
+ { root: scrollRoot, rootMargin: "200px" }
325
+ );
326
+ observer.observe(wrapper);
327
+ return () => observer.disconnect();
328
+ }, []);
329
+
330
+ useEffect(() => {
331
+ if (!visible) return;
332
+ const canvas = canvasRef.current;
333
+ if (!canvas) return;
334
+ let cancelled = false;
335
+
336
+ let thumbnail: ThumbnailHandle | undefined;
337
+ const load = () => {
338
+ if (cancelled) return;
339
+ thumbnail = acquireThumbnail(item);
340
+ void thumbnail.promise
341
+ .then((dataUrl) => {
342
+ if (cancelled || !dataUrl) return;
343
+ const ctx = canvas.getContext("2d");
344
+ if (!ctx) return;
345
+ const img = document.createElement("img");
346
+ img.onload = () => {
347
+ if (cancelled) return;
348
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
349
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
350
+ };
351
+ img.src = dataUrl;
352
+ })
353
+ .catch(() => {
354
+ if (!cancelled) setFailed(true);
355
+ });
356
+ };
357
+ const timer = window.setTimeout(load, deferMs);
358
+
359
+ return () => {
360
+ cancelled = true;
361
+ window.clearTimeout(timer);
362
+ thumbnail?.release();
363
+ };
364
+ }, [deferMs, item, visible]);
365
+
366
+ return (
367
+ <div
368
+ ref={wrapperRef}
369
+ className="relative flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-muted/60"
370
+ >
371
+ <canvas
372
+ ref={canvasRef}
373
+ width={96}
374
+ height={96}
375
+ className={cn("h-full w-full transition-opacity", failed ? "opacity-0" : "opacity-100")}
376
+ aria-hidden="true"
377
+ />
378
+ {failed ? <Leaf className="size-7 text-muted-foreground" aria-hidden="true" /> : null}
379
+ <span className="pointer-events-none absolute inset-x-1 bottom-1 truncate rounded bg-background/55 px-1.5 py-1 text-center text-[10px] font-medium text-foreground opacity-0 transition-opacity group-hover:opacity-100">
380
+ {item.label}
381
+ </span>
382
+ </div>
383
+ );
384
+ });
385
+
386
+ export type PropCatalogProps = {
387
+ items: readonly PropCatalogItem[];
388
+ selectedId?: string | null;
389
+ categories?: readonly PropCatalogCategory[];
390
+ className?: string;
391
+ onItemPointerDown?: (item: PropCatalogItem, event: ReactPointerEvent<HTMLButtonElement>) => void;
392
+ };
393
+
394
+ export function PropCatalog({
395
+ items,
396
+ selectedId = null,
397
+ categories = defaultPropCatalogCategories,
398
+ className,
399
+ onItemPointerDown,
400
+ }: PropCatalogProps) {
401
+ const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? "seating");
402
+ const [hoveredCategory, setHoveredCategory] = useState<{ id: string; left: number } | null>(null);
403
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
404
+ const [canScrollRight, setCanScrollRight] = useState(false);
405
+ const categoryScrollRef = useRef<HTMLDivElement>(null);
406
+ const categoryBarRef = useRef<HTMLDivElement>(null);
407
+ const visibleItems = useMemo(() => {
408
+ return items.filter((item) => item.category === activeCategory);
409
+ }, [activeCategory, items]);
410
+
411
+ const updateCategoryScroll = () => {
412
+ const element = categoryScrollRef.current;
413
+ if (!element) return;
414
+ setCanScrollLeft(element.scrollLeft > 1);
415
+ setCanScrollRight(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
416
+ setHoveredCategory(null);
417
+ };
418
+
419
+ useEffect(() => {
420
+ updateCategoryScroll();
421
+ const element = categoryScrollRef.current;
422
+ if (!element) return;
423
+ const observer =
424
+ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateCategoryScroll);
425
+ observer?.observe(element);
426
+ element.addEventListener("scroll", updateCategoryScroll, { passive: true });
427
+ return () => {
428
+ observer?.disconnect();
429
+ element.removeEventListener("scroll", updateCategoryScroll);
430
+ };
431
+ }, [categories.length]);
432
+
433
+ const scrollCategories = (direction: "left" | "right") => {
434
+ const element = categoryScrollRef.current;
435
+ if (!element) return;
436
+ const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
437
+ element.scrollTo({ left: direction === "left" ? 0 : maxScrollLeft, behavior: "auto" });
438
+ // The left arrow becomes visible after the first rightward scroll, which
439
+ // can reduce the viewport by one button width. Re-read the final extent
440
+ // after that layout update so the last category is never stranded.
441
+ if (direction === "right") {
442
+ requestAnimationFrame(() =>
443
+ requestAnimationFrame(() => {
444
+ const finalMaxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
445
+ element.scrollTo({ left: finalMaxScrollLeft, behavior: "auto" });
446
+ })
447
+ );
448
+ }
449
+ };
450
+
451
+ const setCategoryHover = (id: string, target: HTMLElement) => {
452
+ const bar = categoryBarRef.current;
453
+ if (!bar) return;
454
+ const targetRect = target.getBoundingClientRect();
455
+ const barRect = bar.getBoundingClientRect();
456
+ setHoveredCategory({ id, left: targetRect.left + targetRect.width / 2 - barRect.left });
457
+ };
458
+
459
+ return (
460
+ <div className={cn("flex min-h-0 flex-col gap-3", className)} aria-label="Prop catalog">
461
+ <div
462
+ ref={categoryBarRef}
463
+ className="relative shrink-0 pb-1"
464
+ role="tablist"
465
+ aria-label="Prop categories"
466
+ >
467
+ {canScrollLeft ? (
468
+ <span
469
+ aria-hidden="true"
470
+ className="absolute left-0 top-0 z-10 size-8 rounded-md bg-background"
471
+ />
472
+ ) : null}
473
+ <button
474
+ type="button"
475
+ tabIndex={canScrollLeft ? 0 : -1}
476
+ aria-hidden={!canScrollLeft}
477
+ className={cn(
478
+ "absolute left-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground",
479
+ !canScrollLeft ? "pointer-events-none invisible" : null
480
+ )}
481
+ aria-label="Scroll categories left"
482
+ onClick={() => scrollCategories("left")}
483
+ >
484
+ <ChevronLeft className="size-5" aria-hidden="true" />
485
+ </button>
486
+ <div
487
+ ref={categoryScrollRef}
488
+ className="w-full overflow-x-auto overflow-y-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
489
+ >
490
+ <div className="flex min-w-max gap-1">
491
+ {categories.map(({ id, label, icon: Icon }) => (
492
+ <button
493
+ key={id}
494
+ type="button"
495
+ role="tab"
496
+ aria-label={label}
497
+ aria-selected={activeCategory === id}
498
+ className={cn(
499
+ "group/category relative flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background/30 text-muted-foreground transition-colors hover:border-primary/70 hover:bg-accent/70 hover:text-accent-foreground",
500
+ activeCategory === id ? "border-primary bg-accent text-accent-foreground" : null
501
+ )}
502
+ onClick={() => setActiveCategory(id)}
503
+ onMouseEnter={(event) => setCategoryHover(id, event.currentTarget)}
504
+ onMouseLeave={() => setHoveredCategory(null)}
505
+ onFocus={(event) => setCategoryHover(id, event.currentTarget)}
506
+ onBlur={() => setHoveredCategory(null)}
507
+ >
508
+ <Icon className="size-4" aria-hidden="true" />
509
+ </button>
510
+ ))}
511
+ </div>
512
+ </div>
513
+ {canScrollRight ? (
514
+ <span
515
+ aria-hidden="true"
516
+ className="absolute right-0 top-0 z-10 size-8 rounded-md bg-background"
517
+ />
518
+ ) : null}
519
+ <button
520
+ type="button"
521
+ tabIndex={canScrollRight ? 0 : -1}
522
+ aria-hidden={!canScrollRight}
523
+ className={cn(
524
+ "absolute right-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground",
525
+ !canScrollRight ? "pointer-events-none invisible" : null
526
+ )}
527
+ aria-label="Scroll categories right"
528
+ onClick={() => scrollCategories("right")}
529
+ >
530
+ <ChevronRight className="size-5" aria-hidden="true" />
531
+ </button>
532
+ {hoveredCategory ? (
533
+ <div
534
+ className="pointer-events-none absolute top-full z-20 mt-1 rounded bg-background/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-md backdrop-blur-sm"
535
+ style={{ left: hoveredCategory.left, transform: "translateX(-50%)" }}
536
+ >
537
+ {categories.find((category) => category.id === hoveredCategory.id)?.label}
538
+ </div>
539
+ ) : null}
540
+ </div>
541
+ {visibleItems.length > 0 ? (
542
+ <div
543
+ className="min-h-0 flex-1 overflow-y-auto pr-1"
544
+ role="tabpanel"
545
+ data-model-thumbnail-root
546
+ >
547
+ <div className="grid grid-cols-3 gap-2">
548
+ {visibleItems.map((item) => (
549
+ <Card
550
+ key={item.id}
551
+ className={cn(
552
+ "group overflow-hidden border-border bg-card/60 p-1 transition-colors",
553
+ selectedId === item.id
554
+ ? "border-primary bg-accent/70"
555
+ : "hover:border-primary/50 hover:bg-accent/40"
556
+ )}
557
+ >
558
+ <button
559
+ type="button"
560
+ aria-label={`Add ${item.label}`}
561
+ className="block w-full rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
562
+ onPointerDown={(event) => {
563
+ if (event.button !== 0) return;
564
+ event.preventDefault();
565
+ event.stopPropagation();
566
+ onItemPointerDown?.(item, event);
567
+ }}
568
+ >
569
+ <ModelThumbnail item={item} />
570
+ <span className="sr-only">{item.label}</span>
571
+ </button>
572
+ </Card>
573
+ ))}
574
+ </div>
575
+ </div>
576
+ ) : (
577
+ <p className="rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted-foreground">
578
+ No props in this category yet.
579
+ </p>
580
+ )}
581
+ </div>
582
+ );
583
+ }