@motion-proto/live-tokens 0.61.0 → 0.63.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 (83) hide show
  1. package/.claude/skills/live-tokens-adjust-geometry/SKILL.md +4 -4
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +21 -3
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +16 -48
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +10 -1
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +7 -5
  6. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +1 -1
  7. package/.claude/skills/live-tokens-create-component/references/token-naming.md +50 -0
  8. package/.claude/skills/live-tokens-generate-theme/SKILL.md +16 -9
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +8 -6
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +19 -5
  11. package/CHANGELOG.md +114 -0
  12. package/bin/migrate.mjs +6 -2
  13. package/dist-plugin/adjust/index.cjs +1 -1
  14. package/dist-plugin/adjust/index.d.cts +1 -1
  15. package/dist-plugin/adjust/index.d.ts +1 -1
  16. package/dist-plugin/adjust/index.js +1 -1
  17. package/dist-plugin/{chunk-232GZGQU.js → chunk-NDJJORKJ.js} +342 -5
  18. package/dist-plugin/{chunk-Y5CNFSSV.js → chunk-RVE3MNKM.js} +1 -1
  19. package/dist-plugin/{chunk-OIOXU7FR.js → chunk-ZHPX7ZYQ.js} +83 -25
  20. package/dist-plugin/{dataPaths-CRfD1LdA.d.ts → dataPaths-DZUzVv8H.d.cts} +3 -3
  21. package/dist-plugin/{dataPaths-CRfD1LdA.d.cts → dataPaths-DZUzVv8H.d.ts} +3 -3
  22. package/dist-plugin/fontPairing/index.cjs +1 -1
  23. package/dist-plugin/fontPairing/index.d.cts +1 -1
  24. package/dist-plugin/fontPairing/index.d.ts +1 -1
  25. package/dist-plugin/fontPairing/index.js +1 -1
  26. package/dist-plugin/generateColorsAndType/index.cjs +1 -1
  27. package/dist-plugin/generateColorsAndType/index.d.cts +1 -1
  28. package/dist-plugin/generateColorsAndType/index.d.ts +1 -1
  29. package/dist-plugin/generateColorsAndType/index.js +1 -1
  30. package/dist-plugin/index.cjs +461 -58
  31. package/dist-plugin/index.d.cts +1 -1
  32. package/dist-plugin/index.d.ts +1 -1
  33. package/dist-plugin/index.js +31 -23
  34. package/dist-plugin/migrateData/index.cjs +350 -9
  35. package/dist-plugin/migrateData/index.d.cts +1 -1
  36. package/dist-plugin/migrateData/index.d.ts +1 -1
  37. package/dist-plugin/migrateData/index.js +9 -5
  38. package/dist-plugin/tokensCssMigrations/index.cjs +83 -25
  39. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  40. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  41. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  42. package/package.json +4 -2
  43. package/src/app/site.css +32 -0
  44. package/src/editor/component-editor/CardEditor.svelte +7 -1
  45. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  46. package/src/editor/core/preview/lookPreview.ts +46 -5
  47. package/src/editor/core/productionPulse.ts +6 -2
  48. package/src/editor/core/sketch/maskField.ts +75 -39
  49. package/src/editor/core/sketch/sketchLayer.ts +32 -11
  50. package/src/editor/core/sketch/sketchStore.ts +311 -86
  51. package/src/editor/core/sketch/{sketchPresetService.ts → sketchStyleService.ts} +14 -14
  52. package/src/editor/core/sketch/{sketchPresets.ts → sketchStyles.ts} +27 -28
  53. package/src/editor/core/themes/themeDocumentSync.ts +2 -0
  54. package/src/editor/core/themes/themeInit.ts +19 -1
  55. package/src/editor/core/themes/themeService.ts +7 -2
  56. package/src/editor/core/themes/themeTypes.ts +5 -0
  57. package/src/editor/docs/content/editing-tokens.md +1 -1
  58. package/src/editor/docs/content/sketch-mode.md +34 -18
  59. package/src/editor/docs/content/themes-workflow.md +30 -21
  60. package/src/editor/docs/content/where-themes-live.md +9 -5
  61. package/src/editor/docs/content.generated.ts +4 -4
  62. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  63. package/src/editor/ui/EditorViewSwitcher.svelte +3 -3
  64. package/src/editor/ui/ThemePanel.svelte +47 -1
  65. package/src/editor/ui/sections/textStyles.ts +29 -1
  66. package/src/editor/ui/sketch/SketchPreview.svelte +3 -3
  67. package/src/editor/ui/sketch/SketchTab.svelte +120 -134
  68. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  69. package/src/live-tokens/data/themes/autumn.json +3 -3
  70. package/src/live-tokens/data/themes/halloween.json +3 -3
  71. package/src/live-tokens/data/themes/midnight-study.json +61 -49
  72. package/src/live-tokens/data/themes/ocean.json +3 -3
  73. package/src/live-tokens/data/themes/royal-velvet.json +3 -3
  74. package/src/live-tokens/data/themes/sketchy.json +3 -3
  75. package/src/live-tokens/data/themes/spring-meadow.json +3 -3
  76. package/src/live-tokens/data/themes/sunset.json +3 -3
  77. package/src/system/components/Card.svelte +27 -9
  78. package/src/system/components/FloatingTokenTags.css +10 -8
  79. package/src/system/components/ImageLightbox.svelte +5 -2
  80. package/src/system/components/SectionDivider.svelte +3 -3
  81. package/src/system/components/SegmentedControl.svelte +11 -9
  82. package/src/system/styles/tokens.css +28 -5
  83. package/template/src/pages/Home.svelte +1 -11
