@motion-proto/live-tokens 0.63.0 → 0.64.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.64.0 — A site picks the sketch through the front door
4
+
5
+ ### Added
6
+
7
+ - **A public entry point for the sketch layer, `@motion-proto/live-tokens/sketch`.**
8
+ A site that wants to offer its visitors a sketchstyle picker had nothing to
9
+ build one from: nothing sketch-related was exported, so the only route to the
10
+ looks was a bundler alias aimed at `src/editor/core/sketch/`. The new entry
11
+ carries `SKETCH_LOOKS` (the shipped looks, with the label and blurb a picker
12
+ shows), `setSketch(id | null)`, and the `sketchPick` store. All of it routes
13
+ through `sketchStore`, which stays the one owner of the live look, so a pick
14
+ made on the page and a dial moved in the Sketchstyle view are the same state.
15
+ `setSketch` throws on an id it does not know rather than returning quietly.
16
+
17
+ `sketchPick` reports three states, not two. The effect can be on under a look
18
+ no shipped sketchstyle names — one saved to a file, or one a theme carried —
19
+ and a picker that folds that into "off" tells the visitor the page is crisp
20
+ while it is visibly drawn. A dial moved off a shipped look still names it,
21
+ which is `selectSketchStyle`'s own rule: the pick says where the look came
22
+ from and `sketchDirty` says it has since drifted.
23
+
24
+ ### Fixed
25
+
26
+ - **"+ add fallback" took the Variables tab down.** The button offered a stack
27
+ its preferred generic, and substituted the matching System UI preset when that
28
+ generic was already present — but never checked whether the preset was there
29
+ too. Every shipped stack carries both, so the click appended a slot the stack
30
+ already held. Slot rows are keyed by their own content, so the duplicate threw
31
+ `each_key_duplicate` and killed the tab; the mutation had already been
32
+ debounce-written to localStorage by then, so a reload crashed on the same key
33
+ rather than recovering, and font editing was over until storage was cleared by
34
+ hand. The button now walks the whole system-and-generic ladder for a fallback
35
+ the stack lacks, and disables itself once every one is in use. Rows are also
36
+ keyed to survive a repeat, so a stack already persisted in the broken state
37
+ renders and the extra row can be removed with its own X.
38
+
39
+ - **A family Google Fonts rejected reported nothing useful.** Google omits
40
+ `Access-Control-Allow-Origin` from its error responses, so in a browser a 400
41
+ rejects the fetch rather than arriving as `ok: false` — which left the
42
+ `not on Google Fonts` branch unreachable and put a bare CORS failure in its
43
+ place. Both shapes now read as "no CSS came back". The retry that follows is
44
+ why it matters: the CSS2 API matches family names case-sensitively, and
45
+ `domine` 400s where `Domine` resolves, so a lower-cased typing is tried again
46
+ in Google's own casing before the family is called missing.
47
+
48
+ - **The by-name field accepted a pasted embed.** The whole `<link>` snippet went
49
+ to Google as a family name, and the 400 it earned came back as the same opaque
50
+ CORS failure. The field now recognises an embed or an `@font-face` block and
51
+ points at the Paste tab, which has parsed both all along.
52
+
53
+ - **The Sketchstyle view's dials went dead against a layer the store did not
54
+ install.** `installed` was a module-local flag, so a layer painted by anything
55
+ but `render` left the store believing the page was crisp: the on/off switch
56
+ had nothing to take down, and every dial wrote settings that reached no
57
+ document — silently, since the page was drawn the whole time. It is now read
58
+ from the DOM, for the reason `applySketchLayer` already compares against it:
59
+ with the overlay open two instances of the module render into one page, and
60
+ the document is the only ground they share.
61
+
3
62
  ## 0.63.0 — A theme carries its sketchstyle
4
63
 
5
64
  ### 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.0",
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,60 @@
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
+ }
@@ -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
  }
@@ -136,17 +136,34 @@
136
136
  });
137
137
  }
138
138
 
