@motion-proto/live-tokens 0.62.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.
Files changed (71) hide show
  1. package/.claude/skills/live-tokens-adjust-geometry/SKILL.md +4 -4
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +21 -3
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +16 -48
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +10 -1
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +7 -5
  6. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +1 -1
  7. package/.claude/skills/live-tokens-create-component/references/token-naming.md +50 -0
  8. package/.claude/skills/live-tokens-generate-theme/SKILL.md +16 -9
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +8 -6
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +19 -5
  11. package/CHANGELOG.md +100 -0
  12. package/bin/migrate.mjs +6 -2
  13. package/dist-plugin/adjust/index.cjs +1 -1
  14. package/dist-plugin/adjust/index.d.cts +1 -1
  15. package/dist-plugin/adjust/index.d.ts +1 -1
  16. package/dist-plugin/adjust/index.js +1 -1
  17. package/dist-plugin/{chunk-232GZGQU.js → chunk-NDJJORKJ.js} +342 -5
  18. package/dist-plugin/{chunk-Y5CNFSSV.js → chunk-RVE3MNKM.js} +1 -1
  19. package/dist-plugin/{chunk-OPYOK2CA.js → chunk-ZHPX7ZYQ.js} +39 -1
  20. package/dist-plugin/{dataPaths-CRfD1LdA.d.ts → dataPaths-DZUzVv8H.d.cts} +3 -3
  21. package/dist-plugin/{dataPaths-CRfD1LdA.d.cts → dataPaths-DZUzVv8H.d.ts} +3 -3
  22. package/dist-plugin/fontPairing/index.cjs +32 -8
  23. package/dist-plugin/fontPairing/index.d.cts +1 -1
  24. package/dist-plugin/fontPairing/index.d.ts +1 -1
  25. package/dist-plugin/fontPairing/index.js +32 -8
  26. package/dist-plugin/generateColorsAndType/index.cjs +1 -1
  27. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  28. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  29. package/dist-plugin/generateColorsAndType/index.js +1 -1
  30. package/dist-plugin/index.cjs +417 -34
  31. package/dist-plugin/index.d.cts +1 -1
  32. package/dist-plugin/index.d.ts +1 -1
  33. package/dist-plugin/index.js +31 -23
  34. package/dist-plugin/migrateData/index.cjs +350 -9
  35. package/dist-plugin/migrateData/index.d.cts +1 -1
  36. package/dist-plugin/migrateData/index.d.ts +1 -1
  37. package/dist-plugin/migrateData/index.js +9 -5
  38. package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
  39. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  40. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  41. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  42. package/package.json +9 -2
  43. package/src/app/site.css +16 -0
  44. package/src/editor/core/fonts/googleFontsUrl.ts +44 -8
  45. package/src/editor/core/preview/lookPreview.ts +46 -5
  46. package/src/editor/core/productionPulse.ts +6 -2
  47. package/src/editor/core/sketch/index.ts +60 -0
  48. package/src/editor/core/sketch/maskField.ts +5 -5
  49. package/src/editor/core/sketch/sketchLayer.ts +19 -8
  50. package/src/editor/core/sketch/sketchStore.ts +238 -85
  51. package/src/editor/core/sketch/{sketchPresetService.ts → sketchStyleService.ts} +14 -14
  52. package/src/editor/core/sketch/{sketchPresets.ts → sketchStyles.ts} +17 -17
  53. package/src/editor/core/themes/themeDocumentSync.ts +2 -0
  54. package/src/editor/core/themes/themeInit.ts +19 -1
  55. package/src/editor/core/themes/themeService.ts +7 -2
  56. package/src/editor/core/themes/themeTypes.ts +5 -0
  57. package/src/editor/docs/content/editing-tokens.md +1 -1
  58. package/src/editor/docs/content/sketch-mode.md +27 -16
  59. package/src/editor/docs/content/themes-workflow.md +30 -21
  60. package/src/editor/docs/content/where-themes-live.md +9 -5
  61. package/src/editor/docs/content.generated.ts +4 -4
  62. package/src/editor/overlay/LiveTokensRouter.svelte +1 -1
  63. package/src/editor/ui/EditorViewSwitcher.svelte +3 -3
  64. package/src/editor/ui/FontStackEditor.svelte +49 -11
  65. package/src/editor/ui/ProjectFontsSection.svelte +6 -0
  66. package/src/editor/ui/ThemePanel.svelte +47 -1
  67. package/src/editor/ui/sections/textStyles.ts +15 -1
  68. package/src/editor/ui/sketch/SketchPreview.svelte +3 -3
  69. package/src/editor/ui/sketch/SketchTab.svelte +87 -53
  70. package/src/system/components/FloatingTokenTags.css +10 -8
  71. package/src/system/styles/tokens.css +17 -2
