@motion-proto/live-tokens 0.60.1 → 0.62.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 (49) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +1 -1
  2. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +1 -1
  3. package/CHANGELOG.md +107 -0
  4. package/dist-plugin/{chunk-XWXIMTWZ.js → chunk-7TQQTHI6.js} +10 -1
  5. package/dist-plugin/{chunk-LW4SR7AZ.js → chunk-O6GQ6GBH.js} +1 -1
  6. package/dist-plugin/{chunk-E5QYON4L.js → chunk-OPYOK2CA.js} +77 -26
  7. package/dist-plugin/generateColorsAndType/index.js +2 -2
  8. package/dist-plugin/index.cjs +179 -25
  9. package/dist-plugin/index.js +99 -4
  10. package/dist-plugin/tokensCssMigrations/index.cjs +76 -25
  11. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  12. package/package.json +6 -1
  13. package/src/app/site.css +16 -0
  14. package/src/editor/component-editor/CardEditor.svelte +7 -1
  15. package/src/editor/component-editor/ImageLightboxEditor.svelte +2 -1
  16. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  17. package/src/editor/core/sketch/maskField.ts +70 -34
  18. package/src/editor/core/sketch/sketchLayer.ts +24 -3
  19. package/src/editor/core/sketch/sketchPresets.ts +10 -11
  20. package/src/editor/core/sketch/sketchStore.ts +89 -18
  21. package/src/editor/docs/Docs.svelte +1 -0
  22. package/src/editor/docs/chapters.ts +1 -0
  23. package/src/editor/docs/content/light-and-dark.md +90 -0
  24. package/src/editor/docs/content/sketch-mode.md +7 -2
  25. package/src/editor/docs/content.generated.ts +2 -1
  26. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  27. package/src/editor/ui/sections/textStyles.ts +15 -1
  28. package/src/editor/ui/sketch/SketchTab.svelte +33 -81
  29. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  30. package/src/live-tokens/data/themes/autumn.json +4 -3
  31. package/src/live-tokens/data/themes/halloween.json +4 -3
  32. package/src/live-tokens/data/themes/midnight-study.json +62 -49
  33. package/src/live-tokens/data/themes/ocean.json +4 -3
  34. package/src/live-tokens/data/themes/royal-velvet.json +4 -3
  35. package/src/live-tokens/data/themes/sketchy.json +4 -3
  36. package/src/live-tokens/data/themes/spring-meadow.json +4 -3
  37. package/src/live-tokens/data/themes/sunset.json +4 -3
  38. package/src/live-tokens/data/tokens.generated.css +5 -0
  39. package/src/system/backdrop/backdrop.ts +197 -0
  40. package/src/system/backdrop/index.ts +16 -0
  41. package/src/system/backdrop/watch.ts +139 -0
  42. package/src/system/components/Card.svelte +27 -9
  43. package/src/system/components/FloatingTokenTags.svelte +1 -1
  44. package/src/system/components/ImageLightbox.svelte +8 -4
  45. package/src/system/components/SectionDivider.svelte +3 -3
  46. package/src/system/components/SegmentedControl.svelte +11 -9
  47. package/src/system/styles/tokens.css +33 -5
  48. package/template/src/pages/Home.svelte +1 -11
  49. package/src/system/internal/backgroundContrast.ts +0 -62
@@ -5,7 +5,7 @@ import {
5
5
  parseGradientValue,
6
6
  reconcilePalettesFromCssVars,
7
7
  sanitizeFileName
8
- } from "./chunk-LW4SR7AZ.js";
8
+ } from "./chunk-O6GQ6GBH.js";
9
9
  import {
10
10
  THEME_SCHEMA_VERSION,
11
11
  detectLegacyLayout,
@@ -23,10 +23,12 @@ import {
23
23
  runAdditiveTokensCssMigrations,
24
24
  runTokensCssMigrations,
25
25
  validateTokensCss
26
- } from "./chunk-E5QYON4L.js";
26
+ } from "./chunk-OPYOK2CA.js";
27
27
  import {
28
- hexToOklch
29
- } from "./chunk-XWXIMTWZ.js";
28
+ hexToOklch,
29
+ oklchToRgb255,
30
+ parseOklchCss
31
+ } from "./chunk-7TQQTHI6.js";
30
32
  import {
31
33
  resolveDataDirs
32
34
  } from "./chunk-Y5CNFSSV.js";