@@ -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
 
@@ -547,6 +525,84 @@ ${css.slice(at)}`;
547
525
  }
548
526
  };
549
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
+
569
+ // vite-plugin/tokensCssMigrations/migrations/2026-08-27-editorial-large-steps.ts
570
+ var BUNDLE3 = [
571
+ { name: "--editorial-xl-font-family", value: "var(--font-editorial)" },
572
+ { name: "--editorial-xl-font-size", value: "var(--font-size-xl)" },
573
+ { name: "--editorial-xl-font-weight", value: "var(--font-weight-normal)" },
574
+ { name: "--editorial-xl-line-height", value: "var(--line-height-tighter)" },
575
+ { name: "--editorial-xl-letter-spacing", value: "var(--letter-spacing-normal)" },
576
+ { name: "--editorial-lg-font-family", value: "var(--font-editorial)" },
577
+ { name: "--editorial-lg-font-size", value: "var(--font-size-lg)" },
578
+ { name: "--editorial-lg-font-weight", value: "var(--font-weight-normal)" },
579
+ { name: "--editorial-lg-line-height", value: "var(--line-height-tight)" },
580
+ { name: "--editorial-lg-letter-spacing", value: "var(--letter-spacing-normal)" }
581
+ ];
582
+ var tokensCssMigration_2026_08_27_editorialLargeSteps = {
583
+ id: "2026-08-27-editorial-large-steps",
584
+ kind: "additive",
585
+ description: "Add the --editorial-lg-* and --editorial-xl-* steps above the reading size",
586
+ apply(css) {
587
+ return ensureScale(css, {
588
+ sectionComment: "Editorial \u2014 the steps above the reading size",
589
+ anchorPrefixes: [
590
+ "--editorial-",
591
+ "--eyebrow-",
592
+ "--code-",
593
+ "--body-",
594
+ "--heading-",
595
+ "--letter-spacing-",
596
+ "--line-height-",
597
+ "--font-weight-",
598
+ "--font-size-",
599
+ "--font-"
600
+ ],
601
+ entries: BUNDLE3
602
+ });
603
+ }
604
+ };
605
+
550
606
  // vite-plugin/files/dataPaths.ts
551
607
  var import_fs = __toESM(require("fs"), 1);
552
608
  var import_path = __toESM(require("path"), 1);
@@ -593,7 +649,9 @@ var TOKENS_CSS_MIGRATIONS = [
593
649
  tokensCssMigration_2026_08_19_oklchColorValues,
594
650
  tokensCssMigration_2026_08_25_editorialTypeRole,
595
651
  tokensCssMigration_2026_08_26_gradientStops,
596
- tokensCssMigration_2026_08_27_backdropPolarity
652
+ tokensCssMigration_2026_08_27_backdropPolarity,
653
+ tokensCssMigration_2026_08_27_editorialSizeSteps,
654
+ tokensCssMigration_2026_08_27_editorialLargeSteps
597
655
  ];
598
656
  function runTokensCssMigrations(css) {
599
657
  return foldMigrations(css, () => true);
@@ -1,4 +1,4 @@
1
- export { r as readLiveTokensConfig } from '../dataPaths-CRfD1LdA.cjs';
1
+ export { r as readLiveTokensConfig } from '../dataPaths-DZUzVv8H.cjs';
2
2
 
3
3
  /**
4
4
  * A `tokens-css` migration: a pure, idempotent transform on the text of a
@@ -1,4 +1,4 @@
1
- export { r as readLiveTokensConfig } from '../dataPaths-CRfD1LdA.js';
1
+ export { r as readLiveTokensConfig } from '../dataPaths-DZUzVv8H.js';
2
2
 
3
3
  /**
4
4
  * A `tokens-css` migration: a pure, idempotent transform on the text of a
@@ -13,11 +13,11 @@ import {
13
13
  runTokensCssMigrations,
14
14
  semverBumpType,
15
15
  validateTokensCss
16
- } from "../chunk-OIOXU7FR.js";
16
+ } from "../chunk-ZHPX7ZYQ.js";
17
17
  import "../chunk-7TQQTHI6.js";
18
18
  import {
19
19
  readLiveTokensConfig
20
- } from "../chunk-Y5CNFSSV.js";
20
+ } from "../chunk-RVE3MNKM.js";
21
21
  export {
22
22
  TOKENS_CSS_MIGRATIONS,
23
23
  collectDefinedTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -137,13 +137,15 @@
137
137
  "check:token-contract": "node scripts/check-token-contract.mjs",
138
138
  "check:preset-themes": "node scripts/check-preset-themes.mjs",
139
139
  "check:skills": "node scripts/check-skills.mjs",
140
+ "check:skill-atlas": "node scripts/sync-skill-atlas.mjs",
140
141
  "sync:component-defaults": "node scripts/sync-component-defaults.mjs --write",
141
142
  "sync:docs": "node scripts/sync-docs.mjs --write",
143
+ "sync:skill-atlas": "node scripts/sync-skill-atlas.mjs --write",
142
144
  "seed:preset-theme": "node scripts/seed-preset-theme.mjs",
143
145
  "collapse:theme": "node scripts/collapse-theme-to-default.mjs",
144
146
  "check:smoke-install": "bash scripts/smoke-install.sh",
145
147
  "check:smoke-create": "bash scripts/smoke-create.sh",
146
- "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:smoke-install && npm run check:smoke-create"
148
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:no-tooling-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:token-contract && npm run check:preset-themes && npm run check:skills && npm run check:skill-atlas && npm run check:smoke-install && npm run check:smoke-create"
147
149
  },
148
150
  "peerDependencies": {
149
151
  "@sveltejs/vite-plugin-svelte": "^7.0",
package/src/app/site.css CHANGED
@@ -205,6 +205,38 @@ blockquote {
205
205
  letter-spacing: var(--body-sm-letter-spacing);
206
206
  }
207
207
 
208
+ .editorial-xl {
209
+ font-family: var(--editorial-xl-font-family);
210
+ font-size: var(--editorial-xl-font-size);
211
+ font-weight: var(--editorial-xl-font-weight);
212
+ line-height: var(--editorial-xl-line-height);
213
+ letter-spacing: var(--editorial-xl-letter-spacing);
214
+ }
215
+
216
+ .editorial-lg {
217
+ font-family: var(--editorial-lg-font-family);
218
+ font-size: var(--editorial-lg-font-size);
219
+ font-weight: var(--editorial-lg-font-weight);
220
+ line-height: var(--editorial-lg-line-height);
221
+ letter-spacing: var(--editorial-lg-letter-spacing);
222
+ }
223
+
224
+ .editorial-md {
225
+ font-family: var(--editorial-md-font-family);
226
+ font-size: var(--editorial-md-font-size);
227
+ font-weight: var(--editorial-md-font-weight);
228
+ line-height: var(--editorial-md-line-height);
229
+ letter-spacing: var(--editorial-md-letter-spacing);
230
+ }
231
+
232
+ .editorial-sm {
233
+ font-family: var(--editorial-sm-font-family);
234
+ font-size: var(--editorial-sm-font-size);
235
+ font-weight: var(--editorial-sm-font-weight);
236
+ line-height: var(--editorial-sm-line-height);
237
+ letter-spacing: var(--editorial-sm-letter-spacing);
238
+ }
239
+
208
240
  .eyebrow {
209
241
  font-family: var(--eyebrow-font-family);
210
242
  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
  }
@@ -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;
@@ -6,6 +6,7 @@ import { batchCssVarChanges, setCssVar, removeCssVar } from '../cssVarSync';
6
6
  import { applyFontSources } from '../fonts/fontLoader';
7
7
  import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
8
8
  import { loadTheme } from '../themes/themeService';
9
+ import { previewSketchStyle, revertSketchStylePreview } from '../sketch/sketchStore';
9
10
 
10
11
  /**
11
12
  * Client-side rendering of a saved file, for browsing looks without committing
@@ -20,6 +21,11 @@ import { loadTheme } from '../themes/themeService';
20
21
  * of the same theme produces, and a revert re-derives from the live store
21
22
  * rather than replaying DOM values scraped before the preview.
22
23
  *
24
+ * A theme's sketchstyle rides along the same way: `previewTheme` paints it
25
+ * through `previewSketchStyle`, which reaches the page without touching the
26
+ * live sketch buffer, and a revert re-derives from that buffer exactly as the
27
+ * CSS vars do.
28
+ *
23
29
  * One preview is live at a time, whichever kind it is: painting reverts first,
24
30
  * so every look is a diff against the user's real state.
25
31
  */
@@ -76,6 +82,14 @@ export function liveLook(): RenderedLook {
76
82
 
77
83
  let livePreview: RenderedLook | null = null;
78
84
  let defaultsPromise: Promise<Theme> | null = null;
85
+ // Whether the current preview session has a sketchstyle painted over the live
86
+ // buffer, so a colors-only preview (which never previews sketch) and revert
87
+ // know whether there is anything to hand back.
88
+ let sketchPreviewActive = false;
89
+ // Bumped whenever a preview session ends or is superseded. `previewTheme` awaits
90
+ // the defaults theme, and a Cancel landing inside that await was overwritten when
91
+ // it resolved, stranding a preview the picker believed it had already taken down.
92
+ let generation = 0;
79
93
 
80
94
  function loadDefaults(): Promise<Theme> {
81
95
  // A rejected promise must not be memoized, or one failed fetch (a dev-server
@@ -109,30 +123,55 @@ function applyPreview(look: RenderedLook): void {
109
123
  livePreview = look;
110
124
  }
111
125
 
112
- /** Paint a whole look: the theme's colors and type and every component config
113
- * it carries. */
126
+ /** Paint a whole look: the theme's colors and type, every component config
127
+ * it carries, and its sketchstyle — present or not, since a theme with none
128
+ * paints crisp regardless of what is live (invariant 3). */
114
129
  export async function previewTheme(theme: Theme): Promise<void> {
115
- applyPreview(themeLook(theme, await loadDefaults()));
130
+ const gen = generation;
131
+ const defaults = await loadDefaults();
132
+ if (gen !== generation) return;
133
+ applyPreview(themeLook(theme, defaults));
134
+ previewSketchStyle(theme.sketchStyle);
135
+ sketchPreviewActive = true;
116
136
  }
117
137
 
118
- /** Paint colors and type over the components as they stand. */
138
+ /** Paint colors and type over the components as they stand. Not a whole
139
+ * look, so the sketchstyle stays live too — reverting a sketch preview a
140
+ * prior row left painted, if one is running. */
119
141
  export function previewColorsAndType(colorsAndType: ColorsAndType): void {
142
+ generation++;
120
143
  applyPreview(colorsAndTypeLook(colorsAndType));
144
+ if (sketchPreviewActive) {
145
+ revertSketchStylePreview();
146
+ sketchPreviewActive = false;
147
+ }
121
148
  }
122
149
 
123
150
  /** Restore the live editor state. No-op when no preview is running. */
124
151
  export function revertPreview(): void {
152
+ generation++;
125
153
  if (!livePreview) return;
126
154
  paint(liveLook(), livePreview);
127
155
  livePreview = null;
156
+ if (sketchPreviewActive) {
157
+ revertSketchStylePreview();
158
+ sketchPreviewActive = false;
159
+ }
128
160
  }
129
161
 
130
162
  /** Release the preview without repainting. The caller must immediately load
131
163
  * the exact look being previewed into the store. This is the Save handoff: the
132
164
  * selected theme is already on screen, so restoring the old live look before
133
- * applying it would add work and create a visible flash across the request. */
165
+ * applying it would add work and create a visible flash across the request.
166
+ * The sketchstyle stays painted for the same reason, though the handoff is not
167
+ * quite free: when the live state was crisp, `openThemeSketchStyle` writes the
168
+ * settings before the flag, so the sheet and the filter bank come down and go
169
+ * straight back up in between. That order is still the right one, since writing
170
+ * the flag first would paint the OLD dials for a frame. */
134
171
  export function commitPreview(): void {
172
+ generation++;
135
173
  livePreview = null;
174
+ sketchPreviewActive = false;
136
175
  }
137
176
 
138
177
  export function isPreviewing(): boolean {
@@ -141,6 +180,8 @@ export function isPreviewing(): boolean {
141
180
 
142
181
  /** Test-only: drop the live preview and the cached defaults theme. */
143
182
  export function __resetPreviewForTests(): void {
183
+ generation++;
144
184
  livePreview = null;
145
185
  defaultsPromise = null;
186
+ sketchPreviewActive = false;
146
187
  }
@@ -35,8 +35,12 @@ export function bumpComponentActiveRevision(): void {
35
35
  * component editor's Adopt has to clear it for the panel.
36
36
  *
37
37
  * Set by the client writes that move the live look (`writeWorkingColorsAndType`,
38
- * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`); cleared by
39
- * `adoptLook`. Module-level, so it survives the remounts a view switch causes.
38
+ * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`, and every
39
+ * sketch gesture that changes what the effect paints: `setSketchEnabled`,
40
+ * `updateSketchSettings`, `selectSketchStyle`, `selectSavedSketchStyle`);
41
+ * cleared by `adoptLook` and by `hydrateAppliedTheme`, on this document and on
42
+ * every peer an Apply broadcasts to. Module-level, so it survives the
43
+ * remounts a view switch causes.
40
44
  */
41
45
  export const liveMovedSinceBake = writable(false);
42
46
 
@@ -8,14 +8,18 @@
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.
17
21
  */
18
- import type { SketchSettings } from './sketchPresets';
22
+ import type { SketchStyle } from './sketchStyles';
19
23
 
20
24
  /** What the tile spans in page px. Blobs are fitted a whole number to the
21
25
  tile, which is what lets the lattice wrap; the dial's px reading is the
@@ -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
 
@@ -107,7 +111,7 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
107
111
  * wherever the octave crossed zero, which is the marbled look; plain sum is
108
112
  * cloud.
109
113
  */
110
- function rawField(s: SketchSettings, seed: number, raster: number, cells: number): Float32Array {
114
+ function rawField(s: SketchStyle, seed: number, raster: number, cells: number): Float32Array {
111
115
  const noise = makePerlin(seed);
112
116
  const veined = s.maskGrain === 'turbulence';
113
117
  const octaves = Math.max(1, Math.round(s.maskOctaves));
@@ -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;
@@ -222,7 +256,7 @@ function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
222
256
  all four; the levels, the posterising and the blur are one pass each. */
223
257
  let rawCache: { key: string; field: Float32Array } | null = null;
224
258
 
225
- function cachedRaw(s: SketchSettings, seed: number): Float32Array {
259
+ function cachedRaw(s: SketchStyle, seed: number): Float32Array {
226
260
  const key = [s.maskBlob, s.maskOctaves, s.maskGrain, seed].join('|');
227
261
  if (rawCache?.key !== key) {
228
262
  const cells = Math.max(1, Math.round(MASK_TILE / s.maskBlob));
@@ -234,11 +268,13 @@ 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
- s: SketchSettings, seed = 9, through?: MaskStage,
277
+ s: SketchStyle, seed = 9, through?: MaskStage,
242
278
  ): { field: Float32Array; raster: number } {
243
279
  const raw = cachedRaw(s, seed);
244
280
  if (through === 'noise') return { field: raw, raster: RASTER };
@@ -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
  }
@@ -370,7 +406,7 @@ const CACHE_MAX = 6;
370
406
  const cache = new Map<string, string>();
371
407
 
372
408
  /** The field as a `url(...)` for `mask-image`. */
373
- export function buildMaskUri(s: SketchSettings, seed = 9, through?: MaskStage): string {
409
+ export function buildMaskUri(s: SketchStyle, seed = 9, through?: MaskStage): string {
374
410
  const key = [...KEYS.map((k) => s[k]), seed, through ?? 'all'].join('|');
375
411
  const hit = cache.get(key);
376
412
  if (hit) return hit;