@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 +59 -0
- package/dist-plugin/fontPairing/index.cjs +31 -7
- package/dist-plugin/fontPairing/index.js +31 -7
- package/package.json +6 -1
- package/src/editor/core/fonts/googleFontsUrl.ts +44 -8
- package/src/editor/core/sketch/index.ts +60 -0
- package/src/editor/core/sketch/sketchLayer.ts +11 -0
- package/src/editor/core/sketch/sketchStore.ts +9 -10
- package/src/editor/ui/FontStackEditor.svelte +49 -11
- package/src/editor/ui/ProjectFontsSection.svelte +6 -0
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
|
|
265
|
-
const
|
|
266
|
-
|
|
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
|
-
`"${
|
|
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(),
|
|
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
|
|
282
|
-
if (!res
|
|
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
|
|
223
|
-
const
|
|
224
|
-
|
|
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
|
-
`"${
|
|
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(),
|
|
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
|
|
240
|
-
if (!res
|
|
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.
|
|
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
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
-
`"${
|
|
92
|
-
`
|
|
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(),
|
|
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
|
|
112
|
-
if (!res
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 (
|
|
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
|
|
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));
|