@motion-proto/live-tokens 0.56.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/live-tokens-pair-fonts/SKILL.md +2 -2
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/bin/cli.mjs +2 -1
- package/dist-plugin/adjust/index.cjs +1 -0
- package/dist-plugin/adjust/index.d.cts +2 -2
- package/dist-plugin/adjust/index.d.ts +2 -2
- package/dist-plugin/adjust/index.js +1 -1
- package/dist-plugin/{chunk-TKZBVIW5.js → chunk-E5QYON4L.js} +59 -2
- package/dist-plugin/{chunk-PB2JTK2H.js → chunk-LW4SR7AZ.js} +39 -6
- package/dist-plugin/{chunk-YR6GPXW2.js → chunk-XWXIMTWZ.js} +0 -5
- package/dist-plugin/{chunk-D3ZVKOR4.js → chunk-Y5CNFSSV.js} +1 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.cts → dataPaths-CRfD1LdA.d.cts} +3 -0
- package/dist-plugin/{dataPaths-DBN0RPuT.d.ts → dataPaths-CRfD1LdA.d.ts} +3 -0
- package/dist-plugin/fontPairing/index.cjs +4 -2
- package/dist-plugin/fontPairing/index.d.cts +3 -3
- package/dist-plugin/fontPairing/index.d.ts +3 -3
- package/dist-plugin/fontPairing/index.js +4 -3
- package/dist-plugin/generateColorsAndType/index.cjs +22 -9
- package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
- package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
- package/dist-plugin/generateColorsAndType/index.js +8 -5
- package/dist-plugin/index.cjs +158 -9
- package/dist-plugin/index.d.cts +1 -1
- package/dist-plugin/index.d.ts +1 -1
- package/dist-plugin/index.js +71 -4
- package/dist-plugin/migrateData/index.cjs +1 -0
- package/dist-plugin/migrateData/index.d.cts +1 -1
- package/dist-plugin/migrateData/index.d.ts +1 -1
- package/dist-plugin/migrateData/index.js +1 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.cts → themeTypes-DSV3Zisf.d.cts} +12 -1
- package/dist-plugin/{themeTypes-BAqtv4XO.d.ts → themeTypes-DSV3Zisf.d.ts} +12 -1
- package/dist-plugin/tokensCssMigrations/index.cjs +58 -1
- package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
- package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
- package/dist-plugin/tokensCssMigrations/index.js +3 -3
- package/package.json +3 -1
- package/src/editor/core/fonts/applyFontPairing.ts +3 -2
- package/src/editor/core/fonts/fontLoader.ts +1 -0
- package/src/editor/core/fonts/fontMigration.ts +31 -1
- package/src/editor/core/palettes/paletteDerivation.ts +9 -2
- package/src/editor/core/sketch/maskField.ts +381 -0
- package/src/editor/core/sketch/sketchLayer.ts +1028 -0
- package/src/editor/core/sketch/sketchPresetService.ts +65 -0
- package/src/editor/core/sketch/sketchPresets.ts +328 -0
- package/src/editor/core/sketch/sketchStore.ts +190 -0
- package/src/editor/core/store/editorPersistence.ts +28 -12
- package/src/editor/core/store/editorTypes.ts +4 -1
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/gradientSource.ts +15 -1
- package/src/editor/core/themes/parsers/gradient.ts +62 -4
- package/src/editor/core/themes/slices/gradients.ts +75 -14
- package/src/editor/core/themes/themeTypes.ts +6 -1
- package/src/editor/docs/Docs.svelte +4 -3
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/01-overview.md +2 -1
- package/src/editor/docs/content/editing-tokens.md +5 -1
- package/src/editor/docs/content/sketch-mode.md +89 -0
- package/src/editor/docs/content.generated.ts +3 -2
- package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
- package/src/editor/pages/EditorShell.svelte +43 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
- package/src/editor/ui/FontStackEditor.svelte +3 -0
- package/src/editor/ui/GradientEditor.svelte +38 -1
- package/src/editor/ui/UIReveal.svelte +7 -1
- package/src/editor/ui/UISegmentedControl.svelte +4 -8
- package/src/editor/ui/sections/GradientsSection.svelte +47 -1
- package/src/editor/ui/sketch/SketchDial.svelte +124 -0
- package/src/editor/ui/sketch/SketchPreview.svelte +119 -0
- package/src/editor/ui/sketch/SketchRange.svelte +185 -0
- package/src/editor/ui/sketch/SketchTab.svelte +1263 -0
- package/src/live-tokens/data/colors-and-type/autumn.json +17 -0
- package/src/live-tokens/data/colors-and-type/default.json +17 -0
- package/src/live-tokens/data/colors-and-type/halloween.json +17 -0
- package/src/live-tokens/data/colors-and-type/midnight-study.json +17 -0
- package/src/live-tokens/data/colors-and-type/ocean.json +17 -0
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +17 -0
- package/src/live-tokens/data/colors-and-type/sketches.json +2520 -0
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +17 -0
- package/src/live-tokens/data/colors-and-type/sunset.json +17 -0
- package/src/live-tokens/data/themes/autumn.json +17 -0
- package/src/live-tokens/data/themes/halloween.json +17 -0
- package/src/live-tokens/data/themes/midnight-study.json +17 -0
- package/src/live-tokens/data/themes/ocean.json +17 -0
- package/src/live-tokens/data/themes/royal-velvet.json +17 -0
- package/src/live-tokens/data/themes/sketches.json +3984 -0
- package/src/live-tokens/data/themes/spring-meadow.json +17 -0
- package/src/live-tokens/data/themes/sunset.json +17 -0
- package/src/live-tokens/data/tokens.generated.css +1 -0
- package/src/system/styles/tokens.css +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
|
|
6
6
|
"keywords": [
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"src/live-tokens/data/colors-and-type/midnight-study.json",
|
|
31
31
|
"src/live-tokens/data/colors-and-type/ocean.json",
|
|
32
32
|
"src/live-tokens/data/colors-and-type/royal-velvet.json",
|
|
33
|
+
"src/live-tokens/data/colors-and-type/sketches.json",
|
|
33
34
|
"src/live-tokens/data/colors-and-type/spring-meadow.json",
|
|
34
35
|
"src/live-tokens/data/colors-and-type/sunset.json",
|
|
35
36
|
"src/live-tokens/data/themes/autumn.json",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"src/live-tokens/data/themes/midnight-study.json",
|
|
38
39
|
"src/live-tokens/data/themes/ocean.json",
|
|
39
40
|
"src/live-tokens/data/themes/royal-velvet.json",
|
|
41
|
+
"src/live-tokens/data/themes/sketches.json",
|
|
40
42
|
"src/live-tokens/data/themes/spring-meadow.json",
|
|
41
43
|
"src/live-tokens/data/themes/sunset.json",
|
|
42
44
|
"dist-plugin",
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
import { parseGoogleFontsUrl } from './fontParse';
|
|
9
9
|
import { familyIndex, resolvedFaceName } from './fontPairing';
|
|
10
10
|
|
|
11
|
-
export type PairingSlot = 'display' | 'body' | 'serif' | 'mono';
|
|
11
|
+
export type PairingSlot = 'display' | 'body' | 'serif' | 'mono' | 'editorial';
|
|
12
12
|
|
|
13
13
|
export interface PairingFace {
|
|
14
14
|
name: string;
|
|
@@ -44,13 +44,14 @@ export interface FontPairingResult {
|
|
|
44
44
|
|
|
45
45
|
/** Slot order is also the order stamped sources land in `fontSources`;
|
|
46
46
|
* `check:preset-themes` reads the preset pair back out positionally. */
|
|
47
|
-
export const SLOT_ORDER: PairingSlot[] = ['display', 'body', 'serif', 'mono'];
|
|
47
|
+
export const SLOT_ORDER: PairingSlot[] = ['display', 'body', 'serif', 'mono', 'editorial'];
|
|
48
48
|
|
|
49
49
|
export const SLOT_VARIABLES: Record<PairingSlot, FontStackVariable> = {
|
|
50
50
|
display: '--font-display',
|
|
51
51
|
body: '--font-sans',
|
|
52
52
|
serif: '--font-serif',
|
|
53
53
|
mono: '--font-mono',
|
|
54
|
+
editorial: '--font-editorial',
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export function slugifyFamily(name: string): string {
|
|
@@ -115,9 +115,37 @@ export function defaultFontStacks(sources: FontSource[]): FontStack[] {
|
|
|
115
115
|
{ kind: 'generic', value: 'monospace' },
|
|
116
116
|
],
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
variable: '--font-editorial',
|
|
120
|
+
slots: [
|
|
121
|
+
...pick('Manrope'),
|
|
122
|
+
{ kind: 'system', preset: 'system-ui-sans' },
|
|
123
|
+
{ kind: 'generic', value: 'sans-serif' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
118
126
|
];
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Seed `--font-editorial` from `--font-sans` on files written before the
|
|
131
|
+
* editorial role existed. The role ships as "the body face until a consumer
|
|
132
|
+
* repoints it", so cloning the body stack is the migration: a theme that never
|
|
133
|
+
* touches editorial keeps rendering exactly as it did.
|
|
134
|
+
*
|
|
135
|
+
* Presence-based like the rest of this module, so re-running is a no-op and no
|
|
136
|
+
* schemaVersion step is needed.
|
|
137
|
+
*/
|
|
138
|
+
function ensureEditorialStack(stacks: FontStack[]): boolean {
|
|
139
|
+
if (stacks.some((s) => s.variable === '--font-editorial')) return false;
|
|
140
|
+
const sans = stacks.find((s) => s.variable === '--font-sans');
|
|
141
|
+
if (!sans) return false;
|
|
142
|
+
stacks.push({
|
|
143
|
+
variable: '--font-editorial',
|
|
144
|
+
slots: sans.slots.map((slot) => ({ ...slot })),
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// Older themes have `kind: 'font-face'` sources with embedded cssText that
|
|
122
150
|
// references local woff2 files. The url() resolution for those embedded refs
|
|
123
151
|
// is fragile in consumers, so swap any font-face source whose cssText is for
|
|
@@ -161,9 +189,11 @@ export function migrateColorsAndTypeFonts(colorsAndType: ColorsAndType): { migra
|
|
|
161
189
|
if (!colorsAndType.fontStacks || colorsAndType.fontStacks.length === 0) {
|
|
162
190
|
colorsAndType.fontStacks = defaultFontStacks(colorsAndType.fontSources);
|
|
163
191
|
migrated = true;
|
|
192
|
+
} else if (ensureEditorialStack(colorsAndType.fontStacks)) {
|
|
193
|
+
migrated = true;
|
|
164
194
|
}
|
|
165
195
|
if (colorsAndType.cssVariables) {
|
|
166
|
-
for (const key of ['--font-display', '--font-sans', '--font-serif', '--font-mono']) {
|
|
196
|
+
for (const key of ['--font-display', '--font-sans', '--font-serif', '--font-mono', '--font-editorial']) {
|
|
167
197
|
if (key in colorsAndType.cssVariables) {
|
|
168
198
|
delete colorsAndType.cssVariables[key];
|
|
169
199
|
migrated = true;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* derives the same CSS vars from the persisted config.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { cssColorToOklch,
|
|
16
|
+
import { cssColorToOklch, oklchToCss, gamutClamp, type Oklch } from './oklch';
|
|
17
17
|
import { type CurveAnchor, sampleCurve, makeAnchor, tangentAnchor, resmoothAutoCurve } from '../../ui/curveEngine';
|
|
18
18
|
import type { PaletteConfig } from '../themes/themeTypes';
|
|
19
19
|
|
|
@@ -204,7 +204,14 @@ export type DerivedValue =
|
|
|
204
204
|
* then serialized as `oklch()`. Idempotent on the already-in-gamut per-step
|
|
205
205
|
* derivation output. `raw` passes through untouched. */
|
|
206
206
|
export function serializeDerivedValue(value: DerivedValue): string {
|
|
207
|
-
|
|
207
|
+
// Emitted unclamped: the stored basis is OKLCH, and clamping into sRGB threw
|
|
208
|
+
// away chroma a wide-gamut display can actually show. Values already inside
|
|
209
|
+
// sRGB serialize identically (`gamutClamp` returns them untouched), so this
|
|
210
|
+
// moves nothing in an existing theme; only intent authored beyond sRGB — a
|
|
211
|
+
// Tailwind v4 ramp, say — now survives to the browser, which does its own
|
|
212
|
+
// gamut mapping at paint. `oklchToCssClamped` still backs the hex readouts,
|
|
213
|
+
// where a clamped projection is the whole point.
|
|
214
|
+
return value.kind === 'raw' ? value.css : oklchToCss(value.l, value.c, value.h);
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
export function computePaletteOklch(
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ink coverage field: a Perlin noise tile, levelled, posterised and
|
|
3
|
+
* blurred, handed out as a greyscale PNG that CSS masks with `mask-mode:
|
|
4
|
+
* luminance` — white keeps the fill, black erases it.
|
|
5
|
+
*
|
|
6
|
+
* The field is built here rather than by `feTurbulence` because the filter
|
|
7
|
+
* primitive's output is not a value anyone can reason about: it lands in a
|
|
8
|
+
* narrow band around its own midpoint (about 0.24 to 0.77 for fractal noise,
|
|
9
|
+
* whatever the octaves), so a dial that walks a cut across 0 to 1 spends most
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* Everything is integer maths on a Float32Array with no DOM, so what a test
|
|
16
|
+
* asserts is what the browser paints.
|
|
17
|
+
*/
|
|
18
|
+
import type { SketchSettings } from './sketchPresets';
|
|
19
|
+
|
|
20
|
+
/** What the tile spans in page px. Blobs are fitted a whole number to the
|
|
21
|
+
tile, which is what lets the lattice wrap; the dial's px reading is the
|
|
22
|
+
nearest whole fit, never more than a few px off what it says. */
|
|
23
|
+
export const MASK_TILE = 600;
|
|
24
|
+
|
|
25
|
+
/** Page px per sample. The field is blobs and gradients, so painting it at half
|
|
26
|
+
resolution and letting the browser scale it up costs nothing visible and
|
|
27
|
+
quarters both the work and the data URI. */
|
|
28
|
+
const SAMPLE_PX = 2;
|
|
29
|
+
const RASTER = MASK_TILE / SAMPLE_PX;
|
|
30
|
+
|
|
31
|
+
/** The stage previews, in the order the field is built. */
|
|
32
|
+
export type MaskStage = 'noise' | 'output' | 'blur';
|
|
33
|
+
|
|
34
|
+
/* ---------------------------------------------------------------- noise --- */
|
|
35
|
+
|
|
36
|
+
function mulberry32(seed: number): () => number {
|
|
37
|
+
let a = seed >>> 0;
|
|
38
|
+
return () => {
|
|
39
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
40
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
41
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
42
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Perlin noise on a lattice that wraps every `period` cells, so a tile drawn
|
|
48
|
+
* from it meets itself at both edges. Returns roughly -0.7 to 0.7.
|
|
49
|
+
*
|
|
50
|
+
* Hand-rolled because the wrap is the whole requirement and no established
|
|
51
|
+
* noise package offers one: `simplex-noise` and its neighbours all sample an
|
|
52
|
+
* endless plane. The usual way to tile them is to walk two circles through 4D
|
|
53
|
+
* noise, and it degenerates at the ratio this effect works at — a tile only
|
|
54
|
+
* four or five blobs across makes those circles smaller than one noise feature,
|
|
55
|
+
* and the field comes back as a regular grid of dots instead of cloud. A
|
|
56
|
+
* lattice takes its period as an integer and wraps exactly at any size.
|
|
57
|
+
*
|
|
58
|
+
* Gradients are drawn from the whole circle, one per lattice point, rather than
|
|
59
|
+
* from the eight compass directions the textbook version uses. Eight leaves
|
|
60
|
+
* every blob squared off against the axes.
|
|
61
|
+
*/
|
|
62
|
+
const GRAD_COUNT = 256;
|
|
63
|
+
|
|
64
|
+
function makePerlin(seed: number): (x: number, y: number, period: number) => number {
|
|
65
|
+
const rand = mulberry32(seed);
|
|
66
|
+
const perm = new Uint8Array(512);
|
|
67
|
+
const src = new Uint8Array(256);
|
|
68
|
+
for (let i = 0; i < 256; i++) src[i] = i;
|
|
69
|
+
for (let i = 255; i > 0; i--) {
|
|
70
|
+
const j = Math.floor(rand() * (i + 1));
|
|
71
|
+
const t = src[i]; src[i] = src[j]; src[j] = t;
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < 512; i++) perm[i] = src[i & 255];
|
|
74
|
+
|
|
75
|
+
const gx = new Float32Array(GRAD_COUNT), gy = new Float32Array(GRAD_COUNT);
|
|
76
|
+
for (let i = 0; i < GRAD_COUNT; i++) {
|
|
77
|
+
const a = rand() * Math.PI * 2;
|
|
78
|
+
gx[i] = Math.cos(a); gy[i] = Math.sin(a);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
|
|
82
|
+
const lerp = (t: number, a: number, b: number) => a + t * (b - a);
|
|
83
|
+
|
|
84
|
+
return (x, y, period) => {
|
|
85
|
+
const x0 = Math.floor(x), y0 = Math.floor(y);
|
|
86
|
+
const xf = x - x0, yf = y - y0;
|
|
87
|
+
const p = Math.max(1, Math.round(period));
|
|
88
|
+
const xi = ((x0 % p) + p) % p, yi = ((y0 % p) + p) % p;
|
|
89
|
+
const xi1 = (xi + 1) % p, yi1 = (yi + 1) % p;
|
|
90
|
+
|
|
91
|
+
const g = (cx: number, cy: number, dx: number, dy: number) => {
|
|
92
|
+
const h = perm[(perm[cx & 255] + cy) & 255];
|
|
93
|
+
return gx[h] * dx + gy[h] * dy;
|
|
94
|
+
};
|
|
95
|
+
const u = fade(xf), v = fade(yf);
|
|
96
|
+
return lerp(v,
|
|
97
|
+
lerp(u, g(xi, yi, xf, yf), g(xi1, yi, xf - 1, yf)),
|
|
98
|
+
lerp(u, g(xi, yi1, xf, yf - 1), g(xi1, yi1, xf - 1, yf - 1)));
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The raw field: octaves of Perlin, each half the size and half the weight of
|
|
104
|
+
* the one before, stretched onto its own measured range.
|
|
105
|
+
*
|
|
106
|
+
* `veined` folds each octave at zero before summing. The fold puts a crease
|
|
107
|
+
* wherever the octave crossed zero, which is the marbled look; plain sum is
|
|
108
|
+
* cloud.
|
|
109
|
+
*/
|
|
110
|
+
function rawField(s: SketchSettings, seed: number, raster: number, cells: number): Float32Array {
|
|
111
|
+
const noise = makePerlin(seed);
|
|
112
|
+
const veined = s.maskGrain === 'turbulence';
|
|
113
|
+
const octaves = Math.max(1, Math.round(s.maskOctaves));
|
|
114
|
+
const out = new Float32Array(raster * raster);
|
|
115
|
+
const step = cells / raster;
|
|
116
|
+
|
|
117
|
+
let weightSum = 0;
|
|
118
|
+
for (let o = 0; o < octaves; o++) weightSum += 1 / (1 << o);
|
|
119
|
+
|
|
120
|
+
for (let y = 0; y < raster; y++) {
|
|
121
|
+
for (let x = 0; x < raster; x++) {
|
|
122
|
+
let sum = 0;
|
|
123
|
+
for (let o = 0; o < octaves; o++) {
|
|
124
|
+
const f = 1 << o;
|
|
125
|
+
const n = noise(x * step * f, y * step * f, cells * f);
|
|
126
|
+
sum += (veined ? Math.abs(n) : n) / f;
|
|
127
|
+
}
|
|
128
|
+
out[y * raster + x] = sum / weightSum;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return normalise(out);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stretch the field onto 0..1 by its own 0.5th and 99.5th percentiles.
|
|
136
|
+
*
|
|
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.
|
|
142
|
+
*
|
|
143
|
+
* The percentiles rather than the extremes, so one freak pixel cannot flatten
|
|
144
|
+
* the rest of the tile.
|
|
145
|
+
*/
|
|
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))];
|
|
150
|
+
const span = hi - lo;
|
|
151
|
+
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));
|
|
153
|
+
return f;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------- levelling --- */
|
|
157
|
+
|
|
158
|
+
/** Output levels. The field's darkest point lands at Min and its brightest at
|
|
159
|
+
Max, so nothing is barer than the low handle or denser than the high one.
|
|
160
|
+
Input levels cut the field instead, and since the field is stretched to run
|
|
161
|
+
from black, any low handle above zero punched a hole through the fill. */
|
|
162
|
+
function applyOutput(f: Float32Array, min: number, max: number): Float32Array {
|
|
163
|
+
const span = max - min;
|
|
164
|
+
for (let i = 0; i < f.length; i++) f[i] = min + f[i] * span;
|
|
165
|
+
return f;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function posterise(f: Float32Array, steps: number): Float32Array {
|
|
169
|
+
if (steps < 2) return f;
|
|
170
|
+
for (let i = 0; i < f.length; i++) {
|
|
171
|
+
const band = Math.min(steps - 1, Math.floor(f[i] * steps));
|
|
172
|
+
f[i] = band / (steps - 1);
|
|
173
|
+
}
|
|
174
|
+
return f;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Three box passes, wrapped at the tile edges so the blur cannot draw a rim
|
|
178
|
+
where the tile repeats.
|
|
179
|
+
|
|
180
|
+
The width is the one the filter spec derives for three boxes to land on a
|
|
181
|
+
gaussian of a given deviation, so the dial's px are the px it gets. */
|
|
182
|
+
function blur(f: Float32Array, raster: number, std: number): Float32Array {
|
|
183
|
+
const radius = Math.round((std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2);
|
|
184
|
+
if (radius < 1) return f;
|
|
185
|
+
let cur = f;
|
|
186
|
+
for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, raster, radius);
|
|
187
|
+
return cur;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
|
|
191
|
+
const mid = new Float32Array(f.length);
|
|
192
|
+
const width = radius * 2 + 1;
|
|
193
|
+
for (let y = 0; y < n; y++) {
|
|
194
|
+
const row = y * n;
|
|
195
|
+
let sum = 0;
|
|
196
|
+
for (let i = -radius; i <= radius; i++) sum += f[row + ((i % n) + n) % n];
|
|
197
|
+
for (let x = 0; x < n; x++) {
|
|
198
|
+
mid[row + x] = sum / width;
|
|
199
|
+
sum += f[row + ((x + radius + 1) % n)] - f[row + ((x - radius + n) % n)];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const out = new Float32Array(f.length);
|
|
203
|
+
for (let x = 0; x < n; x++) {
|
|
204
|
+
let sum = 0;
|
|
205
|
+
for (let i = -radius; i <= radius; i++) sum += mid[(((i % n) + n) % n) * n + x];
|
|
206
|
+
for (let y = 0; y < n; y++) {
|
|
207
|
+
out[y * n + x] = sum / width;
|
|
208
|
+
sum += mid[((y + radius + 1) % n) * n + x] - mid[((y - radius + n) % n) * n + x];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ---------------------------------------------------------------- field --- */
|
|
215
|
+
|
|
216
|
+
/** The raw field, kept across calls. The tab asks for the same tile at four
|
|
217
|
+
different stages on every dial move, and the noise is the expensive part of
|
|
218
|
+
all four; the levels, the posterising and the blur are one pass each. */
|
|
219
|
+
let rawCache: { key: string; field: Float32Array } | null = null;
|
|
220
|
+
|
|
221
|
+
function cachedRaw(s: SketchSettings, seed: number): Float32Array {
|
|
222
|
+
const key = [s.maskBlob, s.maskOctaves, s.maskGrain, seed].join('|');
|
|
223
|
+
if (rawCache?.key !== key) {
|
|
224
|
+
const cells = Math.max(1, Math.round(MASK_TILE / s.maskBlob));
|
|
225
|
+
rawCache = { key, field: rawField(s, seed, RASTER, cells) };
|
|
226
|
+
}
|
|
227
|
+
return rawCache.field;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The finished field, 0 (bare) to 1 (inked), row-major.
|
|
232
|
+
*
|
|
233
|
+
* Three stages and no fourth: the last one IS the result, so the strip of
|
|
234
|
+
* previews in the tab accounts for the whole of what the dials do.
|
|
235
|
+
*/
|
|
236
|
+
export function buildMaskField(
|
|
237
|
+
s: SketchSettings, seed = 9, through?: MaskStage,
|
|
238
|
+
): { field: Float32Array; raster: number } {
|
|
239
|
+
const raw = cachedRaw(s, seed);
|
|
240
|
+
if (through === 'noise') return { field: raw, raster: RASTER };
|
|
241
|
+
|
|
242
|
+
const levelled = posterise(
|
|
243
|
+
applyOutput(Float32Array.from(raw), s.maskOutputMin, s.maskOutputMax), s.maskPosterize,
|
|
244
|
+
);
|
|
245
|
+
if (through === 'output') return { field: levelled, raster: RASTER };
|
|
246
|
+
|
|
247
|
+
return { field: blur(levelled, RASTER, s.maskSoftness / SAMPLE_PX), raster: RASTER };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------ png --- */
|
|
251
|
+
|
|
252
|
+
const CRC_TABLE = (() => {
|
|
253
|
+
const t = new Int32Array(256);
|
|
254
|
+
for (let n = 0; n < 256; n++) {
|
|
255
|
+
let c = n;
|
|
256
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
257
|
+
t[n] = c;
|
|
258
|
+
}
|
|
259
|
+
return t;
|
|
260
|
+
})();
|
|
261
|
+
|
|
262
|
+
function crc32(bytes: Uint8Array): number {
|
|
263
|
+
let c = -1;
|
|
264
|
+
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
265
|
+
return (c ^ -1) >>> 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function adler32(bytes: Uint8Array): number {
|
|
269
|
+
let a = 1, b = 0;
|
|
270
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
271
|
+
a = (a + bytes[i]) % 65521;
|
|
272
|
+
b = (b + a) % 65521;
|
|
273
|
+
}
|
|
274
|
+
return ((b << 16) | a) >>> 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Deflate's stored form: no compression, which needs no compressor. A field
|
|
278
|
+
this smooth would give a real deflate most of its size back, but nothing in
|
|
279
|
+
the platform offers one synchronously, and the alternative — the browser's
|
|
280
|
+
own PNG encoder — is a canvas, which no test can reach. */
|
|
281
|
+
function storedDeflate(raw: Uint8Array): Uint8Array {
|
|
282
|
+
const MAX = 65535;
|
|
283
|
+
const blocks = Math.max(1, Math.ceil(raw.length / MAX));
|
|
284
|
+
const out = new Uint8Array(2 + raw.length + blocks * 5 + 4);
|
|
285
|
+
let p = 0;
|
|
286
|
+
out[p++] = 0x78; out[p++] = 0x01;
|
|
287
|
+
for (let i = 0; i < blocks; i++) {
|
|
288
|
+
const start = i * MAX;
|
|
289
|
+
const len = Math.min(MAX, raw.length - start);
|
|
290
|
+
out[p++] = i === blocks - 1 ? 1 : 0;
|
|
291
|
+
out[p++] = len & 0xff; out[p++] = (len >> 8) & 0xff;
|
|
292
|
+
out[p++] = ~len & 0xff; out[p++] = (~len >> 8) & 0xff;
|
|
293
|
+
out.set(raw.subarray(start, start + len), p);
|
|
294
|
+
p += len;
|
|
295
|
+
}
|
|
296
|
+
const sum = adler32(raw);
|
|
297
|
+
out[p++] = (sum >>> 24) & 0xff; out[p++] = (sum >>> 16) & 0xff;
|
|
298
|
+
out[p++] = (sum >>> 8) & 0xff; out[p++] = sum & 0xff;
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
303
|
+
|
|
304
|
+
function base64(bytes: Uint8Array): string {
|
|
305
|
+
let out = '';
|
|
306
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
307
|
+
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
|
308
|
+
out += BASE64[a >> 2];
|
|
309
|
+
out += BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)];
|
|
310
|
+
out += i + 1 < bytes.length ? BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)] : '=';
|
|
311
|
+
out += i + 2 < bytes.length ? BASE64[c & 63] : '=';
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function chunk(type: string, data: Uint8Array): Uint8Array {
|
|
317
|
+
const body = new Uint8Array(4 + data.length);
|
|
318
|
+
for (let i = 0; i < 4; i++) body[i] = type.charCodeAt(i);
|
|
319
|
+
body.set(data, 4);
|
|
320
|
+
const out = new Uint8Array(body.length + 8);
|
|
321
|
+
const view = new DataView(out.buffer);
|
|
322
|
+
view.setUint32(0, data.length);
|
|
323
|
+
out.set(body, 4);
|
|
324
|
+
view.setUint32(out.length - 4, crc32(body));
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** An 8-bit greyscale PNG of the field. */
|
|
329
|
+
export function fieldToPng(field: Float32Array, raster: number): string {
|
|
330
|
+
const raw = new Uint8Array((raster + 1) * raster);
|
|
331
|
+
for (let y = 0; y < raster; y++) {
|
|
332
|
+
raw[y * (raster + 1)] = 0;
|
|
333
|
+
for (let x = 0; x < raster; x++) {
|
|
334
|
+
const v = field[y * raster + x];
|
|
335
|
+
raw[y * (raster + 1) + 1 + x] = Math.round(Math.min(1, Math.max(0, v)) * 255);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const ihdr = new Uint8Array(13);
|
|
339
|
+
const view = new DataView(ihdr.buffer);
|
|
340
|
+
view.setUint32(0, raster);
|
|
341
|
+
view.setUint32(4, raster);
|
|
342
|
+
ihdr[8] = 8; // bit depth
|
|
343
|
+
ihdr[9] = 0; // greyscale
|
|
344
|
+
const parts = [
|
|
345
|
+
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
346
|
+
chunk('IHDR', ihdr),
|
|
347
|
+
chunk('IDAT', storedDeflate(raw)),
|
|
348
|
+
chunk('IEND', new Uint8Array(0)),
|
|
349
|
+
];
|
|
350
|
+
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
351
|
+
let at = 0;
|
|
352
|
+
for (const p of parts) { png.set(p, at); at += p.length; }
|
|
353
|
+
return `data:image/png;base64,${base64(png)}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Keyed on the dials the field is built from, so the fill, the icons and the
|
|
357
|
+
preview share one build and a dial that has nothing to do with coverage
|
|
358
|
+
never triggers another. The tab holds four of them at once, the field at
|
|
359
|
+
three stages beside the finished one, with headroom over that. */
|
|
360
|
+
const KEYS = [
|
|
361
|
+
'maskBlob', 'maskOctaves', 'maskGrain', 'maskOutputMin', 'maskOutputMax',
|
|
362
|
+
'maskPosterize', 'maskSoftness',
|
|
363
|
+
] as const;
|
|
364
|
+
|
|
365
|
+
const CACHE_MAX = 6;
|
|
366
|
+
const cache = new Map<string, string>();
|
|
367
|
+
|
|
368
|
+
/** The field as a `url(...)` for `mask-image`. */
|
|
369
|
+
export function buildMaskUri(s: SketchSettings, seed = 9, through?: MaskStage): string {
|
|
370
|
+
const key = [...KEYS.map((k) => s[k]), seed, through ?? 'all'].join('|');
|
|
371
|
+
const hit = cache.get(key);
|
|
372
|
+
if (hit) return hit;
|
|
373
|
+
|
|
374
|
+
const { field, raster } = buildMaskField(s, seed, through);
|
|
375
|
+
// Single quotes: base64 has none of its own, and the value has to survive
|
|
376
|
+
// being set from an inline style attribute as well as from a stylesheet.
|
|
377
|
+
const uri = `url('${fieldToPng(field, raster)}')`;
|
|
378
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);
|
|
379
|
+
cache.set(key, uri);
|
|
380
|
+
return uri;
|
|
381
|
+
}
|