@motion-proto/live-tokens 0.7.1 → 0.9.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 (96) hide show
  1. package/.claude/skills/live-tokens-add-component/SKILL.md +488 -0
  2. package/README.md +34 -0
  3. package/dist-plugin/index.cjs +707 -90
  4. package/dist-plugin/index.d.cts +1 -0
  5. package/dist-plugin/index.d.ts +1 -0
  6. package/dist-plugin/index.js +707 -90
  7. package/package.json +6 -2
  8. package/src/app/site.css +1 -1
  9. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +34 -27
  10. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  11. package/src/editor/component-editor/NotificationEditor.svelte +3 -1
  12. package/src/editor/component-editor/SectionDividerEditor.svelte +439 -112
  13. package/src/editor/component-editor/StandardButtonsEditor.svelte +13 -1
  14. package/src/editor/component-editor/editors.d.ts +10 -0
  15. package/src/editor/component-editor/index.ts +16 -1
  16. package/src/editor/component-editor/registry.ts +103 -26
  17. package/src/editor/component-editor/scaffolding/AngleDial.svelte +52 -13
  18. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -11
  19. package/src/editor/component-editor/scaffolding/ComponentsTab.svelte +2 -2
  20. package/src/editor/component-editor/scaffolding/LinkedBlock.svelte +0 -1
  21. package/src/editor/component-editor/scaffolding/RadialShapePad.svelte +483 -0
  22. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +15 -2
  23. package/src/editor/component-editor/scaffolding/StateBlock.svelte +103 -15
  24. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +9 -6
  25. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +13 -1
  26. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +239 -25
  27. package/src/editor/component-editor/scaffolding/buildTypeGroupTokens.ts +1 -0
  28. package/src/editor/component-editor/scaffolding/componentSources.ts +3 -3
  29. package/src/editor/component-editor/scaffolding/defaultSections.ts +15 -10
  30. package/src/editor/component-editor/scaffolding/types.ts +11 -0
  31. package/src/editor/core/components/componentConfigKeys.ts +22 -3
  32. package/src/editor/core/components/componentConfigService.ts +2 -2
  33. package/src/editor/core/components/componentPersist.ts +7 -5
  34. package/src/editor/core/manifests/manifestService.ts +58 -3
  35. package/src/editor/core/palettes/familySwap.ts +99 -0
  36. package/src/editor/core/palettes/paletteDerivation.ts +69 -0
  37. package/src/editor/core/palettes/tokenRegistry.ts +4 -1
  38. package/src/editor/core/store/editorStore.ts +206 -12
  39. package/src/editor/core/store/editorTypes.ts +55 -12
  40. package/src/editor/core/store/gradientSource.ts +192 -0
  41. package/src/editor/core/themes/migrations/2026-05-19-collapsiblesection-drop-frame-surface.ts +28 -0
  42. package/src/editor/core/themes/migrations/2026-05-19-sectiondivider-rich-gradient.ts +35 -0
  43. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +82 -0
  44. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +24 -0
  45. package/src/editor/core/themes/migrations/2026-05-22-sectiondivider-intrinsics-to-css.ts +81 -0
  46. package/src/editor/core/themes/migrations/index.ts +10 -0
  47. package/src/editor/core/themes/slices/components.ts +27 -4
  48. package/src/editor/core/themes/slices/gradients.ts +88 -13
  49. package/src/editor/core/themes/themeInit.ts +2 -2
  50. package/src/editor/core/themes/themeTypes.ts +56 -1
  51. package/src/editor/index.ts +10 -1
  52. package/src/editor/overlay/ColumnsOverlay.svelte +0 -1
  53. package/src/editor/overlay/LiveEditorOverlay.svelte +1 -4
  54. package/src/editor/pages/ComponentEditorPage.svelte +53 -3
  55. package/src/editor/pages/EditorShell.svelte +53 -3
  56. package/src/editor/styles/ui-editor.css +1 -0
  57. package/src/editor/styles/ui-form-controls.css +19 -20
  58. package/src/editor/ui/BezierCurveEditor.svelte +114 -63
  59. package/src/editor/ui/EditorViewSwitcher.svelte +0 -1
  60. package/src/editor/ui/FileLoadList.svelte +22 -5
  61. package/src/editor/ui/FontStackEditor.svelte +214 -76
  62. package/src/editor/ui/GradientEditor.svelte +435 -215
  63. package/src/editor/ui/GradientStopPicker.svelte +11 -3
  64. package/src/editor/ui/ManifestFileManager.svelte +71 -4
  65. package/src/editor/ui/PaletteEditor.svelte +52 -79
  66. package/src/editor/ui/ProjectFontsSection.svelte +328 -293
  67. package/src/editor/ui/ThemeFileManager.svelte +0 -4
  68. package/src/editor/ui/UIFontFamilySelector.svelte +0 -1
  69. package/src/editor/ui/UIFontSizeSelector.svelte +3 -0
  70. package/src/editor/ui/UIInfoPopover.svelte +0 -1
  71. package/src/editor/ui/UILetterSpacingSelector.svelte +65 -0
  72. package/src/editor/ui/UIPaletteSelector.svelte +31 -4
  73. package/src/editor/ui/UIPillButton.svelte +33 -3
  74. package/src/editor/ui/UISegmentedControl.svelte +114 -0
  75. package/src/editor/ui/UITokenSelector.svelte +4 -1
  76. package/src/editor/ui/VariablesTab.svelte +41 -35
  77. package/src/editor/ui/palette/OverridesPanel.svelte +14 -37
  78. package/src/editor/ui/palette/PaletteBase.svelte +3 -3
  79. package/src/editor/ui/sections/ColumnsSection.svelte +1 -2
  80. package/src/editor/ui/sections/GradientsSection.svelte +1 -1
  81. package/src/editor/ui/sections/OverlaysSection.svelte +1 -1
  82. package/src/editor/ui/sections/ShadowsSection.svelte +1 -1
  83. package/src/system/components/Button.svelte +2 -2
  84. package/src/system/components/Card.svelte +29 -1
  85. package/src/system/components/CollapsibleSection.svelte +25 -2
  86. package/src/system/components/Dialog.svelte +24 -4
  87. package/src/system/components/FloatingTokenTags.css +43 -24
  88. package/src/system/components/FloatingTokenTags.svelte +88 -137
  89. package/src/system/components/Notification.svelte +8 -1
  90. package/src/system/components/SectionDivider.svelte +532 -381
  91. package/src/system/styles/CONVENTIONS.md +1 -1
  92. package/src/system/styles/fonts.css +3 -16
  93. package/src/system/styles/tokens.css +356 -1199
  94. package/src/system/styles/tokens.generated.css +544 -0
  95. package/src/editor/component-editor/scaffolding/DividerEditor.svelte +0 -94
  96. package/src/editor/component-editor/scaffolding/GradientCard.svelte +0 -296
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { FontSource } from '../core/themes/themeTypes';
2
+ import type { FontFamily, FontSource } from '../core/themes/themeTypes';
3
3
  import { editorState, setFontSources, transaction } from '../core/store/editorStore';
