@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 +91 -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 +70 -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 +120 -33
- package/src/editor/ui/ProjectFontsSection.svelte +6 -0
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
|
|
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.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
|
|
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,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 {
|
|
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
|
}
|
|
@@ -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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
312
|
-
|
|
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));
|