@newtonedev/editor 0.1.5 → 0.1.7

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 (62) hide show
  1. package/dist/Editor.d.ts +1 -1
  2. package/dist/Editor.d.ts.map +1 -1
  3. package/dist/components/CodeBlock.d.ts.map +1 -1
  4. package/dist/components/ConfiguratorPanel.d.ts +17 -0
  5. package/dist/components/ConfiguratorPanel.d.ts.map +1 -0
  6. package/dist/components/FontPicker.d.ts +4 -2
  7. package/dist/components/FontPicker.d.ts.map +1 -1
  8. package/dist/components/PresetSelector.d.ts.map +1 -1
  9. package/dist/components/PreviewWindow.d.ts +9 -3
  10. package/dist/components/PreviewWindow.d.ts.map +1 -1
  11. package/dist/components/PrimaryNav.d.ts +7 -0
  12. package/dist/components/PrimaryNav.d.ts.map +1 -0
  13. package/dist/components/RightSidebar.d.ts +4 -1
  14. package/dist/components/RightSidebar.d.ts.map +1 -1
  15. package/dist/components/Sidebar.d.ts +1 -10
  16. package/dist/components/Sidebar.d.ts.map +1 -1
  17. package/dist/components/TableOfContents.d.ts +2 -1
  18. package/dist/components/TableOfContents.d.ts.map +1 -1
  19. package/dist/components/sections/DynamicRangeSection.d.ts.map +1 -1
  20. package/dist/components/sections/FontsSection.d.ts +3 -1
  21. package/dist/components/sections/FontsSection.d.ts.map +1 -1
  22. package/dist/hooks/useEditorState.d.ts +4 -1
  23. package/dist/hooks/useEditorState.d.ts.map +1 -1
  24. package/dist/index.cjs +2893 -2248
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2895 -2251
  29. package/dist/index.js.map +1 -1
  30. package/dist/preview/ComponentDetailView.d.ts +9 -2
  31. package/dist/preview/ComponentDetailView.d.ts.map +1 -1
  32. package/dist/preview/ComponentRenderer.d.ts +2 -1
  33. package/dist/preview/ComponentRenderer.d.ts.map +1 -1
  34. package/dist/preview/IconBrowserView.d.ts +7 -0
  35. package/dist/preview/IconBrowserView.d.ts.map +1 -0
  36. package/dist/types.d.ts +17 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils/lookupFontMetrics.d.ts +19 -0
  39. package/dist/utils/lookupFontMetrics.d.ts.map +1 -0
  40. package/dist/utils/measureFonts.d.ts +18 -0
  41. package/dist/utils/measureFonts.d.ts.map +1 -0
  42. package/package.json +1 -1
  43. package/src/Editor.tsx +57 -11
  44. package/src/components/CodeBlock.tsx +42 -14
  45. package/src/components/ConfiguratorPanel.tsx +77 -0
  46. package/src/components/FontPicker.tsx +38 -29
  47. package/src/components/PresetSelector.tsx +8 -33
  48. package/src/components/PreviewWindow.tsx +20 -4
  49. package/src/components/PrimaryNav.tsx +76 -0
  50. package/src/components/RightSidebar.tsx +103 -40
  51. package/src/components/Sidebar.tsx +4 -211
  52. package/src/components/TableOfContents.tsx +41 -78
  53. package/src/components/sections/DynamicRangeSection.tsx +2 -225
  54. package/src/components/sections/FontsSection.tsx +61 -93
  55. package/src/hooks/useEditorState.ts +68 -9
  56. package/src/index.ts +2 -0
  57. package/src/preview/ComponentDetailView.tsx +576 -73
  58. package/src/preview/ComponentRenderer.tsx +6 -4
  59. package/src/preview/IconBrowserView.tsx +187 -0
  60. package/src/types.ts +15 -0
  61. package/src/utils/lookupFontMetrics.ts +52 -0
  62. package/src/utils/measureFonts.ts +41 -0
@@ -1,29 +1,367 @@
1
- import { useState } from "react";
2
- import { useTokens } from "@newtonedev/components";
1
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
2
+ import { useTokens, useNewtoneTheme, NewtoneProvider } from "@newtonedev/components";
3
3
  import { srgbToHex } from "newtone";