4
4
  import { applyFontSources, applyFontStacks } from '../core/fonts/fontLoader';
5
5
  import {
@@ -10,34 +10,42 @@
10
10
  type ParsedFamily,
11
11
  } from '../core/fonts/fontParse';
12
12
  import UIPillButton from './UIPillButton.svelte';
13
-
14
- type AddMode = 'closed' | 'url' | 'fontface';
15
- interface Props { addMode?: AddMode }
16
- let { addMode = $bindable('closed') }: Props = $props();
17
-
18
- // URL paste — accepts a bare URL, a `<link>` tag, or an `@import url(...)` block.
19
- let urlInput = $state('');
20
- let urlError = $state('');
21
- let urlDiscovering = $state(false);
13
+ import UISegmentedControl from './UISegmentedControl.svelte';
14
+ import UIInfoPopover from './UIInfoPopover.svelte';
15
+
16
+ type AddMode = 'closed' | 'name' | 'paste';
17
+ let addMode: AddMode = $state('closed');
18
+
19
+ // By-name (Google Fonts) — type a family name; we build the CSS2 URL.
20
+ let nameInput = $state('');
21
+ let nameError = $state('');
22
+ let nameDiscovering = $state(false);
23
+ let nameParsed: ParsedFamily[] | null = $state(null);
24
+
25
+ // Unified paste — accepts a bare URL, `<link>` tag, `@import url(...)`,
26
+ // or one or more `@font-face { ... }` rules. We sniff which on Detect.
27
+ let pasteInput = $state('');
28
+ let pasteError = $state('');
29
+ let pasteDiscovering = $state(false);
22
30
  let urlParsed: ParsedFamily[] | null = $state(null);
23
31
  let urlPickedNames = $state(new Set<string>());
24
32
  let urlNeedsManualFamilies = $state(false);
25
33
  let urlManualFamilies = $state('');
26
-
27
- // @font-face paste
28
- let fontFaceText = $state('');
29
34
  let fontFaceParsed: ParsedFamily[] = $state([]);
30
35
 
31
36
  function reset() {
32
37
  addMode = 'closed';
33
- urlInput = '';
34
- urlError = '';
35
- urlDiscovering = false;
38
+ nameInput = '';
39
+ nameError = '';
40
+ nameDiscovering = false;
41
+ nameParsed = null;
42
+ pasteInput = '';
43
+ pasteError = '';
44
+ pasteDiscovering = false;
36
45
  urlParsed = null;
37
46
  urlPickedNames = new Set();
38
47
  urlNeedsManualFamilies = false;
39
48
  urlManualFamilies = '';
40
- fontFaceText = '';
41
49
  fontFaceParsed = [];
42
50
  }
43
51
 
@@ -65,16 +73,31 @@
65
73
  applyFontStacks(fontStacksList, next);
66
74
  }
67
75
 
