@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.
- package/.claude/skills/live-tokens-create-component/SKILL.md +13 -2
- package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +202 -0
- package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
- package/.claude/skills/live-tokens-pick-component/SKILL.md +1 -1
- package/CHANGELOG.md +192 -0
- package/README.md +1 -1
- package/bin/cli.mjs +2 -1
- package/dist-plugin/adjust/index.cjs +1 -0
- package/dist-plugin/adjust/index.d.cts +2 -2
- package/dist-plugin/adjust/index.d.ts +2 -2
- package/dist-plugin/adjust/index.js +1 -1
- package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
- package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
- package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
- package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
- package/dist-plugin/fontPairing/index.cjs +4 -2
- package/dist-plugin/fontPairing/index.d.cts +3 -3
- package/dist-plugin/fontPairing/index.d.ts +3 -3
- package/dist-plugin/fontPairing/index.js +4 -3
- package/dist-plugin/generateColorsAndType/index.cjs +22 -9
- package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
- package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
- package/dist-plugin/generateColorsAndType/index.js +8 -5
- package/dist-plugin/index.cjs +158 -9
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +71 -4
- package/dist-plugin/migrateData/index.cjs +1 -0
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +1 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
- package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +3 -3
- package/package.json +3 -1
- package/src/editor/core/fonts/applyFontPairing.ts +3 -2
- package/src/editor/core/fonts/fontLoader.ts +1 -0
- package/src/editor/core/fonts/fontMigration.ts +31 -1
- package/src/editor/core/palettes/paletteDerivation.ts +9 -2
- package/src/editor/core/sketch/maskField.ts +385 -0
- package/src/editor/core/sketch/sketchLayer.ts +1106 -0
- package/src/editor/core/sketch/sketchPresetService.ts +65 -0
- package/src/editor/core/sketch/sketchPresets.ts +345 -0
- package/src/editor/core/sketch/sketchStore.ts +190 -0
- package/src/editor/core/store/editorPersistence.ts +28 -12
- package/src/editor/core/store/editorTypes.ts +4 -1
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/gradientSource.ts +15 -1
- package/src/editor/core/themes/parsers/gradient.ts +62 -4
- package/src/editor/core/themes/slices/gradients.ts +75 -14
- package/src/editor/core/themes/themeTypes.ts +6 -1
- package/src/editor/docs/Docs.svelte +4 -3
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/01-overview.md +2 -1
- package/src/editor/docs/content/editing-tokens.md +5 -1
- package/src/editor/docs/content/sketch-mode.md +183 -0
- package/src/editor/docs/content.generated.ts +3 -2
- package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
- package/src/editor/pages/EditorShell.svelte +43 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +30 -17
- package/src/editor/ui/FontStackEditor.svelte +3 -0
- package/src/editor/ui/GradientEditor.svelte +38 -1
- package/src/editor/ui/UIReveal.svelte +7 -1
- package/src/editor/ui/UISegmentedControl.svelte +4 -8
- package/src/editor/ui/sections/GradientsSection.svelte +47 -1
- package/src/editor/ui/sketch/SketchDial.svelte +124 -0
- package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
- package/src/editor/ui/sketch/SketchRange.svelte +185 -0
- package/src/editor/ui/sketch/SketchTab.svelte +1264 -0
- package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
- package/src/live-tokens/data/colors-and-type/default.json +17 -0
- package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
- package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
- package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
- package/src/live-tokens/data/colors-and-type/sketchy.json +2520 -0
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
- package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
- package/src/live-tokens/data/themes/autumn.json +17 -0
- package/src/live-tokens/data/themes/halloween.json +17 -0
- package/src/live-tokens/data/themes/midnight-study.json +17 -0
- package/src/live-tokens/data/themes/ocean.json +17 -0
- package/src/live-tokens/data/themes/royal-velvet.json +17 -0
- package/src/live-tokens/data/themes/sketchy.json +3984 -0
- package/src/live-tokens/data/themes/spring-meadow.json +17 -0
- package/src/live-tokens/data/themes/sunset.json +17 -0
- package/src/live-tokens/data/tokens.generated.css +1 -0
- package/src/system/components/Card.svelte +12 -1
- package/src/system/components/SectionDivider.svelte +4 -0
- 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
|
+
}
|