@@ -55,6 +57,87 @@ function migratePaletteColorsToOklch(editorConfigs) {
55
57
  return out;
56
58
  }
57
59
 
60
+ // src/system/backdrop/backdrop.ts
61
+ var LIGHT_BACKDROP = 0.2;
62
+ var OPAQUE = 0.98;
63
+ var HEX_RE = /#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
64
+ var OKLCH_RE = /oklch\(\s*[\d.]+%?\s+[\d.]+%?\s+[\d.]+(?:deg)?\s*(?:\/\s*([\d.]+%?)\s*)?\)/gi;
65
+ var RGB_RE = /rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/gi;
66
+ var EXOTIC_RE = /\b(?:color-mix|hwb|lab|lch|oklab|color)\([^()]*\)/gi;
67
+ function channel(value) {
68
+ const parsed = Number.parseFloat(value);
69
+ return value.endsWith("%") ? parsed * 2.55 : parsed;
70
+ }
71
+ function alpha(value) {
72
+ if (value === void 0) return 1;
73
+ const parsed = Number.parseFloat(value);
74
+ return value.endsWith("%") ? parsed / 100 : parsed;
75
+ }
76
+ function parseHex(hex) {
77
+ const digits = hex.slice(1);
78
+ const full = digits.length <= 4 ? digits.split("").map((part) => part + part).join("") : digits;
79
+ return {
80
+ r: Number.parseInt(full.slice(0, 2), 16),
81
+ g: Number.parseInt(full.slice(2, 4), 16),
82
+ b: Number.parseInt(full.slice(4, 6), 16),
83
+ a: full.length >= 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1
84
+ };
85
+ }
86
+ function colorsIn(background) {
87
+ const colors = [];
88
+ for (const match of background.matchAll(HEX_RE)) colors.push(parseHex(match[0]));
89
+ for (const match of background.matchAll(OKLCH_RE)) {
90
+ const parsed = parseOklchCss(match[0]);
91
+ if (parsed) colors.push({ ...oklchToRgb255(parsed.l, parsed.c, parsed.h), a: alpha(match[1]) });
92
+ }
93
+ for (const match of background.matchAll(RGB_RE)) {
94
+ colors.push({
95
+ r: channel(match[1]),
96
+ g: channel(match[2]),
97
+ b: channel(match[3]),
98
+ a: alpha(match[4])
99
+ });
100
+ }
101
+ for (const match of background.matchAll(EXOTIC_RE)) {
102
+ const resolved = viaCanvas(match[0]);
103
+ if (resolved) colors.push(resolved);
104
+ }
105
+ return colors;
106
+ }
107
+ function linearChannel(value) {
108
+ const srgb = Math.max(0, Math.min(255, value)) / 255;
109
+ return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
110
+ }
111
+ function luminance({ r, g, b }) {
112
+ return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
113
+ }
114
+ var SEED = "#010203";
115
+ var probe;
116
+ function viaCanvas(value) {
117
+ if (typeof document === "undefined") return null;
118
+ if (probe === void 0) {
119
+ probe = document.createElement("canvas").getContext?.("2d", { willReadFrequently: true }) ?? null;
120
+ }
121
+ if (!probe) return null;
122
+ probe.fillStyle = SEED;
123
+ probe.fillStyle = value;
124
+ if (probe.fillStyle === SEED) return null;
125
+ probe.clearRect(0, 0, 1, 1);
126
+ probe.fillRect(0, 0, 1, 1);
127
+ const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
128
+ return { r, g, b, a: a / 255 };
129
+ }
130
+ function backgroundLuminance(background) {
131
+ const opaque = colorsIn(background).filter((color) => color.a >= OPAQUE);
132
+ if (!opaque.length) return null;
133
+ return opaque.reduce((total, color) => total + luminance(color), 0) / opaque.length;
134
+ }
135
+ function polarityOfBackground(background) {
136
+ const measured = backgroundLuminance(background);
137
+ if (measured === null) return null;
138
+ return measured >= LIGHT_BACKDROP ? "light" : "dark";
139
+ }
140
+
58
141
  // vite-plugin/files/routeTable.ts
