@motion-proto/live-tokens 0.63.0 → 0.64.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.64.2 — A stack can gain a second font
4
+
5
+ ### Fixed
6
+
7
+ - **A stack could not gain a second font.** "+ add fallback" only ever walked
8
+ the system-and-generic ladder, and the row `<select>` could only retarget a
9
+ row that already existed — so the one path to a font was to add a fallback
10
+ and then change it, and once every fallback was in use the button disabled
11
+ itself and the path closed. The add button now leads with any project font
12
+ the stack doesn't carry, names it (`+ add Domine`), and drops it in with the
13
+ other fonts above the fallbacks.
14
+
15
+ - **Duplicate slots survived 0.64 and made a stack look frozen.** 0.64 kept a
16
+ stack persisted by the old add bug renderable, but left the repeats in place:
17
+ two identical rows are indistinguishable, so dragging one past the other
18
+ changed nothing on screen, and each repeat ate a rung of the add ladder.
19
+ Repeats are now dropped as the stack is read, and no row offers a value
20
+ another row in the same stack holds, so the state can't be re-entered.
21
+
22
+ ## 0.64.1 — The sketch entry point carries its last symbol
23
+
24
+ ### Added
25
+
26
+ - **`hasPersistedSketchState` joins `@motion-proto/live-tokens/sketch`.** A site
27
+ moving its visitors off its own storage key has to guard that one-time carry
28
+ on whether the store has already recorded a decision, or it overwrites a pick
29
+ the visitor has since made in the Sketchstyle view with the stale one. It was
30
+ the only symbol that errand still needed and the only one 0.64.0 left inside,
31
+ so the bundler alias the entry point set out to retire survived for it alone.
32
+ Reading `lt.sketchTouched` directly was never the answer: the key is ours to
33
+ rename.
34
+
35
+ ## 0.64.0 — A site picks the sketch through the front door
36
+
37
+ ### Added
38
+
39
+ - **A public entry point for the sketch layer, `@motion-proto/live-tokens/sketch`.**
40
+ A site that wants to offer its visitors a sketchstyle picker had nothing to
41
+ build one from: nothing sketch-related was exported, so the only route to the
42
+ looks was a bundler alias aimed at `src/editor/core/sketch/`. The new entry
43
+ carries `SKETCH_LOOKS` (the shipped looks, with the label and blurb a picker
44
+ shows), `setSketch(id | null)`, and the `sketchPick` store. All of it routes
45
+ through `sketchStore`, which stays the one owner of the live look, so a pick
46
+ made on the page and a dial moved in the Sketchstyle view are the same state.
47
+ `setSketch` throws on an id it does not know rather than returning quietly.
48
+
49
+ `sketchPick` reports three states, not two. The effect can be on under a look
50
+ no shipped sketchstyle names — one saved to a file, or one a theme carried —
51
+ and a picker that folds that into "off" tells the visitor the page is crisp
52
+ while it is visibly drawn. A dial moved off a shipped look still names it,
53
+ which is `selectSketchStyle`'s own rule: the pick says where the look came
54
+ from and `sketchDirty` says it has since drifted.
55
+
56
+ ### Fixed
57
+
58
+ - **"+ add fallback" took the Variables tab down.** The button offered a stack
59
+ its preferred generic, and substituted the matching System UI preset when that
60
+ generic was already present — but never checked whether the preset was there
61
+ too. Every shipped stack carries both, so the click appended a slot the stack
62
+ already held. Slot rows are keyed by their own content, so the duplicate threw
63
+ `each_key_duplicate` and killed the tab; the mutation had already been
64
+ debounce-written to localStorage by then, so a reload crashed on the same key
65
+ rather than recovering, and font editing was over until storage was cleared by
66
+ hand. The button now walks the whole system-and-generic ladder for a fallback
67
+ the stack lacks, and disables itself once every one is in use. Rows are also
68
+ keyed to survive a repeat, so a stack already persisted in the broken state
69
+ renders and the extra row can be removed with its own X.
70
+
71
+ - **A family Google Fonts rejected reported nothing useful.** Google omits
72
+ `Access-Control-Allow-Origin` from its error responses, so in a browser a 400
73
+ rejects the fetch rather than arriving as `ok: false` — which left the
74
+ `not on Google Fonts` branch unreachable and put a bare CORS failure in its
75
+ place. Both shapes now read as "no CSS came back". The retry that follows is
76
+ why it matters: the CSS2 API matches family names case-sensitively, and
77
+ `domine` 400s where `Domine` resolves, so a lower-cased typing is tried again
78
+ in Google's own casing before the family is called missing.
79
+
80
+ - **The by-name field accepted a pasted embed.** The whole `<link>` snippet went
81
+ to Google as a family name, and the 400 it earned came back as the same opaque
82
+ CORS failure. The field now recognises an embed or an `@font-face` block and
83
+ points at the Paste tab, which has parsed both all along.
84
+
85
+ - **The Sketchstyle view's dials went dead against a layer the store did not
86
+ install.** `installed` was a module-local flag, so a layer painted by anything
87
+ but `render` left the store believing the page was crisp: the on/off switch
88
+ had nothing to take down, and every dial wrote settings that reached no
89
+ document — silently, since the page was drawn the whole time. It is now read
90
+ from the DOM, for the reason `applySketchLayer` already compares against it:
91
+ with the overlay open two instances of the module render into one page, and
92
+ the document is the only ground they share.
93
+
3
94
  ## 0.63.0 — A theme carries its sketchstyle
