@motion-proto/live-tokens 0.56.1 → 0.57.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 (90) hide show
  1. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
  2. package/CHANGELOG.md +124 -0
  3. package/README.md +1 -1
  4. package/bin/cli.mjs +2 -1
  5. package/dist-plugin/adjust/index.cjs +1 -0
  6. package/dist-plugin/adjust/index.d.cts +2 -2
  7. package/dist-plugin/adjust/index.d.ts +2 -2
  8. package/dist-plugin/adjust/index.js +1 -1
  9. package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
  10. package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
  11. package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
  12. package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
  13. package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
  14. package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
  15. package/dist-plugin/fontPairing/index.cjs +4 -2
  16. package/dist-plugin/fontPairing/index.d.cts +3 -3
  17. package/dist-plugin/fontPairing/index.d.ts +3 -3
  18. package/dist-plugin/fontPairing/index.js +4 -3
  19. package/dist-plugin/generateColorsAndType/index.cjs +22 -9
  20. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  21. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.js +8 -5
  23. package/dist-plugin/index.cjs +158 -9
  24. package/dist-plugin/index.d.cts +1 -1
  25. package/dist-plugin/index.d.ts +1 -1
  26. package/dist-plugin/index.js +71 -4
  27. package/dist-plugin/migrateData/index.cjs +1 -0
  28. package/dist-plugin/migrateData/index.d.cts +1 -1
  29. package/dist-plugin/migrateData/index.d.ts +1 -1
  30. package/dist-plugin/migrateData/index.js +1 -1
  31. package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
  32. package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
  33. package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
  34. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  35. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  36. package/dist-plugin/tokensCssMigrations/index.js +3 -3
  37. package/package.json +3 -1
  38. package/src/editor/core/fonts/applyFontPairing.ts +3 -2
  39. package/src/editor/core/fonts/fontLoader.ts +1 -0
  40. package/src/editor/core/fonts/fontMigration.ts +31 -1
  41. package/src/editor/core/palettes/paletteDerivation.ts +9 -2
  42. package/src/editor/core/sketch/maskField.ts +381 -0
  43. package/src/editor/core/sketch/sketchLayer.ts +1028 -0
  44. package/src/editor/core/sketch/sketchPresetService.ts +65 -0
  45. package/src/editor/core/sketch/sketchPresets.ts +328 -0
  46. package/src/editor/core/sketch/sketchStore.ts +190 -0
  47. package/src/editor/core/store/editorPersistence.ts +28 -12
  48. package/src/editor/core/store/editorTypes.ts +4 -1
  49. package/src/editor/core/store/editorViewStore.ts +2 -2
  50. package/src/editor/core/store/gradientSource.ts +15 -1
  51. package/src/editor/core/themes/parsers/gradient.ts +62 -4
  52. package/src/editor/core/themes/slices/gradients.ts +75 -14
  53. package/src/editor/core/themes/themeTypes.ts +6 -1
  54. package/src/editor/docs/Docs.svelte +4 -3
  55. package/src/editor/docs/chapters.ts +1 -0
  56. package/src/editor/docs/content/01-overview.md +2 -1
  57. package/src/editor/docs/content/editing-tokens.md +5 -1
  58. package/src/editor/docs/content/sketch-mode.md +89 -0
  59. package/src/editor/docs/content.generated.ts +3 -2
  60. package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
  61. package/src/editor/pages/EditorShell.svelte +43 -0
  62. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  63. package/src/editor/ui/FontStackEditor.svelte +3 -0
  64. package/src/editor/ui/GradientEditor.svelte +38 -1
  65. package/src/editor/ui/UIReveal.svelte +7 -1
  66. package/src/editor/ui/UISegmentedControl.svelte +4 -8
  67. package/src/editor/ui/sections/GradientsSection.svelte +47 -1
  68. package/src/editor/ui/sketch/SketchDial.svelte +124 -0
  69. package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
  70. package/src/editor/ui/sketch/SketchRange.svelte +185 -0
  71. package/src/editor/ui/sketch/SketchTab.svelte +1263 -0
  72. package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
  73. package/src/live-tokens/data/colors-and-type/default.json +17 -0
  74. package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
  75. package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
  76. package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
  77. package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
  78. package/src/live-tokens/data/colors-and-type/sketches.json +2520 -0
  79. package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
  80. package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
  81. package/src/live-tokens/data/themes/autumn.json +17 -0
  82. package/src/live-tokens/data/themes/halloween.json +17 -0
  83. package/src/live-tokens/data/themes/midnight-study.json +17 -0
  84. package/src/live-tokens/data/themes/ocean.json +17 -0
  85. package/src/live-tokens/data/themes/royal-velvet.json +17 -0
  86. package/src/live-tokens/data/themes/sketches.json +3984 -0
  87. package/src/live-tokens/data/themes/spring-meadow.json +17 -0
  88. package/src/live-tokens/data/themes/sunset.json +17 -0
  89. package/src/live-tokens/data/tokens.generated.css +1 -0
  90. package/src/system/styles/tokens.css +15 -2