59
142
  async function dispatch(req, res, routes) {
60
143
  const url = req.url || "";
@@ -277,6 +360,7 @@ function themeFileApi(opts) {
277
360
  }
278
361
  const colorsAndTypeData = productionTheme?.colorsAndType;
279
362
  let colorsAndTypeVarCount = 0;
363
+ let pageBackground = "";
280
364
  if (colorsAndTypeData) {
281
365
  const cssVars = { ...colorsAndTypeData.cssVariables || {} };
282
366
  Object.assign(cssVars, palettesToVars(migratePaletteColorsToOklch(colorsAndTypeData.editorConfigs ?? {})));
@@ -284,6 +368,7 @@ function themeFileApi(opts) {
284
368
  for (const [name, value] of Object.entries(resolvedFontVars)) {
285
369
  cssVars[name] = value;
286
370
  }
371
+ pageBackground = cssVars["--page-bg"] ?? "";
287
372
  colorsAndTypeVarCount = Object.keys(cssVars).length;
288
373
  if (colorsAndTypeVarCount > 0) {
289
374
  lines.push(`/* Production theme: ${productionThemeName} */`);
@@ -295,6 +380,16 @@ function themeFileApi(opts) {
295
380
  lines.push("");
296
381
  }
297
382
  }
383
+ {
384
+ const polarity = pageBackground ? polarityOfBackground(pageBackground) : null;
385
+ if (polarity) {
386
+ lines.push(`/* Page polarity, from --page-bg */`);
387
+ lines.push(":where(:root) {");
388
+ lines.push(` color-scheme: ${polarity};`);
389
+ lines.push("}");
390
+ lines.push("");
391
+ }
392
+ }
298
393
  let componentOverrideCount = 0;
299
394
  if (fs.existsSync(COMPONENT_CONFIGS_DIR)) {
300
395
  const blocks = [];
@@ -464,37 +464,15 @@ var tokensCssMigration_2026_08_19_oklchColorValues = {
464
464
 
465
465
  // vite-plugin/tokensCssMigrations/migrations/2026-08-25-editorial-type-role.ts
466
466
  var STACK = [{ name: "--font-editorial", value: "var(--font-sans)" }];
467
- var BUNDLE2 = [
468
- { name: "--editorial-font-family", value: "var(--font-editorial)" },
469
- { name: "--editorial-font-size", value: "var(--font-size-md)" },
470
- { name: "--editorial-font-weight", value: "var(--font-weight-normal)" },
471
- { name: "--editorial-line-height", value: "var(--line-height-normal)" },
472
- { name: "--editorial-letter-spacing", value: "var(--letter-spacing-normal)" }
473
- ];
474
467
  var tokensCssMigration_2026_08_25_editorialTypeRole = {
475
468
  id: "2026-08-25-editorial-type-role",
476
469
  kind: "additive",
477
- description: "Add the editorial type role (--font-editorial and the --editorial-* bundle)",
470
+ description: "Add the editorial type role (--font-editorial)",
478
471
  apply(css) {
479
- const withStack = ensureScale(css, {
472
+ return ensureScale(css, {
480
473
  anchorPrefixes: ["--font-mono", "--font-serif", "--font-sans", "--font-display"],
481
474
  entries: STACK
482
475
  });
483
- return ensureScale(withStack, {
484
- sectionComment: "Editorial \u2014 the long-reading role, body face until repointed",
485
- anchorPrefixes: [
486
- "--eyebrow-",
487
- "--code-",
488
- "--body-",
489
- "--heading-",
490
- "--letter-spacing-",
491
- "--line-height-",
492
- "--font-weight-",
493
- "--font-size-",
494
- "--font-"
495
- ],
496
- entries: BUNDLE2
497
- });
498
476
  }
499
477
  };
500
478
 
@@ -517,6 +495,77 @@ var tokensCssMigration_2026_08_26_gradientStops = {
517
495
  }
518
496
  };
519
497
 
498
+ // vite-plugin/tokensCssMigrations/migrations/2026-08-27-backdrop-polarity.ts
499
+ var MARKER = "[data-backdrop='dark']";
500
+ var RULES = `/* BACKDROP POLARITY \u2014 one attribute says which way a surface leans. State it
501
+ in markup (\`<section data-backdrop="dark">\`) or let \`use:backdrop\` measure
502
+ and stamp it. Either way \`light-dark()\` inside resolves the right half, and
503
+ a rule can key on the attribute for what colour alone cannot carry. */
504
+
505
+ [data-backdrop='light'] {
506
+ color-scheme: light;
507
+ }
508
+
509
+ [data-backdrop='dark'] {
510
+ color-scheme: dark;
511
+ }
512
+ `;
513
+ var tokensCssMigration_2026_08_27_backdropPolarity = {
514
+ id: "2026-08-27-backdrop-polarity",
515
+ kind: "additive",
516
+ description: "Add the [data-backdrop] \u2192 color-scheme rules",
517
+ apply(css) {
518
+ if (css.includes(MARKER)) return css;
519
+ const at = css.indexOf(":root");
520
+ if (at === -1) return `${css.trimEnd()}
521
+
522
+ ${RULES}`;
523
+ return `${css.slice(0, at)}${RULES}
524
+ ${css.slice(at)}`;
525
+ }
526
+ };
527
+
528
+ // vite-plugin/tokensCssMigrations/migrations/2026-08-27-editorial-size-steps.ts
529
+ var AXES = ["font-family", "font-size", "font-weight", "line-height", "letter-spacing"];
530
+ var BUNDLE2 = [
531
+ { name: "--editorial-md-font-family", value: "var(--font-editorial)" },
532
+ { name: "--editorial-md-font-size", value: "var(--font-size-md)" },
533
+ { name: "--editorial-md-font-weight", value: "var(--font-weight-normal)" },
534
+ { name: "--editorial-md-line-height", value: "var(--line-height-normal)" },
535
+ { name: "--editorial-md-letter-spacing", value: "var(--letter-spacing-normal)" },
536
+ { name: "--editorial-sm-font-family", value: "var(--font-editorial)" },
537
+ { name: "--editorial-sm-font-size", value: "var(--font-size-sm)" },
538
+ { name: "--editorial-sm-font-weight", value: "var(--font-weight-normal)" },
539
+ { name: "--editorial-sm-line-height", value: "var(--line-height-tight)" },
540
+ { name: "--editorial-sm-letter-spacing", value: "var(--letter-spacing-normal)" }
541
+ ];
542
+ var tokensCssMigration_2026_08_27_editorialSizeSteps = {
543
+ id: "2026-08-27-editorial-size-steps",
544
+ kind: "breaking",
545
+ description: "Split the editorial bundle into --editorial-md-* and --editorial-sm-* steps",
546
+ apply(css) {
547
+ let out = css;
548
+ for (const axis of AXES) {
549
+ out = renameToken(out, `--editorial-${axis}`, `--editorial-md-${axis}`);
550
+ }
551
+ return ensureScale(out, {
552
+ sectionComment: "Editorial \u2014 the long-reading role, body face until repointed",
553
+ anchorPrefixes: [
554
+ "--eyebrow-",
555
+ "--code-",
556
+ "--body-",
557
+ "--heading-",
558
+ "--letter-spacing-",
559
+ "--line-height-",
560
+ "--font-weight-",
561
+ "--font-size-",
562
+ "--font-"
563
+ ],
564
+ entries: BUNDLE2
565
+ });
566
+ }
567
+ };
568
+
520
569
  // vite-plugin/files/dataPaths.ts
521
570
  var import_fs = __toESM(require("fs"), 1);
522
571
  var import_path = __toESM(require("path"), 1);
@@ -562,7 +611,9 @@ var TOKENS_CSS_MIGRATIONS = [
562
611
  tokensCssMigration_2026_07_20_semanticTextStyles,
563
612
  tokensCssMigration_2026_08_19_oklchColorValues,
564
613
  tokensCssMigration_2026_08_25_editorialTypeRole,
565
- tokensCssMigration_2026_08_26_gradientStops
614
+ tokensCssMigration_2026_08_26_gradientStops,
615
+ tokensCssMigration_2026_08_27_backdropPolarity,
616
+ tokensCssMigration_2026_08_27_editorialSizeSteps
566
617
  ];
567
618
  function runTokensCssMigrations(css) {
568
619
  return foldMigrations(css, () => true);
@@ -13,8 +13,8 @@ import {
13
13
  runTokensCssMigrations,
14
14
  semverBumpType,
15
15
  validateTokensCss
16
- } from "../chunk-E5QYON4L.js";
17
- import "../chunk-XWXIMTWZ.js";
16
+ } from "../chunk-OPYOK2CA.js";
17
+ import "../chunk-7TQQTHI6.js";
18
18
  import {
19
19
  readLiveTokensConfig
20
20
  } from "../chunk-Y5CNFSSV.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.60.1",
3
+ "version": "0.62.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -90,6 +90,11 @@
90
90
  "svelte": "./src/editor/docs/Docs.svelte",
91
91
  "default": "./src/editor/docs/Docs.svelte"
92
92
  },
93
+ "./backdrop": {
94
+ "svelte": "./src/system/backdrop/index.ts",
95
+ "types": "./src/system/backdrop/index.ts",
96
+ "default": "./src/system/backdrop/index.ts"
97
+ },
93
98
  "./components/*": {
94
99
  "svelte": "./src/system/components/*",
95
100
  "default": "./src/system/components/*"
package/src/app/site.css CHANGED
@@ -205,6 +205,22 @@ blockquote {
205
205
  letter-spacing: var(--body-sm-letter-spacing);
206
206
  }
207
207
 
208
+ .editorial-md {
209
+ font-family: var(--editorial-md-font-family);
210
+ font-size: var(--editorial-md-font-size);
211
+ font-weight: var(--editorial-md-font-weight);
212
+ line-height: var(--editorial-md-line-height);
213
+ letter-spacing: var(--editorial-md-letter-spacing);
214
+ }
215
+
216
+ .editorial-sm {
217
+ font-family: var(--editorial-sm-font-family);
218
+ font-size: var(--editorial-sm-font-size);
219
+ font-weight: var(--editorial-sm-font-weight);
220
+ line-height: var(--editorial-sm-line-height);
221
+ letter-spacing: var(--editorial-sm-letter-spacing);
222
+ }
223
+
208
224
  .eyebrow {
209
225
  font-family: var(--eyebrow-font-family);
210
226
  font-size: var(--eyebrow-font-size);
@@ -134,7 +134,7 @@
134
134
  }
135
135
  </script>
136
136
 
137
- <ComponentEditorBase {component} title="Card" description="Generic card with icon, title, and slotted body." tokens={allTokens} {linked}>
137
+ <ComponentEditorBase {component} title="Card" description="Generic card with icon, title, and slotted body. The bare variant drops the header and keeps every frame and body token." tokens={allTokens} {linked}>
138
138
  <VariantGroup
139
139
  name="card"
140
140
  title="Card"
@@ -165,6 +165,9 @@
165
165
  <Card title="Card title" icon="fas fa-star" class={previewClass}>
166
166
  <div class="content-placeholder">Content Placeholder</div>
167
167
  </Card>
168
+ <Card variant="bare" class={previewClass}>
169
+ <div class="content-placeholder">Bare variant</div>
170
+ </Card>
168
171
  </div>
169
172
  {/snippet}
170
173
  </VariantGroup>
@@ -172,6 +175,9 @@
172
175
 
173
176
  <style>
174
177
  .card-demo {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: var(--ui-space-12);
175
181
  min-width: 16rem;
176
182
  max-width: 28rem;
177
183
  }
@@ -8,6 +8,7 @@
8
8
  // for the interactive controls. No second variant.
9
9
  const states: Record<string, Token[]> = {
10
10
  tile: [
11
+ { label: 'fill color', groupKey: 'surface', variable: '--imagelightbox-tile-surface' },
11
12
  { label: 'corner radius', groupKey: 'radius', variable: '--imagelightbox-tile-radius' },
12
13
  { label: 'border color', groupKey: 'border', variable: '--imagelightbox-tile-border' },
13
14
  { label: 'border width', groupKey: 'width', variable: '--imagelightbox-tile-border-width' },
@@ -50,7 +51,7 @@
50
51
  <ComponentEditorBase
51
52
  {component}
52
53
  title="Image Lightbox"
53
- description="Click an inline image to expand it into a centered modal with a backdrop. Pass multiple images for a gallery (chevrons + counter). Extended mode adds zoom controls and drag panning. `shadow` picks where the one tile-shadow token falls: `box` casts it from the tile rectangle, `content` from the image's own alpha, or `none`."
54
+ description="Click an inline image to expand it into a centered modal with a backdrop. Pass multiple images for a gallery (chevrons + counter). Extended mode adds zoom controls and drag panning. `shadow` picks where the one tile-shadow token falls: `box` casts it from the tile rectangle, `content` from the image's own alpha, or `none`. The tile fill sits behind the image in both states: none by default, so cut-out art floats on the page, and any fill you set frames it instead."
54
55
  tokens={allTokens}
55
56
  >
56
57
  <VariantGroup name="imagelightbox" title="Image Lightbox" {states} {component}>
@@ -11,6 +11,8 @@
11
11
  import type { CssVarRef } from '../../core/store/editorTypes';
12
12
  import { cssStringToRef, refToCss } from '../../core/store/cssVarRef';
13
13
  import { getEditorContext } from './editorContext';
14
+ import { setSketchScope } from '../../core/sketch/sketchLayer';
15
+ import { sketchEnabled, sketchSettings } from '../../core/sketch/sketchStore';
14
16
  import type { Token, TypeGroupConfig } from './types';
15
17
  import type { Sibling } from './siblings';
16
18
 
@@ -84,6 +86,15 @@
84
86
  let bgMode: 'default' | 'image' | 'color' = $state('default');
85
87
  let bgVar = $derived(`--backdrop-${component ?? name}-surface`);
86
88
 
89
+ // The demo shows the component as the page will draw it, sketch included, so
90
+ // the scope attribute goes on this wrapper. It stops here: the tab strips and
91
+ // property rows around it are chrome and run on --ui-* tokens the layer knows
92
+ // nothing about. Only one of the four render sites below is ever mounted.
93
+ let sketchStage = $state<HTMLDivElement | undefined>(undefined);
94
+ $effect(() => {
95
+ setSketchScope(sketchStage ?? null, $sketchEnabled ? $sketchSettings : null);
96
+ });
97
+
87
98
  const editorCtx = getEditorContext();
88
99
  const linkedOrderStore = editorCtx?.linkedOrder ?? writable<Map<string, number> | null>(null);
89
100
  const focusedVariantStore = editorCtx?.focusedVariant ?? writable<string | null>(null);
@@ -397,7 +408,9 @@
397
408
  {/if}
398
409
  </div>
399
410
  {#if unboxedPreview}
400
- {@render children?.({ activeState: activeTab })}
411
+ <div class="sketch-scope" bind:this={sketchStage}>
412
+ {@render children?.({ activeState: activeTab })}
413
+ </div>
401
414
  {:else}
402
415
  <ShadowBackdrop mode={bgMode} colorVariable={bgVar} padding={backdropPadding}>
403
416
  {#snippet controls()}
@@ -407,7 +420,9 @@
407
420
  {@render canvasToolbarExtras?.()}
408
421
  </div>
409
422
  {/snippet}
423
+ <div class="sketch-scope" bind:this={sketchStage}>
410
424
  {@render children?.({ activeState: activeTab })}
425
+ </div>
411
426
  </ShadowBackdrop>
412
427
  {/if}
413
428
 
@@ -508,10 +523,14 @@
508
523
  {/if}
509
524
  {:else}
510
525
  {#if unboxedPreview}
511
- {@render children?.({ activeState: '' })}
526
+ <div class="sketch-scope" bind:this={sketchStage}>
527
+ {@render children?.({ activeState: '' })}
528
+ </div>
512
529
  {:else}
513
530
  <ShadowBackdrop mode={bgMode} colorVariable={bgVar} padding={backdropPadding}>
531
+ <div class="sketch-scope" bind:this={sketchStage}>
514
532
  {@render children?.({ activeState: '' })}
533
+ </div>
515
534
  </ShadowBackdrop>
516
535
  {/if}
517
536
  <TokenLayout
@@ -572,6 +591,10 @@
572
591
  pointer-events: none;
573
592
  }
574
593
 
594
+ .sketch-scope {
595
+ display: contents;
596
+ }
597
+
575
598
  .preview-header {
576
599
  display: flex;
577
600
  align-items: center;
@@ -8,9 +8,13 @@
8
8
  * narrow band around its own midpoint (about 0.24 to 0.77 for fractal noise,
9
9
  * whatever the octaves), so a dial that walks a cut across 0 to 1 spends most
10
10
  * of its travel outside the field entirely and the little that lands inside
11
- * comes out as one flat mid-grey. Generated here, the tile is stretched onto
12
- * its own measured range before any dial sees it, so Min and Max always have
13
- * the whole field to work with and always read as levels.
11
+ * comes out as one flat mid-grey.
12
+ *
13
+ * Generated here, the tile is auto-levelled before any dial sees it, and the
14
+ * dials that follow are a levels control: Steps quantises the field, Output
15
+ * squeezes it into the density range the mask paints between. Neither ever
16
+ * clips: the field arrives running black to white and every tone survives to
17
+ * the other end in the order it started.
14
18
  *
15
19
  * Everything is integer maths on a Float32Array with no DOM, so what a test
16
20
  * asserts is what the browser paints.
@@ -29,7 +33,7 @@ const SAMPLE_PX = 2;
29
33
  const RASTER = MASK_TILE / SAMPLE_PX;
30
34
 
31
35
  /** The stage previews, in the order the field is built. */
32
- export type MaskStage = 'noise' | 'output' | 'blur';
36
+ export type MaskStage = 'noise' | 'levels' | 'blur';
33
37
 
34
38
  /* ---------------------------------------------------------------- noise --- */
35
39
 
@@ -128,41 +132,55 @@ function rawField(s: SketchSettings, seed: number, raster: number, cells: number
128
132
  out[y * raster + x] = sum / weightSum;
129
133
  }
130
134
  }
131
- return normalise(out);
135
+ return equalise(out);
132
136
  }
133
137
 
138
+ const BINS = 4096;
139
+
134
140
  /**
135
- * Stretch the field onto 0..1 by its own 0.5th and 99.5th percentiles.
141
+ * Auto levels: map the field through its own cumulative distribution, so tone
142
+ * is spread evenly from black to white whatever the noise underneath.
136
143
  *
137
- * This is the difference between levels that work and a grey wash. Noise of any
138
- * kind piles up around its middle, and how far it reaches depends on the grain
139
- * and the octave count, so a fixed range would be wrong for every setting but
140
- * one. Measured per tile, Min and Max always address a field that runs black to
141
- * white, and the two dials mean the same thing at every other setting.
144
+ * A black-and-white-point stretch is not enough on its own. Noise piles up
145
+ * around its middle and the veined fold piles it up at the bottom — half a
146
+ * one-layer veined tile sits under 0.29 — so a tile stretched by its extremes
147
+ * still reads as one dark wash, and every dial downstream cuts the range at
148
+ * points most of the field is nowhere near. Equalised, the median lands at 0.5
149
+ * and each fifth of the range holds a fifth of the tile, so a handle at 30
150
+ * addresses the darkest 30% of the field at every grain and octave count.
142
151
  *
143
- * The percentiles rather than the extremes, so one freak pixel cannot flatten
144
- * the rest of the tile.
152
+ * Piecewise-linear through a histogram rather than by rank, so the mapping
153
+ * stays smooth and cannot band a gradient it is meant to spread.
145
154
  */
146
- function normalise(f: Float32Array): Float32Array {
147
- const sorted = Float32Array.from(f).sort();
148
- const lo = sorted[Math.floor(0.005 * (sorted.length - 1))];
149
- const hi = sorted[Math.ceil(0.995 * (sorted.length - 1))];
155
+ function equalise(f: Float32Array): Float32Array {
156
+ let lo = Infinity, hi = -Infinity;
157
+ for (const v of f) { if (v < lo) lo = v; if (v > hi) hi = v; }
150
158
  const span = hi - lo;
151
159
  if (span <= 1e-6) return f.fill(0.5);
152
- for (let i = 0; i < f.length; i++) f[i] = Math.min(1, Math.max(0, (f[i] - lo) / span));
160
+
161
+ const count = new Float64Array(BINS);
162
+ for (const v of f) count[Math.min(BINS - 1, Math.floor(((v - lo) / span) * BINS))]++;
163
+ const below = new Float64Array(BINS + 1);
164
+ for (let b = 0; b < BINS; b++) below[b + 1] = below[b] + count[b];
165
+
166
+ for (let i = 0; i < f.length; i++) {
167
+ const t = ((f[i] - lo) / span) * BINS;
168
+ const b = Math.min(BINS - 1, Math.floor(t));
169
+ f[i] = (below[b] + (t - b) * count[b]) / f.length;
170
+ }
153
171
  return f;
154
172
  }
155
173
 
156
174
  /* ------------------------------------------------------------- levelling --- */
157
175
 
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.
176
+ /** Output levels, the same squeeze Photoshop's output handles apply. The whole
177
+ field is stretched into the gap between them, so the pair states the range
178
+ the mask paints: nothing is barer than Min or denser than Max, and every
179
+ tone in between keeps its place in the order.
161
180
 
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. */
181
+ Never a cut. Clipping to the handles instead would make Min the value most
182
+ of the field sits AT rather than its floor, and the fill would come out a
183
+ flat wash at the floor with a thin bright tail above it. */
166
184
  function applyOutput(f: Float32Array, min: number, max: number): Float32Array {
167
185
  const span = max - min;
168
186
  for (let i = 0; i < f.length; i++) f[i] = min + f[i] * span;
@@ -178,14 +196,30 @@ function posterise(f: Float32Array, steps: number): Float32Array {
178
196
  return f;
179
197
  }
180
198
 
181
- /** Three box passes, wrapped at the tile edges so the blur cannot draw a rim
182
- where the tile repeats.
199
+ /** Wrapped box blur, so the blur cannot draw a rim where the tile repeats.
183
200
 
184
- The width is the one the filter spec derives for three boxes to land on a
185
- gaussian of a given deviation, so the dial's px are the px it gets. */
201
+ Three boxes at the width the filter spec derives land on a gaussian of a
202
+ given deviation, which is what makes the dial's px the px it gets. But a box
203
+ radius is a whole number of samples and a sample is two page px, so rounding
204
+ to one put the dial on a 2px ladder with a dead zone at the bottom: every
205
+ setting under 2.5px came out perfectly sharp, and everything from 2.5 to
206
+ 4.2px came out identical. Mixing the two radii either side of the exact one
207
+ puts the dial back on a continuous scale, and radius zero is the field
208
+ itself, so the bottom of the travel eases in instead of switching on. */
186
209
  function blur(f: Float32Array, raster: number, std: number): Float32Array {
187
- const radius = Math.round((std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2);
188
- if (radius < 1) return f;
210
+ const exact = (std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2;
211
+ if (exact <= 0) return f;
212
+ const lower = Math.floor(exact);
213
+ const mix = exact - lower;
214
+ const low = lower < 1 ? f : boxes(f, raster, lower);
215
+ if (mix < 1e-6) return low;
216
+ const high = boxes(f, raster, lower + 1);
217
+ const out = new Float32Array(f.length);
218
+ for (let i = 0; i < f.length; i++) out[i] = low[i] + (high[i] - low[i]) * mix;
219
+ return out;
220
+ }
221
+
222
+ function boxes(f: Float32Array, raster: number, radius: number): Float32Array {
189
223
  let cur = f;
190
224
  for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, raster, radius);
191
225
  return cur;
@@ -234,8 +268,10 @@ function cachedRaw(s: SketchSettings, seed: number): Float32Array {
234
268
  /**
235
269
  * The finished field, 0 (bare) to 1 (inked), row-major.
236
270
  *
237
- * Three stages and no fourth: the last one IS the result, so the strip of
238
- * previews in the tab accounts for the whole of what the dials do.
271
+ * `through` stops the pipeline early. Nothing in the app asks for a part of it
272
+ * — the tab shows the finished field and nothing else — but the levels are
273
+ * three passes over one array, and the seam is where the tests read what each
274
+ * pass did.
239
275
  */
240
276
  export function buildMaskField(
241
277
  s: SketchSettings, seed = 9, through?: MaskStage,
@@ -246,7 +282,7 @@ export function buildMaskField(
246
282
  const levelled = applyOutput(
247
283
  posterise(Float32Array.from(raw), s.maskPosterize), s.maskOutputMin, s.maskOutputMax,
248
284
  );
249
- if (through === 'output') return { field: levelled, raster: RASTER };
285
+ if (through === 'levels') return { field: levelled, raster: RASTER };
250
286
 
251
287
  return { field: blur(levelled, RASTER, s.maskSoftness / SAMPLE_PX), raster: RASTER };
252
288
  }