@zakkster/lite-color-engine 1.3.0 → 1.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,207 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-07-13
4
+
5
+ ### Added
6
+
7
+ - **Blue-noise dither kernels** — shared kernel for the whole ecosystem
8
+ (studio, hueforge, gradient consumers) so there is exactly one
9
+ implementation to reason about:
10
+ - `getBlueNoise64()` — returns a shared, read-only `Uint8Array(4096)`
11
+ holding a 64x64 void-and-cluster blue-noise tile. Lazily decoded from
12
+ an inlined base64 blob on first call (sub-millisecond); subsequent
13
+ calls return the same reference. Histogram is exactly uniform (each
14
+ byte 0..255 appears 16 times). Index via
15
+ `tile[((y & 63) << 6) | (x & 63)]`; the tile is torus-tileable.
16
+ Generator committed as `tools/generate-bluenoise.mjs` with a fixed
17
+ seed. The tile is gated on its *spectral* properties, not just its
18
+ fingerprint — see `test/bluenoise-spectral.test.js`.
19
+ - `packOklchBufferToUint32Dithered(buf, off, alpha = 1.0, noise01 = 0.5)`
20
+ — scalar dithered packer. Threshold offset applied in gamma-encoded space, with
21
+ the same `noise01` shared across R, G, B (luminance-patterned dither,
22
+ no chroma speckle). Alpha is undithered. Parity guarantee:
23
+ `noise01 === 0.5` reproduces the byte output of
24
+ `packOklchBufferToUint32` bit-for-bit.
25
+ - `packOklchBufferToUint32IntoNDithered(src, offSrc, dst, offDst, n,
26
+ alpha, noiseTile, x0, y0, rowWidth)` — batch variant that walks the
27
+ tile in row-major order over an arbitrary destination rectangle. Zero
28
+ allocations; no modulo/division in the hot loop.
29
+
30
+ **Honest note on `useLut`:** the batch dithered packer does *not* accept
31
+ the 4k transfer LUT flag. `SRGB_LUT` stores rounded bytes, which
32
+ discards the sub-integer information the dither threshold shift needs;
33
+ enabling it would silently collapse dither to plain rounding. A
34
+ companion `Float32Array` LUT of encoded floats (~16 KB, lazy) is a
35
+ v1.6 candidate that would restore this axis.
36
+
37
+ - **Batch P3 packer:** `packOklchBufferToUint32P3IntoN(src, offSrc, dst,
38
+ offDst, n, alpha?, useLut?)` — P3 sibling of the v1.3 sRGB batch. The
39
+ P3 transfer function is identical to sRGB (IEC 61966-2-1), so the same
40
+ `SRGB_LUT` is reused when `useLut=true` — no separate 4 KB table is
41
+ allocated. Same accuracy story as the sRGB LUT path (within 1 LSB of
42
+ exact) and same fast-packer throughput class.
43
+
44
+ - **Batch MINDE:** `gamutMapToSrgbBufferN(inBuf, off, outBuf, offOut, n)`
45
+ (on the `./gamut` subpath) — batch sibling of the scalar MINDE mapper.
46
+ Bit-for-bit identical to `n` scalar calls; the batch amortizes call
47
+ overhead for authoring / LUT-building large color runs (hueforge P3
48
+ exporters, studio bake paths) that today loop the scalar. Setup-time /
49
+ bake-time convenience — MINDE stays out of the per-frame hot path.
50
+
51
+ - **Cyclic LUT bake:** `bakeGradientToUint32` gained a trailing `opts`
52
+ parameter with `{ closed: true }`. In closed mode, stops are treated
53
+ cyclically (last stop wraps back to first), sample positions become
54
+ `i / resolution` (period spacing, no duplicated endpoint), and
55
+ `easeFn` outputs are period-wrapped via `t - floor(t)` instead of
56
+ clamped. Pairs with `sampleColorLUTWrapped` on the consumer side. The
57
+ seam continuity is asserted: `|lut[res-1] - lut[0]|` per channel is
58
+ bounded by the maximum adjacent-cell interior delta.
59
+
60
+ - **Preflight gate (stale-base cure):** `npm run preflight` fetches the
61
+ latest published tarball via `npm pack` and hash-compares every file
62
+ in `files[]` against the working tree. Hard-fails on code drift
63
+ (`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc
64
+ drift (`README.md`, `CHANGELOG.md`, `LICENSE.md`, `llms.txt`). This is
65
+ the structural cure for the v1.3/v1.4 regressions that started from an
66
+ unrebased working tree. Session protocol: run preflight before feature
67
+ work.
68
+
69
+ ### Fixed (pre-release, against the 1.5.0 release candidate)
70
+
71
+ - **Blue-noise tile: Phase 3 of the void-and-cluster generator was
72
+ inverted.** The RC selected the 0-cell with the *highest* filtered
73
+ ones-field — the cell most surrounded by 1s, which is an **isolated**
74
+ zero (the largest void of the complement), not its tightest cluster.
75
+ The correct selector is the *minimum*: because the filter is toroidal
76
+ and constant-sum, `zerosField = sum(K) - onesField`, so
77
+ `argmax(zerosField) === argmin(onesField)` and Phase 3 reduces to the
78
+ same `findLargestVoid()` call as Phase 2.
79
+
80
+ Effect: ranks below 2048 were unaffected, so every threshold pattern at
81
+ or below mid-gray was correct — and every threshold pattern **above**
82
+ it was 5–7x over-clustered. Dithering was sound in the shadows and
83
+ broken in the highlights, which is exactly where a dither earns its
84
+ keep. Radial power at `r=1` was 2.05 against a white-noise floor of
85
+ 0.083: at the tile period the RC tile was *worse than random*.
86
+
87
+ The tile fingerprint therefore rotates:
88
+ `78926ec1…` → `8867ddb65e16379ad42244fa24b82dcbd813c684ce14944f86a9e3e8f6b72968`.
89
+ Same seed (`0xc0ffee1e`), same generator, one comparator.
90
+
91
+ - **The RC gates could not see any of it.** All 17 tests in
92
+ `dither.test.js` passed the defective tile. A uniform histogram is
93
+ invariant under *any* permutation of ranks (a linear ramp satisfies
94
+ it); the SHA-256 assertion pins the artifact rather than validating it,
95
+ and was cementing the defect into the contract; and "dithered
96
+ run-length halved vs undithered" is satisfied by white noise too — it
97
+ tests *that* there is noise, not that the noise is blue. The
98
+ generator's own self-check measured field variance at `T=128` **only**,
99
+ which is precisely the one threshold an inverted Phase 3 cannot affect,
100
+ and it reported an identical healthy 0.5133 for the broken and the
101
+ fixed tile.
102
+
103
+ New `test/bluenoise-spectral.test.js` gates the property the feature is
104
+ named after: minority-phase clumpiness under 0.70 across `T=16..240`,
105
+ that curve symmetric about `T=128` (the assertion that fails the RC
106
+ first), radial power for `r<=4` below the white-noise floor, high-band
107
+ power above it, and wrap-edge decorrelation within 82% of interior. The
108
+ generator now runs the same sweep as a hard gate and refuses to emit a
109
+ tile that fails it.
110
+
111
+ - `packOklchBufferToUint32Dithered` had no parameter defaults while every
112
+ sibling packer defaults `alpha = 1.0`. Calling it with the plain
113
+ packer's arity returned `0x00000000` — silent transparent black. It now
114
+ defaults `alpha = 1.0, noise01 = 0.5`, so a 2-argument call is exactly
115
+ `packOklchBufferToUint32`.
116
+
117
+ - **The sRGB transfer function had 8 copies in `src/runtime.js`** — 6 of them
118
+ added by the F1 dither work, which inlined the encode per channel in both
119
+ dithered packers. They now derive from a single `srgbEncode(c)` helper, which
120
+ `SRGB_LUT` and `linearToSrgbByte` also route through: one definition of the
121
+ IEC 61966-2-1 constants in the module. The dither packers genuinely cannot use
122
+ `linearToSrgbByte` (it rounds) or `SRGB_LUT` (it stores rounded bytes) — they
123
+ need the sub-integer encoded value — but that argued for extracting the shared
124
+ step, not for copying it six times. The real exposure was the
125
+ `noise01 === 0.5` parity guarantee: it holds only while the dither path and
126
+ the plain path agree on the encode, and nothing structural was keeping them in
127
+ step. Now they are the same function, and the parity test guards it.
128
+
129
+ Measured, since "the duplication forces V8 to monomorphize the loop" is a
130
+ testable claim: interleaved A/B, 40 rounds, 12 primer passes, N=100k. The
131
+ helper form came in at 0.9978x / 0.9981x / 0.9967x / 0.9983x of the inlined
132
+ form on the dithered batch, and within +/-0.3% on the scalar. V8 inlines it
133
+ outright. The duplication bought nothing.
134
+
135
+ `src/Gamut.js` and `src/Remap.js` keep their own single copies **on purpose**.
136
+ Both modules currently have zero imports, and `Gamut.js` is a subpath export
137
+ (`./gamut`); making it import from `runtime.js` would drag the module-level
138
+ `SRGB_LUT` build and the 5.4 KB blue-noise blob into the gamut consumer's
139
+ graph. One duplicated line each is the cheaper trade. A shared `src/transfer.js`
140
+ would collapse the last two without that cost, if it is ever worth a file.
141
+
142
+ - `getBlueNoise64()` read `Buffer` as a free identifier. Webpack 4 and
143
+ browserify pattern-match that token and inject the ~20 KB buffer
144
+ polyfill into browser bundles — for a package whose pitch is a
145
+ single zero-dependency file. Now reads `globalThis.Buffer`, which is
146
+ not matched; browser builds stay on the `atob` path.
147
+
148
+ ### Demo
149
+
150
+ - `demo/index.html` particle cull guard rewritten from `(out of range)` to
151
+ `!(in range)`. Identical for every finite value and the same four compares —
152
+ but NaN makes every comparison false, so the old form let a NaN coordinate
153
+ through to `(pY[i] | 0) * w + (pX[i] | 0)`, where `NaN | 0` is `0` and the
154
+ particle silently paints pixel (0,0) forever. Not reachable today (the `+ 1`
155
+ in `d2` rules out the divide-by-zero and `F_DAMP` bounds the integration);
156
+ this is a free guard against a future edit to the force math.
157
+
158
+ ### Known / deferred
159
+
160
+ - **Dither DC bias.** `noise01 = byte / 256` has mean `127.5/256 =
161
+ 0.498046875`, not `0.5`, so `floor(v + noise)` darkens by a constant
162
+ `-1/512` LSB on every pixel. It is deterministic, not noise, and
163
+ invisible (0.2% of one code value) — but it is a bias, and the JSDoc
164
+ leans on "the mean and variance of a uniform distribution" as if that
165
+ mean were 0.5. The fix, `(byte + 0.5) / 256`, would centre it exactly —
166
+ at the cost of the `noise01 === 0.5` parity guarantee, since no byte
167
+ maps to 127.5. Kept as-is for v1.5; the parity property is worth more
168
+ than 1/512 of a code value. Revisit alongside the v1.6 float-LUT.
169
+
170
+ ### Notes
171
+
172
+ - No breaking changes. All v1.4 exports (batch kernels, 4k LUT, Display
173
+ P3, CSS formatters, `sampleColorLUT`) are retained with identical
174
+ byte-level output.
175
+ - Existing open-mode `bakeGradientToUint32` calls are byte-identical to
176
+ v1.4 (verified by regression tests) — closed mode is opt-in via the
177
+ new trailing `opts` argument.
178
+ - Zero-GC audit: every new hot-path function is allocation-free after
179
+ the one-time lazy noise decode.
180
+
181
+ ---
182
+
183
+ ## [1.4.0] - 2026-07-10
184
+
185
+ ### Added
186
+ - **CSS formatters (round-trip emit):**
187
+ - `formatOklchCss(buf, off, alpha?)` - emits `oklch(L% C H)` (or `.../ alpha` when
188
+ alpha is supplied and < 1). Round-trips through `parseCSSColor`.
189
+ - `formatHex(buf, off)` - emits `#rrggbb` via the accurate sRGB pack path.
190
+ Round-trips with `parseHexToBuffer` to within 1 LSB per channel.
191
+ - Both are authoring-time helpers (they allocate a string) and are not for
192
+ per-frame hot paths.
193
+
194
+ ### Fixed
195
+ - Named-color table: `darkslateggrey` -> `darkslategrey` and `navajawhite` ->
196
+ `navajowhite` (previously unreachable typo keys), and `lightsteelblue` now
197
+ returns its correct value `#b0c4de` instead of powderblue's `#b0e0e6`.
198
+
199
+ ### Notes
200
+ - No breaking changes; all v1.3 exports (batch kernels, 4k LUT, Display P3,
201
+ `sampleColorLUT`) are retained.
202
+
203
+ ---
204
+
3
205
  ## [1.3.0] - 2026-07-10
4
206
 
5
207
  ### Added