139
- function addSlot(variable: FontStackVariable) {
140
- const stack = stacks.find((s) => s.variable === variable);
139
+ /** Fallbacks offered by "+ add fallback", best match for the variable first.
140
+ * Every stack already ships with its preferred generic *and* its preferred
141
+ * preset, so both leading candidates are usually taken; the ladder gives the
142
+ * button something unused to reach for. */
143
+ function addCandidates(variable: FontStackVariable): FontStackSlot[] {
141
144
  const generic: GenericFamily =
142
145
  variable === '--font-mono' ? 'monospace' : variable === '--font-serif' ? 'serif' : 'sans-serif';
146
+ const preset: SystemCascadePreset =
147
+ variable === '--font-mono' ? 'system-ui-mono' : variable === '--font-serif' ? 'system-ui-serif' : 'system-ui-sans';
148
+ return [
149
+ { kind: 'generic', value: generic },
150
+ { kind: 'system', preset },
151
+ ...SYSTEM_PRESETS.map((p) => ({ kind: 'system' as const, preset: p })),
152
+ ...GENERIC_VALUES.map((g) => ({ kind: 'generic' as const, value: g })),
153
+ ];
154
+ }
155
+
156
+ /** A slot duplicated within a stack collides with itself in the keyed each,
157
+ * so only one the stack doesn't already hold may be added. */
158
+ function nextAddableSlot(variable: FontStackVariable): FontStackSlot | null {
159
+ const stack = stacks.find((s) => s.variable === variable);
143
160
  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
- }
161
+ return addCandidates(variable).find((c) => !existing.has(slotKey(c))) ?? null;
162
+ }
163
+
164
+ function addSlot(variable: FontStackVariable) {
165
+ const newSlot = nextAddableSlot(variable);
166
+ if (!newSlot) return;
150
167
  updateStack(variable, (slots) => {
151
168
  // Insert above the terminal fallback (always the last slot) so the
152
169
  // terminal stays at the bottom.
@@ -156,6 +173,20 @@
156
173
  });
157
174
  }
158
175
 
176
+ /** Two identical slots in one stack would collide in the keyed each and throw
177
+ * `each_key_duplicate`, taking the whole tab down — and the bad stack is
178
+ * already persisted by then, so the crash repeats on every reload. Suffix
179
+ * repeats so such a stack renders and can be edited back into shape. */
180
+ function keyedSlots(slots: FontStackSlot[]): { slot: FontStackSlot; key: string }[] {
181
+ const seen = new Map<string, number>();
182
+ return slots.map((slot) => {
183
+ const base = slotKey(slot);
184
+ const n = seen.get(base) ?? 0;
185
+ seen.set(base, n + 1);
186
+ return { slot, key: n === 0 ? base : `${base}#${n}` };
187
+ });
188
+ }
189
+
159
190
  /* Drag UX: the source row lifts (opacity, shadow); a white insertion bar
160
191
  sits in the gap between rows at the projected drop position. The array
161
192
  is only mutated on drop. animate:flip then slides every row to its new
@@ -232,7 +263,7 @@
232
263
  <span class="stack-variable">{variableLabel(stack.variable)}</span>
233
264
  </div>
234
265
  <div class="font-stack-list">
235
- {#each stack.slots as slot, i (slotKey(slot))}
266
+ {#each keyedSlots(stack.slots) as { slot, key }, i (key)}
236
267
  {@const isTerminal = i === stack.slots.length - 1}
237
268
  <!-- svelte-ignore a11y_no_static_element_interactions -->
238
269
  <div
@@ -308,7 +339,13 @@
308
339
  </div>
309
340
  {/each}
310
341
  </div>
311
- <button type="button" class="add-fallback" onclick={() => addSlot(stack.variable)}>
342
+ <button
343
+ type="button"
344
+ class="add-fallback"
345
+ disabled={nextAddableSlot(stack.variable) === null}
346
+ title={nextAddableSlot(stack.variable) === null ? 'Every system and generic fallback is already in this stack' : undefined}
347
+ onclick={() => addSlot(stack.variable)}
348
+ >
312
349
  + add fallback
313
350
  </button>
314
351
  </div>
@@ -493,8 +530,9 @@
493
530
  border-radius: var(--ui-radius-sm);
494
531
  cursor: pointer;
495
532
  }
496
- .add-fallback:hover {
533
+ .add-fallback:hover:not(:disabled) {
497
534
  color: var(--ui-text-primary);
498
535
  border-color: var(--ui-border);
499
536
  }
537
+ .add-fallback:disabled { opacity: 0.35; cursor: not-allowed; }
500
538
  </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));