@@ -0,0 +1,1028 @@
1
+ /**
2
+ * Sketch effect layer.
3
+ *
4
+ * Builds an SVG filter bank and a stylesheet from one SketchSettings and
5
+ * injects both into every document cssVarSync tracks, so the host page behind
6
+ * the overlay iframe gets the same effect the editor's preview shows.
7
+ *
8
+ * Nothing here reads or writes theme values. The effect is a layer over
9
+ * whatever theme is active: it paints ::before/::after pseudo-elements from the
10
+ * component's own --<component>-<variant>-{surface,border,radius} tokens and
11
+ * hides the component's real background and border behind them.
12
+ *
13
+ * Scope is an attribute, not a class, so a caller can switch it on for a whole
14
+ * document root or for a single preview container:
15
+ * data-sketch present = on, absent = off
16
+ */
17
+ import { getSyncedDocuments } from '../cssVarSync';
18
+ import { buildMaskUri, MASK_TILE } from './maskField';
19
+ import type { SketchSettings } from './sketchPresets';
20
+
21
+ const DEFS_ATTR = 'data-sketch-defs';
22
+ const STYLE_ATTR = 'data-sketch-style';
23
+ const ID = 'lt-sketch';
24
+
25
+ /** Distinct noise seeds. Equal-sized neighbours drawn from one seed are
26
+ identical twins, which is the single biggest tell that it is not hand drawn. */
27
+ const SEEDS = [0, 1, 2, 3, 4];
28
+
29
+ const HATCH_ANGLE = 45;
30
+ /** The second set of stripes leans and spaces itself a little off the first.
31
+ Half a pixel of pitch against 7 puts the beat about 90px apart, and the two
32
+ degrees of lean turn that beat so it is never a clean band. */
33
+ const HATCH_BEAT_ANGLE = 2;
34
+ const HATCH_BEAT_PITCH = 6.5;
35
+
36
+ /**
37
+ * One drawable part. `stem` is the token stem: the fill reads
38
+ * `--{stem}-surface` and the stroke `--{stem}-border`, the naming every
39
+ * component already follows, so most rows are one line. `fill`/`stroke`
40
+ * override that where a component breaks the pattern.
41
+ *
42
+ * `positioned` marks a part that already establishes its own containing block.
43
+ * The host rule must not force `position:relative` onto it, which would move an
44
+ * absolutely-positioned element to wherever its flow position happens to be.
45
+ *
46
+ * `strokeless` keeps the effect off `::after`, for a part that owns that
47
+ * pseudo-element itself.
48
+ */
49
+ interface PartSpec {
50
+ sel: string;
51
+ stem?: string;
52
+ fill?: string;
53
+ stroke?: string;
54
+ /** Hatch ink, where the stroke is transparent. A header or a content strip
55
+ draws no outline of its own but still carries a surface, and the hatch
56
+ falls back to the stroke, so binding the two left it striped in nothing.
57
+ These name the ink their PARENT is outlined in, so the whole component
58
+ reads as one drawing rather than as a box with a shaded panel dropped in.
59
+ A part with a visible stroke needs no entry; a part with no fill wants
60
+ none, because there is no surface there to shade. */
61
+ hatch?: string;
62
+ /** Overrides `--{stem}-radius` where a component names its corners something
63
+ else, and supplies the value for a part that has no stem. `--sketch-radius`
64
+ inherits, so every part states one: left unset, a square header inside a
65
+ rounded card would pick up the card's corners. */
66
+ radius?: string;
67
+ /** Same contract for `--{stem}-shadow`. The shadow moves onto the drawn box,
68
+ because the one the component casts belongs to a rectangle the sketch is
69
+ no longer drawing. */
70
+ shadow?: string;
71
+ /** Keeps its own `overflow`, against the default. Every part gives its clip
72
+ up under the layer, because a clip is a flat rectangle and a flat
73
+ rectangle is exactly what slices the drawn box off. Most of them clip only
74
+ to round off what sits inside, which the drawn box now does itself.
75
+
76
+ Set this where the clip carries something real: a scroller, a fill bar
77
+ held to its track, a picture held to its frame. Those keep it and take the
78
+ drawn corner radii on the host instead, so the clip at least turns the
79
+ same way the ink does. */
80
+ clips?: boolean;
81
+ positioned?: boolean;
82
+ strokeless?: boolean;
83
+ }
84
+
85
+ const BADGE_VARIANTS = [
86
+ 'primary', 'accent', 'special', 'neutral', 'alternate',
87
+ 'canvas', 'info', 'success', 'warning', 'danger',
88
+ ];
89
+ /** `outline` is filled by its border alone, so it is listed separately. */
90
+ const FILLED_BUTTON_VARIANTS = ['primary', 'secondary', 'danger', 'success', 'warning'];
91
+ const STATUS_VARIANTS = ['info', 'success', 'warning', 'danger'];
92
+
93
+ /**
94
+ * Every component the effect knows how to redraw. Interaction states are not
95
+ * listed here — a state does not add a layer, it only repaints one, so they
96
+ * live in STATE_COLOURS below.
97
+ */
98
+ const PART_SPECS: readonly PartSpec[] = [
99
+ // Buttons
100
+ ...FILLED_BUTTON_VARIANTS.map((v) => ({ sel: `.button.${v}`, stem: `button-${v}` })),
101
+ {
102
+ sel: '.button.outline', fill: 'transparent', stroke: 'var(--button-outline-border)',
103
+ radius: 'var(--button-outline-radius, 0px)',
104
+ },
105
+ ...FILLED_BUTTON_VARIANTS.map((v) => ({ sel: `.icon-button.${v}`, stem: `iconbutton-${v}` })),
106
+ {
107
+ sel: '.icon-button.outline', fill: 'transparent', stroke: 'var(--iconbutton-outline-border)',
108
+ radius: 'var(--iconbutton-outline-radius, 0px)',
109
+ },
110
+
111
+ // Chips
112
+ ...BADGE_VARIANTS.map((v) => ({ sel: `.badge-${v}`, stem: `badge-${v}` })),
113
+ // CornerBadge's own element only anchors and rebinds: the colour it names is
114
+ // handed down to the Badge inside it, and `.badge-*` above draws it there.
115
+ // Drawing it here as well stacks a second box behind the first.
116
+
117
+ // Containers
118
+ { sel: '.card', stem: 'card-default' },
119
+ {
120
+ sel: '.card-header', fill: 'var(--card-default-header-surface)', stroke: 'transparent',
121
+ hatch: 'var(--card-default-border)',
122
+ },
123
+ {
124
+ sel: '.panel', fill: 'var(--panel-stage-surface)', stroke: 'var(--panel-frame-border)',
125
+ radius: 'var(--panel-frame-radius, 0px)',
126
+ },
127
+ // Scrolls sideways.
128
+ { sel: '.codesnippet', stem: 'codesnippet', clips: true },
129
+ // Scrolls sideways.
130
+ { sel: '.table-wrapper', stem: 'table-default', clips: true },
131
+ { sel: '.dialog', stem: 'dialog' },
132
+ {
133
+ sel: '.dialog-header', fill: 'var(--dialog-header-surface)', stroke: 'transparent',
134
+ hatch: 'var(--dialog-border)',
135
+ },
136
+ // The container variant is a frame around two painted children, and holds no
137
+ // colour of its own. Filling it with the header's surface floods the whole
138
+ // section with it, and repaints all of it on a hover only the header answers.
139
+ {
140
+ sel: '.es-root.variant-container', fill: 'transparent',
141
+ stroke: 'var(--collapsiblesection-container-frame-border)',
142
+ radius: 'var(--collapsiblesection-container-frame-radius, 0px)',
143
+ },
144
+ {
145
+ sel: '.es-root.variant-container > .section-header',
146
+ fill: 'var(--collapsiblesection-container-default-surface)', stroke: 'transparent',
147
+ hatch: 'var(--collapsiblesection-container-frame-border)',
148
+ },
149
+ {
150
+ sel: '.es-root.variant-container > .section-content',
151
+ fill: 'var(--collapsiblesection-container-expanded-surface)', stroke: 'transparent',
152
+ hatch: 'var(--collapsiblesection-container-frame-border)',
153
+ },
154
+ {
155
+ sel: '.sidenavigation',
156
+ fill: 'var(--sidenavigation-panel-surface)',
157
+ stroke: 'var(--sidenavigation-panel-border)',
158
+ // Its close animation is a width transition whose overflow the clip hides.
159
+ clips: true,
160
+ },
161
+ // The arrow is the tooltip's own ::after, so the box takes the fill only.
162
+ { sel: '.tooltip', stem: 'tooltip', positioned: true, strokeless: true },
163
+
164
+ // Status blocks
165
+ ...STATUS_VARIANTS.map((v) => ({ sel: `.callout-${v}`, stem: `callout-${v}` })),
166
+ // Same shape as the container section above: the notification's colour is on
167
+ // its header strip, and the body below it is left to the page.
168
+ ...STATUS_VARIANTS.map((v) => ({
169
+ sel: `.notification.${v}`, fill: 'transparent',
170
+ stroke: `var(--notification-${v}-border)`,
171
+ radius: `var(--notification-${v}-radius, 0px)`,
172
+ })),
173
+ ...STATUS_VARIANTS.map((v) => ({
174
+ sel: `.notification.${v} .notification-header`,
175
+ fill: `var(--notification-${v}-surface)`, stroke: 'transparent',
176
+ hatch: `var(--notification-${v}-border)`,
177
+ })),
178
+
179
+ // Controls
180
+ { sel: '.input-control', stem: 'input-default', radius: 'var(--input-radius, 0px)' },
181
+ {
182
+ sel: '.menuselect', fill: 'var(--menuselect-menu-surface)',
183
+ stroke: 'var(--menuselect-menu-border)',
184
+ radius: 'var(--menuselect-menu-radius, 0px)',
185
+ shadow: 'var(--menuselect-menu-shadow, none)',
186
+ },
187
+ { sel: '.tab', stem: 'tabbar-default', radius: 'var(--tabbar-default-tab-top-radius, 0px)' },
188
+ { sel: '.tab.active', stem: 'tabbar-active', radius: 'var(--tabbar-active-tab-top-radius, 0px)' },
189
+ {
190
+ sel: '.segmented-control',
191
+ fill: 'var(--segmentedcontrol-bar-surface)',
192
+ stroke: 'var(--segmentedcontrol-bar-border)',
193
+ radius: 'var(--segmentedcontrol-bar-radius, 0px)',
194
+ },
195
+ // A segment carries the layer at all times and starts invisible, so that
196
+ // selecting or hovering one repaints it rather than materialising a
197
+ // pseudo-element that was not there a moment ago. STATE_COLOURS supplies
198
+ // the two lit states.
199
+ {
200
+ sel: '.segment', fill: 'transparent', stroke: 'transparent',
201
+ radius: 'var(--segmentedcontrol-selected-radius, 0px)',
202
+ },
203
+ {
204
+ sel: '.progress-track',
205
+ fill: 'var(--progressbar-track-surface)',
206
+ stroke: 'var(--progressbar-track-border)',
207
+ radius: 'var(--progressbar-radius, 0px)',
208
+ // Holds the fill bar to the track, which is the whole component.
209
+ clips: true,
210
+ },
211
+ {
212
+ sel: '.image', fill: 'transparent', stroke: 'var(--image-default-border)',
213
+ radius: 'var(--image-default-radius, 0px)',
214
+ // Holds the picture inside the frame; without it a zoom spills out square.
215
+ clips: true,
216
+ },
217
+ { sel: '.toggle .track', stem: 'toggle-track' },
218
+ { sel: '.toggle.on .track', stem: 'toggle-on-track', radius: 'var(--toggle-track-radius, 0px)' },
219
+
220
+ // Rules. A hairline is a thin filled box, so it needs the fill layer and no
221
+ // outline: a line does not get drawn around.
222
+ { sel: '.sd-hairline', fill: 'var(--_divider-hairline-color)', strokeless: true },
223
+
224
+ // Consumer opt-in. A page element that is not a component but paints a
225
+ // token-driven surface sets --sketch-fill / --sketch-stroke itself and
226
+ // carries this class; the layer then treats it like any other part.
227
+ { sel: '.sketch-surface' },
228
+ { sel: '.sketch-rule', strokeless: true },
229
+ ];
230
+
231
+ /**
232
+ * Colours for the interaction states, applied on top of a part's base rule.
233
+ *
234
+ * The host's real background is forced transparent, so without these a hover
235
+ * simply does not read. Only the two colour properties change: the noise seed
236
+ * is keyed to nth-child, so the wobble holds still while the fill repaints,
237
+ * which is what makes this safe to do on hover at all.
238
+ *
239
+ * `stroke` is omitted where the component ships no hover border token; the
240
+ * base stroke then stays, rather than falling through to a neutral that has
241
+ * nothing to do with the component.
242
+ *
243
+ * `.force-hover` is the editor's own preview of the hover state.
244
+ */
245
+ interface StateSpec {
246
+ sel: string;
247
+ fill: string;
248
+ stroke?: string;
249
+ }
250
+
251
+ /** `{stem}-hover-{surface,border}` is the shipped naming for a hover state. */
252
+ function hoverPair(sel: string, stem: string, withBorder = true): StateSpec {
253
+ return {
254
+ sel: `${sel}:hover, ${sel}.force-hover`,
255
+ fill: `var(--${stem}-hover-surface)`,
256
+ ...(withBorder ? { stroke: `var(--${stem}-hover-border)` } : {}),
257
+ };
258
+ }
259
+
260
+ const STATE_COLOURS: readonly StateSpec[] = [
261
+ ...FILLED_BUTTON_VARIANTS.map((v) => hoverPair(`.button.${v}`, `button-${v}`)),
262
+ hoverPair('.button.outline', 'button-outline'),
263
+ ...FILLED_BUTTON_VARIANTS.map((v) => hoverPair(`.icon-button.${v}`, `iconbutton-${v}`)),
264
+ hoverPair('.icon-button.outline', 'iconbutton-outline'),
265
+ hoverPair('.tab', 'tabbar'),
266
+ // A menu's hover belongs to the item under the pointer. The item is not a
267
+ // drawn part, so it keeps its own background and lights up on its own.
268
+ {
269
+ sel: '.es-root.variant-container > .section-header:hover,'
270
+ + ' .es-root.variant-container.force-hover > .section-header',
271
+ fill: 'var(--collapsiblesection-container-hover-surface)',
272
+ },
273
+
274
+ // Segments: the two lit states over the transparent base.
275
+ {
276
+ sel: '.segment.selected',
277
+ fill: 'var(--segmentedcontrol-selected-surface)',
278
+ stroke: 'var(--segmentedcontrol-selected-border)',
279
+ },
280
+ {
281
+ sel: '.segment:hover, .segment.force-hover',
282
+ fill: 'var(--segmentedcontrol-option-hover-surface)',
283
+ },
284
+
285
+ // Toggle puts the state before the part rather than after it.
286
+ {
287
+ sel: '.toggle:hover .track, .toggle.force-hover .track',
288
+ fill: 'var(--toggle-hover-track-surface)',
289
+ },
290
+ {
291
+ sel: '.toggle.on:hover .track, .toggle.on.force-hover .track',
292
+ fill: 'var(--toggle-on-hover-track-surface)',
293
+ },
294
+ ];
295
+
296
+ /** Exported so a test can check that every selected part is also painted. */
297
+ export const PART_SELECTORS: readonly string[] = PART_SPECS.map((p) => p.sel);
298
+
299
+ const PARTS = PART_SELECTORS.join(', ');
300
+ const FLOW_PARTS = PART_SPECS.filter((p) => !p.positioned).map((p) => p.sel).join(', ');
301
+ const STROKE_PARTS = PART_SPECS.filter((p) => !p.strokeless).map((p) => p.sel).join(', ');
302
+ const UNCLIPPED = PART_SPECS.filter((p) => !p.clips).map((p) => p.sel).join(', ');
303
+ const CLIPPED = PART_SPECS.filter((p) => p.clips).map((p) => p.sel).join(', ');
304
+
305
+ export function buildDefsMarkup(s: SketchSettings): string {
306
+ /**
307
+ * `warp` is the shape stage: one wave of noise whose wavelength spans a whole
308
+ * component, so the four corners sample different parts of the field and the
309
+ * box comes out a quadrangle with gently bowed sides. The fine stage that
310
+ * follows is the pen wobble.
311
+ *
312
+ * It has to be a displacement rather than a transform. Any transform that
313
+ * moves a corner does it in proportion to the box, so one setting wrecks a
314
+ * card and does nothing to a button; a displacement moves every corner the
315
+ * same number of pixels whatever it is drawing.
316
+ *
317
+ * Fill and stroke are handed the same warp seed and scale, so they distort
318
+ * together and only the fine stage disagrees.
319
+ *
320
+ * `pressure` is the along-stroke thinning, and it goes FIRST, before the
321
+ * shape stage moves anything. It used to be a CSS mask on the stroke layer,
322
+ * which meant it sat still in the element's own coordinates while the warp
323
+ * carried the line out from under it: past a certain travel the line no
324
+ * longer met the patches the mask had been cut for, and whole edges were
325
+ * erased instead of stretches of them. Thinning the ink here, ahead of the
326
+ * warp, means the thin patches ride along with the line they belong to and
327
+ * the shape dial has no ceiling.
328
+ */
329
+ const displace = (
330
+ id: string, freq: number, seed: number, scale: number, pad: number,
331
+ warp: number, warpSeed: number,
332
+ opts: { pressure?: number; retrace?: readonly [number, number]; warpFreq?: number } = {},
333
+ ) => {
334
+ // The box is shared with the fill, so its wave runs at the fill's
335
+ // frequency even when the pen has a wavelength of its own.
336
+ const { pressure = 0, retrace, warpFreq = freq } = opts;
337
+
338
+ // One pass of the pen. `tag` suffixes every result name, so a second pass
339
+ // can be laid into the same filter without its stages shadowing the
340
+ // first's.
341
+ const pass = (tag: string, passSeed: number, stages: string[]): string => {
342
+ let src = 'SourceGraphic';
343
+
344
+ if (pressure > 0) {
345
+ stages.push(
346
+ `<feTurbulence type="fractalNoise" baseFrequency="${PRESSURE_FREQUENCY}" numOctaves="2" seed="${passSeed + 31}" result="pn${tag}"/>`,
347
+ // Turbulence carries its own alpha. Flatten it first, or the luminance
348
+ // read below is of a field that is already partly see-through.
349
+ `<feComponentTransfer in="pn${tag}" result="pf${tag}"><feFuncA type="linear" slope="0" intercept="1"/></feComponentTransfer>`,
350
+ `<feColorMatrix in="pf${tag}" type="luminanceToAlpha" result="pl${tag}"/>`,
351
+ `<feComponentTransfer in="pl${tag}" result="pm${tag}">` +
352
+ `<feFuncA type="linear" slope="${(1 + pressure * 2.5).toFixed(3)}" intercept="${(0.5 - pressure * 0.9).toFixed(3)}"/>` +
353
+ `</feComponentTransfer>`,
354
+ `<feComposite in="${src}" in2="pm${tag}" operator="in" result="inked${tag}"/>`,
355
+ );
356
+ src = `inked${tag}`;
357
+ }
358
+
359
+ // The shape stage keeps the filter's own warp seed on both passes. It is
360
+ // the box, not the pen: reseed it and the second pass leans a different
361
+ // quadrilateral, which reads as two boxes rather than as one gone round
362
+ // twice.
363
+ if (warp > 0) {
364
+ stages.push(
365
+ `<feTurbulence type="fractalNoise" baseFrequency="${(warpFreq * WARP_FREQUENCY).toFixed(5)}" numOctaves="1" seed="${warpSeed}" result="w${tag}"/>`,
366
+ squareOff(s, `w${tag}`, `wb${tag}`),
367
+ `<feDisplacementMap in="${src}" in2="${squaredResult(s, `w${tag}`, `wb${tag}`)}" scale="${swing(warp)}" xChannelSelector="R" yChannelSelector="G" result="box${tag}"/>`,
368
+ );
369
+ src = `box${tag}`;
370
+ }
371
+
372
+ stages.push(
373
+ `<feTurbulence type="fractalNoise" baseFrequency="${freq.toFixed(5)}" numOctaves="${s.roughness}" seed="${passSeed}" result="n${tag}"/>`,
374
+ squareOff(s, `n${tag}`, `nb${tag}`),
375
+ `<feDisplacementMap in="${src}" in2="${squaredResult(s, `n${tag}`, `nb${tag}`)}" scale="${swing(scale)}" xChannelSelector="R" yChannelSelector="G" result="drawn${tag}"/>`,
376
+ );
377
+
378
+ return `drawn${tag}`;
379
+ };
380
+
381
+ const stages: string[] = [];
382
+ const first = pass('', seed, stages);
383
+ if (retrace) {
384
+ // A second pen stroke rather than a copy of the first: its own wobble
385
+ // seed and its own thinning, so the two runs part company along their
386
+ // length instead of tracking each other a couple of px apart. They are
387
+ // merged, not composited, so a translucent nib doubles where they cross
388
+ // and stays pale where only one landed.
389
+ const second = pass('r', seed + RETRACE_SEED, stages);
390
+ stages.push(
391
+ `<feOffset in="${second}" dx="${retrace[0].toFixed(2)}" dy="${retrace[1].toFixed(2)}" result="shifted"/>`,
392
+ `<feMerge><feMergeNode in="${first}"/><feMergeNode in="shifted"/></feMerge>`,
393
+ );
394
+ }
395
+
396
+ return `<filter id="${id}" x="-${pad}%" y="-${pad}%" width="${100 + pad * 2}%" height="${100 + pad * 2}%" color-interpolation-filters="sRGB">` +
397
+ stages.join('') +
398
+ `</filter>`;
399
+ };
400
+
401
+ // Room for both stages to push the edge outward, for the retrace, and for the
402
+ // shadow the fill layer casts. Too tight and the filter region itself becomes
403
+ // a flat rectangle that cuts the drawn box off — the same failure as an
404
+ // overflow clip, one layer further down.
405
+ //
406
+ // A filter region can only be a percentage of the box it is filtering, so a
407
+ // short component gets fewer pixels of headroom than a tall one out of the
408
+ // same number. The small bank is padded far harder for that reason: it draws
409
+ // the badges and rules, where the travel is a large share of the height.
410
+ const pad = 30 + s.cornerTravel * 3.2;
411
+ const warp = s.cornerTravel;
412
+ // The dials speak in wavelengths, the filter wants cycles per px.
413
+ const base = 1 / s.wobble;
414
+
415
+ // A reseeded second pass is built into the stroke bank itself, because a CSS
416
+ // filter chain can only ever duplicate what the stage before it produced: to
417
+ // send the line through a different wobble the two passes have to live in
418
+ // one filter. Each bank lands its pass a different distance and a different
419
+ // way, so neighbours do not retrace alike.
420
+ const reseeded = s.doubleStroke && s.retracePass === 'reseeded';
421
+ const shift = (i: number): { retrace?: readonly [number, number] } =>
422
+ (reseeded
423
+ ? { retrace: [RETRACE_SHIFT[i][0] * s.retraceOffset, RETRACE_SHIFT[i][1] * s.retraceOffset] }
424
+ : {});
425
+
426
+ const banks = SEEDS.map((seed, i) =>
427
+ // The warp seed is the instance's own, NOT the offset one: fill and stroke
428
+ // share the box and disagree only about the pen.
429
+ displace(`${ID}-fill-${seed}`, base, seed, s.fillTravel, pad, warp, seed + 41) +
430
+ // Offset seeds so the outline never tracks the fill exactly. That
431
+ // disagreement at the edges is the whole effect.
432
+ displace(`${ID}-stroke-${seed}`, base / s.borderWavelength, seed + 17, s.strokeTravel, pad, warp, seed + 41,
433
+ { pressure: s.pressureMod, warpFreq: base, ...shift(i) }) +
434
+ // Small components take a reduced, higher-frequency displacement.
435
+ displace(`${ID}-fill-sm-${seed}`, base * 2.2, seed, s.fillTravel * 0.35, pad + 40, warp * 0.35, seed + 41) +
436
+ displace(`${ID}-stroke-sm-${seed}`, base * 2.2 / s.borderWavelength, seed + 17, s.strokeTravel * 0.35, pad + 40, warp * 0.35, seed + 41,
437
+ { pressure: s.pressureMod, warpFreq: base * 2.2, ...shift(i) }) +
438
+ // Icons are glyphs a few tens of pixels across, read at a glance, with no
439
+ // redundancy to lose. A high-frequency field at a small amplitude wobbles
440
+ // the outline without pulling a stroke off the shape it belongs to.
441
+ displace(`${ID}-icon-${seed}`, base / s.iconWavelength, seed + 63, s.iconTravel, 40, 0, 0),
442
+ ).join('');
443
+
444
+ // Ink pooling. Blur spreads the stroke, then a steep alpha ramp re-sharpens
445
+ // it. Where two runs of the border sit within blur distance of each other,
446
+ // which is what happens at a corner and wherever the second pass crosses the
447
+ // first, the fields sum past the top of the ramp and fill in.
448
+ //
449
+ // The ramp is placed against the line each instance actually draws. A blurred
450
+ // run of width w peaks at erf(w / 2√2σ) of its own ink, and that peak falls
451
+ // as the radius grows, so a fixed threshold meant a different thing on every
452
+ // line: a 1.25px pencil stroke dropped under it past a radius of about 1.3
453
+ // and pooled to nothing, which is why the dial did nothing on a pencil.
454
+ //
455
+ // Anchoring it to the settings alone is not enough either. Pen weight is a
456
+ // per-instance cycle, so two cards side by side draw different widths at
457
+ // different ink, and one ramp cut for the nominal line put one of them near
458
+ // its floor and the other in the middle of it: the same dials read as a wet
459
+ // blur on the first card and as two clean passes on the second. One bank per
460
+ // step of that cycle puts every instance at the same place on its own ramp,
461
+ // and the only spread left is the honest one, where a harder press lays down
462
+ // a wetter line.
463
+ //
464
+ // The crisp source is merged back on top, so pooling only ever ADDS.
465
+ const poolFilter = (i: number, jw: number) => {
466
+ const width = s.strokeWidth * (1 + jw * s.pressure);
467
+ // At full ink the colour passes through untouched, so the cycle moves the
468
+ // weight of the line but not its density.
469
+ const ink = s.strokeInk < 1 ? Math.min(1, s.strokeInk + (jw * INK_VARY) / 100) : 1;
470
+ // The floor only keeps the gain finite where the width dial is at zero and
471
+ // there is no line to pool. Anything above it is anchored honestly, however
472
+ // faint: a thin pale run wants a steep ramp, not a clamped one.
473
+ const peak = Math.max(1e-4, ink * erf(width / (Math.max(s.pooling, 0.05) * 2 * Math.SQRT2)));
474
+ const gain = 1 / ((POOL_TOP - POOL_FOOT) * peak);
475
+ const thresholded = ink < 1 ? 'hard' : 'pooled';
476
+ return `<filter id="${ID}-pool-${i}" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">` +
477
+ `<feGaussianBlur stdDeviation="${s.pooling}" result="b"/>` +
478
+ // The ramp drives alpha to 1, which flattened a translucent nib back to
479
+ // a solid one and left the ink dial doing nothing under any preset that
480
+ // pooled. Scaling the pooled alpha back down to this instance's ink keeps
481
+ // the bulge and keeps the line see-through, so the crisp pass merged on
482
+ // top still reads as a second coat.
483
+ `<feColorMatrix in="b" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${gain.toFixed(3)} ${(-POOL_FOOT * peak * gain).toFixed(3)}" result="${thresholded}"/>` +
484
+ (ink < 1
485
+ ? `<feComponentTransfer in="${thresholded}" result="pooled">` +
486
+ `<feFuncA type="linear" slope="${ink.toFixed(3)}"/>` +
487
+ `</feComponentTransfer>`
488
+ : '') +
489
+ `<feMerge><feMergeNode in="pooled"/><feMergeNode in="SourceGraphic"/></feMerge>` +
490
+ `</filter>`;
491
+ };
492
+
493
+ const pool = s.pooling > 0
494
+ ? JITTER_Y_WEIGHT.map(([, jw], i) => poolFilter(i, jw)).join('')
495
+ : '';
496
+
497
+ return `<defs>${banks}${pool}</defs>`;
498
+ }
499
+
500
+ /** Per-instance variation. Three coprime cycles give 7x11x13 = 1001
501
+ combinations, so the pattern does not visibly repeat on a real page. */
502
+ const JITTER_X_SCALE = [
503
+ '--sketch-jx: 0.8; --sketch-js: -0.4;',
504
+ '--sketch-jx: -0.35; --sketch-js: 0.7;',
505
+ '--sketch-jx: 0.15; --sketch-js: -0.85;',
506
+ '--sketch-jx: -0.9; --sketch-js: 0.2;',
507
+ '--sketch-jx: 0.55; --sketch-js: -0.15;',
508
+ '--sketch-jx: -0.6; --sketch-js: 0.95;',
509
+ '--sketch-jx: 0.3; --sketch-js: -0.55;',
510
+ ];
511
+ /** Offset and pen weight, as numbers rather than as the declarations they
512
+ become: the weight decides how wide and how wet each instance draws, so the
513
+ pooling bank has to be built from the same eleven values. */
514
+ const JITTER_Y_WEIGHT: readonly (readonly [number, number])[] = [
515
+ [0.45, -0.6], [-0.8, 0.85], [0.9, -0.25], [-0.2, 0.5], [0.6, -0.95], [-0.95, 0.3],
516
+ [0.1, 0.7], [-0.5, -0.45], [0.75, 0.15], [-0.3, -0.8], [0.25, 0.6],
517
+ ];
518
+ const JITTER_ROT = [
519
+ -0.7, 0.35, 0.85, -0.15, 0.5, -0.9, 0.2, 0.65, -0.4, 0.95, -0.25, 0.75, -0.6,
520
+ ];
521
+
522
+ /** The field tiles from each element's own origin, so identical parts would
523
+ otherwise get identical blotches. Shift the sampling window per instance. */
524
+ const MASK_POS = ['0 0', '-137px -211px', '-311px -97px', '-73px -389px', '-419px -263px'];
525
+
526
+ /**
527
+ * Per-instance corner shape. Four coefficients in [0, 1], one per corner in
528
+ * `border-radius` order, scaled by the cornerSpread dial and added on top of
529
+ * the part's own radius. At a 32px dial a row reads as roughly 32, 27, 8, 4.
530
+ *
531
+ * Every corner is independent, but they run in a band from half the spread to
532
+ * all of it, never down to nothing. Letting a coefficient reach zero put a
533
+ * square corner next to one carrying the whole dial, and on anything as short
534
+ * as a button that is a scallop: a half-round end with a sharp point beside it.
535
+ * A hand holds a rough size and misses it by a bit each time, so half to full
536
+ * is the shape of the error. The four land in a different order row to row.
537
+ *
538
+ * Added, never subtracted, because a hand-drawn look tends to sit on a theme
539
+ * whose corners are already tight: a coefficient set that swung both ways
540
+ * spent half its range clamped at zero.
541
+ */
542
+ const CORNER_ROUNDING = [
543
+ [1, 0.62, 0.85, 0.5],
544
+ [0.55, 1, 0.68, 0.9],
545
+ [0.8, 0.5, 0.95, 0.65],
546
+ [0.95, 0.72, 0.52, 1],
547
+ [0.6, 0.88, 0.75, 0.52],
548
+ [1, 0.55, 0.7, 0.82],
549
+ [0.7, 0.95, 0.5, 0.75],
550
+ [0.85, 0.58, 1, 0.68],
551
+ [0.52, 0.78, 0.62, 0.98],
552
+ ];
553
+
554
+ /** `scale` on a displacement map is the full swing: a point travels half of it
555
+ either side of where it started, and only where the field peaks. Every dial
556
+ the tab shows is stated as that peak travel in px, so the doubling happens
557
+ here, in the one place the maps are written. */
558
+ const swing = (travel: number) => String(Number((travel * 2).toFixed(4)));
559
+
560
+ /** Squares the displacement wave off around 0.5, its zero, so full amplitude is
561
+ spent along the whole edge rather than only where the wave peaks. Both
562
+ channels take it: the map reads x from R and y from G. */
563
+ function squareOff(s: SketchSettings, from: string, to: string): string {
564
+ if (s.waveform <= 1) return '';
565
+ const slope = s.waveform.toFixed(2);
566
+ const intercept = ((1 - s.waveform) / 2).toFixed(3);
567
+ return `<feComponentTransfer in="${from}" result="${to}">` +
568
+ `<feFuncR type="linear" slope="${slope}" intercept="${intercept}"/>` +
569
+ `<feFuncG type="linear" slope="${slope}" intercept="${intercept}"/>` +
570
+ `</feComponentTransfer>`;
571
+ }
572
+
573
+ const squaredResult = (s: SketchSettings, from: string, to: string) =>
574
+ (s.waveform > 1 ? to : from);
575
+
576
+ /** Along-stroke pressure wavelength. Low, with a high floor in the transfer
577
+ above, so the line mostly holds and only thins in patches. */
578
+ const PRESSURE_FREQUENCY = 0.0165;
579
+
580
+ /** Per-instance swing on the ink density, in percentage points, off the same
581
+ cycle that carries stroke weight. */
582
+ const INK_VARY = 14;
583
+
584
+ /** Where the pooling ramp sits against a straight run's own blurred peak: the
585
+ foot just under it, so the line keeps a faint wet edge and nothing more, and
586
+ the top above it, so only somewhere two runs sum comes out solid. Anchoring
587
+ both to the peak is what makes one radius mean the same thing on a hairline
588
+ and on a 6px nib.
589
+
590
+ The foot used to sit at 0.45, which put a straight run at 58% alpha along
591
+ its whole length. Both ends of the ramp are fixed by construction, so with
592
+ the sides that wet the corners had nowhere left to go: they saturated at
593
+ every dial value, and the only thing the dial still moved was how far the
594
+ edge spread. That is a blur radius, not pooling. */
595
+ const POOL_FOOT = 0.85;
596
+ const POOL_TOP = 1.6;
597
+
598
+ /** Winitzki's approximation, inside 0.2% across the range, which is finer than
599
+ a dial step. Used for the alpha a box of ink keeps at its centre once a
600
+ gaussian of a given radius has spread it. */
601
+ function erf(x: number): number {
602
+ const t = x * x;
603
+ const a = 0.147;
604
+ return Math.sqrt(1 - Math.exp((-t * (4 / Math.PI + a * t)) / (1 + a * t)));
605
+ }
606
+
607
+ /** Where a reseeded second pass lands, per seed bank, as a share of the offset
608
+ range on each axis. Distances differ as well as directions: two passes that
609
+ always part by the same amount read as a printing offset, not as a hand.
610
+ Copy mode takes its share off the per-instance jitter cycles instead, which
611
+ are longer, so it varies over 77 components rather than 5. */
612
+ const RETRACE_SHIFT: readonly (readonly [number, number])[] = [
613
+ [0.9, -0.35], [-0.2, 0.35], [0.4, 0.95], [-0.7, -0.15], [0.1, -0.75],
614
+ ];
615
+
616
+ /** Seed distance between the two passes. Far enough that the second wobble
617
+ shares nothing with the first. */
618
+ const RETRACE_SEED = 53;
619
+
620
+ /** The shape stage's wavelength, as a fraction of the pen-wobble frequency.
621
+ Long: a wave that spans several components leaves each one sitting on a
622
+ smooth slope of the field, so its four corners disagree while its edges
623
+ travel together and stay straight. Shorten it and the field turns over
624
+ inside a single box, which ripples the edges and tears a thin stroke apart
625
+ rather than leaning the shape. */
626
+ const WARP_FREQUENCY = 0.08;
627
+
628
+
629
+ export function buildStylesheet(s: SketchSettings): string {
630
+ const on = '[data-sketch]';
631
+ const parts = `:is(${PARTS})`;
632
+ const el = `${on} ${parts}`;
633
+ const strokeEl = `${on} :is(${STROKE_PARTS})`;
634
+
635
+ // `--radius-none` is a bare `0`, so a part the theme leaves square hands the
636
+ // corner maths a number where it needs a length: `calc(0 + 0.95 * 16px)` is
637
+ // invalid, and an invalid calc takes the whole border-radius down to its
638
+ // initial value. Registering the property makes the browser reject the
639
+ // unitless value on the way in and substitute 0px, so one square component
640
+ // can no longer flatten the corners of every other one.
641
+ const registrations = `@property --sketch-radius{syntax:"<length>";inherits:true;initial-value:0px;}`;
642
+
643
+ const vars =
644
+ `${on}{` +
645
+ `--sketch-stroke-width:${s.strokeWidth}px;` +
646
+ `--sketch-ink:${s.strokeInk};` +
647
+ `--sketch-stroke-style:${s.strokeStyle};` +
648
+ `--sketch-hatch-angle:${HATCH_ANGLE}deg;` +
649
+ `--sketch-hatch-ink:${Math.round(s.hatchInk * 100)}%;` +
650
+ `--sketch-fill-filter:url(#${ID}-fill-0);` +
651
+ `--sketch-stroke-filter:url(#${ID}-stroke-0);` +
652
+ `--sketch-mask:${s.maskOn || s.iconMaskOn ? buildMaskUri(s) : 'none'};` +
653
+ `--sketch-mask-tile:${MASK_TILE}px;` +
654
+ `--sketch-jit-x-base:${s.jitterX}px;` +
655
+ `--sketch-jit-y-base:${s.jitterY}px;` +
656
+ `--sketch-jit-rot-base:${s.jitterRot}deg;` +
657
+ `--sketch-jit-scale-base:${s.jitterScale};` +
658
+ `--sketch-corner-spread-base:${s.cornerSpread}px;` +
659
+ `--sketch-pressure:${s.pressure};` +
660
+ // opacity(1) is the no-op that lets pooling be switched out of the chain.
661
+ // The weight cycle below hands each instance the bank cut for the line it
662
+ // draws; this one only decides whether pooling is in the chain at all.
663
+ `--sketch-pool:${s.pooling > 0 ? `url(#${ID}-pool-0)` : 'opacity(1)'};` +
664
+ `}`;
665
+
666
+ /**
667
+ * Ink coverage: the field laid over a layer as a luminance mask, tiled from
668
+ * that element's own origin and sampled at a different offset per instance,
669
+ * so two parts the same size are not blotched alike.
670
+ *
671
+ * `mask-clip: no-clip` is load-bearing on the fill. A mask clips what it masks
672
+ * to the border box by default, and the shadow the fill casts lies outside
673
+ * that box, so switching coverage on took every shadow with it. Unclipped, the
674
+ * tile runs on over the shadow and thins it where the ink beside it is thin,
675
+ * which is what ink that is not there would do.
676
+ */
677
+ const coverage = (tile: string, pos: string) =>
678
+ `mask-image:var(--sketch-mask, none);` +
679
+ `mask-size:${tile} ${tile};` +
680
+ `mask-mode:luminance;mask-repeat:repeat;mask-clip:no-clip;` +
681
+ `mask-position:var(${pos}, 0 0);`;
682
+
683
+ // Icons. A glyph has no box to redraw, so it takes the filter directly rather
684
+ // than through a redrawn ::before. Body type is deliberately left alone: an
685
+ // icon is a shape and survives a wobble, a paragraph is not.
686
+ //
687
+ // `--sketch-icon-off` is the opt-out. It inherits, so any chrome that lives
688
+ // in the host document (the overlay bar) sets it once on its own root and
689
+ // every icon under it resolves to `none` regardless of specificity.
690
+ // `svg` covers inline artwork the same way. The injected filter bank is
691
+ // itself an svg in the body, so it has to be excluded or it filters itself.
692
+ const iconSel = `[class*="fa-"], svg:not([${DEFS_ATTR}])`;
693
+ const iconsOn = s.iconTravel > 0 || s.iconMaskOn;
694
+ const icons = iconsOn
695
+ ? `${on} :is(${iconSel}){` +
696
+ (s.iconTravel > 0
697
+ ? `filter:var(--sketch-icon-off, var(--sketch-icon-filter, url(#${ID}-icon-0)));`
698
+ : '') +
699
+ // The ink mask reads as coverage on a glyph the way it does on a fill,
700
+ // but the component tile is hundreds of px across: a whole icon would
701
+ // sample one patch of it and either survive intact or disappear. A
702
+ // tile near icon size puts several blotches across each glyph.
703
+ (s.iconMaskOn ? coverage(`${s.iconMaskTile}px`, '--sketch-icon-mask-pos') : '') +
704
+ `}` +
705
+ SEEDS.map((seed, i) =>
706
+ `${on} :is(${iconSel}):nth-child(5n + ${i + 1})` +
707
+ `{--sketch-icon-filter:url(#${ID}-icon-${seed});--sketch-icon-mask-pos:${MASK_POS[i]};}`,
708
+ ).join('')
709
+ : '';
710
+
711
+ // The drawn box, shared by both layers so fill and outline describe the same
712
+ // shape and only the displacement seeds disagree.
713
+ //
714
+ // Corners are read from the part's own radius token rather than inherited,
715
+ // because `border-radius: inherit` is all-or-nothing: there is no way to add
716
+ // to a value CSS never hands over. At the dial's zero the inherit stays, so
717
+ // the shape is untouched until the user asks for it, and a part whose radius
718
+ // token is missing keeps its real corners.
719
+ const cornerRadius = (i: number) =>
720
+ `max(0px, calc(var(--sketch-radius, 0px) + var(--sketch-c${i}, 0) * var(--sketch-corner-spread, 0px)))`;
721
+ const corners = s.cornerSpread > 0
722
+ ? `border-radius:${[1, 2, 3, 4].map(cornerRadius).join(' ')};`
723
+ : 'border-radius:inherit;';
724
+
725
+ const host =
726
+ `${el}{` +
727
+ `--sketch-jit-x:var(--sketch-jit-x-base);` +
728
+ `--sketch-jit-y:var(--sketch-jit-y-base);` +
729
+ `--sketch-jit-rot:var(--sketch-jit-rot-base);` +
730
+ `--sketch-jit-scale:var(--sketch-jit-scale-base);` +
731
+ `--sketch-corner-spread:var(--sketch-corner-spread-base);` +
732
+ `z-index:0;` +
733
+ // The shadow is re-cast on the fill layer, where it follows the shape
734
+ // that is actually drawn.
735
+ `background:transparent !important;border-color:transparent !important;` +
736
+ `box-shadow:none !important;` +
737
+ `}` +
738
+ `${on} :is(${UNCLIPPED}){overflow:visible !important;}` +
739
+ // The five that keep their clip take the drawn radii on the host, so what
740
+ // the clip cuts turns the same way the ink does at the corners. It cannot
741
+ // follow the displacement, which is a filter and has no geometry to clip to.
742
+ `${el}:is(${CLIPPED}){${corners}}` +
743
+ // Only parts that sit in flow. Forcing this onto an absolutely-positioned
744
+ // part would drop it back to its flow position.
745
+ `${on} :is(${FLOW_PARTS}){position:relative;}`;
746
+
747
+ // Fill layer. Own seed, own offset, sits behind the content.
748
+ //
749
+ // `inset` and `transition` are !important because the layer CLAIMS this
750
+ // pseudo-element from whatever the component was using it for. Button drives
751
+ // a hover shimmer off ::before — parked at left:-100%, sliding to left:100%
752
+ // over 0.5s — and its hover rule outweighs this one on specificity. Left
753
+ // alone, the fill wipes across the button on hover, and again the moment the
754
+ // effect is switched on, because `left` animates from -100% to 0.
755
+ const fill =
756
+ `${el}::before{` +
757
+ `content:'';position:absolute;inset:0 !important;transition:none !important;` +
758
+ `z-index:-1;${corners}` +
759
+ `background:var(--sketch-fill, var(--surface-neutral-lower));` +
760
+ `box-shadow:var(--sketch-shadow, none);` +
761
+ `filter:var(--sketch-fill-filter);` +
762
+ (s.maskOn ? coverage('var(--sketch-mask-tile)', '--sketch-mask-pos') : '') +
763
+ `transform:translate(` +
764
+ `calc(var(--sketch-jx, 0) * var(--sketch-jit-x, 0px)),` +
765
+ `calc(var(--sketch-jy, 0) * var(--sketch-jit-y, 0px))` +
766
+ `) rotate(calc(var(--sketch-jr, 0) * var(--sketch-jit-rot, 0deg)))` +
767
+ ` scale(calc(1 + (var(--sketch-js, 0) + 1) / 2 * var(--sketch-jit-scale, 0)));` +
768
+ `pointer-events:none;` +
769
+ `}`;
770
+
771
+ // The hatch is laid over the fill as a second background LAYER rather than
772
+ // as `background-image` beside a `background-color`, because a part is free
773
+ // to name a gradient as its fill: the Kit's lead block hands the layer the
774
+ // same wash it paints itself with. A gradient is not a `<color>`, so it took
775
+ // `background-color` down as invalid at computed-value time and the surface
776
+ // went transparent under the hatch. The shorthand's last layer accepts either
777
+ // a colour or an image, so both kinds of fill survive here.
778
+ //
779
+ // Hatch ink falls back to the outline colour but is its own property, so a
780
+ // part that deliberately draws no outline can still be hatched. Half the
781
+ // parts that carry a surface set `--sketch-stroke: transparent` (headers,
782
+ // segments, notifications), and binding the stripes to it left every one of
783
+ // them hatched in an invisible colour.
784
+ //
785
+ // Two sets of stripes, nearly parallel and nearly the same pitch. Where they
786
+ // fall in step the shading doubles up and where they fall out of it they
787
+ // thin, and the beat between the two pitches is long enough to cross a
788
+ // component once or twice, so a hatched card is dense in one corner and
789
+ // pale in another the way a hand shades an area. A single set is a ruled
790
+ // pattern whatever the pen does to its edges.
791
+ const hatchInk = (share: string) =>
792
+ `color-mix(in srgb, var(--sketch-hatch-color, var(--sketch-stroke, currentColor))` +
793
+ ` calc(var(--sketch-hatch-ink) * ${share}), transparent)`;
794
+ const fillStyles =
795
+ `[data-sketch][data-sketch-fill='hatched'] ${parts}::before{` +
796
+ `background:repeating-linear-gradient(calc(var(--sketch-hatch-angle) + ${HATCH_BEAT_ANGLE}deg),` +
797
+ `${hatchInk('0.55')} 0 1px,transparent 1px ${HATCH_BEAT_PITCH}px),` +
798
+ `repeating-linear-gradient(var(--sketch-hatch-angle),` +
799
+ `${hatchInk('1')} 0 1.5px,` +
800
+ `transparent 1.5px 7px),` +
801
+ `var(--sketch-fill, var(--surface-neutral-lower));` +
802
+ `}`;
803
+
804
+ // A translucent nib. The retrace pass below overlaps this one, so where both
805
+ // landed the colour doubles and where only one did it stays pale: a line
806
+ // whose density varies across its own weight, which is the difference
807
+ // between a marker and a pen. At full ink the colour is passed through
808
+ // untouched, so a preset that wants a hard line still gets one.
809
+ //
810
+ // The density is varied per instance off the same cycle that varies the
811
+ // stroke weight, which is the honest pairing: the harder the nib is pressed
812
+ // the wider AND the wetter the line, so a heavy stroke reads dark and a light
813
+ // one reads thin and pale rather than every stroke being equally grey.
814
+ const ink = (fallback: string) =>
815
+ (s.strokeInk < 1
816
+ ? `color-mix(in srgb, var(--sketch-stroke, ${fallback})` +
817
+ ` calc(var(--sketch-ink, 1) * 100% + var(--sketch-jw, 0) * ${INK_VARY}%), transparent)`
818
+ : `var(--sketch-stroke, ${fallback})`);
819
+
820
+ // Second stroke pass, copied rather than redrawn: `drop-shadow` duplicates
821
+ // the alpha silhouette it is handed, and what it is handed here is the
822
+ // already-displaced ring, so the copy carries the same wobble and lands a few
823
+ // px off it. Offset rather than concentric, because an `outline` can only sit
824
+ // parallel and reads as a double rule. The other mode sends the line through
825
+ // its own seed instead, which only a second pass inside the filter can do.
826
+ //
827
+ // The dial is the range, not the distance: `--sketch-jx`/`--sketch-jy` are the
828
+ // per-instance cycles, so each component parts its passes by its own share of
829
+ // it on each axis, and 77 go by before a pairing repeats.
830
+ //
831
+ // The two paths stack because both are translucent. Where they cross, two
832
+ // coats of 35% ink make 58%; where only one landed it stays at 35%. That
833
+ // difference across the weight of the line is the marker.
834
+ //
835
+ // It sits ahead of pooling in the chain so the two runs merge where they
836
+ // touch rather than being goo'd apart, and the direction comes off the same
837
+ // per-instance cycle as the fill offset, so no two components retrace alike.
838
+ const retraceStep = s.retraceOffset.toFixed(2);
839
+ const retrace = s.retracePass === 'copy'
840
+ ? `[data-sketch][data-sketch-passes='double'] :is(${STROKE_PARTS})::after{` +
841
+ `--sketch-retrace:drop-shadow(` +
842
+ `calc(var(--sketch-jx, 0.6) * ${retraceStep}px)` +
843
+ ` calc(var(--sketch-jy, -0.4) * ${retraceStep}px)` +
844
+ ` 0 ${ink('currentColor')});` +
845
+ `}`
846
+ // A reseeded pass is drawn inside the stroke bank, where it can take its own
847
+ // seed, so there is nothing to lay into the chain here.
848
+ : '';
849
+
850
+ // Outline layer. Own seed, above the content.
851
+ const stroke =
852
+ `${strokeEl}::after{` +
853
+ `content:'';position:absolute;inset:0 !important;transition:none !important;` +
854
+ `z-index:1;${corners}` +
855
+ `border-style:var(--sketch-stroke-style);` +
856
+ `border-color:${ink('var(--border-neutral)')};` +
857
+ // Pen pressure: each instance carries its own weight off the nth-child cycle.
858
+ `border-width:calc(var(--sketch-stroke-width) * (1 + var(--sketch-jw, 0) * var(--sketch-pressure, 0)));` +
859
+ `filter:var(--sketch-stroke-filter) var(--sketch-retrace, opacity(1))` +
860
+ ` var(--sketch-pool, opacity(1));` +
861
+ `pointer-events:none;` +
862
+ `}`;
863
+
864
+ const seedRotation = SEEDS.map((seed, i) =>
865
+ `${el}:nth-child(5n + ${i + 1}){` +
866
+ `--sketch-fill-filter:url(#${ID}-fill-${seed});` +
867
+ `--sketch-stroke-filter:url(#${ID}-stroke-${seed});` +
868
+ `--sketch-mask-pos:${MASK_POS[i]};` +
869
+ `}`,
870
+ ).join('');
871
+
872
+ const shape = s.cornerSpread > 0
873
+ ? CORNER_ROUNDING.map((row, i) =>
874
+ `${el}:nth-child(9n + ${i + 1}){` +
875
+ row.map((c, k) => `--sketch-c${k + 1}:${c};`).join('') +
876
+ `}`,
877
+ ).join('')
878
+ : '';
879
+
880
+ const jitter =
881
+ JITTER_X_SCALE.map((v, i) => `${el}:nth-child(7n + ${i + 1}){${v}}`).join('') +
882
+ JITTER_Y_WEIGHT.map(([jy, jw], i) =>
883
+ `${el}:nth-child(11n + ${i + 1}){--sketch-jy: ${jy}; --sketch-jw: ${jw};` +
884
+ // The pen weight this instance draws at is what decides where its line
885
+ // lands on the pooling ramp, so it takes the bank cut for that weight.
886
+ (s.pooling > 0 ? `--sketch-pool:url(#${ID}-pool-${i});` : '') +
887
+ `}`).join('') +
888
+ JITTER_ROT.map((v, i) => `${el}:nth-child(13n + ${i + 1}){--sketch-jr:${v};}`).join('');
889
+
890
+ // Large panels tilt less than the type inside them can tolerate; small chips
891
+ // can take more rotation but less travel.
892
+ const perPart =
893
+ `${el}:is(.card, .card-header, .panel, .dialog, .table-wrapper, .sidenavigation)` +
894
+ `{--sketch-jit-rot:calc(var(--sketch-jit-rot-base) * 0.3);}` +
895
+ // A rule is one or two pixels tall. The full-size displacement tears it into
896
+ // dashes and a translate that large lifts it clean off its own row, so it
897
+ // takes the small filter bank and a fraction of the travel.
898
+ `${el}:is(.sd-hairline, .sketch-rule){` +
899
+ `--sketch-fill-filter:url(#${ID}-fill-sm-1);` +
900
+ `--sketch-jit-x:calc(var(--sketch-jit-x-base) * 0.3);` +
901
+ `--sketch-jit-y:calc(var(--sketch-jit-y-base) * 0.15);` +
902
+ `--sketch-jit-rot:0deg;--sketch-jit-scale:0;` +
903
+ // A rule is a line, not a box. Rounding its ends reads as a mistake
904
+ // rather than as a hand.
905
+ `--sketch-corner-spread:0px;` +
906
+ `}` +
907
+ `${el}:is(.badge, .toggle .track){` +
908
+ // A chip is smaller than one blob at the tile the cards read it at, so it
909
+ // lands wholly inside a patch and comes out either untouched or gone.
910
+ // Shrinking the tile puts several blotches across it, which is the same
911
+ // move the icon mask makes for a glyph.
912
+ `--sketch-mask-tile:${Math.round(MASK_TILE * 0.3)}px;` +
913
+ `--sketch-jit-rot:calc(var(--sketch-jit-rot-base) * 1.6);` +
914
+ `--sketch-jit-x:calc(var(--sketch-jit-x-base) * 0.5);` +
915
+ `--sketch-jit-y:calc(var(--sketch-jit-y-base) * 0.5);` +
916
+ `}` +
917
+ SEEDS.map((seed, i) =>
918
+ `${on} .badge:nth-child(5n + ${i + 1}){` +
919
+ `--sketch-fill-filter:url(#${ID}-fill-sm-${seed});` +
920
+ `--sketch-stroke-filter:url(#${ID}-stroke-sm-${seed});` +
921
+ `}`,
922
+ ).join('') +
923
+ `${on} .toggle .track{` +
924
+ `--sketch-fill-filter:url(#${ID}-fill-sm-2);` +
925
+ `--sketch-stroke-filter:url(#${ID}-stroke-sm-2);` +
926
+ `}`;
927
+
928
+ // `.sketch-surface` names its own colours, so it emits no rule and keeps
929
+ // whatever the page set.
930
+ const colours = PART_SPECS.map((p) => {
931
+ const f = p.fill ?? (p.stem ? `var(--${p.stem}-surface)` : null);
932
+ const st = p.stroke ?? (p.stem ? `var(--${p.stem}-border)` : null);
933
+ if (!f && !st) return '';
934
+ const r = p.radius ?? (p.stem ? `var(--${p.stem}-radius, 0px)` : '0px');
935
+ const sh = p.shadow ?? (p.stem ? `var(--${p.stem}-shadow, none)` : 'none');
936
+ // Every part states a hatch ink for the same reason it states a radius:
937
+ // custom properties inherit, so a badge sitting in a hatched card header
938
+ // would otherwise stripe itself in the CARD's ink. Where a part has no ink
939
+ // of its own the value is the indirection, not the colour, so it resolves
940
+ // against this element's own stroke and keeps following it into hover.
941
+ return `${on} ${p.sel}{${f ? `--sketch-fill:${f};` : ''}${st ? `--sketch-stroke:${st};` : ''}` +
942
+ `--sketch-hatch-color:${p.hatch ?? 'var(--sketch-stroke)'};` +
943
+ `--sketch-radius:${r};--sketch-shadow:${sh};}`;
944
+ }).join('');
945
+
946
+ // After `colours`, so a state wins over its part's base rule at equal weight.
947
+ const states = STATE_COLOURS.map((st) => {
948
+ const sel = st.sel.split(',').map((s) => `${on} ${s.trim()}`).join(',');
949
+ return `${sel}{--sketch-fill:${st.fill};${st.stroke ? `--sketch-stroke:${st.stroke};` : ''}}`;
950
+ }).join('');
951
+
952
+ return [
953
+ registrations, vars, icons, host, fill, fillStyles, retrace, stroke,
954
+ seedRotation, shape, jitter, perPart, colours, states,
955
+ ].join('\n');
956
+ }
957
+
958
+ function styleNode(doc: Document): HTMLStyleElement {
959
+ const existing = doc.head.querySelector<HTMLStyleElement>(`style[${STYLE_ATTR}]`);
960
+ if (existing) return existing;
961
+ const node = doc.createElement('style');
962
+ node.setAttribute(STYLE_ATTR, '');
963
+ doc.head.appendChild(node);
964
+ return node;
965
+ }
966
+
967
+ function defsNode(doc: Document): SVGSVGElement {
968
+ const existing = doc.body.querySelector<SVGSVGElement>(`svg[${DEFS_ATTR}]`);
969
+ if (existing) return existing;
970
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
971
+ svg.setAttribute(DEFS_ATTR, '');
972
+ svg.setAttribute('width', '0');
973
+ svg.setAttribute('height', '0');
974
+ svg.setAttribute('aria-hidden', 'true');
975
+ svg.style.position = 'absolute';
976
+ doc.body.appendChild(svg);
977
+ return svg;
978
+ }
979
+
980
+ /**
981
+ * Install the filter bank and stylesheet in every synced document. Idempotent:
982
+ * repeated calls rewrite the same two nodes rather than stacking new ones.
983
+ *
984
+ * This only makes the effect *available*. An element opts in by carrying
985
+ * data-sketch, which is what `setSketchScope` writes.
986
+ */
987
+ export function applySketchLayer(settings: SketchSettings): void {
988
+ const defs = buildDefsMarkup(settings);
989
+ const css = buildStylesheet(settings);
990
+ for (const doc of getSyncedDocuments()) {
991
+ defsNode(doc).innerHTML = defs;
992
+ styleNode(doc).textContent = css;
993
+ }
994
+ }
995
+
996
+ /** Remove the injected nodes and every scope attribute from all synced documents. */
997
+ export function removeSketchLayer(): void {
998
+ for (const doc of getSyncedDocuments()) {
999
+ doc.head.querySelector(`style[${STYLE_ATTR}]`)?.remove();
1000
+ doc.body.querySelector(`svg[${DEFS_ATTR}]`)?.remove();
1001
+ doc.querySelectorAll('[data-sketch]').forEach((el) => setSketchScope(el as HTMLElement, null));
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Mark one element as an effect scope. Pass null settings to clear it.
1007
+ * The host page's root and the editor's own preview container are both scopes,
1008
+ * which is why this takes an element rather than assuming documentElement.
1009
+ */
1010
+ export function setSketchScope(el: HTMLElement | null, settings: SketchSettings | null): void {
1011
+ if (!el) return;
1012
+ if (!settings) {
1013
+ el.removeAttribute('data-sketch');
1014
+ el.removeAttribute('data-sketch-fill');
1015
+ el.removeAttribute('data-sketch-passes');
1016
+ return;
1017
+ }
1018
+ el.setAttribute('data-sketch', '');
1019
+ el.setAttribute('data-sketch-fill', settings.fillStyle);
1020
+ el.setAttribute('data-sketch-passes', settings.doubleStroke ? 'double' : 'single');
1021
+ }
1022
+
1023
+ /** The host page behind the overlay iframe, when there is one. */
1024
+ export function hostRoot(): HTMLElement | null {
1025
+ const docs = getSyncedDocuments();
1026
+ const host = docs.find((d) => d !== document);
1027
+ return host ? host.documentElement : null;
1028
+ }