@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,455 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { googleFontStylesheetUrl, POPULAR_GOOGLE_FONT_FAMILIES } from "./fontCatalog";
3
+ import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
4
+ import {
5
+ DEFAULT_FONT_FAMILIES,
6
+ FIELD,
7
+ GENERIC_FONT_FAMILIES,
8
+ LABEL,
9
+ localFontSortScore,
10
+ sanitizeFontFilePart,
11
+ sortFontOptions,
12
+ uniqueFontFamilies,
13
+ uniqueFontOptions,
14
+ type FontOption,
15
+ type LocalFontData,
16
+ } from "./propertyPanelHelpers";
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Font helper functions */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ function splitFontFamilies(value: string): string[] {
23
+ const families: string[] = [];
24
+ let current = "";
25
+ let quote: '"' | "'" | null = null;
26
+ for (const char of value) {
27
+ if ((char === '"' || char === "'") && !quote) {
28
+ quote = char;
29
+ continue;
30
+ }
31
+ if (char === quote) {
32
+ quote = null;
33
+ continue;
34
+ }
35
+ if (char === "," && !quote) {
36
+ if (current.trim()) families.push(current.trim());
37
+ current = "";
38
+ continue;
39
+ }
40
+ current += char;
41
+ }
42
+ if (current.trim()) families.push(current.trim());
43
+ return families.map((f) => f.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
44
+ }
45
+
46
+ function primaryFontFamily(value: string): string {
47
+ return splitFontFamilies(value)[0] ?? "inherit";
48
+ }
49
+
50
+ function quoteFontFamily(family: string): string {
51
+ const trimmed = family.trim();
52
+ if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
53
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
54
+ }
55
+
56
+ function buildFontFamilyValue(family: string): string {
57
+ const trimmed = family.trim();
58
+ if (!trimmed) return "inherit";
59
+ if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
60
+ return `${quoteFontFamily(trimmed)}, ui-sans-serif, system-ui, sans-serif`;
61
+ }
62
+
63
+ function collectDocumentFontFamilies(): string[] {
64
+ if (typeof document === "undefined") return [];
65
+ const fontSet = document.fonts;
66
+ if (!fontSet) return [];
67
+ return Array.from(fontSet, (ff) => ff.family.replace(/^["']|["']$/g, "").trim())
68
+ .filter(Boolean)
69
+ .sort((a, b) => a.localeCompare(b));
70
+ }
71
+
72
+ function fontSearchKey(value: string): string {
73
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
74
+ }
75
+
76
+ function fontMatchesQuery(family: string, query: string): boolean {
77
+ const normalizedQuery = query.trim().toLowerCase();
78
+ if (!normalizedQuery) return true;
79
+ if (family.toLowerCase().includes(normalizedQuery)) return true;
80
+ return fontSearchKey(family).includes(fontSearchKey(normalizedQuery));
81
+ }
82
+
83
+ function loadGoogleFontStylesheet(family: string): void {
84
+ if (typeof document === "undefined") return;
85
+ const trimmed = family.trim();
86
+ if (!trimmed) return;
87
+ const id = `studio-google-font-${trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
88
+ if (document.getElementById(id)) return;
89
+ const preconnect = document.querySelector('link[data-studio-google-font-preconnect="true"]');
90
+ if (!preconnect) {
91
+ const el = document.createElement("link");
92
+ el.setAttribute("data-studio-google-font-preconnect", "true");
93
+ el.rel = "preconnect";
94
+ el.href = "https://fonts.gstatic.com";
95
+ el.crossOrigin = "anonymous";
96
+ document.head.appendChild(el);
97
+ }
98
+ const link = document.createElement("link");
99
+ link.id = id;
100
+ link.rel = "stylesheet";
101
+ link.href = googleFontStylesheetUrl(trimmed);
102
+ document.head.appendChild(link);
103
+ }
104
+
105
+ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
106
+ if (typeof document === "undefined") return;
107
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
108
+ if (document.getElementById(id)) return;
109
+ const style = document.createElement("style");
110
+ style.id = id;
111
+ style.textContent = importedFontFaceCss(asset);
112
+ document.head.appendChild(style);
113
+ }
114
+
115
+ /* ------------------------------------------------------------------ */
116
+ /* FontFamilyField */
117
+ /* ------------------------------------------------------------------ */
118
+
119
+ export function FontFamilyField({
120
+ value,
121
+ disabled,
122
+ importedFonts,
123
+ onImportFonts,
124
+ onCommit,
125
+ }: {
126
+ value: string;
127
+ disabled?: boolean;
128
+ importedFonts: ImportedFontAsset[];
129
+ onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
130
+ onCommit: (nextValue: string) => void;
131
+ }) {
132
+ const currentFamily = primaryFontFamily(value);
133
+ const containerRef = useRef<HTMLDivElement | null>(null);
134
+ const inputRef = useRef<HTMLInputElement | null>(null);
135
+ const fontInputRef = useRef<HTMLInputElement | null>(null);
136
+ const [open, setOpen] = useState(false);
137
+ const [query, setQuery] = useState("");
138
+ const [localFonts, setLocalFonts] = useState<string[]>([]);
139
+ const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
140
+ const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
141
+ const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
142
+ const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
143
+ const [importingFonts, setImportingFonts] = useState(false);
144
+ const [fontNotice, setFontNotice] = useState<string | null>(null);
145
+ const canQueryLocalFonts =
146
+ typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
147
+
148
+ useEffect(() => {
149
+ if (!open) return;
150
+ const handlePointerDown = (event: PointerEvent) => {
151
+ const target = event.target;
152
+ if (!(target instanceof Node)) return;
153
+ if (!containerRef.current?.contains(target)) setOpen(false);
154
+ };
155
+ document.addEventListener("pointerdown", handlePointerDown);
156
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
157
+ }, [open]);
158
+
159
+ useEffect(() => {
160
+ if (!open) return;
161
+ requestAnimationFrame(() => inputRef.current?.focus());
162
+ }, [open]);
163
+
164
+ useEffect(() => {
165
+ let cancelled = false;
166
+ void fetch("/api/fonts")
167
+ .then((r) => (r.ok ? r.json() : null))
168
+ .then((data: { fonts?: string[] } | null) => {
169
+ if (cancelled || !Array.isArray(data?.fonts)) return;
170
+ setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!]));
171
+ })
172
+ .catch(() => undefined);
173
+ return () => {
174
+ cancelled = true;
175
+ };
176
+ }, []);
177
+
178
+ useEffect(() => {
179
+ let cancelled = false;
180
+ setLoadingGoogleFonts(true);
181
+ void fetch("/api/fonts/google")
182
+ .then((r) => (r.ok ? r.json() : null))
183
+ .then((data: { fonts?: string[] } | null) => {
184
+ if (cancelled || !Array.isArray(data?.fonts)) return;
185
+ setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES]));
186
+ })
187
+ .catch(() => undefined)
188
+ .finally(() => {
189
+ if (!cancelled) setLoadingGoogleFonts(false);
190
+ });
191
+ return () => {
192
+ cancelled = true;
193
+ };
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) {
198
+ loadGoogleFontStylesheet(currentFamily);
199
+ }
200
+ const imported = importedFonts.find(
201
+ (f) => f.family.toLowerCase() === currentFamily.toLowerCase(),
202
+ );
203
+ if (imported) loadImportedFontStylesheet(imported);
204
+ }, [currentFamily, googleFonts, importedFonts]);
205
+
206
+ const loadBrowserLocalFonts = async () => {
207
+ if (!canQueryLocalFonts || !window.queryLocalFonts) {
208
+ setFontNotice("This browser does not expose installed fonts. Import a font file instead.");
209
+ return;
210
+ }
211
+ setLoadingLocalFonts(true);
212
+ setFontNotice(null);
213
+ try {
214
+ const fonts = await window.queryLocalFonts();
215
+ const sorted = [...fonts].sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
216
+ const families = sorted
217
+ .map((f) => f.family)
218
+ .filter((name): name is string => Boolean(name))
219
+ .map((name) => fontFamilyFromAssetPath(`${name}.ttf`));
220
+ setLocalFontData(sorted);
221
+ setLocalFonts((cur) => uniqueFontFamilies([...cur, ...families]));
222
+ setFontNotice(fonts.length === 0 ? "No browser-local fonts were returned." : null);
223
+ } catch (error) {
224
+ const name = error instanceof Error ? error.name : "";
225
+ setFontNotice(
226
+ name === "NotAllowedError"
227
+ ? "Local font access was denied. Import a font file instead."
228
+ : "Local font access is unavailable. Import a font file instead.",
229
+ );
230
+ } finally {
231
+ setLoadingLocalFonts(false);
232
+ }
233
+ };
234
+
235
+ const handleImportFonts = async (files: FileList | File[] | null) => {
236
+ if (!files?.length || !onImportFonts) return;
237
+ setImportingFonts(true);
238
+ setFontNotice(null);
239
+ try {
240
+ const imported = await onImportFonts(files);
241
+ for (const font of imported) loadImportedFontStylesheet(font);
242
+ const first = imported[0];
243
+ if (first) {
244
+ onCommit(buildFontFamilyValue(first.family));
245
+ setQuery("");
246
+ setOpen(false);
247
+ } else {
248
+ setFontNotice("No supported font files were imported.");
249
+ }
250
+ } finally {
251
+ setImportingFonts(false);
252
+ }
253
+ };
254
+
255
+ const projectFontAssets = useMemo(
256
+ () =>
257
+ uniqueFontOptions(
258
+ importedFonts.map((f): FontOption => ({ family: f.family, source: "Imported" })),
259
+ ),
260
+ [importedFonts],
261
+ );
262
+
263
+ const options = useMemo(() => {
264
+ const documentFonts = collectDocumentFontFamilies();
265
+ const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
266
+ const taggedLocal = localFonts.map(
267
+ (family): FontOption => ({
268
+ family,
269
+ source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
270
+ }),
271
+ );
272
+ return sortFontOptions(
273
+ uniqueFontOptions([
274
+ { family: currentFamily, source: "Current" },
275
+ ...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })),
276
+ ...projectFontAssets,
277
+ ...googleFonts.map((f): FontOption => ({ family: f, source: "Google" })),
278
+ ...taggedLocal,
279
+ ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
280
+ ]),
281
+ );
282
+ }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
283
+
284
+ const filteredOptions = useMemo(() => {
285
+ const matches = options.filter((o) => fontMatchesQuery(o.family, query));
286
+ if (query.trim()) return matches.slice(0, 200);
287
+ const bySource = new Map<string, FontOption[]>();
288
+ for (const m of matches) {
289
+ const list = bySource.get(m.source) ?? [];
290
+ list.push(m);
291
+ bySource.set(m.source, list);
292
+ }
293
+ const result: FontOption[] = [];
294
+ for (const s of ["Current", "Document", "Imported"]) result.push(...(bySource.get(s) ?? []));
295
+ result.push(...(bySource.get("Google") ?? []).slice(0, 100));
296
+ result.push(...(bySource.get("Local") ?? []).slice(0, 80));
297
+ result.push(...(bySource.get("System") ?? []));
298
+ return result;
299
+ }, [options, query]);
300
+
301
+ const importLocalFont = async (family: string): Promise<ImportedFontAsset | null> => {
302
+ if (!onImportFonts) return null;
303
+ const candidates = localFontData
304
+ .filter((f) => fontFamilyFromAssetPath(`${f.family}.ttf`) === family)
305
+ .sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
306
+ const font = candidates.find((entry) => typeof entry.blob === "function");
307
+ if (!font?.blob) return null;
308
+ const blob = await font.blob();
309
+ const style = sanitizeFontFilePart(font.style ?? "Regular") || "Regular";
310
+ const name = sanitizeFontFilePart(`${family} ${style}`) || family;
311
+ const file = new File([blob], `${name}.ttf`, { type: blob.type || "font/ttf" });
312
+ const imported = await onImportFonts([file]);
313
+ return (
314
+ imported.find((a) => a.family.toLowerCase() === family.toLowerCase()) ?? imported[0] ?? null
315
+ );
316
+ };
317
+
318
+ const commitFamily = async (option: FontOption) => {
319
+ if (option.source === "Local") {
320
+ setImportingFonts(true);
321
+ setFontNotice(null);
322
+ try {
323
+ const imported = await importLocalFont(option.family);
324
+ if (imported) {
325
+ loadImportedFontStylesheet(imported);
326
+ onCommit(buildFontFamilyValue(imported.family));
327
+ setQuery("");
328
+ setOpen(false);
329
+ return;
330
+ }
331
+ onCommit(buildFontFamilyValue(option.family));
332
+ setQuery("");
333
+ setOpen(false);
334
+ } finally {
335
+ setImportingFonts(false);
336
+ }
337
+ return;
338
+ }
339
+ if (option.source === "Google") loadGoogleFontStylesheet(option.family);
340
+ const imported = importedFonts.find(
341
+ (f) => f.family.toLowerCase() === option.family.toLowerCase(),
342
+ );
343
+ if (imported) loadImportedFontStylesheet(imported);
344
+ onCommit(buildFontFamilyValue(option.family));
345
+ setQuery("");
346
+ setOpen(false);
347
+ };
348
+
349
+ return (
350
+ <div ref={containerRef} className="relative grid min-w-0 gap-1.5">
351
+ <span className={LABEL}>Font family</span>
352
+ <button
353
+ type="button"
354
+ disabled={disabled}
355
+ onClick={() => setOpen((next) => !next)}
356
+ className={`${FIELD} flex h-10 items-center justify-between gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed`}
357
+ >
358
+ <span
359
+ className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100"
360
+ style={{ fontFamily: value }}
361
+ >
362
+ {currentFamily}
363
+ </span>
364
+ <span className="flex-shrink-0 text-[10px] uppercase tracking-[0.14em] text-neutral-600">
365
+ Font
366
+ </span>
367
+ </button>
368
+
369
+ {open && (
370
+ <div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-950 shadow-2xl">
371
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
372
+ <input
373
+ ref={inputRef}
374
+ type="text"
375
+ value={query}
376
+ disabled={disabled}
377
+ placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
378
+ onChange={(e) => setQuery(e.target.value)}
379
+ onKeyDown={(e) => {
380
+ if (e.key === "Escape") {
381
+ e.preventDefault();
382
+ setOpen(false);
383
+ }
384
+ if (e.key === "Enter" && filteredOptions[0]) {
385
+ e.preventDefault();
386
+ commitFamily(filteredOptions[0]);
387
+ }
388
+ }}
389
+ className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
390
+ />
391
+ {canQueryLocalFonts && (
392
+ <button
393
+ type="button"
394
+ disabled={disabled || loadingLocalFonts}
395
+ onClick={loadBrowserLocalFonts}
396
+ className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
397
+ >
398
+ {loadingLocalFonts ? "..." : "Local"}
399
+ </button>
400
+ )}
401
+ <button
402
+ type="button"
403
+ disabled={disabled || importingFonts || !onImportFonts}
404
+ onClick={() => fontInputRef.current?.click()}
405
+ className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
406
+ >
407
+ {importingFonts ? "..." : "Import"}
408
+ </button>
409
+ <input
410
+ ref={fontInputRef}
411
+ type="file"
412
+ accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
413
+ multiple
414
+ aria-label="Import local font files"
415
+ disabled={disabled || importingFonts || !onImportFonts}
416
+ className="hidden"
417
+ onChange={async (event) => {
418
+ await handleImportFonts(event.target.files);
419
+ event.target.value = "";
420
+ }}
421
+ />
422
+ </div>
423
+ {fontNotice && (
424
+ <div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
425
+ {fontNotice}
426
+ </div>
427
+ )}
428
+ <div className="max-h-64 overflow-y-auto p-1">
429
+ {filteredOptions.length === 0 ? (
430
+ <div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
431
+ ) : (
432
+ filteredOptions.map((option) => (
433
+ <button
434
+ key={`${option.source}-${option.family}`}
435
+ type="button"
436
+ onClick={() => commitFamily(option)}
437
+ className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
438
+ option.family === currentFamily
439
+ ? "bg-studio-accent/15 text-neutral-50"
440
+ : "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
441
+ }`}
442
+ >
443
+ <span className="min-w-0 truncate font-medium">{option.family}</span>
444
+ <span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
445
+ {option.source}
446
+ </span>
447
+ </button>
448
+ ))
449
+ )}
450
+ </div>
451
+ </div>
452
+ )}
453
+ </div>
454
+ );
455
+ }