@motion-proto/live-tokens 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/skills/live-tokens-create-component/SKILL.md +13 -2
  2. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +202 -0
  3. package/.claude/skills/live-tokens-pick-component/SKILL.md +1 -1
  4. package/CHANGELOG.md +117 -0
  5. package/dist-plugin/generateColorsAndType/index.cjs +4 -3
  6. package/dist-plugin/generateColorsAndType/index.js +4 -3
  7. package/package.json +3 -3
  8. package/src/editor/component-editor/ImageLightboxEditor.svelte +11 -2
  9. package/src/editor/core/sketch/maskField.ts +10 -6
  10. package/src/editor/core/sketch/sketchLayer.ts +107 -29
  11. package/src/editor/core/sketch/sketchPresets.ts +34 -17
  12. package/src/editor/core/themes/parsers/shadow.ts +12 -4
  13. package/src/editor/docs/content/editing-tokens.md +2 -2
  14. package/src/editor/docs/content/sketch-mode.md +106 -12
  15. package/src/editor/docs/content.generated.ts +2 -2
  16. package/src/editor/ui/ColorEditPanel.svelte +13 -9
  17. package/src/editor/ui/EditorViewSwitcher.svelte +20 -20
  18. package/src/editor/ui/PaletteEditor.svelte +11 -3
  19. package/src/editor/ui/UIInlineEditActions.svelte +97 -0
  20. package/src/editor/ui/sketch/SketchTab.svelte +6 -5
  21. package/src/live-tokens/data/colors-and-type/autumn.json +5 -5
  22. package/src/live-tokens/data/colors-and-type/default.json +5 -5
  23. package/src/live-tokens/data/colors-and-type/halloween.json +5 -5
  24. package/src/live-tokens/data/colors-and-type/midnight-study.json +5 -5
  25. package/src/live-tokens/data/colors-and-type/ocean.json +5 -5
  26. package/src/live-tokens/data/colors-and-type/royal-velvet.json +5 -5
  27. package/src/live-tokens/data/colors-and-type/{sketches.json → sketchy.json} +6 -6
  28. package/src/live-tokens/data/colors-and-type/spring-meadow.json +5 -5
  29. package/src/live-tokens/data/colors-and-type/sunset.json +5 -5
  30. package/src/live-tokens/data/themes/autumn.json +5 -5
  31. package/src/live-tokens/data/themes/halloween.json +5 -5
  32. package/src/live-tokens/data/themes/midnight-study.json +5 -5
  33. package/src/live-tokens/data/themes/ocean.json +5 -5
  34. package/src/live-tokens/data/themes/royal-velvet.json +5 -5
  35. package/src/live-tokens/data/themes/{sketches.json → sketchy.json} +32 -32
  36. package/src/live-tokens/data/themes/spring-meadow.json +5 -5
  37. package/src/live-tokens/data/themes/sunset.json +5 -5
  38. package/src/live-tokens/data/tokens.generated.css +5 -5
  39. package/src/system/components/Card.svelte +12 -1
  40. package/src/system/components/ImageLightbox.svelte +26 -2
  41. package/src/system/components/SectionDivider.svelte +4 -0
  42. package/src/system/styles/tokens.css +5 -5
@@ -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);
@@ -109,10 +109,15 @@ export interface SketchSettings {
109
109
  iconWavelength: number;
110
110
  /** Ink coverage over icons. Independent of `maskOn`, which governs fills. */
111
111
  iconMaskOn: boolean;
112
- /** Icon mask tile, in px. Near glyph size puts several blotches across one
113
- icon; at the fill's tile size a whole icon samples a single patch and
114
- either survives intact or vanishes. */
115
- iconMaskTile: number;
112
+ /** Icon mask tile, as a share of the glyph it covers. A px size cannot be
113
+ right for both a 16px icon and a page-wide illustration: the tile that
114
+ blotches the drawing swallows the icon whole, and one cut for the icon
115
+ is dust on the drawing. Stated against the glyph, the tile scales with
116
+ whatever it is laid on. At 1 every glyph gets one period of the field
117
+ across it; below that the field repeats inside the glyph and the blotches
118
+ get finer; above it a glyph reads part of a blotch and the mask acts as
119
+ an uneven wash over the whole thing. */
120
+ iconMaskScale: number;
116
121
  }