4
4
  import { getComponent } from "@newtonedev/components";
5
+ import type { TextRole, BreakpointKey, TextSize, RoleScales } from "@newtonedev/fonts";
6
+ import { ROLE_DEFAULT_WEIGHTS, BREAKPOINT_ROLE_SCALE, scaleRoleStep, resolveResponsiveSize } from "@newtonedev/fonts";
5
7
  import { ComponentRenderer } from "./ComponentRenderer";
8
+ import { IconBrowserView } from "./IconBrowserView";
9
+ import type { EditorFontEntry } from "../types";
10
+
11
+ /** Variant IDs that map to unique text roles and get inline weight controls. */
12
+ const ROLE_VARIANT_IDS = new Set([
13
+ "body",
14
+ "headline",
15
+ "title",
16
+ "heading",
17
+ "subheading",
18
+ "label",
19
+ "caption",
20
+ ]);
6
21
 
7
22
  interface ComponentDetailViewProps {
8
23
  readonly componentId: string;
9
24
  readonly selectedVariantId: string | null;
10
- readonly propOverrides?: Record<string, unknown>;
11
25
  readonly onSelectVariant: (variantId: string) => void;
26
+ readonly propOverrides?: Record<string, unknown>;
27
+ readonly onPropOverride?: (name: string, value: unknown) => void;
28
+ readonly roleWeights?: Partial<Record<TextRole, number>>;
29
+ readonly onRoleWeightChange?: (role: TextRole, weight: number) => void;
30
+ readonly fontCatalog?: readonly EditorFontEntry[];
31
+ readonly scopeFontMap?: Record<string, string>;
32
+ }
33
+
34
+ /**
35
+ * Determine what kind of weight control to show for a font.
36
+ * Returns 'slider' for variable fonts (smooth) and fixed-weight fonts (with stops),
37
+ * or 'none' for single-weight fonts.
38
+ */
39
+ function getWeightControlType(
40
+ family: string | undefined,
41
+ fontCatalog: readonly EditorFontEntry[] | undefined,
42
+ ): { type: "slider"; min: number; max: number; stops?: readonly number[] } | { type: "none" } {
43
+ if (!family) return { type: "none" };
44
+
45
+ const entry = fontCatalog?.find((f) => f.family === family);
46
+
47
+ if (entry) {
48
+ if (entry.isVariable) {
49
+ return {
50
+ type: "slider",
51
+ min: entry.weightAxisRange?.min ?? 100,
52
+ max: entry.weightAxisRange?.max ?? 900,
53
+ };
54
+ }
55
+ if (entry.availableWeights && entry.availableWeights.length > 1) {
56
+ const sorted = [...entry.availableWeights].sort((a, b) => a - b);
57
+ return { type: "slider", min: sorted[0], max: sorted[sorted.length - 1], stops: sorted };
58
+ }
59
+ return { type: "none" };
60
+ }
61
+
62
+ // Not in catalog — system font or unknown → treat as variable
63
+ return { type: "slider", min: 100, max: 900 };
64
+ }
65
+
66
+ /**
67
+ * Unified weight slider. Smooth for variable fonts, snaps to stops for fixed-weight fonts.
68
+ * Renders tick marks when stops are provided.
69
+ */
70
+ function WeightSlider({
71
+ value,
72
+ min,
73
+ max,
74
+ stops,
75
+ onChange,
76
+ textColor,
77
+ accentColor,
78
+ }: {
79
+ readonly value: number;
80
+ readonly min: number;
81
+ readonly max: number;
82
+ readonly stops?: readonly number[];
83
+ readonly onChange: (v: number) => void;
84
+ readonly textColor: string;
85
+ readonly accentColor: string;
86
+ }) {
87
+ const snap = useCallback(
88
+ (v: number) => {
89
+ if (!stops) return v;
90
+ return stops.reduce((prev, curr) =>
91
+ Math.abs(curr - v) < Math.abs(prev - v) ? curr : prev,
92
+ );
93
+ },
94
+ [stops],
95
+ );
96
+
97
+ const handleChange = useCallback(
98
+ (e: React.ChangeEvent<HTMLInputElement>) => {
99
+ onChange(snap(Number(e.target.value)));
100
+ },
101
+ [onChange, snap],
102
+ );
103
+
104
+ const range = max - min;
105
+
106
+ return (
107
+ <div
108
+ style={{
109
+ display: "flex",
110
+ alignItems: "center",
111
+ gap: 8,
112
+ flexShrink: 0,
113
+ }}
114
+ onClick={(e) => e.stopPropagation()}
115
+ >
116
+ <div style={{ position: "relative", width: 80 }}>
117
+ <input
118
+ type="range"
119
+ min={min}
120
+ max={max}
121
+ step={1}
122
+ value={value}
123
+ onChange={handleChange}
124
+ style={{
125
+ width: 80,
126
+ accentColor,
127
+ cursor: "pointer",
128
+ display: "block",
129
+ }}
130
+ />
131
+ {stops && range > 0 && (
132
+ <div
133
+ style={{
134
+ position: "relative",
135
+ width: 80,
136
+ height: 4,
137
+ marginTop: -2,
138
+ pointerEvents: "none",
139
+ }}
140
+ >
141
+ {stops.map((s) => (
142
+ <div
143
+ key={s}
144
+ style={{
145
+ position: "absolute",
146
+ left: `${((s - min) / range) * 100}%`,
147
+ width: 2,
148
+ height: 4,
149
+ backgroundColor: s === value ? accentColor : textColor,
150
+ opacity: s === value ? 1 : 0.3,
151
+ borderRadius: 1,
152
+ transform: "translateX(-50%)",
153
+ }}
154
+ />
155
+ ))}
156
+ </div>
157
+ )}
158
+ </div>
159
+ <span
160
+ style={{
161
+ fontSize: 11,
162
+ fontWeight: 500,
163
+ color: textColor,
164
+ width: 28,
165
+ textAlign: "right",
166
+ fontVariantNumeric: "tabular-nums",
167
+ }}
168
+ >
169
+ {value}
170
+ </span>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ const PREVIEW_TEXT = "The quick brown fox";
176
+
177
+ /**
178
+ * Measures its container width via ResizeObserver and displays the live
179
+ * resolved fontSize/lineHeight from resolveResponsiveSize.
180
+ */
181
+ function TextAnnotation({
182
+ role,
183
+ roleScales,
184
+ fontFamily,
185
+ calibrations,
186
+ weight,
187
+ textColor,
188
+ accentColor,
189
+ previewText = PREVIEW_TEXT,
190
+ }: {
191
+ readonly role: TextRole;
192
+ readonly roleScales: RoleScales;
193
+ readonly fontFamily?: string;
194
+ readonly calibrations?: Record<string, number>;
195
+ readonly weight: number;
196
+ readonly textColor: string;
197
+ readonly accentColor: string;
198
+ readonly previewText?: string;
199
+ }) {
200
+ const containerRef = useRef<HTMLDivElement>(null);
201
+ const [containerWidth, setContainerWidth] = useState<number | null>(null);
202
+
203
+ useEffect(() => {
204
+ const el = containerRef.current?.parentElement;
205
+ if (!el) return;
206
+ const observer = new ResizeObserver((entries) => {
207
+ const w = entries[0]?.contentRect.width;
208
+ if (w && w > 0) setContainerWidth(w);
209
+ });
210
+ observer.observe(el);
211
+ return () => observer.disconnect();
212
+ }, []);
213
+
214
+ const step = roleScales[role].md;
215
+ const minFs = Math.max(8, Math.round(step.fontSize * 0.7));
216
+ const maxFs = step.fontSize;
217
+
218
+ const resolved = useMemo(() => {
219
+ if (containerWidth == null) return { fontSize: maxFs, lineHeight: step.lineHeight };
220
+ return resolveResponsiveSize(
221
+ {
222
+ role,
223
+ size: "md" as TextSize,
224
+ responsive: true,
225
+ fontFamily,
226
+ maxFontSize: maxFs,
227
+ minFontSize: minFs,
228
+ },
229
+ roleScales,
230
+ { containerWidth, characterCount: previewText.length },
231
+ calibrations,
232
+ );
233
+ }, [role, step, roleScales, fontFamily, calibrations, containerWidth, minFs, maxFs]);
234
+
235
+ const range = maxFs - minFs;
236
+ const position = range > 0 ? ((resolved.fontSize - minFs) / range) * 100 : 100;
237
+
238
+ return (
239
+ <div
240
+ ref={containerRef}
241
+ style={{
242
+ marginTop: 4,
243
+ fontSize: 10,
244
+ color: textColor,
245
+ fontVariantNumeric: "tabular-nums",
246
+ letterSpacing: 0.2,
247
+ }}
248
+ >
249
+ <span>{resolved.fontSize}/{resolved.lineHeight} · {weight}</span>
250
+ <div
251
+ style={{
252
+ display: "flex",
253
+ alignItems: "center",
254
+ gap: 4,
255
+ marginTop: 3,
256
+ }}
257
+ >
258
+ <span style={{ width: 16, textAlign: "right", flexShrink: 0 }}>{minFs}</span>
259
+ <div
260
+ style={{
261
+ flex: 1,
262
+ height: 1,
263
+ backgroundColor: textColor,
264
+ opacity: 0.3,
265
+ position: "relative",
266
+ }}
267
+ >
268
+ <div
269
+ style={{
270
+ position: "absolute",
271
+ left: `${position}%`,
272
+ top: "50%",
273
+ width: 6,
274
+ height: 6,
275
+ borderRadius: "50%",
276
+ backgroundColor: accentColor,
277
+ transform: "translate(-50%, -50%)",
278
+ }}
279
+ />
280
+ </div>
281
+ <span style={{ width: 16, flexShrink: 0 }}>{maxFs}</span>
282
+ </div>
283
+ </div>
284
+ );
12
285
  }
13
286
 
14
287
  export function ComponentDetailView({
15
288
  componentId,
16
289
  selectedVariantId,
17
- propOverrides,
18
290
  onSelectVariant,
291
+ propOverrides,
292
+ onPropOverride,
293
+ roleWeights,
294
+ onRoleWeightChange,
295
+ fontCatalog,
296
+ scopeFontMap,
19
297
  }: ComponentDetailViewProps) {
20
298
  const tokens = useTokens();
299
+ const { config } = useNewtoneTheme();
21
300
  const component = getComponent(componentId);
22
301
  const [hoveredId, setHoveredId] = useState<string | null>(null);
302
+ const [previewBreakpoint, setPreviewBreakpoint] = useState<BreakpointKey>("lg");
303
+ const [previewText, setPreviewText] = useState(PREVIEW_TEXT);
304
+
305
+ const scaledConfig = useMemo(() => {
306
+ if (previewBreakpoint === "lg") return config;
307
+ const scales = BREAKPOINT_ROLE_SCALE[previewBreakpoint];
308
+ const baseRoles = config.typography.roles;
309
+ const scaledRoles = {} as Record<string, Record<string, { fontSize: number; lineHeight: number }>>;
310
+ for (const role of Object.keys(baseRoles) as TextRole[]) {
311
+ const scale = scales[role];
312
+ scaledRoles[role] = {} as Record<string, { fontSize: number; lineHeight: number }>;
313
+ for (const size of ["sm", "md", "lg"] as TextSize[]) {
314
+ const step = baseRoles[role][size];
315
+ scaledRoles[role][size] = scale === 1.0 ? step : scaleRoleStep(step, scale);
316
+ }
317
+ }
318
+ return { ...config, typography: { ...config.typography, roles: scaledRoles as unknown as typeof config.typography.roles } };
319
+ }, [config, previewBreakpoint]);
23
320
 
24
321
  if (!component) return null;
25
322
 
323
+ if (componentId === "icon" && propOverrides && onPropOverride) {
324
+ return (
325
+ <div
326
+ style={{
327
+ padding: "32px 0 0",
328
+ height: "100%",
329
+ display: "flex",
330
+ flexDirection: "column",
331
+ }}
332
+ >
333
+ <div style={{ padding: "0 32px", marginBottom: 24 }}>
334
+ <h2
335
+ style={{
336
+ fontSize: 22,
337
+ fontWeight: 700,
338
+ color: srgbToHex(tokens.textPrimary.srgb),
339
+ margin: 0,
340
+ marginBottom: 4,
341
+ }}
342
+ >
343
+ {component.name}
344
+ </h2>
345
+ <p
346
+ style={{
347
+ fontSize: 14,
348
+ color: srgbToHex(tokens.textSecondary.srgb),
349
+ margin: 0,
350
+ }}
351
+ >
352
+ {component.description}
353
+ </p>
354
+ </div>
355
+ <IconBrowserView
356
+ selectedIconName={(propOverrides.name as string) ?? "add"}
357
+ onIconSelect={(name) => onPropOverride("name", name)}
358
+ />
359
+ </div>
360
+ );
361
+ }
362
+
26
363
  const interactiveColor = srgbToHex(tokens.accent.fill.srgb);
364
+ const isTextComponent = componentId === "text";
27
365
 
28
366
  return (
29
367
  <div style={{ padding: 32 }}>
@@ -43,84 +381,249 @@ export function ComponentDetailView({
43
381
  fontSize: 14,
44
382
  color: srgbToHex(tokens.textSecondary.srgb),
45
383
  margin: 0,
46
- marginBottom: 32,
384
+ marginBottom: isTextComponent ? 16 : 32,
47
385
  }}
48
386
  >
49
387
  {component.description}
50
388
  </p>
51
389
 
52
- <div
53
- style={{
54
- display: "grid",
55
- gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
56
- gap: 16,
57
- }}
58
- >
59
- {component.variants.map((variant) => {
60
- const isSelected = selectedVariantId === variant.id;
61
- const isHovered = hoveredId === variant.id;
62
-
63
- const borderColor = isSelected
64
- ? interactiveColor
65
- : isHovered
66
- ? `${interactiveColor}66`
67
- : srgbToHex(tokens.border.srgb);
68
-
69
- return (
70
- <button
71
- key={variant.id}
72
- onClick={() => onSelectVariant(variant.id)}
73
- onMouseEnter={() => setHoveredId(variant.id)}
74
- onMouseLeave={() => setHoveredId(null)}
75
- style={{
76
- display: "flex",
77
- flexDirection: "column",
78
- alignItems: "stretch",
79
- padding: 16,
80
- borderRadius: 12,
81
- border: `2px solid ${borderColor}`,
82
- backgroundColor: srgbToHex(tokens.backgroundElevated.srgb),
83
- cursor: "pointer",
84
- textAlign: "left",
85
- transition: "border-color 150ms ease",
86
- }}
87
- >
88
- <div
390
+ {isTextComponent && (
391
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, marginBottom: 16 }}>
392
+ <input
393
+ type="text"
394
+ value={previewText}
395
+ onChange={(e) => setPreviewText(e.target.value)}
396
+ placeholder={PREVIEW_TEXT}
397
+ style={{
398
+ width: "100%",
399
+ padding: "8px 12px",
400
+ fontSize: 14,
401
+ fontFamily: "'SF Mono', 'Fira Code', Menlo, monospace",
402
+ color: srgbToHex(tokens.textPrimary.srgb),
403
+ backgroundColor: srgbToHex(tokens.backgroundSunken.srgb),
404
+ border: `1px solid ${srgbToHex(tokens.border.srgb)}`,
405
+ borderRadius: 8,
406
+ boxSizing: "border-box",
407
+ outline: "none",
408
+ }}
409
+ />
410
+ <div style={{ display: "flex", gap: 2 }}>
411
+ {(["sm", "md", "lg"] as const).map((bp) => {
412
+ const isActive = previewBreakpoint === bp;
413
+ return (
414
+ <button
415
+ key={bp}
416
+ onClick={() => setPreviewBreakpoint(bp)}
417
+ style={{
418
+ padding: "4px 10px",
419
+ fontSize: 11,
420
+ fontWeight: isActive ? 600 : 400,
421
+ color: isActive ? interactiveColor : srgbToHex(tokens.textSecondary.srgb),
422
+ backgroundColor: isActive ? `${interactiveColor}18` : "transparent",
423
+ border: `1px solid ${isActive ? interactiveColor : srgbToHex(tokens.border.srgb)}`,
424
+ borderRadius: 4,
425
+ cursor: "pointer",
426
+ textTransform: "uppercase",
427
+ letterSpacing: 0.5,
428
+ }}
429
+ >
430
+ {bp}
431
+ </button>
432
+ );
433
+ })}
434
+ </div>
435
+ </div>
436
+ )}
437
+
438
+ {component.previewLayout === "list" ? (
439
+ <NewtoneProvider config={scaledConfig}>
440
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
441
+ {component.variants.map((variant) => {
442
+ const isSelected = selectedVariantId === variant.id;
443
+ const isHovered = hoveredId === variant.id;
444
+
445
+ const borderColor = isSelected
446
+ ? interactiveColor
447
+ : isHovered
448
+ ? `${interactiveColor}66`
449
+ : srgbToHex(tokens.border.srgb);
450
+
451
+ // Weight control for text roles
452
+ const showWeightControl =
453
+ isTextComponent &&
454
+ ROLE_VARIANT_IDS.has(variant.id) &&
455
+ onRoleWeightChange;
456
+
457
+ const role = variant.props.role as string | undefined;
458
+ const scope = (variant.props.scope as string) ?? "main";
459
+
460
+ let weightControl: React.ReactNode = null;
461
+ if (showWeightControl && role) {
462
+ const family = scopeFontMap?.[scope];
463
+ const controlInfo = getWeightControlType(family, fontCatalog);
464
+ const currentWeight =
465
+ roleWeights?.[role as TextRole] ??
466
+ ROLE_DEFAULT_WEIGHTS[role as TextRole] ??
467
+ 400;
468
+
469
+ if (controlInfo.type === "slider") {
470
+ // Snap to closest stop for fixed-weight fonts
471
+ const displayWeight = controlInfo.stops
472
+ ? controlInfo.stops.reduce((prev, curr) =>
473
+ Math.abs(curr - currentWeight) < Math.abs(prev - currentWeight) ? curr : prev,
474
+ )
475
+ : currentWeight;
476
+
477
+ weightControl = (
478
+ <WeightSlider
479
+ value={displayWeight}
480
+ min={controlInfo.min}
481
+ max={controlInfo.max}
482
+ stops={controlInfo.stops}
483
+ onChange={(w) =>
484
+ onRoleWeightChange(role as TextRole, w)
485
+ }
486
+ textColor={srgbToHex(tokens.textSecondary.srgb)}
487
+ accentColor={interactiveColor}
488
+ />
489
+ );
490
+ }
491
+ }
492
+
493
+ return (
494
+ <button
495
+ key={variant.id}
496
+ onClick={() => onSelectVariant(variant.id)}
497
+ onMouseEnter={() => setHoveredId(variant.id)}
498
+ onMouseLeave={() => setHoveredId(null)}
499
+ style={{
500
+ display: "flex",
501
+ flexDirection: "row",
502
+ alignItems: "center",
503
+ gap: 16,
504
+ padding: "12px 16px",
505
+ borderRadius: 12,
506
+ border: `2px solid ${borderColor}`,
507
+ backgroundColor: srgbToHex(tokens.backgroundElevated.srgb),
508
+ cursor: "pointer",
509
+ textAlign: "left",
510
+ transition: "border-color 150ms ease",
511
+ }}
512
+ >
513
+ <span
514
+ style={{
515
+ fontSize: 11,
516
+ fontWeight: 500,
517
+ color: isSelected
518
+ ? interactiveColor
519
+ : srgbToHex(tokens.textSecondary.srgb),
520
+ width: 88,
521
+ flexShrink: 0,
522
+ textTransform: "uppercase",
523
+ letterSpacing: 0.5,
524
+ }}
525
+ >
526
+ {variant.label}
527
+ </span>
528
+ <div style={{ flex: 1, minWidth: 0 }}>
529
+ <ComponentRenderer
530
+ componentId={componentId}
531
+ props={variant.props}
532
+ previewText={previewText}
533
+ />
534
+ {isTextComponent && role && scaledConfig.typography.roles[role as TextRole] && (
535
+ <TextAnnotation
536
+ role={role as TextRole}
537
+ roleScales={scaledConfig.typography.roles}
538
+ fontFamily={scopeFontMap?.[scope]}
539
+ calibrations={scaledConfig.typography.calibrations}
540
+ weight={
541
+ roleWeights?.[role as TextRole] ??
542
+ ROLE_DEFAULT_WEIGHTS[role as TextRole] ??
543
+ 400
544
+ }
545
+ textColor={srgbToHex(tokens.textTertiary.srgb)}
546
+ accentColor={interactiveColor}
547
+ previewText={previewText}
548
+ />
549
+ )}
550
+ </div>
551
+ {weightControl && <>{weightControl}</>}
552
+ </button>
553
+ );
554
+ })}
555
+ </div>
556
+ </NewtoneProvider>
557
+ ) : (
558
+ <div
559
+ style={{
560
+ display: "grid",
561
+ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
562
+ gap: 16,
563
+ }}
564
+ >
565
+ {component.variants.map((variant) => {
566
+ const isSelected = selectedVariantId === variant.id;
567
+ const isHovered = hoveredId === variant.id;
568
+
569
+ const borderColor = isSelected
570
+ ? interactiveColor
571
+ : isHovered
572
+ ? `${interactiveColor}66`
573
+ : srgbToHex(tokens.border.srgb);
574
+
575
+ return (
576
+ <button
577
+ key={variant.id}
578
+ onClick={() => onSelectVariant(variant.id)}
579
+ onMouseEnter={() => setHoveredId(variant.id)}
580
+ onMouseLeave={() => setHoveredId(null)}
89
581
  style={{
90
582
  display: "flex",
91
- alignItems: "center",
92
- justifyContent: "center",
93
- padding: 20,
94
- marginBottom: 12,
95
- borderRadius: 8,
96
- backgroundColor: srgbToHex(tokens.background.srgb),
97
- minHeight: 56,
583
+ flexDirection: "column",
584
+ alignItems: "stretch",
585
+ padding: 16,
586
+ borderRadius: 12,
587
+ border: `2px solid ${borderColor}`,
588
+ backgroundColor: srgbToHex(tokens.backgroundElevated.srgb),
589
+ cursor: "pointer",
590
+ textAlign: "left",
591
+ transition: "border-color 150ms ease",
98
592
  }}
99
593
  >
100
- <ComponentRenderer
101
- componentId={componentId}
102
- props={
103
- isSelected && propOverrides
104
- ? { ...variant.props, ...propOverrides }
105
- : variant.props
106
- }
107
- />
108
- </div>
109
- <span
110
- style={{
111
- fontSize: 13,
112
- fontWeight: isSelected ? 600 : 500,
113
- color: isSelected
114
- ? interactiveColor
115
- : srgbToHex(tokens.textPrimary.srgb),
116
- }}
117
- >
118
- {variant.label}
119
- </span>
120
- </button>
121
- );
122
- })}
123
- </div>
594
+ <div
595
+ style={{
596
+ display: "flex",
597
+ alignItems: "center",
598
+ justifyContent: "center",
599
+ padding: 20,
600
+ marginBottom: 12,
601
+ borderRadius: 8,
602
+ backgroundColor: srgbToHex(tokens.background.srgb),
603
+ minHeight: 56,
604
+ }}
605
+ >
606
+ <ComponentRenderer
607
+ componentId={componentId}
608
+ props={variant.props}
609
+ />
610
+ </div>
611
+ <span
612
+ style={{
613
+ fontSize: 13,
614
+ fontWeight: isSelected ? 600 : 500,
615
+ color: isSelected
616
+ ? interactiveColor
617
+ : srgbToHex(tokens.textPrimary.srgb),
618
+ }}
619
+ >
620
+ {variant.label}
621
+ </span>
622
+ </button>
623
+ );
624
+ })}
625
+ </div>
626
+ )}
124
627
  </div>
125
628
  );
126
629
  }