68
- async function discoverUrl() {
69
- urlError = '';
76
+ /** One paste field → sniff whether it's @font-face or a URL/embed and
77
+ * populate the matching detected-families state. */
78
+ async function detectPaste() {
79
+ pasteError = '';
70
80
  urlParsed = null;
71
81
  urlNeedsManualFamilies = false;
72
- const url = extractFontsUrl(urlInput);
82
+ fontFaceParsed = [];
83
+ const text = pasteInput.trim();
84
+ if (!text) {
85
+ pasteError = 'Paste a URL, embed, or @font-face rule';
86
+ return;
87
+ }
88
+ if (/@font-face/i.test(text)) {
89
+ fontFaceParsed = parseFontFaceText(text);
90
+ if (fontFaceParsed.length === 0) {
91
+ pasteError = "Couldn't parse @font-face rules";
92
+ }
93
+ return;
94
+ }
95
+ const url = extractFontsUrl(text);
73
96
  if (!url) {
74
- urlError = "Couldn't find a fonts URL in that paste";
97
+ pasteError = "Couldn't find a fonts URL or @font-face rule in that paste";
75
98
  return;
76
99
  }
77
- urlDiscovering = true;
100
+ pasteDiscovering = true;
78
101
  try {
79
102
  const found = await discoverFamiliesFromUrl(url);
80
103
  if (found && found.length > 0) {
@@ -84,21 +107,83 @@
84
107
  urlNeedsManualFamilies = true;
85
108
  }
86
109
  } catch (e) {
87
- urlError = 'Discovery failed';
110
+ pasteError = 'Discovery failed';
88
111
  urlNeedsManualFamilies = true;
89
112
  }
90
- urlDiscovering = false;
113
+ pasteDiscovering = false;
114
+ }
115
+
116
+ /** Build a Google Fonts CSS2 URL for a family name, requesting a wide
117
+ * weight range with italics. Works for variable fonts and most static
118
+ * multi-weight families. Single-weight static fonts (e.g. GFS Didot) will
119
+ * reject the range axis with 400 Bad Request — Chrome then CORBs the
120
+ * response. Such fonts must be persisted as `?family=Name&display=swap`. */
121
+ function googleUrlForName(name: string): string {
122
+ const family = name.trim().replace(/\s+/g, '+');
123
+ return `https://fonts.googleapis.com/css2?family=${family}:ital,wght@0,100..900;1,100..900&display=swap`;
91
124
  }
92
125
 
126
+ async function discoverByName() {
127
+ nameError = '';
128
+ nameParsed = null;
129
+ if (!nameInput.trim()) {
130
+ nameError = 'Enter a family name';
131
+ return;
132
+ }
133
+ nameDiscovering = true;
134
+ try {
135
+ const found = await discoverFamiliesFromUrl(googleUrlForName(nameInput));
136
+ if (found && found.length > 0) {
137
+ nameParsed = found;
138
+ } else {
139
+ nameError = `Couldn't find "${nameInput.trim()}" on Google Fonts`;
140
+ }
141
+ } catch {
142
+ nameError = `Couldn't reach Google Fonts for "${nameInput.trim()}"`;
143
+ }
144
+ nameDiscovering = false;
145
+ }
146
+
147
+ function addNameSource() {
148
+ if (!nameParsed || nameParsed.length === 0) return;
149
+ if (nameDuplicate) return;
150
+ // $state.snapshot() unwraps the reactive proxy. Without it the FontSource
151
+ // we hand to the store carries proxy arrays (weights, etc.) and the next
152
+ // `mutate()` call fails with DataCloneError inside structuredClone.
153
+ const families = $state.snapshot(nameParsed) as ParsedFamily[];
154
+ const source = buildSourceFromUrl(googleUrlForName(nameInput), families);
155
+ commitSources([...fontSourcesList, source]);
156
+ reset();
157
+ }
158
+
159
+ /** Case-insensitive family-name match against existing sources. Used to
160
+ * block duplicate adds and surface a notice under the Add button. */
161
+ function findExistingFamilyByName(name: string): string | null {
162
+ const lower = name.trim().toLowerCase();
163
+ if (!lower) return null;
164
+ for (const src of fontSourcesList) {
165
+ for (const fam of src.families) {
166
+ if (fam.name.toLowerCase() === lower) return fam.name;
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ let nameDuplicate = $derived.by(() => {
173
+ if (!nameParsed || nameParsed.length === 0) return null;
174
+ return findExistingFamilyByName(nameParsed[0].name);
175
+ });
176
+
93
177
  function addUrlSource() {
94
- const url = extractFontsUrl(urlInput);
178
+ const url = extractFontsUrl(pasteInput);
95
179
  if (!url) {
96
- urlError = "Couldn't find a fonts URL in that paste";
180
+ pasteError = "Couldn't find a fonts URL in that paste";
97
181
  return;
98
182
  }
99
183
  let families: ParsedFamily[] = [];
100
184
  if (urlParsed) {
101
- families = urlParsed.filter((f) => urlPickedNames.has(f.name));
185
+ // Snapshot to drop the $state proxy — see comment in addNameSource.
186
+ families = ($state.snapshot(urlParsed) as ParsedFamily[]).filter((f) => urlPickedNames.has(f.name));
102
187
  } else if (urlNeedsManualFamilies) {
103
188
  families = urlManualFamilies
104
189
  .split(',')
@@ -107,7 +192,7 @@
107
192
  .map((name) => ({ name }));
108
193
  }
109
194
  if (families.length === 0) {
110
- urlError = 'Pick at least one family';
195
+ pasteError = 'Pick at least one family';
111
196
  return;
112
197
  }
113
198
  const source = buildSourceFromUrl(url, families);
@@ -115,18 +200,10 @@
115
200
  reset();
116
201
  }
117
202
 
118
- function parseFontFaceTextInput() {
119
- fontFaceParsed = parseFontFaceText(fontFaceText);
120
- }
121
-
122
203
  function addFontFaceSource() {
123
- if (!fontFaceText.trim()) return;
124
- const families = fontFaceParsed.length > 0 ? fontFaceParsed : [];
125
- if (families.length === 0) {
126
- fontFaceParsed = parseFontFaceText(fontFaceText);
127
- if (fontFaceParsed.length === 0) return;
128
- }
129
- const source = buildSourceFromFontFaceText(fontFaceText, families.length > 0 ? families : fontFaceParsed);
204
+ if (fontFaceParsed.length === 0) return;
205
+ const families = $state.snapshot(fontFaceParsed) as ParsedFamily[];
206
+ const source = buildSourceFromFontFaceText(pasteInput, families);
130
207
  commitSources([...fontSourcesList, source]);
131
208
  reset();
132
209
  }
@@ -147,127 +224,168 @@
147
224
  applyFontStacks(updatedStacks, next);
148
225
  }
149
226
 
227
+ /** Resolve a clickable target for the row. We prefer the human-readable
228
+ * specimen/family page (Google Fonts, Adobe Fonts) over the raw CSS/font
229
+ * file — those are rarely what a user wants to look at. */
230
+ function familyHref(source: FontSource, family: FontFamily): string | null {
231
+ if (source.kind === 'google') {
232
+ return `https://fonts.google.com/specimen/${family.name.trim().replace(/\s+/g, '+')}`;
233
+ }
234
+ if (source.kind === 'typekit') {
235
+ const slug = family.name.trim().toLowerCase().replace(/\s+/g, '-');
236
+ return `https://fonts.adobe.com/fonts/${slug}`;
237
+ }
238
+ if (source.kind === 'css-url') return source.url ?? null;
239
+ return null; // font-face: no public page exists
240
+ }
241
+
150
242
  function sourceKindLabel(source: FontSource): string {
151
243
  if (source.kind === 'google') return 'Google';
152
244
  if (source.kind === 'typekit') return 'Typekit';
153
245
  if (source.kind === 'font-face') return 'Local';
154
246
  return 'CSS URL';
155
247
  }
156
-
157
- function stacksReferencing(familyId: string): string[] {
158
- return fontStacksList
159
- .filter((s) => s.slots.some((slot) => slot.kind === 'project' && slot.familyId === familyId))
160
- .map((s) => s.variable);
161
- }
162
-
163
- let expanded = $state(new Set<string>());
164
- function toggleExpanded(familyId: string) {
165
- const next = new Set(expanded);
166
- if (next.has(familyId)) next.delete(familyId); else next.add(familyId);
167
- expanded = next;
168
- }
169
248
  </script>
170
249
 
171
250
  <section class="project-fonts">
172
251
  <header class="pf-header">
173
- <h3 class="group-title">Project Fonts</h3>
252
+ <div class="pf-title-row">
253
+ <h3 class="group-title">Project Fonts</h3>
254
+ <UIInfoPopover title="Installing fonts" ariaLabel="How to install fonts">
255
+ <p>Three ways to install a font, depending on where it lives:</p>
256
+ <p>
257
+ <strong>Google Fonts</strong> — use <em>By name</em> and type the family
258
+ (e.g. <code>Inter</code>). We fetch the CSS2 URL for you.
259
+ </p>
260
+ <p>
261
+ <strong>Hosted CDN (Adobe, Fontshare, custom)</strong> — use <em>Paste</em>
262
+ with a fonts URL, a <code>&lt;link&gt;</code> tag, or an <code>@import url(...)</code> line.
263
+ </p>
264
+ <p>
265
+ <strong>Local files</strong> — drop your <code>.woff2</code> files into
266
+ <code>src/system/styles/fonts/&lt;Family&gt;/</code>, then paste the matching
267
+ <code>@font-face &#123; ... &#125;</code> rules into <em>Paste</em>. The folder ships
268
+ with the production build, so <code>src/...</code> paths resolve at runtime.
269
+ </p>
270
+ </UIInfoPopover>
271
+ </div>
272
+ <UIPillButton
273
+ variant="primary"
274
+ icon="fa-plus"
275
+ onclick={() => { addMode = addMode === 'closed' ? 'name' : 'closed'; }}
276
+ >Add Font</UIPillButton>
174
277
  </header>
175
278
 
176
279
  {#if fontSourcesList.length === 0}
177
280
  <p class="pf-empty">No fonts loaded yet. Use the add button below.</p>
178
281
  {/if}
179
282
 
180
- <div class="pf-sources">
283
+ <ul class="pf-family-list">
181
284
  {#each fontSourcesList as source (source.id)}
182
- <div class="pf-source">
183
- <div class="pf-source-head">
184
- <span class="pf-kind-badge">{sourceKindLabel(source)}</span>
185
- <span class="pf-source-label">{source.label ?? source.kind}</span>
186
- {#if source.url}
187
- <span class="pf-source-url" title={source.url}>{source.url}</span>
188
- {/if}
189
- </div>
190
- <ul class="pf-family-list">
191
- {#each source.families as fam (fam.id)}
192
- {@const refs = stacksReferencing(fam.id)}
193
- {@const isOpen = expanded.has(fam.id)}
194
- {@const hasMultipleWeights = !!fam.weights && fam.weights.length > 1}
195
- <li class="pf-family">
196
- <div class="pf-family-row">
197
- {#if hasMultipleWeights}
198
- <button
199
- type="button"
200
- class="pf-family-disclosure"
201
- class:open={isOpen}
202
- onclick={() => toggleExpanded(fam.id)}
203
- aria-label={isOpen ? 'Collapse weights' : 'Expand weights'}
204
- aria-expanded={isOpen}
205
- ><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
206
- {:else}
207
- <span class="pf-family-disclosure-placeholder" aria-hidden="true"></span>
208
- {/if}
209
- <span class="pf-family-preview" style="font-family: {fam.cssName}, sans-serif;">Ag</span>
210
- <span class="pf-family-name">{fam.name}</span>
211
- <button
212
- type="button"
213
- class="pf-family-remove"
214
- onclick={() => removeFamily(source.id, fam.id)}
215
- aria-label={`Remove ${fam.name}`}
216
- title="Remove family"
217
- ><i class="fas fa-xmark" aria-hidden="true"></i></button>
218
- </div>
219
- {#if refs.length > 0}
220
- <div class="pf-family-meta">
221
- <span class="pf-meta-label">Used by</span>
222
- <span class="pf-meta-value">
223
- {#each refs as r (r)}<span class="pf-meta-pill">{r}</span>{/each}
224
- </span>
225
- </div>
226
- {/if}
227
- {#if isOpen && hasMultipleWeights && fam.weights}
228
- <div class="pf-family-meta">
229
- <span class="pf-meta-label">Weights</span>
230
- <span class="pf-meta-value pf-meta-weights">{fam.weights.join(', ')}</span>
231
- </div>
232
- {/if}
233
- </li>
234
- {/each}
235
- </ul>
236
- </div>
285
+ {#each source.families as fam (fam.id)}
286
+ {@const href = familyHref(source, fam)}
287
+ <li class="pf-family">
288
+ <span class="pf-family-preview" style="font-family: {fam.cssName}, sans-serif;">Ag</span>
289
+ <span class="pf-family-name">{fam.name}</span>
290
+ <UIPillButton
291
+ variant="outline"
292
+ size="compact"
293
+ href={href ?? undefined}
294
+ target={href ? '_blank' : undefined}
295
+ disabled={!href}
296
+ title={href ? `Open ${fam.name} on ${sourceKindLabel(source)}` : 'No public page for local fonts'}
297
+ >{sourceKindLabel(source)}</UIPillButton>
298
+ <button
299
+ type="button"
300
+ class="pf-family-remove"
301
+ onclick={() => removeFamily(source.id, fam.id)}
302
+ aria-label={`Remove ${fam.name}`}
303
+ title="Remove family"
304
+ ><i class="fas fa-xmark" aria-hidden="true"></i></button>
305
+ </li>
306
+ {/each}
237
307
  {/each}
238
- </div>
308
+ </ul>
239
309
 
240
- {#if addMode === 'closed'}
241
- <div class="pf-add-toggle-row">
242
- <UIPillButton icon="fa-plus" onclick={() => (addMode = 'url')}>Add Font</UIPillButton>
243
- </div>
244
- {:else}
310
+ {#if addMode !== 'closed'}
245
311
  <div class="pf-add-panel">
246
- <div class="pf-add-tabs">
247
- <button type="button" class:active={addMode === 'url'} onclick={() => (addMode = 'url')}>Paste URL or embed</button>
248
- <button type="button" class:active={addMode === 'fontface'} onclick={() => (addMode = 'fontface')}>@font-face</button>
312
+ <div class="pf-add-head">
313
+ <span class="pf-add-eyebrow">Browse</span>
314
+ <div class="pf-browse-row">
315
+ <UIPillButton variant="outline" href="https://fonts.google.com/" target="_blank" icon="fa-arrow-up-right-from-square">
316
+ Google Fonts
317
+ </UIPillButton>
318
+ <UIPillButton variant="outline" href="https://fonts.adobe.com/" target="_blank" icon="fa-arrow-up-right-from-square">
319
+ Adobe Fonts
320
+ </UIPillButton>
321
+ <UIPillButton variant="outline" href="https://www.fontshare.com/" target="_blank" icon="fa-arrow-up-right-from-square">
322
+ Fontshare
323
+ </UIPillButton>
324
+ </div>
249
325
  <button type="button" class="pf-add-close" onclick={reset} aria-label="Cancel">×</button>
250
326
  </div>
251
327
 
252
- {#if addMode === 'url'}
253
- <p class="pf-hint">
254
- Pick fonts on
255
- <a href="https://fonts.google.com/selection/embed" target="_blank" rel="noopener noreferrer">Google Fonts <i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i></a>,
256
- copy either the <code>&lt;link&gt;</code> tag or the <code>@import</code> block, and paste it below. Any CSS URL (Typekit, custom host) also works.
257
- </p>
328
+ <div class="pf-add-divider"><span>or add directly</span></div>
329
+
330
+ <UISegmentedControl
331
+ value={addMode}
332
+ options={[
333
+ { value: 'name', label: 'By name (Google)' },
334
+ { value: 'paste', label: 'Paste URL or @font-face' },
335
+ ] as const}
336
+ ariaLabel="Add font by"
337
+ onchange={(v) => (addMode = v)}
338
+ />
339
+
340
+ {#if addMode === 'name'}
341
+ <div class="pf-row">
342
+ <input
343
+ type="text"
344
+ class="ui-form-input pf-name-input"
345
+ placeholder="e.g. Inter, Fraunces, Space Mono"
346
+ bind:value={nameInput}
347
+ onkeydown={(e) => { if (e.key === 'Enter' && !nameParsed) discoverByName(); }}
348
+ />
349
+ {#if nameParsed}
350
+ <UIPillButton variant="primary" onclick={addNameSource} disabled={!!nameDuplicate}>Add</UIPillButton>
351
+ {:else}
352
+ <UIPillButton variant="secondary" onclick={discoverByName} disabled={!nameInput.trim() || nameDiscovering}>
353
+ {nameDiscovering ? 'Checking…' : 'Find'}
354
+ </UIPillButton>
355
+ {/if}
356
+ </div>
357
+ {#if nameError}<div class="pf-error">{nameError}</div>{/if}
358
+ {#if nameParsed}
359
+ {#if nameDuplicate}
360
+ <div class="pf-notice">
361
+ <strong>{nameDuplicate}</strong> is already in your project fonts.
362
+ </div>
363
+ {:else}
364
+ <div class="pf-detected">
365
+ Found <strong>{nameParsed[0].name}</strong>
366
+ {#if nameParsed[0].weights && nameParsed[0].weights.length > 0}
367
+ <span class="pf-check-meta">({nameParsed[0].weights.length} weights)</span>
368
+ {/if}
369
+ </div>
370
+ {/if}
371
+ {/if}
372
+ {:else if addMode === 'paste'}
258
373
  <textarea
259
374
  class="ui-form-input pf-textarea pf-url-input"
260
- placeholder={'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">\n\nor\n\n@import url(\'https://fonts.googleapis.com/css2?...\');\n\nor just the URL'}
261
- rows="4"
262
- bind:value={urlInput}
375
+ placeholder={'A fonts URL, <link> tag, or @import url(...)\n\nor\n\none or more @font-face { ... } rules'}
376
+ rows="5"
377
+ bind:value={pasteInput}
263
378
  ></textarea>
264
379
  <div class="pf-row">
265
- <UIPillButton variant="secondary" onclick={discoverUrl} disabled={!urlInput.trim() || urlDiscovering}>
266
- {urlDiscovering ? 'Checking…' : 'Detect families'}
380
+ <UIPillButton variant="secondary" onclick={detectPaste} disabled={!pasteInput.trim() || pasteDiscovering}>
381
+ {pasteDiscovering ? 'Checking…' : 'Detect'}
267
382
  </UIPillButton>
268
383
  </div>
269
- {#if urlError}<div class="pf-error">{urlError}</div>{/if}
270
- {#if urlParsed}
384
+ {#if pasteError}<div class="pf-error">{pasteError}</div>{/if}
385
+ {#if fontFaceParsed.length > 0}
386
+ <div class="pf-detected">Detected @font-face: {fontFaceParsed.map((f) => f.name).join(', ')}</div>
387
+ <UIPillButton variant="primary" onclick={addFontFaceSource}>Add</UIPillButton>
388
+ {:else if urlParsed}
271
389
  <div class="pf-detected">Detected families — pick which to add:</div>
272
390
  <ul class="pf-checklist">
273
391
  {#each urlParsed as f (f.name)}
@@ -302,18 +420,6 @@
302
420
  />
303
421
  <UIPillButton variant="primary" onclick={addUrlSource} disabled={!urlManualFamilies.trim()}>Add</UIPillButton>
304
422
  {/if}
305
- {:else if addMode === 'fontface'}
306
- <textarea
307
- class="ui-form-input pf-textarea"
308
- placeholder={'Paste one or more @font-face { ... } rules'}
309
- rows="6"
310
- bind:value={fontFaceText}
311
- oninput={parseFontFaceTextInput}
312
- ></textarea>
313
- {#if fontFaceParsed.length > 0}
314
- <div class="pf-detected">Detected: {fontFaceParsed.map((f) => f.name).join(', ')}</div>
315
- {/if}
316
- <UIPillButton variant="primary" onclick={addFontFaceSource} disabled={fontFaceParsed.length === 0}>Add</UIPillButton>
317
423
  {/if}
318
424
  </div>
319
425
  {/if}
@@ -323,8 +429,7 @@
323
429
  .project-fonts {
324
430
  display: flex;
325
431
  flex-direction: column;
326
- gap: var(--ui-space-8);
327
- max-width: 56rem;
432
+ gap: var(--ui-space-12);
328
433
  }
329
434
 
330
435
  .pf-header {
@@ -334,11 +439,17 @@
334
439
  justify-content: space-between;
335
440
  }
336
441
 
442
+ .pf-title-row {
443
+ display: flex;
444
+ align-items: center;
445
+ gap: var(--ui-space-4);
446
+ }
447
+
337
448
  .group-title {
338
449
  margin: 0;
339
- font-size: var(--ui-font-size-lg);
340
- font-weight: var(--ui-font-weight-semibold);
341
- color: var(--ui-text-secondary);
450
+ font-size: var(--ui-font-size-xl);
451
+ font-weight: var(--ui-font-weight-bold);
452
+ color: var(--ui-text-primary);
342
453
  }
343
454
 
344
455
  .pf-empty {
@@ -348,84 +459,32 @@
348
459
  font-size: var(--ui-font-size-sm);
349
460
  }
350
461
 
351
- .pf-sources {
352
- display: flex;
353
- flex-direction: column;
354
- gap: var(--ui-space-8);
355
- }
356
-
357
- .pf-source {
358
- border: 1px solid var(--ui-border-low);
359
- border-radius: var(--ui-radius-md);
360
- display: flex;
361
- flex-direction: column;
362
- }
363
-
364
- .pf-source-head {
365
- display: flex;
366
- align-items: center;
367
- gap: var(--ui-space-8);
368
- padding: var(--ui-space-8) var(--ui-space-12);
369
- border-bottom: 1px solid var(--ui-border-low);
370
- background: var(--ui-surface-subtle, rgba(255,255,255,0.02));
371
- border-radius: var(--ui-radius-md) var(--ui-radius-md) 0 0;
372
- }
373
-
374
- .pf-kind-badge {
375
- font-size: var(--ui-font-size-xs);
376
- color: var(--ui-text-tertiary);
377
- border: 1px solid var(--ui-border-low);
378
- padding: 0 var(--ui-space-4);
379
- border-radius: var(--ui-radius-sm);
380
- font-family: var(--ui-font-mono);
381
- }
382
-
383
- .pf-source-label {
384
- font-size: var(--ui-font-size-lg);
385
- color: var(--ui-text-primary);
386
- font-weight: var(--ui-font-weight-medium);
387
- }
388
-
389
- .pf-source-url {
390
- font-family: var(--ui-font-mono);
391
- font-size: var(--ui-font-size-xs);
392
- color: var(--ui-text-secondary);
393
- overflow: hidden;
394
- text-overflow: ellipsis;
395
- white-space: nowrap;
396
- flex: 1;
397
- min-width: 0;
398
- }
399
-
400
462
  .pf-family-list {
401
463
  list-style: none;
402
464
  margin: 0;
403
- padding: var(--ui-space-8);
465
+ padding: 0;
404
466
  display: grid;
405
- grid-template-columns: repeat(2, minmax(0, 1fr));
406
- gap: var(--ui-space-2) var(--ui-space-8);
467
+ grid-template-columns: repeat(auto-fill, minmax(min(22rem, 100%), 1fr));
468
+ gap: var(--ui-space-6);
469
+ align-items: start;
407
470
  }
408
471
 
409
472
  .pf-family {
410
- display: flex;
411
- flex-direction: column;
412
- min-width: 0;
413
- }
414
-
415
- .pf-family-row {
416
473
  display: grid;
417
- grid-template-columns: 24px 1.75rem 1fr 24px;
474
+ grid-template-columns: 1.75rem 1fr auto 24px;
418
475
  align-items: center;
419
- gap: var(--ui-space-6);
420
- padding: 0 var(--ui-space-4);
421
- border-radius: var(--ui-radius-sm);
422
- min-height: 28px;
423
- }
424
- .pf-family-row:hover {
476
+ gap: var(--ui-space-8);
477
+ min-width: 0;
478
+ padding: var(--ui-space-4) var(--ui-space-8);
479
+ border: 1px solid var(--ui-border-low);
480
+ border-radius: var(--ui-radius-md);
425
481
  background: var(--ui-surface-subtle, rgba(255,255,255,0.02));
482
+ min-height: 36px;
483
+ }
484
+ .pf-family:hover {
485
+ background: var(--ui-surface-hover, rgba(255,255,255,0.04));
426
486
  }
427
487
 
428
- .pf-family-disclosure,
429
488
  .pf-family-remove {
430
489
  display: inline-flex;
431
490
  align-items: center;
@@ -441,20 +500,10 @@
441
500
  font-size: var(--ui-font-size-sm);
442
501
  line-height: 1;
443
502
  }
444
- .pf-family-disclosure:hover,
445
503
  .pf-family-remove:hover {
446
504
  color: var(--ui-text-primary);
447
505
  background: var(--ui-surface-hover, rgba(255,255,255,0.06));
448
506
  }
449
- .pf-family-disclosure i {
450
- transition: transform var(--ui-transition-fast);
451
- }
452
- .pf-family-disclosure.open i { transform: rotate(90deg); }
453
- .pf-family-disclosure-placeholder {
454
- width: 24px;
455
- height: 24px;
456
- display: inline-block;
457
- }
458
507
 
459
508
  .pf-family-preview {
460
509
  font-size: var(--ui-font-size-md);
@@ -473,68 +522,70 @@
473
522
  white-space: nowrap;
474
523
  }
475
524
 
476
- .pf-family-meta {
525
+ .pf-add-panel {
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: var(--ui-space-10);
529
+ padding: var(--ui-space-12);
530
+ border: 1px solid rgba(255, 255, 255, 0.5);
531
+ border-radius: var(--ui-radius-md);
532
+ background: rgba(255, 255, 255, 0.15);
533
+ }
534
+
535
+ .pf-add-head {
477
536
  display: grid;
478
- grid-template-columns: 4rem 1fr;
537
+ grid-template-columns: auto 1fr auto;
479
538
  align-items: center;
480
- gap: var(--ui-space-6);
481
- padding: var(--ui-space-2) var(--ui-space-4) var(--ui-space-2) calc(24px + var(--ui-space-6) + 1.75rem + var(--ui-space-6));
482
- font-size: var(--ui-font-size-xs);
483
- font-family: var(--ui-font-mono);
539
+ gap: var(--ui-space-12);
484
540
  }
485
- .pf-meta-label { color: var(--ui-text-muted); }
486
- .pf-meta-value {
487
- display: flex;
488
- flex-wrap: wrap;
489
- gap: var(--ui-space-2);
541
+ .pf-add-eyebrow {
542
+ font-size: var(--ui-font-size-xs);
543
+ font-weight: var(--ui-font-weight-semibold);
490
544
  color: var(--ui-text-tertiary);
491
- min-width: 0;
545
+ text-transform: uppercase;
546
+ letter-spacing: 0.04em;
492
547
  }
493
- .pf-meta-pill {
494
- padding: 0 var(--ui-space-4);
495
- border-radius: var(--ui-radius-sm);
496
- background: var(--ui-surface-hover, rgba(255,255,255,0.06));
497
- color: var(--ui-text-secondary, var(--ui-text-primary));
498
- }
499
- .pf-meta-weights { word-break: break-word; }
500
-
501
- .pf-add-toggle-row {
548
+ .pf-browse-row {
502
549
  display: flex;
503
- }
504
-
505
- .pf-add-panel {
506
- display: flex;
507
- flex-direction: column;
550
+ flex-wrap: wrap;
508
551
  gap: var(--ui-space-8);
509
- padding: var(--ui-space-8);
510
- border: 1px solid var(--ui-border-low);
511
- border-radius: var(--ui-radius-sm);
512
- }
513
-
514
- .pf-add-tabs {
515
- display: flex;
516
- gap: var(--ui-space-4);
517
552
  }
518
- .pf-add-tabs button {
553
+ .pf-add-close {
519
554
  background: none;
520
- border: 1px solid var(--ui-border-low);
521
- color: var(--ui-text-secondary);
522
- font-size: var(--ui-font-size-sm);
523
- padding: var(--ui-space-4) var(--ui-space-8);
555
+ border: none;
556
+ color: var(--ui-text-muted);
557
+ font-size: var(--ui-font-size-lg);
558
+ line-height: 1;
559
+ padding: var(--ui-space-2) var(--ui-space-6);
524
560
  border-radius: var(--ui-radius-sm);
525
561
  cursor: pointer;
562
+ justify-self: end;
526
563
  }
527
- .pf-add-tabs button:hover {
564
+ .pf-add-close:hover {
528
565
  color: var(--ui-text-primary);
529
- border-color: var(--ui-border-high);
566
+ background: var(--ui-surface-hover, rgba(255,255,255,0.06));
530
567
  }
531
- .pf-add-tabs button.active {
532
- color: var(--ui-text-primary);
533
- background: var(--ui-surface-high);
534
- border-color: var(--ui-border-higher);
568
+
569
+ .pf-add-divider {
570
+ display: flex;
571
+ align-items: center;
572
+ gap: var(--ui-space-8);
573
+ color: var(--ui-text-tertiary);
574
+ font-size: var(--ui-font-size-xs);
575
+ text-transform: uppercase;
576
+ letter-spacing: 0.04em;
535
577
  }
536
- .pf-add-tabs .pf-add-close {
537
- margin-left: auto;
578
+ .pf-add-divider::before,
579
+ .pf-add-divider::after {
580
+ content: '';
581
+ flex: 1;
582
+ height: 1px;
583
+ background: var(--ui-border-low);
584
+ }
585
+
586
+ .pf-name-input {
587
+ flex: 1;
588
+ min-width: 0;
538
589
  }
539
590
 
540
591
  .pf-row {
@@ -547,6 +598,12 @@
547
598
 
548
599
  .pf-detected { color: var(--ui-text-secondary); font-size: var(--ui-font-size-sm); }
549
600
 
601
+ .pf-notice {
602
+ color: var(--ui-text-secondary);
603
+ font-size: var(--ui-font-size-sm);
604
+ }
605
+ .pf-notice strong { color: var(--ui-text-primary); }
606
+
550
607
  .pf-checklist {
551
608
  list-style: none;
552
609
  margin: 0;
@@ -578,26 +635,4 @@
578
635
  overflow-x: auto;
579
636
  }
580
637
 
581
- .pf-hint {
582
- margin: 0;
583
- color: var(--ui-text-tertiary);
584
- font-size: var(--ui-font-size-sm);
585
- line-height: 1.4;
586
- }
587
- .pf-hint a {
588
- color: var(--ui-text-primary);
589
- text-decoration: underline;
590
- text-underline-offset: 2px;
591
- text-decoration-thickness: 1px;
592
- white-space: nowrap;
593
- }
594
- .pf-hint a:hover { text-decoration-thickness: 2px; }
595
- .pf-hint a i { font-size: 0.75em; margin-left: var(--ui-space-2); }
596
- .pf-hint code {
597
- font-family: var(--ui-font-mono);
598
- font-size: 0.92em;
599
- padding: 0 var(--ui-space-2);
600
- background: var(--ui-surface-hover, rgba(255,255,255,0.06));
601
- border-radius: var(--ui-radius-sm);
602
- }
603
638
  </style>