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