4
95
 
5
96
  ### Added
@@ -260,15 +260,39 @@ function censusFrom(css, requested) {
260
260
  italics: family.italics === true
261
261
  };
262
262
  }
263
+ async function probeFor(fetcher, url2) {
264
+ try {
265
+ const res = await fetcher(url2);
266
+ return res.ok ? { res } : { status: res.status };
267
+ } catch {
268
+ return {};
269
+ }
270
+ }
271
+ function titleCase(name) {
272
+ return name.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
273
+ }
263
274
  async function resolveGoogleFont(name, fetcher) {
264
- const probeUrl = discoveryUrl(name);
265
- const probe = await fetcher(probeUrl);
266
- if (!probe.ok) {
275
+ const requested = name.trim();
276
+ const spellings = [.../* @__PURE__ */ new Set([requested, titleCase(requested)])];
277
+ let probe;
278
+ let probeUrl = discoveryUrl(requested);
279
+ let status;
280
+ for (const spelling of spellings) {
281
+ const candidateUrl = discoveryUrl(spelling);
282
+ const attempt = await probeFor(fetcher, candidateUrl);
283
+ if (attempt.res) {
284
+ probe = attempt.res;
285
+ probeUrl = candidateUrl;
286
+ break;
287
+ }
288
+ status ??= attempt.status;
289
+ }
290
+ if (!probe) {
267
291
  throw new Error(
268
- `"${name}" is not on Google Fonts (the API answered ${probe.status}). Check the spelling against fonts.google.com.`
292
+ `"${requested}" is not on Google Fonts` + (status === void 0 ? "" : ` (the API answered ${status})`) + `. Check the spelling against fonts.google.com.`
269
293
  );
270
294
  }
271
- const census = censusFrom(await probe.text(), name);
295
+ const census = censusFrom(await probe.text(), requested);
272
296
  const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
273
297
  const enumerated = census.weights.length > 0 ? url(
274
298
  census.name,
@@ -278,8 +302,8 @@ async function resolveGoogleFont(name, fetcher) {
278
302
  const bare = url(census.name);
279
303
  if (!candidates.includes(bare)) candidates.push(bare);
280
304
  for (const candidate of candidates) {
281
- const res = await fetcher(candidate);
282
- if (!res.ok) continue;
305
+ const { res } = await probeFor(fetcher, candidate);
306
+ if (!res) continue;
283
307
  const served = censusFrom(await res.text(), census.name);
284
308
  return {
285
309
  name: census.name,
@@ -218,15 +218,39 @@ function censusFrom(css, requested) {
218
218
  italics: family.italics === true
219
219
  };
220
220
  }
221
+ async function probeFor(fetcher, url2) {
222
+ try {
223
+ const res = await fetcher(url2);
224
+ return res.ok ? { res } : { status: res.status };
225
+ } catch {
226
+ return {};
227
+ }
228
+ }
229
+ function titleCase(name) {
230
+ return name.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
231
+ }
221
232
  async function resolveGoogleFont(name, fetcher) {
222
- const probeUrl = discoveryUrl(name);
223
- const probe = await fetcher(probeUrl);
224
- if (!probe.ok) {
233
+ const requested = name.trim();
234
+ const spellings = [.../* @__PURE__ */ new Set([requested, titleCase(requested)])];
235
+ let probe;
236
+ let probeUrl = discoveryUrl(requested);
237
+ let status;
238
+ for (const spelling of spellings) {
239
+ const candidateUrl = discoveryUrl(spelling);
240
+ const attempt = await probeFor(fetcher, candidateUrl);
241
+ if (attempt.res) {
242
+ probe = attempt.res;
243
+ probeUrl = candidateUrl;
244
+ break;
245
+ }
246
+ status ??= attempt.status;
247
+ }
248
+ if (!probe) {
225
249
  throw new Error(
226
- `"${name}" is not on Google Fonts (the API answered ${probe.status}). Check the spelling against fonts.google.com.`
250
+ `"${requested}" is not on Google Fonts` + (status === void 0 ? "" : ` (the API answered ${status})`) + `. Check the spelling against fonts.google.com.`
227
251
  );
228
252
  }
229
- const census = censusFrom(await probe.text(), name);
253
+ const census = censusFrom(await probe.text(), requested);
230
254
  const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
231
255
  const enumerated = census.weights.length > 0 ? url(
232
256
  census.name,
@@ -236,8 +260,8 @@ async function resolveGoogleFont(name, fetcher) {
236
260
  const bare = url(census.name);
237
261
  if (!candidates.includes(bare)) candidates.push(bare);
238
262
  for (const candidate of candidates) {
239
- const res = await fetcher(candidate);
240
- if (!res.ok) continue;
263
+ const { res } = await probeFor(fetcher, candidate);
264
+ if (!res) continue;
241
265
  const served = censusFrom(await res.text(), census.name);
242
266
  return {
243
267
  name: census.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.63.0",
3
+ "version": "0.64.2",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -95,6 +95,11 @@
95
95
  "types": "./src/system/backdrop/index.ts",
96
96
  "default": "./src/system/backdrop/index.ts"
97
97
  },
98
+ "./sketch": {
99
+ "svelte": "./src/editor/core/sketch/index.ts",
100
+ "types": "./src/editor/core/sketch/index.ts",
101
+ "default": "./src/editor/core/sketch/index.ts"
102
+ },
98
103
  "./components/*": {
99
104
  "svelte": "./src/system/components/*",
100
105
  "default": "./src/system/components/*"
@@ -77,6 +77,28 @@ function censusFrom(css: string, requested: string): { name: string; weights: nu
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * Google omits `Access-Control-Allow-Origin` from its *error* responses, so in
82
+ * a browser a 400 rejects the promise instead of arriving as `ok: false`. Both
83
+ * shapes mean the same thing here — no CSS came back — so collapse them, and
84
+ * keep the status when there was one to report.
85
+ */
86
+ async function probeFor(fetcher: CssFetcher, url: string): Promise<{ res?: CssResponse; status?: number }> {
87
+ try {
88
+ const res = await fetcher(url);
89
+ return res.ok ? { res } : { status: res.status };
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
95
+ /** `family=domine` answers 400 where `family=Domine` resolves — the CSS2 API
96
+ * matches family names case-sensitively. Retry a lower-cased typing in the
97
+ * casing Google actually uses rather than reporting the font as missing. */
98
+ function titleCase(name: string): string {
99
+ return name.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
100
+ }
101
+
80
102
  /**
81
103
  * Verify a family exists on Google Fonts and settle on the URL to persist.
82
104
  * Two requests in the common case: one to take the census, one to confirm the
@@ -84,15 +106,29 @@ function censusFrom(css: string, requested: string): { name: string; weights: nu
84
106
  * answers 400, so the enumerated form is the fallback rather than the guess.
85
107
  */
86
108
  export async function resolveGoogleFont(name: string, fetcher: CssFetcher): Promise<ResolvedGoogleFont> {
87
- const probeUrl = discoveryUrl(name);
88
- const probe = await fetcher(probeUrl);
89
- if (!probe.ok) {
109
+ const requested = name.trim();
110
+ const spellings = [...new Set([requested, titleCase(requested)])];
111
+ let probe: CssResponse | undefined;
112
+ let probeUrl = discoveryUrl(requested);
113
+ let status: number | undefined;
114
+ for (const spelling of spellings) {
115
+ const candidateUrl = discoveryUrl(spelling);
116
+ const attempt = await probeFor(fetcher, candidateUrl);
117
+ if (attempt.res) {
118
+ probe = attempt.res;
119
+ probeUrl = candidateUrl;
120
+ break;
121
+ }
122
+ status ??= attempt.status;
123
+ }
124
+ if (!probe) {
90
125
  throw new Error(
91
- `"${name}" is not on Google Fonts (the API answered ${probe.status}). ` +
92
- `Check the spelling against fonts.google.com.`,
126
+ `"${requested}" is not on Google Fonts` +
127
+ (status === undefined ? '' : ` (the API answered ${status})`) +
128
+ `. Check the spelling against fonts.google.com.`,
93
129
  );
94
130
  }
95
- const census = censusFrom(await probe.text(), name);
131
+ const census = censusFrom(await probe.text(), requested);
96
132
 
97
133
  const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
98
134
  const enumerated = census.weights.length > 0
@@ -108,8 +144,8 @@ export async function resolveGoogleFont(name: string, fetcher: CssFetcher): Prom
108
144
  if (!candidates.includes(bare)) candidates.push(bare);
109
145
 
110
146
  for (const candidate of candidates) {
111
- const res = await fetcher(candidate);
112
- if (!res.ok) continue;
147
+ const { res } = await probeFor(fetcher, candidate);
148
+ if (!res) continue;
113
149
  const served = censusFrom(await res.text(), census.name);
114
150
  return {
115
151
  name: census.name,
@@ -0,0 +1,70 @@
1
+ import { derived, type Readable } from 'svelte/store';
2
+ import { SKETCH_STYLES } from './sketchStyles';
3
+ import { selectSketchStyle, setSketchEnabled, sketchEnabled, sketchStyleName } from './sketchStore';
4
+
5
+ export interface SketchLook {
6
+ /** What `setSketch` takes. */
7
+ id: string;
8
+ label: string;
9
+ blurb: string;
10
+ }
11
+
12
+ /** The shipped sketchstyles. A picker adds its own "None" row: off is a state
13
+ of the effect, not one of the looks. */
14
+ export const SKETCH_LOOKS: readonly SketchLook[] = Object.entries(SKETCH_STYLES).map(
15
+ ([id, style]) => ({ id, label: style.label, blurb: style.blurb }),
16
+ );
17
+
18
+ /**
19
+ * What the page is drawing with. Three states, not two: the effect can be on
20
+ * under a look no shipped sketchstyle names — one saved to a file, or one a
21
+ * theme carried — and a picker that collapses that into `off` tells the
22
+ * visitor the page is crisp while it is visibly drawn.
23
+ *
24
+ * A dial moved off a shipped look keeps naming it, which is `selectSketchStyle`'s
25
+ * own rule: the pick says where the look came from, and `sketchDirty` says it
26
+ * has since drifted.
27
+ */
28
+ export type SketchPick =
29
+ | { state: 'off' }
30
+ | { state: 'look'; look: SketchLook }
31
+ | { state: 'adjusted' };
32
+
33
+ export const sketchPick: Readable<SketchPick> = derived(
34
+ [sketchEnabled, sketchStyleName],
35
+ ([on, name]): SketchPick => {
36
+ if (!on) return { state: 'off' };
37
+ const look = SKETCH_LOOKS.find((l) => l.id === name);
38
+ return look ? { state: 'look', look } : { state: 'adjusted' };
39
+ },
40
+ );
41
+
42
+ /**
43
+ * Draw the page with one of the shipped looks, or `null` for none.
44
+ *
45
+ * The only supported way for a consumer to drive the effect. Reaching for
46
+ * `applySketchLayer` instead paints a stylesheet the store does not know it
47
+ * owns, and every dial in the Sketchstyle view then writes state that reaches
48
+ * nothing — silently, since the page is already drawn.
49
+ */
50
+ export function setSketch(id: string | null): void {
51
+ if (id === null) {
52
+ setSketchEnabled(false);
53
+ return;
54
+ }
55
+ if (!(id in SKETCH_STYLES)) {
56
+ throw new Error(`Unknown sketchstyle "${id}". Ids come from SKETCH_LOOKS.`);
57
+ }
58
+ selectSketchStyle(id);
59
+ setSketchEnabled(true);
60
+ }
61
+
62
+ /**
63
+ * Whether the store has recorded a decision of its own in this browser.
64
+ *
65
+ * For a consumer carrying visitors over from its own storage key: a one-time
66
+ * carry has to be guarded on this, or it overwrites a pick the visitor has
67
+ * since made in the editor's Sketchstyle view with the stale one. Reading the
68
+ * key directly is not an option worth offering — it is ours to rename.
69
+ */
70
+ export { hasPersistedSketchState } from './sketchStore';
@@ -1101,6 +1101,17 @@ export function applySketchLayer(settings: SketchStyle): void {
1101
1101
  }
1102
1102
  }
1103
1103
 
1104
+ /**
1105
+ * Whether the effect's nodes are in place. Read from the DOM for the reason
1106
+ * `applySketchLayer` compares against it: two instances of this module render
1107
+ * into one page while the overlay is open, so a flag held in either one cannot
1108
+ * see what the other installed.
1109
+ */
1110
+ export function sketchLayerInstalled(): boolean {
1111
+ if (typeof document === 'undefined') return false;
1112
+ return getSyncedDocuments().some((doc) => doc.head.querySelector(`style[${STYLE_ATTR}]`) !== null);
1113
+ }
1114
+
1104
1115
  /** Remove the injected nodes and every scope attribute from all synced documents. */
1105
1116
  export function removeSketchLayer(): void {
1106
1117
  for (const doc of getSyncedDocuments()) {
@@ -5,7 +5,13 @@ import {
5
5
  hydrateSketchStyle,
6
6
  type SketchStyle,
7
7
  } from './sketchStyles';
8
- import { applySketchLayer, hostRoot, removeSketchLayer, setSketchScope } from './sketchLayer';
8
+ import {
9
+ applySketchLayer,
10
+ hostRoot,
11
+ removeSketchLayer,
12
+ setSketchScope,
13
+ sketchLayerInstalled,
14
+ } from './sketchLayer';
9
15
  import { liveMovedSinceBake } from '../productionPulse';
10
16
  import {
11
17
  deleteSketchStyle,
@@ -276,17 +282,13 @@ function persist(key: string, value: string): void {
276
282
  itself between import and first render. */
277
283
  let pageRoot: HTMLElement | null = null;
278
284
 
279
- let installed = false;
280
-
281
285
  function render(enabled: boolean, settings: SketchStyle): void {
282
286
  if (typeof document === 'undefined') return;
283
287
  if (!enabled) {
284
- if (installed) removeSketchLayer();
285
- installed = false;
288
+ if (sketchLayerInstalled()) removeSketchLayer();
286
289
  return;
287
290
  }
288
291
  applySketchLayer(settings);
289
- installed = true;
290
292
  // The editor's own chrome must never pick the effect up. Two roots qualify:
291
293
  // the host page behind the overlay iframe, and this document while it is
292
294
  // showing a page. The preview container scopes itself.
@@ -303,10 +305,7 @@ function render(enabled: boolean, settings: SketchStyle): void {
303
305
  Sketchstyle view's own stage crisp for good, Cancel included. */
304
306
  function paintPreviewRoots(style: SketchStyle | undefined): void {
305
307
  if (typeof document === 'undefined') return;
306
- if (style) {
307
- applySketchLayer(style);
308
- installed = true;
309
- }
308
+ if (style) applySketchLayer(style);
310
309
  setSketchScope(hostRoot(), style ?? null);
311
310
  setSketchScope(pageRoot, style ?? null);
312
311
  }
@@ -64,11 +64,25 @@
64
64
  return slots;
65
65
  }
66
66
 
67
+ /** A repeated slot contributes nothing to the CSS list, and two identical
68
+ * rows are indistinguishable — dragging one past the other looks like a
69
+ * dead control. Stacks written by the pre-0.64 "+ add fallback" bug are
70
+ * still in localStorage, so drop repeats on the way in. */
71
+ function dedupeSlots(slots: FontStackSlot[]): FontStackSlot[] {
72
+ const seen = new Set<string>();
73
+ return slots.filter((slot) => {
74
+ const key = slotKey(slot);
75
+ if (seen.has(key)) return false;
76
+ seen.add(key);
77
+ return true;
78
+ });
79
+ }
80
+
67
81
  function ensureAllStacksPresent(current: FontStack[]): FontStack[] {
68
82
  const byVar = new Map(current.map((s) => [s.variable, s]));
69
83
  return STACK_VARIABLES.map((v) => {
70
84
  const stack = byVar.get(v);
71
- const slots = withTerminalFallback(v, stack?.slots ?? []);
85
+ const slots = withTerminalFallback(v, dedupeSlots(stack?.slots ?? []));
72
86
  return stack ? { ...stack, slots } : { variable: v, slots };
73
87
  });
74
88
  }
@@ -136,26 +150,83 @@
136
150
  });
137
151
  }
138
152
 
139
- function addSlot(variable: FontStackVariable) {
140
- const stack = stacks.find((s) => s.variable === variable);
153
+ /** What the add button reaches for, in order of preference: a project font
154
+ * the stack doesn't carry yet, then the variable's preferred generic and
155
+ * preset, then the rest of the system-and-generic ladder. Project fonts lead
156
+ * because a stack that already holds every fallback otherwise had no way to
157
+ * gain a second font — the row's own <select> could only retarget a row that
158
+ * the button had to create first. */
159
+ function addCandidates(variable: FontStackVariable): FontStackSlot[] {
141
160
  const generic: GenericFamily =
142
161
  variable === '--font-mono' ? 'monospace' : variable === '--font-serif' ? 'serif' : 'sans-serif';
162
+ const preset: SystemCascadePreset =
163
+ variable === '--font-mono' ? 'system-ui-mono' : variable === '--font-serif' ? 'system-ui-serif' : 'system-ui-sans';
164
+ return [
165
+ ...allFamilies.map((f) => ({ kind: 'project' as const, familyId: f.id })),
166
+ { kind: 'generic', value: generic },
167
+ { kind: 'system', preset },
168
+ ...SYSTEM_PRESETS.map((p) => ({ kind: 'system' as const, preset: p })),
169
+ ...GENERIC_VALUES.map((g) => ({ kind: 'generic' as const, value: g })),
170
+ ];
171
+ }
172
+
173
+ /** The terminal row offers its stack's matching preset and generic, minus
174
+ * whatever a row above holds — plus its own current value whatever that is.
175
+ * A <select> with no option matching its value renders the first option
176
+ * instead, so the row would read as a slot the stack doesn't have. */
177
+ function terminalOptions(variable: FontStackVariable, slot: FontStackSlot, taken: Set<string>): FontStackSlot[] {
178
+ const current = slotKey(slot);
179
+ const pair: FontStackSlot[] = [
180
+ { kind: 'system', preset: TERMINAL_SYSTEM_BY_VAR[variable] },
181
+ { kind: 'generic', value: TERMINAL_FALLBACK_BY_VAR[variable] },
182
+ ];
183
+ const offered = pair.filter((c) => slotKey(c) === current || !taken.has(slotKey(c)));
184
+ return offered.some((c) => slotKey(c) === current) ? offered : [slot, ...offered];
185
+ }
186
+
187
+ /** A slot duplicated within a stack collides with itself in the keyed each,
188
+ * so only one the stack doesn't already hold may be added. */
189
+ function nextAddableSlot(variable: FontStackVariable): FontStackSlot | null {
190
+ const stack = stacks.find((s) => s.variable === variable);
143
191
  const existing = new Set((stack?.slots ?? []).map(slotKey));
144
- let newSlot: FontStackSlot = { kind: 'generic', value: generic };
145
- if (existing.has(slotKey(newSlot))) {
146
- const preset: SystemCascadePreset =
147
- variable === '--font-mono' ? 'system-ui-mono' : variable === '--font-serif' ? 'system-ui-serif' : 'system-ui-sans';
148
- newSlot = { kind: 'system', preset };
149
- }
192
+ return addCandidates(variable).find((c) => !existing.has(slotKey(c))) ?? null;
193
+ }
194
+
195
+ function addLabel(variable: FontStackVariable): string {
196
+ const next = nextAddableSlot(variable);
197
+ return next?.kind === 'project' ? `+ add ${slotDisplayName(next)}` : '+ add fallback';
198
+ }
199
+
200
+ /** A font joins the other fonts, above the fallbacks; a fallback lands just
201
+ * above the terminal, which stays at the bottom. */
202
+ function insertIndexFor(slots: FontStackSlot[], slot: FontStackSlot): number {
203
+ if (slot.kind !== 'project') return Math.max(0, slots.length - 1);
204
+ return slots.reduce((acc, s, i) => (s.kind === 'project' ? i : acc), -1) + 1;
205
+ }
206
+
207
+ function addSlot(variable: FontStackVariable) {
208
+ const newSlot = nextAddableSlot(variable);
209
+ if (!newSlot) return;
150
210
  updateStack(variable, (slots) => {
151
- // Insert above the terminal fallback (always the last slot) so the
152
- // terminal stays at the bottom.
153
- const insertAt = Math.max(0, slots.length - 1);
154
- slots.splice(insertAt, 0, newSlot);
211
+ slots.splice(insertIndexFor(slots, newSlot), 0, newSlot);
155
212
  return slots;
156
213
  });
157
214
  }
158
215
 
216
+ /** Two identical slots in one stack would collide in the keyed each and throw
217
+ * `each_key_duplicate`, taking the whole tab down — and the bad stack is
218
+ * already persisted by then, so the crash repeats on every reload. Suffix
219
+ * repeats so such a stack renders and can be edited back into shape. */
220
+ function keyedSlots(slots: FontStackSlot[]): { slot: FontStackSlot; key: string }[] {
221
+ const seen = new Map<string, number>();
222
+ return slots.map((slot) => {
223
+ const base = slotKey(slot);
224
+ const n = seen.get(base) ?? 0;
225
+ seen.set(base, n + 1);
226
+ return { slot, key: n === 0 ? base : `${base}#${n}` };
227
+ });
228
+ }
229
+
159
230
  /* Drag UX: the source row lifts (opacity, shadow); a white insertion bar
160
231
  sits in the gap between rows at the projected drop position. The array
161
232
  is only mutated on drop. animate:flip then slides every row to its new
@@ -232,8 +303,11 @@
232
303
  <span class="stack-variable">{variableLabel(stack.variable)}</span>
233
304
  </div>
234
305
  <div class="font-stack-list">
235
- {#each stack.slots as slot, i (slotKey(slot))}
306
+ {#each keyedSlots(stack.slots) as { slot, key }, i (key)}
236
307
  {@const isTerminal = i === stack.slots.length - 1}
308
+ <!-- A value another row already holds is off this row's menu, so the
309
+ stack can't be edited back into the duplicate state. -->
310
+ {@const taken = new Set(stack.slots.filter((_, j) => j !== i).map(slotKey))}
237
311
  <!-- svelte-ignore a11y_no_static_element_interactions -->
238
312
  <div
239
313
  class="slot-row"
@@ -265,28 +339,34 @@
265
339
  onchange={(e) => onSelectChange(e, stack.variable, i)}
266
340
  >
267
341
  {#if isTerminal}
268
- {@const sys = TERMINAL_SYSTEM_BY_VAR[stack.variable]}
269
- {@const gen = TERMINAL_FALLBACK_BY_VAR[stack.variable]}
270
- <option value={`system:${sys}`}>{sys === 'system-ui-sans' ? 'System UI (sans)' : sys === 'system-ui-serif' ? 'System UI (serif)' : 'System UI (mono)'}</option>
271
- <option value={`generic:${gen}`}>{gen}</option>
342
+ {#each terminalOptions(stack.variable, slot, taken) as opt}
343
+ <option value={slotKey(opt)}>{slotDisplayName(opt)}</option>
344
+ {/each}
272
345
  {:else}
273
- {#if allFamilies.length > 0}
346
+ {@const families = allFamilies.filter((f) => !taken.has(`project:${f.id}`))}
347
+ {@const presets = SYSTEM_PRESETS.filter((p) => !taken.has(`system:${p}`))}
348
+ {@const generics = GENERIC_VALUES.filter((g) => !taken.has(`generic:${g}`))}
349
+ {#if families.length > 0}
274
350
  <optgroup label="Project fonts">
275
- {#each allFamilies as fam}
351
+ {#each families as fam}
276
352
  <option value={`project:${fam.id}`}>{fam.name}</option>
277
353
  {/each}
278
354
  </optgroup>
279
355
  {/if}
280
- <optgroup label="System cascade">
281
- {#each SYSTEM_PRESETS as p}
282
- <option value={`system:${p}`}>{p === 'system-ui-sans' ? 'System UI (sans)' : p === 'system-ui-serif' ? 'System UI (serif)' : 'System UI (mono)'}</option>
283
- {/each}
284
- </optgroup>
285
- <optgroup label="Generic">
286
- {#each GENERIC_VALUES as g}
287
- <option value={`generic:${g}`}>{g}</option>
288
- {/each}
289
- </optgroup>
356
+ {#if presets.length > 0}
357
+ <optgroup label="System cascade">
358
+ {#each presets as p}
359
+ <option value={`system:${p}`}>{p === 'system-ui-sans' ? 'System UI (sans)' : p === 'system-ui-serif' ? 'System UI (serif)' : 'System UI (mono)'}</option>
360
+ {/each}
361
+ </optgroup>
362
+ {/if}
363
+ {#if generics.length > 0}
364
+ <optgroup label="Generic">
365
+ {#each generics as g}
366
+ <option value={`generic:${g}`}>{g}</option>
367
+ {/each}
368
+ </optgroup>
369
+ {/if}
290
370
  {/if}
291
371
  </select>
292
372
  {#if isTerminal}
@@ -308,8 +388,14 @@
308
388
  </div>
309
389
  {/each}
310
390
  </div>
311
- <button type="button" class="add-fallback" onclick={() => addSlot(stack.variable)}>
312
- + add fallback
391
+ <button
392
+ type="button"
393
+ class="add-fallback"
394
+ disabled={nextAddableSlot(stack.variable) === null}
395
+ title={nextAddableSlot(stack.variable) === null ? 'Every project font and fallback is already in this stack' : undefined}
396
+ onclick={() => addSlot(stack.variable)}
397
+ >
398
+ {addLabel(stack.variable)}
313
399
  </button>
314
400
  </div>
315
401
  {/each}
@@ -493,8 +579,9 @@
493
579
  border-radius: var(--ui-radius-sm);
494
580
  cursor: pointer;
495
581
  }
496
- .add-fallback:hover {
582
+ .add-fallback:hover:not(:disabled) {
497
583
  color: var(--ui-text-primary);
498
584
  border-color: var(--ui-border);
499
585
  }
586
+ .add-fallback:disabled { opacity: 0.35; cursor: not-allowed; }
500
587
  </style>
@@ -118,6 +118,12 @@
118
118
  nameError = 'Enter a family name';
119
119
  return;
120
120
  }
121
+ // Pasting the whole embed snippet here would otherwise be sent to Google as
122
+ // a family name, and the 400 comes back as an opaque CORS failure.
123
+ if (extractFontsUrl(nameInput) || /@font-face/i.test(nameInput)) {
124
+ nameError = 'That looks like an embed — use Paste instead of a family name';
125
+ return;
126
+ }
121
127
  nameDiscovering = true;
122
128
  try {
123
129
  nameResolved = await resolveGoogleFont(nameInput, (url) => fetch(url));