@motion-proto/live-tokens 0.57.0 → 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.
@@ -44,7 +44,15 @@ For pattern reference, read any shipped component's source directly from the con
44
44
  ```
45
45
  The schema side-effect happens inside `registerComponent` (which `bootLiveTokens` calls for you), so you don't call `registerComponentSchema` separately. **Do not place a standalone `registerComponent(...)` *before* `bootLiveTokens`** — that registers before the editor's init hooks run, which is the wrong window and can leave editor changes disconnected from the live page. Only call `registerComponent` directly if your app mounts manually (no `bootLiveTokens`), in which case call it before `mount(App, ...)`.
46
46
  4. **Tell the picker** — open `.claude/skills/live-tokens-pick-component/SKILL.md` and add your new component to the **Catalogue** line under the family it belongs to (Action / Input / Selection / Containers / Messaging / Display). If it's confusable with an existing component (a second selection control, a competing container), add a row to that family's decision table explaining the use-case it owns. Without this step, the component exists but [[live-tokens-pick-component]] can't recommend it when a user asks "which component should I use?" — the same rule applies whether the component is first-party (update the picker shipped in this package) or consumer-authored (update the local copy at `.claude/skills/live-tokens-pick-component/SKILL.md` that `setup-claude` placed in your project).
47
- 5. **Verify** with the checklist at the bottom of this file.
47
+ 5. **Join the sketch layer** the effect draws a fixed set of parts, so a new
48
+ component stays crisp while the page around it goes hand-drawn until it opts
49
+ in. A consumer component carries one of four reserved classes on its root and
50
+ names the five `--sketch-*` colours it is drawn with; a first-party component
51
+ adds a `PartSpec` row instead. The layer also takes `background`,
52
+ `border-color`, `box-shadow`, `overflow`, `position` and both pseudo-elements
53
+ away from the element it draws, which constrains where the class can go. Read
54
+ `references/sketch-mode.md`.
55
+ 6. **Verify** with the checklist at the bottom of this file.
48
56
 
49
57
  ## Token discipline
50
58
 
@@ -123,6 +131,7 @@ The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUF
123
131
 
124
132
  The helper strips the `--<component>-` prefix and those segments, keeping the rest: `--mywidget-header-default-text` → `header-text`, `--mywidget-header-default-text-font-family` → `header-text-font-family`. Two parts ending in the same word stay distinct; one slot across variants collapses to one key. To override a single derived key, set `colorGroupKey` on that type-group config — it wins and is never recomputed, so your fix survives. There is no name-based fallback: a bare `buildTypeGroupColorTokens` call emits un-grouped (solo) colors rather than guessing, and a bare *font* helper across multiple slots is a `check-component` warning (its default `font-family`/… keys would merge the slots' fonts).
125
133
 
134
+
126
135
  ## State model
127
136
 
128
137
  Components *can* have two state axes. Many don't: container and messaging components (Card, Badge, Callout, CollapsibleSection) have only variants, no hover/disabled. Skip the rest of this section for those.
@@ -194,10 +203,11 @@ For your own component, copy the pattern and substitute your id. Registering aga
194
203
 
195
204
  ## Extensions
196
205
 
197
- Read the one you need; most components need neither.
206
+ Read the sketch reference for every component; the other two only when they apply.
198
207
 
199
208
  - `references/linked-siblings.md`: variants that share base properties and should move together (Badge, Card, SegmentedControl).
200
209
  - `references/intrinsics.md`: structural or display choices that are not token values (an alignment, an element's visibility), where the runtime default and the editor's read-back must agree.
210
+ - `references/sketch-mode.md`: joining the sketch layer. **Every component needs this.** One class on the root, the five `--sketch-*` values the layer draws with, and the list of what it takes over from the element. Skip it and the component stays crisp while the page around it goes hand-drawn.
201
211
 
202
212
  ## Verification checklist
203
213
 
@@ -230,3 +240,4 @@ Finally navigate to `/live-tokens/components` and confirm the runtime behaviours
230
240
  - [ ] `component-configs/<id>/default.json` is derived from the `:global(:root)` block at boot. Save writes `_working.json`, the unsaved buffer the open theme captures; Save As also writes a named preset.
231
241
  - [ ] Reset returns each variable to its `:global(:root)` default.
232
242
  - [ ] Boot validation is clean (no warnings about the component being missing from the server scan, or about disk-vs-registry drift).
243
+ - [ ] Switch Sketch mode on in the editor and walk the checklist at the end of `references/sketch-mode.md`. The component is drawn in every variant and on hover, in its own colours, not crisp and not wearing another part's palette. Switch it off again and the component is unchanged.
@@ -0,0 +1,202 @@
1
+ # Joining the sketch layer
2
+
3
+ Sketch mode blanks each part's real background and border and repaints them onto
4
+ `::before`/`::after` through a shared noise field. It draws a fixed set of
5
+ selectors: the shipped components, plus four classes reserved for everyone else.
6
+ Your component is skipped until it opts in, so it stays crisp while the page
7
+ around it goes hand-drawn.
8
+
9
+ The whole contract is CSS. There is nothing to import and no function to call:
10
+ the layer exports no runtime API, and a component joins it by carrying a class
11
+ and naming five custom properties.
12
+
13
+ ## What the layer takes over
14
+
15
+ An opted-in element is no longer painting itself. On every drawn part the layer
16
+ forces:
17
+
18
+ | It forces | So you must |
19
+ |----------------------------------------------|----------------------------------------------------------|
20
+ | `background: transparent !important` | Name the fill again as `--sketch-fill` |
21
+ | `border-color: transparent !important` | Name the outline again as `--sketch-stroke` |
22
+ | `box-shadow: none !important` | Name the shadow again as `--sketch-shadow` |
23
+ | `overflow: visible !important` | Never put the class on a box whose clip carries meaning |
24
+ | `position: relative` | Never put the class on an absolutely-positioned root |
25
+ | `z-index: 0` | Expect a new stacking context on that element |
26
+ | `::before` (the fill), `::after` (the stroke) | Never own either pseudo-element on that element |
27
+
28
+ `::after` survives on `sketch-rule` alone, which draws no outline. `::before` is
29
+ claimed on all four.
30
+
31
+ ## Opting in
32
+
33
+ Put one class on the runtime component's root, chosen by **size, not by kind**.
34
+ A card and a modal are both containers; a badge and a pill are both chips.
35
+
36
+ | Class | For |
37
+ |---------------------|-------------------------------------------------------------|
38
+ | `sketch-surface` | A box. The default treatment. |
39
+ | `sketch-container` | A large box. Tilts less, so the type inside stays readable. |
40
+ | `sketch-chip` | A small box. Finer fill mask, more rotation, less travel. |
41
+ | `sketch-rule` | A line rather than a box. No rotation, no rounded ends. |
42
+
43
+ The class opts you in and nothing more. It names no colour, so the layer emits
44
+ no rule for it and whatever your component declares survives.
45
+
46
+ ```svelte
47
+ <div class="mywidget sketch-container {variant}">…</div>
48
+
49
+ <style>
50
+ .mywidget {
51
+ background: var(--mywidget-surface);
52
+ border: var(--mywidget-border-width) solid var(--mywidget-border);
53
+ border-radius: var(--mywidget-radius);
54
+ box-shadow: var(--mywidget-shadow);
55
+
56
+ /* The layer hides all four above. These are what it draws instead. */
57
+ --sketch-fill: var(--mywidget-surface);
58
+ --sketch-stroke: var(--mywidget-border);
59
+ --sketch-hatch-color: var(--mywidget-border);
60
+ --sketch-radius: var(--mywidget-radius);
61
+ --sketch-shadow: var(--mywidget-shadow);
62
+ }
63
+ </style>
64
+ ```
65
+
66
+ State all five. `--sketch-radius` is registered as an inheriting `<length>`, and
67
+ the other four inherit as ordinary custom properties, so a part that states
68
+ nothing is drawn with its **ancestor's** value: a badge inside a hatched card
69
+ stripes itself in the card's ink, and a square header inside a rounded card
70
+ picks up the card's corners.
71
+
72
+ ## Variants, states and inner parts
73
+
74
+ Nothing competes with you for these values, so every case is one more
75
+ declaration at the specificity you already use.
76
+
77
+ ```css
78
+ .mywidget.danger { --sketch-fill: var(--mywidget-danger-surface); }
79
+ .mywidget:hover,
80
+ .mywidget.force-hover { --sketch-stroke: var(--mywidget-hover-border); }
81
+ ```
82
+
83
+ Pair every `:hover` with `.force-hover`, as elsewhere: that is the editor's
84
+ preview of the hover state, and a hover the sketch layer cannot paint reads as
85
+ no hover at all once the real background is transparent.
86
+
87
+ An inner part that carries its own surface (a header strip, a footer) takes its
88
+ own class and its own five values. Where such a part draws no outline, bind the
89
+ hatch ink to the ink its **parent** is outlined in, so the component reads as one
90
+ drawing rather than a shaded panel dropped into a box:
91
+
92
+ ```css
93
+ .mywidget-header {
94
+ --sketch-fill: var(--mywidget-header-surface);
95
+ --sketch-stroke: transparent;
96
+ --sketch-hatch-color: var(--mywidget-border);
97
+ --sketch-radius: 0px;
98
+ }
99
+ ```
100
+
101
+ A part with a visible stroke needs no `--sketch-hatch-color`; it falls back to
102
+ the stroke and follows it into hover. A part with no fill wants none, because
103
+ there is no surface there to shade.
104
+
105
+ A gradient is a valid fill. The `background` shorthand's last layer takes a
106
+ colour or an image, so `--sketch-fill` accepts either.
107
+
108
+ ## Where the class does not go
109
+
110
+ - **A positioned root.** The layer forces `position: relative` on parts that sit
111
+ in flow, which drops an absolutely or fixed-positioned element back to its
112
+ flow position. Put the class on an inner box instead.
113
+ - **A box that clips something real.** `overflow` is forced visible so the ink
114
+ can travel past the border box, and there is no consumer opt-out. A scroller,
115
+ a fill bar held to its track, or a picture held to its frame keeps its clip by
116
+ keeping the class off that element and carrying it on a wrapper.
117
+ - **An element that owns `::before` or `::after`.** The layer claims both. A
118
+ shimmer, a caret or a decorative arrow on the opted-in element is gone.
119
+ - **A shipped part's selector** (`.card`, `.panel`). Borrowing one to get drawn
120
+ works, but it hands your component that part's colours and its damping, and it
121
+ is package-internal. The reserved classes are the contract.
122
+
123
+ ## Rules, which are not boxes
124
+
125
+ A `border` cannot be displaced: the effect moves boxes, and a border is not one.
126
+ Make the rule an element, give it `sketch-rule`, and name its ink as the fill.
127
+
128
+ ```svelte
129
+ <span class="mywidget-rule sketch-rule" aria-hidden="true"></span>
130
+ ```
131
+ ```css
132
+ .mywidget-rule {
133
+ height: var(--border-width-2);
134
+ background: var(--mywidget-divider);
135
+ --sketch-fill: var(--mywidget-divider);
136
+ }
137
+ ```
138
+
139
+ ## Media inside your component
140
+
141
+ A drawn part's `overflow` is visible so the fill and outline can travel past the
142
+ box. A background that bleeds is the effect working. An image that bleeds is
143
+ not, since it keeps square corners while the part around it turns. Media running
144
+ to your component's edge has to carry the corners itself:
145
+
146
+ ```css
147
+ .mywidget-cover {
148
+ overflow: hidden;
149
+ border-top-left-radius: var(--sketch-radius, var(--mywidget-radius));
150
+ border-top-right-radius: var(--sketch-radius, var(--mywidget-radius));
151
+ }
152
+ ```
153
+
154
+ `--sketch-radius` is the radius the layer drew and it inherits, so the fallback
155
+ covers the effect being off. Corner spread is per-corner and per-instance, so at
156
+ high spread the crop is the mean rather than an exact trace of the drawn edge.
157
+
158
+ ## Icons and SVG
159
+
160
+ Icons and inline SVG take the wobble directly, since a glyph has no box to
161
+ redraw. Body type is left alone deliberately: an icon is a shape and survives a
162
+ wobble, a paragraph is not. You opt into none of this; it applies to every
163
+ `[class*="fa-"]` and every `svg` under the scope.
164
+
165
+ `--sketch-icon-off` names what a subtree's glyphs are drawn with instead. It
166
+ inherits, so one declaration covers everything under it:
167
+
168
+ ```css
169
+ /* Crisp. Chrome, a logo, anything that has to stay exact. */
170
+ .mywidget-toolbar { --sketch-icon-off: none; }
171
+
172
+ /* Drawn back rather than off, at a third of the travel. Small artwork, and
173
+ type set as an SVG, which the layer reads as one large glyph. */
174
+ .mywidget-mark { --sketch-icon-off: var(--sketch-icon-soft); }
175
+ ```
176
+
177
+ Travel is stated in px against a glyph whose size the layer cannot know, so the
178
+ dial that suits a card's worth of artwork tears a 16px icon apart. Reach for the
179
+ soft bank before reaching for `none`. The shipped `SectionDivider` is the worked
180
+ example.
181
+
182
+ ## First-party components
183
+
184
+ A component authored inside the package does not use the reserved classes. Add a
185
+ `PartSpec` row to `PART_SPECS` in `src/editor/core/sketch/sketchLayer.ts`
186
+ instead, which is keyed to the component's own token stem and gets the shipped
187
+ damping. `sketchPartTokens.test.ts` then holds you to it: every colour the layer
188
+ paints must be one the component itself assigns to that same element, checked
189
+ against the compiled `<style>` block.
190
+
191
+ ## Verify
192
+
193
+ Switch Sketch mode on from the editor's **Sketch Style** view, then check the
194
+ component in place:
195
+
196
+ - [ ] Drawn, not crisp, in every variant.
197
+ - [ ] Wearing its own colours, not its parent's, including inner parts.
198
+ - [ ] Hover repaints. The wobble holds still while it does.
199
+ - [ ] Hatched fill uses ink that belongs to the component.
200
+ - [ ] Media at the component's edge turns with the drawn corners.
201
+ - [ ] Nothing that has to stay exact is torn: icons, clipped content, overlays.
202
+ - [ ] Switch it off. Every trace is gone and the component is unchanged.
@@ -49,7 +49,7 @@ All four pick one option from a set. The right one depends on **option count**,
49
49
  | `Dialog` | Modal, blocks page | Confirmations, focused tasks the page can't continue around |
50
50
  | `Panel` | Inline, fixed stage | A demo or preview surface whose height must not reflow |
51
51
 
52
- - Default to `Card`. It's the workhorse.
52
+ - Default to `Card`. It's the workhorse. For full-bleed media — cover art, a poster, a chart that reaches its own border — pass `flush` (with `prose={false}`) rather than zeroing its padding tokens from the page.
53
53
  - Reach for `CollapsibleSection` only when the content is *legitimately secondary* (advanced users open it; most skip). Don't use collapse as a styling choice when the content matters.
54
54
  - `Panel` is a stage, not a content container. It pins its own height so what it shows can resize without moving the page, which is what a component preview or a live example needs and what article content does not. Content goes in `Card`.
55
55
  - **Don't use `Dialog` for routine forms.** Reach for it only when the page cannot meaningfully continue until the user decides (destructive confirmations, payment, sign-in). Routine forms go inline in a `Card`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.58.0 — The sketch layer states its contract
4
+
5
+ ### Added
6
+
7
+ - **`flush` on `Card`.** A card holding full-bleed media has to reach its own
8
+ border, but the body's inset stood in the way and the only way around it was
9
+ for the page to zero the five `--card-default-body-padding*` tokens at its own
10
+ scope — reaching around the component to undo something the component did, and
11
+ repeating it at every such card. `flush` drops the inset from the body alone,
12
+ outweighs `size="compact"` so it holds at either size, and pairs with
13
+ `prose={false}`: a flush body is a frame, not a column of text.
14
+ - **`sketch-container` and `sketch-chip`, beside the existing `sketch-surface`
15
+ and `sketch-rule`.** The sketch layer draws a fixed set of selectors, so a
16
+ consumer-authored component stayed crisp while the page around it went
17
+ hand-drawn. The four reserved classes are now a size ladder — a container
18
+ tilts less than the type inside it can tolerate, a chip is smaller than one
19
+ blob of the fill mask — and a consumer picks the band its part belongs to
20
+ instead of inheriting the middle treatment or borrowing a shipped part's
21
+ selector to get drawn at all. The classes name no colours, so the element's
22
+ own `--sketch-fill` / `--sketch-stroke` survive untouched.
23
+
24
+ ### Changed
25
+
26
+ - **The `Sketches` preset theme is now `Sketchy`.** The old name read as a
27
+ collection of sketches rather than as the look itself, which is the one thing
28
+ a preset name has to do. The theme's files move to
29
+ `themes/sketchy.json` and `colors-and-type/sketchy.json`. Presets are served
30
+ read-through from the package, so a consumer who never opened it sees only the
31
+ new name; one whose active or production pointer names `sketches` has to
32
+ repoint it at `sketchy`.
33
+ - **The editor's `Sketch` and `Colors` views are now `Sketch Style` and
34
+ `Color Wheel`,** and the condensed rail cycles Tokens, Components, Sketch
35
+ Style, Color Wheel. `Sketch` named the mode and the view identically while
36
+ `Colors` said less than the view does; the flow order now runs from the
37
+ narrowest edit to the widest.
38
+ - **The sketch layer's contract is now documented from both sides.** The guide
39
+ and the component-authoring skill each described what an element has to
40
+ declare to be drawn, and neither said what the layer takes away from it: the
41
+ element's `background`, `border-color` and `box-shadow` are forced off, its
42
+ `overflow` is forced visible, it is given a stacking context, and both its
43
+ pseudo-elements are claimed. That is what decides where the opt-in class can
44
+ go, so a component that owned a `::before`, clipped its content or positioned
45
+ its root failed in a way neither document accounted for. The skill reference
46
+ now carries the full contract, including the icon controls, the hatch-ink
47
+ fallback for a part with no outline, and the `PART_SPECS` path a first-party
48
+ component takes instead of the reserved classes. The guide gains a short
49
+ section on where the dials live, since nothing said they are held in the
50
+ browser rather than in the theme.
51
+
52
+ ### Fixed
53
+
54
+ - **Sketch mode's "Drawing your own elements" section showed the custom
55
+ properties without the class that opts an element in**, so the example it gave
56
+ drew nothing. It now documents all four reserved classes, how to pick one, and
57
+ that variants, states and gradients are all just further declarations of
58
+ `--sketch-fill` / `--sketch-stroke`.
59
+ - **Nothing said how to keep an image inside a drawn part from bleeding.** A
60
+ part's `overflow` is forced visible so the fill and outline can travel past
61
+ the box, which is right for a background and wrong for a photo: it kept square
62
+ corners while the card around it turned. `--sketch-radius` was already the
63
+ radius the layer drew and already inherited, so the fix was a documentation
64
+ one. Both the Sketch mode chapter and the component-authoring reference now
65
+ show media reading it, with a fallback for the effect being off.
66
+ - **`live-tokens-create-component` never mentioned sketch mode.** Joining the
67
+ layer is now step 5 of the recipe, with `references/sketch-mode.md` behind it
68
+ and a line on the verification checklist. A component authored to the old
69
+ recipe was invisible to the effect and nothing said so.
70
+
3
71
  ## 0.57.0 — A theme can be drawn by hand
4
72
 
5
73
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "src/live-tokens/data/colors-and-type/midnight-study.json",
31
31
  "src/live-tokens/data/colors-and-type/ocean.json",
32
32
  "src/live-tokens/data/colors-and-type/royal-velvet.json",
33
- "src/live-tokens/data/colors-and-type/sketches.json",
33
+ "src/live-tokens/data/colors-and-type/sketchy.json",
34
34
  "src/live-tokens/data/colors-and-type/spring-meadow.json",
35
35
  "src/live-tokens/data/colors-and-type/sunset.json",
36
36
  "src/live-tokens/data/themes/autumn.json",
@@ -38,7 +38,7 @@
38
38
  "src/live-tokens/data/themes/midnight-study.json",
39
39
  "src/live-tokens/data/themes/ocean.json",
40
40
  "src/live-tokens/data/themes/royal-velvet.json",
41
- "src/live-tokens/data/themes/sketches.json",
41
+ "src/live-tokens/data/themes/sketchy.json",
42
42
  "src/live-tokens/data/themes/spring-meadow.json",
43
43
  "src/live-tokens/data/themes/sunset.json",
44
44
  "dist-plugin",
@@ -155,10 +155,14 @@ function normalise(f: Float32Array): Float32Array {
155
155
 
156
156
  /* ------------------------------------------------------------- levelling --- */
157
157
 
158
- /** Output levels. The field's darkest point lands at Min and its brightest at
159
- Max, so nothing is barer than the low handle or denser than the high one.
160
- Input levels cut the field instead, and since the field is stretched to run
161
- from black, any low handle above zero punched a hole through the fill. */
158
+ /** Output levels. The field is stretched into the gap between the handles, so
159
+ the pair states the range the mask paints: nothing is barer than Min or
160
+ denser than Max, and the field keeps its own shape in between.
161
+
162
+ Clamping instead makes Min the value most of the field sits AT rather than
163
+ its rare floor, because the field arrives centred on its own middle: half of
164
+ it is below 0.5, so a floor of 0.6 piles that half onto 0.6 and the fill
165
+ comes out a flat wash at the floor with a thin bright tail above it. */
162
166
  function applyOutput(f: Float32Array, min: number, max: number): Float32Array {
163
167
  const span = max - min;
164
168
  for (let i = 0; i < f.length; i++) f[i] = min + f[i] * span;
@@ -239,8 +243,8 @@ export function buildMaskField(
239
243
  const raw = cachedRaw(s, seed);
240
244
  if (through === 'noise') return { field: raw, raster: RASTER };
241
245
 
242
- const levelled = posterise(
243
- applyOutput(Float32Array.from(raw), s.maskOutputMin, s.maskOutputMax), s.maskPosterize,
246
+ const levelled = applyOutput(
247
+ posterise(Float32Array.from(raw), s.maskPosterize), s.maskOutputMin, s.maskOutputMax,
244
248
  );
245
249
  if (through === 'output') return { field: levelled, raster: RASTER };
246
250
 
@@ -26,6 +26,12 @@ const ID = 'lt-sketch';
26
26
  identical twins, which is the single biggest tell that it is not hand drawn. */
27
27
  const SEEDS = [0, 1, 2, 3, 4];
28
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
+
29
35
  const HATCH_ANGLE = 45;
30
36
  /** The second set of stripes leans and spaces itself a little off the first.
31
37
  Half a pixel of pitch against 7 puts the beat about 90px apart, and the two
@@ -221,10 +227,19 @@ const PART_SPECS: readonly PartSpec[] = [
221
227
  // outline: a line does not get drawn around.
222
228
  { sel: '.sd-hairline', fill: 'var(--_divider-hairline-color)', strokeless: true },
223
229
 
224
- // Consumer opt-in. A page element that is not a component but paints a
225
- // token-driven surface sets --sketch-fill / --sketch-stroke itself and
226
- // carries this class; the layer then treats it like any other part.
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.
227
240
  { sel: '.sketch-surface' },
241
+ { sel: '.sketch-container' },
242
+ { sel: '.sketch-chip' },
228
243
  { sel: '.sketch-rule', strokeless: true },
229
244
  ];
230
245
 
@@ -438,7 +453,8 @@ export function buildDefsMarkup(s: SketchSettings): string {
438
453
  // Icons are glyphs a few tens of pixels across, read at a glance, with no
439
454
  // redundancy to lose. A high-frequency field at a small amplitude wobbles
440
455
  // the outline without pulling a stroke off the shape it belongs to.
441
- displace(`${ID}-icon-${seed}`, base / s.iconWavelength, seed + 63, s.iconTravel, 40, 0, 0),
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),
442
458
  ).join('');
443
459
 
444
460
  // Ink pooling. Blur spreads the stroke, then a steep alpha ramp re-sharpens
@@ -651,6 +667,10 @@ export function buildStylesheet(s: SketchSettings): string {
651
667
  `--sketch-stroke-filter:url(#${ID}-stroke-0);` +
652
668
  `--sketch-mask:${s.maskOn || s.iconMaskOn ? buildMaskUri(s) : 'none'};` +
653
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);` +
654
674
  `--sketch-jit-x-base:${s.jitterX}px;` +
655
675
  `--sketch-jit-y-base:${s.jitterY}px;` +
656
676
  `--sketch-jit-rot-base:${s.jitterRot}deg;` +
@@ -668,15 +688,14 @@ export function buildStylesheet(s: SketchSettings): string {
668
688
  * that element's own origin and sampled at a different offset per instance,
669
689
  * so two parts the same size are not blotched alike.
670
690
  *
671
- * `mask-clip: no-clip` is load-bearing on the fill. A mask clips what it masks
672
- * to the border box by default, and the shadow the fill casts lies outside
673
- * that box, so switching coverage on took every shadow with it. Unclipped, the
674
- * tile runs on over the shadow and thins it where the ink beside it is thin,
675
- * which is what ink that is not there would do.
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.
676
695
  */
677
- const coverage = (tile: string, pos: string) =>
696
+ const coverage = (size: string, pos: string) =>
678
697
  `mask-image:var(--sketch-mask, none);` +
679
- `mask-size:${tile} ${tile};` +
698
+ `mask-size:${size};` +
680
699
  `mask-mode:luminance;mask-repeat:repeat;mask-clip:no-clip;` +
681
700
  `mask-position:var(${pos}, 0 0);`;
682
701
 
@@ -684,11 +703,13 @@ export function buildStylesheet(s: SketchSettings): string {
684
703
  // than through a redrawn ::before. Body type is deliberately left alone: an
685
704
  // icon is a shape and survives a wobble, a paragraph is not.
686
705
  //
687
- // `--sketch-icon-off` is the opt-out. It inherits, so any chrome that lives
688
- // in the host document (the overlay bar) sets it once on its own root and
689
- // every icon under it resolves to `none` regardless of specificity.
690
- // `svg` covers inline artwork the same way. The injected filter bank is
691
- // itself an svg in the body, so it has to be excluded or it filters itself.
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.
692
713
  const iconSel = `[class*="fa-"], svg:not([${DEFS_ATTR}])`;
693
714
  const iconsOn = s.iconTravel > 0 || s.iconMaskOn;
694
715
  const icons = iconsOn
@@ -697,14 +718,24 @@ export function buildStylesheet(s: SketchSettings): string {
697
718
  ? `filter:var(--sketch-icon-off, var(--sketch-icon-filter, url(#${ID}-icon-0)));`
698
719
  : '') +
699
720
  // The ink mask reads as coverage on a glyph the way it does on a fill,
700
- // but the component tile is hundreds of px across: a whole icon would
701
- // sample one patch of it and either survive intact or disappear. A
702
- // tile near icon size puts several blotches across each glyph.
703
- (s.iconMaskOn ? coverage(`${s.iconMaskTile}px`, '--sketch-icon-mask-pos') : '') +
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
+ : '') +
704
733
  `}` +
705
734
  SEEDS.map((seed, i) =>
706
735
  `${on} :is(${iconSel}):nth-child(5n + ${i + 1})` +
707
- `{--sketch-icon-filter:url(#${ID}-icon-${seed});--sketch-icon-mask-pos:${MASK_POS[i]};}`,
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]};}`,
708
739
  ).join('')
709
740
  : '';
710
741
 
@@ -722,6 +753,46 @@ export function buildStylesheet(s: SketchSettings): string {
722
753
  ? `border-radius:${[1, 2, 3, 4].map(cornerRadius).join(' ')};`
723
754
  : 'border-radius:inherit;';
724
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
+
725
796
  const host =
726
797
  `${el}{` +
727
798
  `--sketch-jit-x:var(--sketch-jit-x-base);` +
@@ -754,12 +825,17 @@ export function buildStylesheet(s: SketchSettings): string {
754
825
  // effect is switched on, because `left` animates from -100% to 0.
755
826
  const fill =
756
827
  `${el}::before{` +
757
- `content:'';position:absolute;inset:0 !important;transition:none !important;` +
758
- `z-index:-1;${corners}` +
828
+ `content:'';position:absolute;${bleedBox}transition:none !important;` +
829
+ `z-index:-1;${fillCorners}` +
759
830
  `background:var(--sketch-fill, var(--surface-neutral-lower));` +
760
- `box-shadow:var(--sketch-shadow, none);` +
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);' : '') +
761
837
  `filter:var(--sketch-fill-filter);` +
762
- (s.maskOn ? coverage('var(--sketch-mask-tile)', '--sketch-mask-pos') : '') +
838
+ (s.maskOn ? coverage('var(--sketch-mask-tile) var(--sketch-mask-tile)', '--sketch-mask-pos') : '') +
763
839
  `transform:translate(` +
764
840
  `calc(var(--sketch-jx, 0) * var(--sketch-jit-x, 0px)),` +
765
841
  `calc(var(--sketch-jy, 0) * var(--sketch-jit-y, 0px))` +
@@ -799,6 +875,7 @@ export function buildStylesheet(s: SketchSettings): string {
799
875
  `${hatchInk('1')} 0 1.5px,` +
800
876
  `transparent 1.5px 7px),` +
801
877
  `var(--sketch-fill, var(--surface-neutral-lower));` +
878
+ bleedPaint +
802
879
  `}`;
803
880
 
804
881
  // A translucent nib. The retrace pass below overlaps this one, so where both
@@ -890,7 +967,8 @@ export function buildStylesheet(s: SketchSettings): string {
890
967
  // Large panels tilt less than the type inside them can tolerate; small chips
891
968
  // can take more rotation but less travel.
892
969
  const perPart =
893
- `${el}:is(.card, .card-header, .panel, .dialog, .table-wrapper, .sidenavigation)` +
970
+ `${el}:is(.card, .card-header, .panel, .dialog, .table-wrapper, .sidenavigation,` +
971
+ ` .sketch-container)` +
894
972
  `{--sketch-jit-rot:calc(var(--sketch-jit-rot-base) * 0.3);}` +
895
973
  // A rule is one or two pixels tall. The full-size displacement tears it into
896
974
  // dashes and a translate that large lifts it clean off its own row, so it
@@ -904,7 +982,7 @@ export function buildStylesheet(s: SketchSettings): string {
904
982
  // rather than as a hand.
905
983
  `--sketch-corner-spread:0px;` +
906
984
  `}` +
907
- `${el}:is(.badge, .toggle .track){` +
985
+ `${el}:is(.badge, .toggle .track, .sketch-chip){` +
908
986
  // A chip is smaller than one blob at the tile the cards read it at, so it
909
987
  // lands wholly inside a patch and comes out either untouched or gone.
910
988
  // Shrinking the tile puts several blotches across it, which is the same
@@ -925,8 +1003,8 @@ export function buildStylesheet(s: SketchSettings): string {
925
1003
  `--sketch-stroke-filter:url(#${ID}-stroke-sm-2);` +
926
1004
  `}`;
927
1005
 
928
- // `.sketch-surface` names its own colours, so it emits no rule and keeps
929
- // whatever the page set.
1006
+ // The `.sketch-*` opt-in classes name their own colours, so they emit no rule
1007
+ // and keep whatever the page set.
930
1008
  const colours = PART_SPECS.map((p) => {
931
1009
  const f = p.fill ?? (p.stem ? `var(--${p.stem}-surface)` : null);
932
1010
  const st = p.stroke ?? (p.stem ? `var(--${p.stem}-border)` : null);