117
122
 
118
123
  const base: SketchSettings = {
@@ -122,11 +127,11 @@ const base: SketchSettings = {
122
127
  strokeWidth: 1.5, doubleStroke: false, retraceOffset: 1.2, retracePass: 'copy',
123
128
  fillStyle: 'solid', hatchInk: 0.4, strokeStyle: 'solid',
124
129
  pressure: 0.25, pressureMod: 0.35, pooling: 1.2, strokeInk: 1,
125
- maskOn: true, maskBlob: 100, maskOutputMin: 0.35, maskOutputMax: 1,
130
+ maskOn: true, maskBlob: 100, maskOutputMin: 0.45, maskOutputMax: 1,
126
131
  maskOctaves: 2, maskGrain: 'fractal', maskPosterize: 1, maskSoftness: 1.5,
127
132
  jitterX: 2.5, jitterY: 2.5, jitterRot: 0.6, jitterScale: 0.035,
128
133
  cornerSpread: 10, cornerTravel: 8,
129
- iconTravel: 1.25, iconWavelength: 0.625, iconMaskOn: true, iconMaskTile: 90,
134
+ iconTravel: 1.25, iconWavelength: 0.625, iconMaskOn: true, iconMaskScale: 1,
130
135
  };
131
136
 
132
137
  export const SKETCH_PRESETS: Record<string, SketchSettings> = {
@@ -135,7 +140,7 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
135
140
  blurb: 'Two graphite passes on their own seeds, so the outline disagrees with itself the way a hand coming back round does. Tight grain, little else.',
136
141
  fillTravel: 0.75, strokeTravel: 1.25, wobble: 30, roughness: 3, waveform: 1,
137
142
  strokeWidth: 1.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 1.5, strokeInk: 0.85,
138
- maskBlob: 40, maskOutputMin: 0.55, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0.6,
143
+ maskBlob: 40, maskOutputMin: 0.62, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 1, maskSoftness: 0.6,
139
144
  jitterX: 1.5, jitterY: 1.5, jitterRot: 0.35, jitterScale: 0.022,
140
145
  cornerSpread: 6, cornerTravel: 4.5,
141
146
  pressure: 0.15, pressureMod: 0.3, pooling: 0, iconTravel: 0.75,
@@ -146,10 +151,10 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
146
151
  blurb: 'Broad translucent nib gone round twice on the same line, so the overlap darkens and the ink pools where it slows.',
147
152
  fillTravel: 2, strokeTravel: 1.5, wobble: 56, waveform: 1.4, borderWavelength: 1.3,
148
153
  strokeWidth: 4, doubleStroke: true, retracePass: 'copy', strokeInk: 0.52, retraceOffset: 2.2,
149
- maskBlob: 95, maskOutputMin: 0.4, maskOutputMax: 1, maskOctaves: 2, maskPosterize: 4, maskSoftness: 2.8,
154
+ maskBlob: 115, maskOutputMin: 0.21, maskOutputMax: 1, maskOctaves: 3, maskPosterize: 4, maskSoftness: 2.5,
150
155
  jitterX: 3, jitterY: 3, jitterRot: 0.8, jitterScale: 0.045,
151
156
  cornerSpread: 10, cornerTravel: 8,
152
- pressure: 0.2, pressureMod: 0.25, pooling: 2, iconTravel: 1.25,
157
+ pressure: 0.2, pressureMod: 0.25, pooling: 2, iconTravel: 1.25, iconMaskScale: 4,
153
158
  },
154
159
 
155
160
  whiteboard: {
@@ -157,11 +162,11 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
157
162
  blurb: 'The fattest nib on glass. One long smooth undulation, and a veined mask that streaks the fill like a half-wiped board.',
158
163
  fillTravel: 2.5, strokeTravel: 2.5, wobble: 90, roughness: 1, waveform: 1, borderWavelength: 1.5,
159
164
  strokeWidth: 5.5, doubleStroke: true, retracePass: 'copy', strokeInk: 0.66, retraceOffset: 3,
160
- maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.3, maskOutputMax: 1,
165
+ maskGrain: 'turbulence', maskBlob: 180, maskOutputMin: 0.42, maskOutputMax: 0.9,
161
166
  maskOctaves: 1, maskPosterize: 3, maskSoftness: 5,
162
167
  jitterX: 4.5, jitterY: 4.5, jitterRot: 1.2, jitterScale: 0.07,
163
168
  cornerSpread: 14, cornerTravel: 11,
164
- pressure: 0.15, pressureMod: 0.15, pooling: 3.5, iconTravel: 1.75, iconMaskTile: 140,
169
+ pressure: 0.15, pressureMod: 0.15, pooling: 3.5, iconTravel: 1.75, iconMaskScale: 1.5,
165
170
  },
166
171
 
167
172
  hatched: {
@@ -191,22 +196,22 @@ export const SKETCH_PRESETS: Record<string, SketchSettings> = {
191
196
  blurb: 'Ballpoint in a hurry. Everything loose at once: a square wave sends every edge to full travel, and the second pass lands wherever it lands.',
192
197
  fillTravel: 3, strokeTravel: 2.25, wobble: 50, roughness: 3, waveform: 2.5,
193
198
  strokeWidth: 2.25, doubleStroke: true, retracePass: 'reseeded', retraceOffset: 4, strokeInk: 1,
194
- maskBlob: 150, maskOutputMin: 0.25, maskOutputMax: 1, maskOctaves: 2, maskPosterize: 2, maskSoftness: 6.7,
199
+ maskBlob: 150, maskOutputMin: 0.43, maskOutputMax: 0.95, maskOctaves: 2, maskPosterize: 2, maskSoftness: 6.7,
195
200
  jitterX: 6, jitterY: 6, jitterRot: 1.8, jitterScale: 0.1,
196
201
  cornerSpread: 20, cornerTravel: 17,
197
- pressure: 0.45, pressureMod: 0.6, pooling: 2.5, iconTravel: 2.25,
202
+ pressure: 0.45, pressureMod: 0.6, pooling: 2.5, iconTravel: 2.25, iconMaskScale: 3.6,
198
203
  },
199
204
 
200
205
  dry: {
201
206
  ...base, label: 'Dry marker',
202
- blurb: 'Ink that ran out. One scratchy pass that breaks up along its length, over a fill the veined mask has mostly eaten.',
207
+ blurb: 'Ink that ran out. One scratchy pass that breaks up along its length, over a fill the mask has worn nearly through in patches.',
203
208
  fillTravel: 2.25, strokeTravel: 1.75, wobble: 50, waveform: 2, borderWavelength: 0.5,
204
209
  strokeWidth: 3.5, doubleStroke: false, strokeInk: 0.4,
205
- maskGrain: 'turbulence', maskBlob: 120, maskOutputMin: 0, maskOutputMax: 0.85,
206
- maskOctaves: 2, maskPosterize: 1, maskSoftness: 3,
210
+ maskBlob: 150, maskOutputMin: 0.15, maskOutputMax: 0.91,
211
+ maskOctaves: 2, maskPosterize: 4, maskSoftness: 1.75,
207
212
  jitterX: 5, jitterY: 5, jitterRot: 1.4, jitterScale: 0.08,
208
213
  cornerSpread: 16, cornerTravel: 13,
209
- pressure: 0.4, pressureMod: 0.8, pooling: 1.5, iconTravel: 1.75, iconMaskTile: 60,
214
+ pressure: 0.4, pressureMod: 0.8, pooling: 1.5, iconTravel: 1.75, iconMaskScale: 3.8,
210
215
  },
211
216
  };
212
217
 
@@ -230,6 +235,7 @@ export function hydrateSketchSettings(raw: unknown): SketchSettings {
230
235
  carryLevelsToOutput(stored as Record<string, unknown>, out);
231
236
  halveSwingDials(stored as Record<string, unknown>, out);
232
237
  convertCyclesToWavelength(stored as Record<string, unknown>, out);
238
+ convertIconTileToScale(stored as Record<string, unknown>, out);
233
239
  restoreDerivedRetrace(stored as Record<string, unknown>, out);
234
240
  return out;
235
241
  }
@@ -275,6 +281,17 @@ function convertCyclesToWavelength(stored: Record<string, unknown>, out: SketchS
275
281
  if (typeof layers === 'number') out.roughness = Math.min(3, Math.max(1, layers));
276
282
  }
277
283
 
284
+ /** The icon mask tile used to be a px size, which is a unit the glyph it covers
285
+ has no say in: 90px against a 16px icon put a whole glyph inside one patch
286
+ of the field, so it came out either untouched or gone. It is a share of the
287
+ glyph now, and the old default reads as one period across the glyph, which
288
+ is what that size was aiming at. */
289
+ function convertIconTileToScale(stored: Record<string, unknown>, out: SketchSettings): void {
290
+ const tile = stored.iconMaskTile;
291
+ if (typeof tile !== 'number') return;
292
+ out.iconMaskScale = Math.min(5, Math.max(0.5, Number((tile / 90).toFixed(2))));
293
+ }
294
+
278
295
  function halveSwingDials(stored: Record<string, unknown>, out: SketchSettings): void {
279
296
  for (const [legacy, key] of Object.entries(SWING_DIALS)) {
280
297
  const value = stored[legacy];
@@ -1,7 +1,14 @@
1
1
  /**
2
2
  * The single source of truth for the CSS form of a shadow scale token:
3
3
  *
4
- * <x>px <y>px <blur>px <spread>px hsla(<h>, <s>%, <l>%, <a>)
4
+ * <x>px <y>px <blur>px [<spread>px] hsla(<h>, <s>%, <l>%, <a>)
5
+ *
6
+ * The spread slot is written only when it is non-zero. A three-length shadow is
7
+ * legal in `box-shadow` (spread defaults to 0) and in `filter: drop-shadow()`,
8
+ * which has no spread slot at all — so one token dresses both, and a component
9
+ * casting from an image's alpha reads the same variable as one casting from a
10
+ * box. Dial a spread and the token grows its fourth length: still a shadow,
11
+ * no longer a filter.
5
12
  *
6
13
  * Everything that reads or writes that form goes through `parseShadowCss` /
7
14
  * `shadowTokenCss`, so the theme generator (Node, no store) and the editor
@@ -33,16 +40,17 @@ export function computeAngleDistance(x: number, y: number): { angle: number; dis
33
40
  }
34
41
 
35
42
  export function shadowTokenCss(t: ShadowValue): string {
36
- return `${t.x}px ${t.y}px ${t.blur}px ${t.spread}px hsla(${t.hue}, ${t.saturation}%, ${t.lightness}%, ${t.opacity})`;
43
+ const spread = t.spread ? `${t.spread}px ` : '';
44
+ return `${t.x}px ${t.y}px ${t.blur}px ${spread}hsla(${t.hue}, ${t.saturation}%, ${t.lightness}%, ${t.opacity})`;
37
45
  }
38
46
 
39
47
  export function parseShadowCss(variable: string, raw: string): ShadowToken | null {
40
- const m = raw.trim().match(/^(-?\d+)px\s+(-?\d+)px\s+(\d+)px\s+(-?\d+)px\s+hsla\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%,\s*([\d.]+)\)$/);
48
+ const m = raw.trim().match(/^(-?\d+)px\s+(-?\d+)px\s+(\d+)px\s+(?:(-?\d+)px\s+)?hsla\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%,\s*([\d.]+)\)$/);
41
49
  if (!m) return null;
42
50
  const x = parseInt(m[1], 10);
43
51
  const y = parseInt(m[2], 10);
44
52
  const blur = parseInt(m[3], 10);
45
- const spread = parseInt(m[4], 10);
53
+ const spread = m[4] === undefined ? 0 : parseInt(m[4], 10);
46
54
  const hue = Math.round(parseFloat(m[5]));
47
55
  const saturation = Math.round(parseFloat(m[6]));
48
56
  const lightness = Math.round(parseFloat(m[7]));
@@ -7,11 +7,11 @@ The editor has four views:
7
7
 
8
8
  - **Tokens**: the design-system primitives (colour, type, spacing, and so on).
9
9
  They apply everywhere your site uses them.
10
- - **Colors**: the harmony wheel, the palette curves, and the story your colours
10
+ - **Color Wheel**: the harmony wheel, the palette curves, and the story your colours
11
11
  tell across a page.
12
12
  - **Components**: per-component editors. Re-Assign what tokens a component uses
13
13
  without changing the underlying system.
14
- - **Sketch**: an effect layer that redraws the page by hand. See
14
+ - **Sketch Style**: an effect layer that redraws the page by hand. See
15
15
  [Sketch mode](sketch-mode.md).
16
16
 
17
17
  This page covers **Tokens**. For components, see
@@ -9,7 +9,7 @@ theme and writes nothing back, so it never touches a token, never lands in a
9
9
  theme file, and never reaches the CSS you ship. Turn it off and every trace of
10
10
  it goes.
11
11
 
12
- Open the **Sketch** view in the editor and switch **Sketch mode** on. The effect
12
+ Open the **Sketch Style** view in the editor and switch **Sketch mode** on. The effect
13
13
  applies to the page behind the editor as well as to the preview, so what you see
14
14
  in context is what it does.
15
15
 
@@ -63,12 +63,93 @@ your own, alongside the shipped seven, as a file under
63
63
  sit on it, and the shape of its wave. A square wave sends nearly every edge to
64
64
  full travel, which is what makes the effect stronger rather than bigger.
65
65
 
66
+ ## Where the settings live
67
+
68
+ The dials you are moving live in your browser, so the effect follows you across
69
+ reloads and stays off everyone else's screen. **Save** writes a named preset to
70
+ `src/live-tokens/data/sketch-presets/`, which is the only thing that reaches
71
+ disk.
72
+
73
+ Sketch mode is a tool for looking at the page, not a layer the page can ship.
74
+ Nothing is written into a theme, `tokens.generated.css` never sees it, and a
75
+ production build has no sketch layer in it at all.
76
+
66
77
  ## Drawing your own elements
67
78
 
68
- The effect displaces boxes, so anything drawn some other way is left alone. A
69
- rule made from a `border` is not a box; make it an element and tell the layer
70
- what to draw it with:
79
+ The layer draws a fixed set of parts: the shipped components, and four classes
80
+ it reserves for you. Nothing else is touched, so a page element or a
81
+ consumer-authored component is left crisp until it carries one of them.
82
+
83
+ | Class | For |
84
+ |---------------------|-----------------------------------------------------------|
85
+ | `sketch-surface` | A box. The default treatment. |
86
+ | `sketch-container` | A large box. Tilts less, so the type inside stays readable. |
87
+ | `sketch-chip` | A small box. Finer fill mask, more rotation, less travel. |
88
+ | `sketch-rule` | A line rather than a box. No rotation, no rounded ends. |
89
+
90
+ Pick by size, not by kind: a card and a modal both take `sketch-container`, a
91
+ badge and a pill both take `sketch-chip`.
92
+
93
+ The class opts the element in; it names no colours, so the element states its
94
+ own. `--sketch-fill`, `--sketch-stroke`, `--sketch-hatch-color`,
95
+ `--sketch-radius` and `--sketch-shadow` name the fill, the outline, the hatching
96
+ ink, the corners and the shadow for one element and everything inside it. The
97
+ layer blanks the real background and border, so an element whose fill matters
98
+ under Sketch mode has to name it here as well as paint it.
99
+
100
+ The layer also paints on the element's `::before` and `::after`, forces its
101
+ `overflow` visible, and gives it a stacking context of its own. Keep the class
102
+ off anything that owns a pseudo-element, clips its content, or is positioned
103
+ absolutely, and put it on a wrapper instead.
104
+
105
+ ```css
106
+ .my-callout {
107
+ background: var(--surface-brand-lowest);
108
+ border: var(--border-width-1) solid var(--border-brand);
109
+ border-radius: var(--radius-xl);
110
+
111
+ --sketch-fill: var(--surface-brand-lowest);
112
+ --sketch-stroke: var(--border-brand);
113
+ --sketch-radius: var(--radius-xl);
114
+ }
115
+ ```
116
+
117
+ A gradient is a valid fill: the shorthand's last layer takes a colour or an
118
+ image, so `--sketch-fill` accepts either. States work the same way, since
119
+ nothing is competing with you for the value:
120
+
121
+ ```css
122
+ .my-callout:hover { --sketch-stroke: var(--border-brand-strong); }
123
+ ```
124
+
125
+ ## Images inside a drawn part
71
126
 
127
+ A drawn part's `overflow` is forced visible, because the fill and outline are
128
+ painted on pseudo-elements that travel past the box and would otherwise be cut
129
+ off at its edge. A background that bleeds is the effect working. An image that
130
+ bleeds is not: it keeps its square corners while the card around it turns.
131
+
132
+ Media that runs to a part's edge therefore has to carry that part's corners
133
+ itself. `--sketch-radius` is the radius the layer drew, and it inherits, so a
134
+ child can read it and fall back to its own value when Sketch mode is off:
135
+
136
+ ```css
137
+ .cover {
138
+ overflow: hidden;
139
+ border-top-left-radius: var(--sketch-radius, var(--card-default-radius));
140
+ border-top-right-radius: var(--sketch-radius, var(--card-default-radius));
141
+ }
142
+ ```
143
+
144
+ Corner spread is per-corner and per-instance, so at high spread the crop is the
145
+ mean rather than an exact trace of the drawn edge.
146
+
147
+ A rule made from a `border` is not a box and cannot be displaced. Make it an
148
+ element, give it `sketch-rule`, and name its ink:
149
+
150
+ ```html
151
+ <div class="rule sketch-rule"></div>
152
+ ```
72
153
  ```css
73
154
  .rule {
74
155
  height: var(--border-width-2);
@@ -77,13 +158,26 @@ what to draw it with:
77
158
  }
78
159
  ```
79
160
 
80
- `--sketch-fill`, `--sketch-stroke`, `--sketch-hatch-color` and `--sketch-radius`
81
- name the fill, the outline, the hatching ink and the corners for one element and
82
- everything inside it. Since the layer blanks the real background, an element
83
- whose fill matters under Sketch mode should name it here as well as paint it.
84
-
85
161
  Icons and inline SVG take the wobble directly, since a glyph has no box to
86
162
  redraw. Body type is left alone: an icon is a shape and survives a wobble, a
87
- paragraph is not. To keep the icons in a subtree crisp, set
88
- `--sketch-icon-off: none` on its root. It inherits, so one declaration covers
89
- everything under it.
163
+ paragraph is not.
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
+ .app-bar { --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
+ .wordmark { --sketch-icon-off: var(--sketch-icon-soft); }
175
+ ```
176
+
177
+ The **Blotch size** dial under Icons and SVG is a share of the glyph rather than
178
+ a px size, because no px size is right for both a 16px icon and a page-wide
179
+ drawing. At 100% every glyph gets one period of the field across it whatever its
180
+ size. Below that the field repeats inside the glyph and the blotches get finer.
181
+ Above it a glyph reads part of one blotch, so the mask thins the whole glyph
182
+ unevenly instead of breaking it up. The fill's blotches stay in px, since a
183
+ component does have a size to state one against.