@@ -13,11 +13,11 @@ import {
13
13
  runTokensCssMigrations,
14
14
  semverBumpType,
15
15
  validateTokensCss
16
- } from "../chunk-OPYOK2CA.js";
16
+ } from "../chunk-ZHPX7ZYQ.js";
17
17
  import "../chunk-7TQQTHI6.js";
18
18
  import {
19
19
  readLiveTokensConfig
20
- } from "../chunk-Y5CNFSSV.js";
20
+ } from "../chunk-RVE3MNKM.js";
21
21
  export {
22
22
  TOKENS_CSS_MIGRATIONS,
23
23
  collectDefinedTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.62.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/*"
@@ -137,13 +142,15 @@
137
142
  "check:token-contract": "node scripts/check-token-contract.mjs",
138
143
  "check:preset-themes": "node scripts/check-preset-themes.mjs",
139
144
  "check:skills": "node scripts/check-skills.mjs",
145
+ "check:skill-atlas": "node scripts/sync-skill-atlas.mjs",
140
146
  "sync:component-defaults": "node scripts/sync-component-defaults.mjs --write",
141
147
  "sync:docs": "node scripts/sync-docs.mjs --write",
148
+ "sync:skill-atlas": "node scripts/sync-skill-atlas.mjs --write",
142
149
  "seed:preset-theme": "node scripts/seed-preset-theme.mjs",
143
150
  "collapse:theme": "node scripts/collapse-theme-to-default.mjs",
144
151
  "check:smoke-install": "bash scripts/smoke-install.sh",
145
152
  "check:smoke-create": "bash scripts/smoke-create.sh",
146
- "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:smoke-install && npm run check:smoke-create"
153
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:skill-atlas && npm run check:smoke-install && npm run check:smoke-create"
147
154
  },
148
155
  "peerDependencies": {
149
156
  "@sveltejs/vite-plugin-svelte": "^7.0",
package/src/app/site.css CHANGED
@@ -205,6 +205,22 @@ blockquote {
205
205
  letter-spacing: var(--body-sm-letter-spacing);
206
206
  }
207
207
 
208
+ .editorial-xl {
209
+ font-family: var(--editorial-xl-font-family);
210
+ font-size: var(--editorial-xl-font-size);
211
+ font-weight: var(--editorial-xl-font-weight);
212
+ line-height: var(--editorial-xl-line-height);
213
+ letter-spacing: var(--editorial-xl-letter-spacing);
214
+ }
215
+
216
+ .editorial-lg {
217
+ font-family: var(--editorial-lg-font-family);
218
+ font-size: var(--editorial-lg-font-size);
219
+ font-weight: var(--editorial-lg-font-weight);
220
+ line-height: var(--editorial-lg-line-height);
221
+ letter-spacing: var(--editorial-lg-letter-spacing);
222
+ }
223
+
208
224
  .editorial-md {
209
225
  font-family: var(--editorial-md-font-family);
210
226
  font-size: var(--editorial-md-font-size);
@@ -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,
@@ -6,6 +6,7 @@ import { batchCssVarChanges, setCssVar, removeCssVar } from '../cssVarSync';
6
6
  import { applyFontSources } from '../fonts/fontLoader';
7
7
  import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
8
8
  import { loadTheme } from '../themes/themeService';
9
+ import { previewSketchStyle, revertSketchStylePreview } from '../sketch/sketchStore';
9
10
 
10
11
  /**
11
12
  * Client-side rendering of a saved file, for browsing looks without committing
@@ -20,6 +21,11 @@ import { loadTheme } from '../themes/themeService';
20
21
  * of the same theme produces, and a revert re-derives from the live store
21
22
  * rather than replaying DOM values scraped before the preview.
22
23
  *
24
+ * A theme's sketchstyle rides along the same way: `previewTheme` paints it
25
+ * through `previewSketchStyle`, which reaches the page without touching the
26
+ * live sketch buffer, and a revert re-derives from that buffer exactly as the
27
+ * CSS vars do.
28
+ *
23
29
  * One preview is live at a time, whichever kind it is: painting reverts first,
24
30
  * so every look is a diff against the user's real state.
25
31
  */
@@ -76,6 +82,14 @@ export function liveLook(): RenderedLook {
76
82
 
77
83
  let livePreview: RenderedLook | null = null;
78
84
  let defaultsPromise: Promise<Theme> | null = null;
85
+ // Whether the current preview session has a sketchstyle painted over the live
86
+ // buffer, so a colors-only preview (which never previews sketch) and revert
87
+ // know whether there is anything to hand back.
88
+ let sketchPreviewActive = false;
89
+ // Bumped whenever a preview session ends or is superseded. `previewTheme` awaits
90
+ // the defaults theme, and a Cancel landing inside that await was overwritten when
91
+ // it resolved, stranding a preview the picker believed it had already taken down.
92
+ let generation = 0;
79
93
 
80
94
  function loadDefaults(): Promise<Theme> {
81
95
  // A rejected promise must not be memoized, or one failed fetch (a dev-server
@@ -109,30 +123,55 @@ function applyPreview(look: RenderedLook): void {
109
123
  livePreview = look;
110
124
  }
111
125
 
112
- /** Paint a whole look: the theme's colors and type and every component config
113
- * it carries. */
126
+ /** Paint a whole look: the theme's colors and type, every component config
127
+ * it carries, and its sketchstyle — present or not, since a theme with none
128
+ * paints crisp regardless of what is live (invariant 3). */
114
129
  export async function previewTheme(theme: Theme): Promise<void> {
115
- applyPreview(themeLook(theme, await loadDefaults()));
130
+ const gen = generation;
131
+ const defaults = await loadDefaults();
132
+ if (gen !== generation) return;
133
+ applyPreview(themeLook(theme, defaults));
134
+ previewSketchStyle(theme.sketchStyle);
135
+ sketchPreviewActive = true;
116
136
  }
117
137
 
118
- /** Paint colors and type over the components as they stand. */
138
+ /** Paint colors and type over the components as they stand. Not a whole
139
+ * look, so the sketchstyle stays live too — reverting a sketch preview a
140
+ * prior row left painted, if one is running. */
119
141
  export function previewColorsAndType(colorsAndType: ColorsAndType): void {
142
+ generation++;
120
143
  applyPreview(colorsAndTypeLook(colorsAndType));
144
+ if (sketchPreviewActive) {
145
+ revertSketchStylePreview();
146
+ sketchPreviewActive = false;
147
+ }
121
148
  }
122
149
 
123
150
  /** Restore the live editor state. No-op when no preview is running. */
124
151
  export function revertPreview(): void {
152
+ generation++;
125
153
  if (!livePreview) return;
126
154
  paint(liveLook(), livePreview);
127
155
  livePreview = null;
156
+ if (sketchPreviewActive) {
157
+ revertSketchStylePreview();
158
+ sketchPreviewActive = false;
159
+ }
128
160
  }
129
161
 
130
162
  /** Release the preview without repainting. The caller must immediately load
131
163
  * the exact look being previewed into the store. This is the Save handoff: the
132
164
  * selected theme is already on screen, so restoring the old live look before
133
- * applying it would add work and create a visible flash across the request. */
165
+ * applying it would add work and create a visible flash across the request.
166
+ * The sketchstyle stays painted for the same reason, though the handoff is not
167
+ * quite free: when the live state was crisp, `openThemeSketchStyle` writes the
168
+ * settings before the flag, so the sheet and the filter bank come down and go
169
+ * straight back up in between. That order is still the right one, since writing
170
+ * the flag first would paint the OLD dials for a frame. */
134
171
  export function commitPreview(): void {
172
+ generation++;
135
173
  livePreview = null;
174
+ sketchPreviewActive = false;
136
175
  }
137
176
 
138
177
  export function isPreviewing(): boolean {
@@ -141,6 +180,8 @@ export function isPreviewing(): boolean {
141
180
 
142
181
  /** Test-only: drop the live preview and the cached defaults theme. */
143
182
  export function __resetPreviewForTests(): void {
183
+ generation++;
144
184
  livePreview = null;
145
185
  defaultsPromise = null;
186
+ sketchPreviewActive = false;
146
187
  }
@@ -35,8 +35,12 @@ export function bumpComponentActiveRevision(): void {
35
35
  * component editor's Adopt has to clear it for the panel.
36
36
  *
37
37
  * Set by the client writes that move the live look (`writeWorkingColorsAndType`,
38
- * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`); cleared by
39
- * `adoptLook`. Module-level, so it survives the remounts a view switch causes.
38
+ * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`, and every
39
+ * sketch gesture that changes what the effect paints: `setSketchEnabled`,
40
+ * `updateSketchSettings`, `selectSketchStyle`, `selectSavedSketchStyle`);
41
+ * cleared by `adoptLook` and by `hydrateAppliedTheme`, on this document and on
42
+ * every peer an Apply broadcasts to. Module-level, so it survives the
43
+ * remounts a view switch causes.
40
44
  */
41
45
  export const liveMovedSinceBake = writable(false);
42
46
 
@@ -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
+ }
@@ -19,7 +19,7 @@
19
19
  * Everything is integer maths on a Float32Array with no DOM, so what a test
20
20
  * asserts is what the browser paints.
21
21
  */
22
- import type { SketchSettings } from './sketchPresets';
22
+ import type { SketchStyle } from './sketchStyles';
23
23
 
24
24
  /** What the tile spans in page px. Blobs are fitted a whole number to the
25
25
  tile, which is what lets the lattice wrap; the dial's px reading is the
@@ -111,7 +111,7 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
111
111
  * wherever the octave crossed zero, which is the marbled look; plain sum is
112
112
  * cloud.
113
113
  */
114
- function rawField(s: SketchSettings, seed: number, raster: number, cells: number): Float32Array {
114
+ function rawField(s: SketchStyle, seed: number, raster: number, cells: number): Float32Array {
115
115
  const noise = makePerlin(seed);
116
116
  const veined = s.maskGrain === 'turbulence';
117
117
  const octaves = Math.max(1, Math.round(s.maskOctaves));
@@ -256,7 +256,7 @@ function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
256
256
  all four; the levels, the posterising and the blur are one pass each. */
257
257
  let rawCache: { key: string; field: Float32Array } | null = null;
258
258
 
259
- function cachedRaw(s: SketchSettings, seed: number): Float32Array {
259
+ function cachedRaw(s: SketchStyle, seed: number): Float32Array {
260
260
  const key = [s.maskBlob, s.maskOctaves, s.maskGrain, seed].join('|');
261
261
  if (rawCache?.key !== key) {
262
262
  const cells = Math.max(1, Math.round(MASK_TILE / s.maskBlob));
@@ -274,7 +274,7 @@ function cachedRaw(s: SketchSettings, seed: number): Float32Array {
274
274
  * pass did.
275
275
  */
276
276
  export function buildMaskField(
277
- s: SketchSettings, seed = 9, through?: MaskStage,
277
+ s: SketchStyle, seed = 9, through?: MaskStage,
278
278
  ): { field: Float32Array; raster: number } {
279
279
  const raw = cachedRaw(s, seed);
280
280
  if (through === 'noise') return { field: raw, raster: RASTER };
@@ -406,7 +406,7 @@ const CACHE_MAX = 6;
406
406
  const cache = new Map<string, string>();
407
407
 
408
408
  /** The field as a `url(...)` for `mask-image`. */
409
- export function buildMaskUri(s: SketchSettings, seed = 9, through?: MaskStage): string {
409
+ export function buildMaskUri(s: SketchStyle, seed = 9, through?: MaskStage): string {
410
410
  const key = [...KEYS.map((k) => s[k]), seed, through ?? 'all'].join('|');
411
411
  const hit = cache.get(key);
412
412
  if (hit) return hit;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Sketch effect layer.
3
3
  *
4
- * Builds an SVG filter bank and a stylesheet from one SketchSettings and
4
+ * Builds an SVG filter bank and a stylesheet from one SketchStyle and
5
5
  * injects both into every document cssVarSync tracks, so the host page behind
6
6
  * the overlay iframe gets the same effect the editor's preview shows.
7
7
  *
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { getSyncedDocuments } from '../cssVarSync';
18
18
  import { buildMaskUri, MASK_TILE } from './maskField';
19
- import type { SketchSettings } from './sketchPresets';
19
+ import type { SketchStyle } from './sketchStyles';
20
20
 
21
21
  const DEFS_ATTR = 'data-sketch-defs';
22
22
  const STYLE_ATTR = 'data-sketch-style';
@@ -325,7 +325,7 @@ const UNCLIPPED = PART_SPECS.filter((p) => !p.clips).map((p) => p.sel).join(', '
325
325
  const CLIPPED = PART_SPECS.filter((p) => p.clips).map((p) => p.sel).join(', ');
326
326
  const UNMASKED = PART_SPECS.filter((p) => p.unmasked).map((p) => p.sel).join(', ');
327
327
 
328
- export function buildDefsMarkup(s: SketchSettings): string {
328
+ export function buildDefsMarkup(s: SketchStyle): string {
329
329
  /**
330
330
  * `warp` is the shape stage: one wave of noise whose wavelength spans a whole
331
331
  * component, so the four corners sample different parts of the field and the
@@ -584,7 +584,7 @@ const swing = (travel: number) => String(Number((travel * 2).toFixed(4)));
584
584
  /** Squares the displacement wave off around 0.5, its zero, so full amplitude is
585
585
  spent along the whole edge rather than only where the wave peaks. Both
586
586
  channels take it: the map reads x from R and y from G. */
587
- function squareOff(s: SketchSettings, from: string, to: string): string {
587
+ function squareOff(s: SketchStyle, from: string, to: string): string {
588
588
  if (s.waveform <= 1) return '';
589
589
  const slope = s.waveform.toFixed(2);
590
590
  const intercept = ((1 - s.waveform) / 2).toFixed(3);
@@ -594,7 +594,7 @@ function squareOff(s: SketchSettings, from: string, to: string): string {
594
594
  `</feComponentTransfer>`;
595
595
  }
596
596
 
597
- const squaredResult = (s: SketchSettings, from: string, to: string) =>
597
+ const squaredResult = (s: SketchStyle, from: string, to: string) =>
598
598
  (s.waveform > 1 ? to : from);
599
599
 
600
600
  /** Along-stroke pressure wavelength. Low, with a high floor in the transfer
@@ -650,7 +650,7 @@ const RETRACE_SEED = 53;
650
650
  const WARP_FREQUENCY = 0.08;
651
651
 
652
652
 
653
- export function buildStylesheet(s: SketchSettings): string {
653
+ export function buildStylesheet(s: SketchStyle): string {
654
654
  const on = '[data-sketch]';
655
655
  const parts = `:is(${PARTS})`;
656
656
  const el = `${on} ${parts}`;
@@ -1083,7 +1083,7 @@ function defsNode(doc: Document): SVGSVGElement {
1083
1083
  * This only makes the effect *available*. An element opts in by carrying
1084
1084
  * data-sketch, which is what `setSketchScope` writes.
1085
1085
  */
1086
- export function applySketchLayer(settings: SketchSettings): void {
1086
+ export function applySketchLayer(settings: SketchStyle): void {
1087
1087
  const defs = buildDefsMarkup(settings);
1088
1088
  const css = buildStylesheet(settings);
1089
1089
  for (const doc of getSyncedDocuments()) {
@@ -1101,6 +1101,17 @@ export function applySketchLayer(settings: SketchSettings): 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()) {
@@ -1115,7 +1126,7 @@ export function removeSketchLayer(): void {
1115
1126
  * The host page's root and the editor's own preview container are both scopes,
1116
1127
  * which is why this takes an element rather than assuming documentElement.
1117
1128
  */
1118
- export function setSketchScope(el: HTMLElement | null, settings: SketchSettings | null): void {
1129
+ export function setSketchScope(el: HTMLElement | null, settings: SketchStyle | null): void {
1119
1130
  if (!el) return;
1120
1131
  if (!settings) {
1121
1132
  el.removeAttribute('data-sketch');