@weasel-js/core 0.8.0 → 1.0.1

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 (41) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/dist/{DrawCommand-BKJ5JPkC.d.ts → DrawCommand-DCmiNkBj.d.ts} +14 -169
  3. package/dist/{chunk-GLZLSPGJ.js → chunk-6SHZHXXR.js} +46 -9
  4. package/dist/chunk-6SHZHXXR.js.map +1 -0
  5. package/dist/chunk-DCIU3SRK.js +505 -0
  6. package/dist/chunk-DCIU3SRK.js.map +1 -0
  7. package/dist/{chunk-DLV5F4NY.js → chunk-FVNWK7U3.js} +1525 -763
  8. package/dist/chunk-FVNWK7U3.js.map +1 -0
  9. package/dist/chunk-IO5Z75X4.js +167 -0
  10. package/dist/chunk-IO5Z75X4.js.map +1 -0
  11. package/dist/{chunk-BPRHGTLQ.js → chunk-UB6A6L77.js} +3 -3
  12. package/dist/{chunk-BPRHGTLQ.js.map → chunk-UB6A6L77.js.map} +1 -1
  13. package/dist/{chunk-CMBE45MT.js → chunk-Z3KNFTTS.js} +6 -6
  14. package/dist/chunk-Z3KNFTTS.js.map +1 -0
  15. package/dist/clipboard.d.ts +1 -1
  16. package/dist/{index-Clf_koyR.d.ts → index-DPI8DV0A.d.ts} +104 -91
  17. package/dist/index.d.ts +352 -43
  18. package/dist/index.js +6 -6
  19. package/dist/insert.js +5 -5
  20. package/dist/insert.js.map +1 -1
  21. package/dist/move.js +2 -2
  22. package/dist/paint-types-CnLIzqq1.d.ts +261 -0
  23. package/dist/patterns-builtin.d.ts +57 -2
  24. package/dist/patterns-builtin.js +1 -97
  25. package/dist/patterns-builtin.js.map +1 -1
  26. package/dist/renderer.d.ts +76 -15
  27. package/dist/renderer.js +6 -6
  28. package/dist/resize.js +2 -2
  29. package/dist/routing.d.ts +5 -5
  30. package/dist/routing.js +1 -1
  31. package/dist/{types-DUpI7Apc.d.ts → types-Kh_osAq9.d.ts} +8 -1
  32. package/dist/{viewToMat3-DFMdZvVl.d.ts → viewToMat3-BvaWCN99.d.ts} +1 -1
  33. package/package.json +6 -6
  34. package/dist/chunk-6DAUIMTO.js +0 -233
  35. package/dist/chunk-6DAUIMTO.js.map +0 -1
  36. package/dist/chunk-CMBE45MT.js.map +0 -1
  37. package/dist/chunk-DLV5F4NY.js.map +0 -1
  38. package/dist/chunk-GLZLSPGJ.js.map +0 -1
  39. package/dist/chunk-Y52N27PF.js +0 -39
  40. package/dist/chunk-Y52N27PF.js.map +0 -1
  41. package/dist/registerTexture-BzHTLhD9.d.ts +0 -25
package/CHANGELOG.md CHANGED
@@ -1,5 +1,753 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d6c2eff: Take the id→node lookup off the render and hit-test paths.
8
+
9
+ Six call sites walked `scene.renderOrder()` and immediately resolved each id
10
+ back through `scene.get` — a map lookup per node, per call, for nodes the
11
+ traversal had already held. They now read `scene.renderOrderNodes()` directly:
12
+ `sceneToAdapter.getNodes`, the move gesture adapter and the default commit
13
+ adapter, `useSceneSelectTool`'s `hitTestArea` and default `pickEvery`, and the
14
+ text-edit hit test. Adapter `getNodes` runs on the render path once a frame.
15
+
16
+ Building the node list is about twice as fast (`npm run bench`, `min`, Apple M2
17
+ Max / Node v26.1.0):
18
+
19
+ | nodes | via `renderOrder` + `get` | via `renderOrderNodes` |
20
+ | ------ | ------------------------- | ---------------------- |
21
+ | 1,000 | 0.031 ms | 0.011 ms |
22
+ | 10,000 | 0.40 ms | 0.19 ms |
23
+
24
+ `tests/bench/scene-ops.bench.ts` gains that comparison, and the committed
25
+ baseline is re-recorded.
26
+
27
+ - d62dc17: Bake the rect batch's transform and alpha into its vertices, so a rotated or
28
+ partly-transparent node no longer breaks a run.
29
+
30
+ `RectBatch.push` now maps the four corners through the model matrix itself and
31
+ the flush draws at `u_model` identity — ~12 flops against the ~66 us a draw call
32
+ costs. An affine maps a rect to a parallelogram, so the two-triangle index
33
+ pattern still covers it. Group alpha multiplies into the vertex alpha the same
34
+ way fill opacity already did. Neither is batch state any more.
35
+
36
+ Frame cost for rects each wrapped in their own transform group — what
37
+ `wrapNodeOutput` emits for a rotated node — M2 Max via ANGLE at 800x600
38
+ (`npm run test:perf`, new `rotated` variant):
39
+
40
+ | rects | before | after |
41
+ | ----- | --------- | ------- |
42
+ | 400 | 24.94 ms | 0.08 ms |
43
+ | 1,600 | 102.78 ms | 0.42 ms |
44
+ | 3,200 | 216.90 ms | 0.52 ms |
45
+
46
+ The color matrix stays a uniform and stays a barrier. The shader applies it and
47
+ its clamp to the straight-alpha source _before_ multiplying by `u_alpha`, so a
48
+ pre-multiplied vertex alpha is the same number only under an identity matrix —
49
+ which is every scene that does not use one. Alpha therefore folds only there,
50
+ and rides `u_alpha` otherwise.
51
+
52
+ - 2604ce2: Merge consecutive solid-fill rects into one draw call.
53
+
54
+ The draw loop cost a flat ~66 us per draw call at every scene size, so a frame
55
+ of 3,200 rects took 212 ms. Solid-fill rects now append into a growable vertex
56
+ buffer and go out as a single `drawElements` at flush. Fill color rides the
57
+ vertices — the batch draws through the existing `pathFillVColor` program with
58
+ `u_color` held at white, which is bit-identical to the flat program's math.
59
+
60
+ Frame cost for a **flat** command stream, M2 Max via ANGLE at 800x600
61
+ (`npm run test:perf`):
62
+
63
+ | rects | before | after |
64
+ | ----- | --------- | ------- |
65
+ | 400 | 25.62 ms | 0.03 ms |
66
+ | 1,600 | 105.63 ms | 0.11 ms |
67
+ | 3,200 | 211.78 ms | 0.15 ms |
68
+
69
+ Painter's order is unchanged. A run absorbs only consecutive commands and
70
+ flushes before anything it cannot express: another fill kind, a stroke, a clip
71
+ push or pop, or a group changing the transform, alpha, or color matrix.
72
+
73
+ That last barrier means `SceneCanvas` does not benefit yet — it emits one
74
+ wrapper group per node, which breaks every run. Consumers building flat command
75
+ streams get the numbers above today. See
76
+ `docs/handoffs/2026-08-14-batched-dispatch.md` for the plan to reach the scene
77
+ path. Nothing else in the loop got faster either: a frame alternating solid and
78
+ gradient rects still costs ~34 us per command, now almost entirely the gradient
79
+ half.
80
+
81
+ - 24ae9f4: Batch solid-fill meshes and stroke ribbons alongside rects, so a stroked shape
82
+ costs one draw instead of two plus a fresh VAO every frame.
83
+
84
+ `RectBatch` becomes `SolidBatch`, with a `pushMesh` alongside `pushRect` that
85
+ appends transformed vertices and rebases the mesh's indices onto the staged run.
86
+ Solid path fills and solid stroke ribbons both take it, and land in the same
87
+ draw as each other: GL rasterizes a draw's primitives in index order, so staging
88
+ the ribbon after its own fill is what keeps the stroke on top.
89
+
90
+ Frame cost at 3,200 commands, M2 Max via ANGLE at 800x600 (`npm run test:perf`,
91
+ new `meshes` and `stroked` variants):
92
+
93
+ | variant | before | after |
94
+ | ----------------------------- | --------- | ------- |
95
+ | solid-fill octagons | 5.63 ms | 0.65 ms |
96
+ | stroked rects (fill + ribbon) | 243.80 ms | 9.41 ms |
97
+
98
+ The stroke figure is the interesting one, and not for the reason the plan
99
+ assumed. A draw call is ~1.8 us when nothing is touched between draws; what
100
+ costs is issuing a draw against a buffer minted that same frame, which is
101
+ exactly what a per-frame stroke ribbon did. Of the 9.41 ms left, 7.9 ms is
102
+ stroke tessellation, which batching does not address.
103
+
104
+ Excluded from a run: stencil fills, inner/outer-aligned polygon strokes, and
105
+ anything carrying per-vertex colors, all as before — plus meshes past a vertex
106
+ cap, since batching re-copies a mesh every frame where the persistent mesh cache
107
+ would not. Rects pay ~0.1 ms per frame at 3,200 for the index buffer becoming a
108
+ per-flush upload rather than a static pattern.
109
+
110
+ - c2ebfdf: Break rect batches on group state rather than on tree shape, so `SceneCanvas`
111
+ gets the batching.
112
+
113
+ A group was a batch barrier because it _might_ move a uniform. `buildSceneTree`
114
+ gives every node its own group with no transform, alpha, colorMatrix or clip, so
115
+ in the scene shape every run broke after one rect and the previous release's
116
+ batching reached nothing the app renders. A run now carries the state it was
117
+ staged under and breaks only when the live state differs by value — which a
118
+ no-op wrapper never does.
119
+
120
+ Scene-shaped frame cost, M2 Max via ANGLE at 800x600 (`npm run test:perf`, new
121
+ `scene` variant — one wrapper group per command):
122
+
123
+ | rects | before | after |
124
+ | ----- | --------- | ------- |
125
+ | 400 | 26.32 ms | 0.03 ms |
126
+ | 1,600 | 105.18 ms | 0.11 ms |
127
+ | 3,200 | 208.72 ms | 0.36 ms |
128
+
129
+ Clips stay hard flush points in both directions: the stencil is GL state that a
130
+ staged run cannot reconstruct, so the flush happens before `pushClip` and before
131
+ `popClip` rather than at group boundaries. Text, images, shaders, strokes and
132
+ non-solid fills flush as before.
133
+
134
+ - dce3306: Cache `renderOrder()` and `renderOrderNodes()` between structural edits.
135
+
136
+ Both walk the whole tree, and both run per frame and per hit-test query, on a
137
+ sequence that only changes when something structural moves. They now build once
138
+ and are served from a cache until it does. On a 10,000-node, four-layer scene a
139
+ repeat call drains in 0.0033 ms against a 0.33 ms rebuild (`npm run bench`,
140
+ `min`).
141
+
142
+ Invalidation hangs off the four writers that can reorder the scene — `attach`,
143
+ `detach`, `kit:setLayer`, `rebuildLayerIndex` — plus `loadState`. Pose and data
144
+ edits do **not** invalidate: they change no order and fire every frame during a
145
+ drag, which is exactly when the cache earns its keep.
146
+
147
+ Repeat calls now return the same array instance rather than a fresh one. It is
148
+ still a snapshot — a structural edit builds a new array, so a reference taken
149
+ earlier keeps the order it was taken with — but callers must not mutate what
150
+ they get back. Both return types have always been `readonly`.
151
+
152
+ - 69395b0: Detached scene renders honor pose rotation and per-node alpha.
153
+
154
+ Rotation and the per-id alpha multiplier were applied by `buildSceneLayer`,
155
+ the main canvas's scene walk. Every other way of painting a scene —
156
+ `<SceneViewCanvas>`, `<MinimapCanvas>`, and `renderSceneToPixels` — goes
157
+ through `buildSceneViewCommands` instead, which applied neither. A rotated
158
+ node came out upright in a minimap, a thumbnail, or a print export, and a
159
+ scene dimmed on screen exported at full strength.
160
+
161
+ Both wraps now live in one helper that both scene walks call, so the detached
162
+ renders match the canvas. Rotation needs nothing from the caller — it comes
163
+ off the pose. Dimming does: `alphaFor` is a new optional prop on
164
+ `<SceneViewCanvas>` and `<MinimapCanvas>`, and a new argument to
165
+ `renderSceneToCanvas`, `renderSceneToPixels`, `planPixelRender`, and
166
+ `buildSceneViewCommands`. Pass the same function `<SceneCanvas>` gets.
167
+
168
+ If you supply a `drawOne` to one of these that rotates its own output, it will
169
+ now rotate twice — emit unrotated geometry and let the pose drive it, which is
170
+ what the main canvas has always required.
171
+
172
+ - 3d93f2e: Detached scene renders now honor layer visibility and container clips.
173
+
174
+ `buildSceneViewCommands` walked `scene.renderOrder()` and painted every node it
175
+ found. That walk knew nothing about scene layers or parentage, so
176
+ `<SceneViewCanvas>`, `<MinimapCanvas>` and `renderSceneToPixels` all painted
177
+ nodes on hidden layers and let a container's children spill past the container.
178
+ The main canvas got both right, because `buildSceneLayer` goes through
179
+ `buildSceneTree`.
180
+
181
+ The detached path now goes through `buildSceneTree` too, which is the dedupe the
182
+ detached-minimap spec called for. One walk, so the two surfaces cannot disagree
183
+ again.
184
+
185
+ Output nesting follows `buildSceneTree`: the view group holds one group per
186
+ **visible** scene layer, each holding one group per node. Code that indexed the
187
+ view group's children as one-per-node — `commands[0].children[i]` — now finds a
188
+ layer group there and needs a further hop. `extraCommands` still come last,
189
+ beside the layer groups.
190
+
191
+ A hand-written `Scene` stand-in must now supply `layers`, `roots`, and
192
+ `children` on containers; scenes from `createScene` and `sceneFromJSON` already
193
+ do.
194
+
195
+ - e367165: One enumeration of the `TargetSpec` forms. `@weasel-js/gestures` now exports
196
+ `parseTargetSpec`, which resolves a target spec to a discriminated
197
+ `TargetSpecForm` (`body` / `kind` / `affordance` / `predicate`), and the three
198
+ places that used to re-derive the string prefixes independently — `matchTarget`,
199
+ and `targetRank` / `targetConsultsAffordance` in core's dispatcher matcher —
200
+ switch on it exhaustively. Adding a form to `TargetSpec` is now a compile error
201
+ at every site that has to handle it.
202
+
203
+ For consumers: `matchTarget`'s `specTarget` parameter and core's
204
+ `targetConsultsAffordance` take `TargetSpec | undefined` instead of `unknown`,
205
+ so a target string that is no known form is a type error rather than a silent
206
+ no-match. The predicate form has a name, `TargetPredicate`, carrying the
207
+ `readsAffordance` flag the exclusive-claim filter reads. Runtime behavior is
208
+ unchanged.
209
+
210
+ - 52e9c57: Walk the scene once in `renderOrder()` instead of once per layer.
211
+
212
+ The generator behind `renderOrder()` and `toJSON()` was layer-major in the
213
+ literal sense: it ran a full DFS of the tree for every layer and yielded only
214
+ the nodes belonging to that pass, so producing N ids cost L×N work. A single
215
+ DFS now buckets each node by its layer and concatenates the buckets, which is
216
+ O(N + L). The emitted sequence is unchanged — same layer-major order, same
217
+ DFS-preorder within each layer, same skip for dangling child ids — and a
218
+ differential test holds the new implementation to a transcription of the old
219
+ one across 200 generated scenes plus mutation, layer-edit and undo sequences.
220
+
221
+ Over 10k nodes the layer sweep goes from 0.37 ms / 1.03 ms / 3.54 ms / 12.93 ms
222
+ at 1 / 4 / 16 / 64 layers to 0.30 / 0.35 / 0.40 / 0.42. The flat single-layer
223
+ case improves too, 0.37 ms → 0.33 ms at 10k nodes and 2.1x at 100 nodes, since
224
+ one pass replaces the per-yield generator overhead.
225
+
226
+ `renderOrder()` now returns an array rather than a generator, so a caller that
227
+ stops early no longer avoids the rest of the walk. Every caller in the repo
228
+ drains it fully except four test helpers reading the first id from a handful of
229
+ nodes. Its declared type stays `Iterable<NodeId>`.
230
+
231
+ - d68e734: Memoize the per-node AABB in the area hit-test, for silhouette poses.
232
+
233
+ `hitTestArea` — the marquee and lasso dep source — recomputed every node's
234
+ bounding box on every query. For a polygon pose that means walking the whole
235
+ command stream and allocating a rect, per node, before the fast-reject could
236
+ discard it. The box is now cached through `nodeMemo`, keyed on the node's
237
+ `pose` and `data` references, so a repeat query over an unedited scene reuses
238
+ it and an edit through any scene op invalidates it.
239
+
240
+ Measured on 24-gon scenes (`npm run bench`, `min` column, same machine
241
+ back-to-back): 10,000 nodes 11.85 ms → 1.16 ms per query, 1,000 nodes
242
+ 1.14 ms → 0.15 ms. Query-rect size now moves the number (0.117 ms at 17 hits
243
+ against 0.141 ms at 1,000 hits on a 1,000-node scene, previously flat at
244
+ ~1.15 ms either way) because the silhouette kernel, not the bounds
245
+ computation, is what survives the reject.
246
+
247
+ Rect-pose scenes pay 5–17% for it: `aabbOfPose` returns a rect pose
248
+ unchanged, so there is nothing to cache, and deciding that per node costs
249
+ more than the call it skips. 10,000 rect nodes go 0.76 ms → 0.85 ms.
250
+
251
+ - ca9673a: Stop re-sending unchanged uniforms on every draw command.
252
+
253
+ `u_proj` is constant for a whole frame and `u_colorMatrix` is the identity in
254
+ every scene that does not use a color matrix, yet both were uploaded for every
255
+ command — along with a fresh `Float32Array(16)` and a transpose per draw to
256
+ build the color matrix, and a fresh `screenToClip` matrix per draw to build the
257
+ projection. GL holds uniform state per program object, so all of that was
258
+ buying nothing.
259
+
260
+ `draw.ts` now remembers what it last sent each program and skips the upload
261
+ when the value has not changed. On a frame of 1,000 solid rects that takes
262
+ `uniformMatrix4fv` from 1,000 calls to 1 and `uniformMatrix3fv` from 2,000 to
263
+ 2, and removes two per-draw allocations.
264
+
265
+ The cache hangs off the `DrawContext`, which is rebuilt per frame, so it cannot
266
+ outlive a frame or go stale against GL state changed between frames. It covers
267
+ only the four uniforms this module is the sole writer of — `u_color` and
268
+ `u_alpha` have several writers and are still sent every draw.
269
+
270
+ This does not measurably change frame time on an M2 Max: the draw loop is bound
271
+ by per-draw-call cost (~68 us per command), not by uniform uploads. It removes
272
+ the calls and the allocations; `docs/TODO.md` tracks what the remaining cost
273
+ actually is.
274
+
275
+ - fa1ed05: Dragging a text node no longer re-lays it out.
276
+
277
+ `layoutRuns` baked the text's position into every quad, decoration rule and
278
+ line box, so `layoutCache` had to carry that position in its key. Panning and
279
+ zooming still hit the cache, but _moving_ a text node missed on every frame —
280
+ at 500 wrapped glyphs a move cost 0.130 ms against a 1.7e-4 ms hit, which is
281
+ the same 0.134 ms a full miss cost. Moving text paid as if there were no cache
282
+ at all.
283
+
284
+ Layout now emits geometry relative to the text's own top-left and `drawText`
285
+ translates while packing vertices, alongside the `verticalAlign` offset it
286
+ already applied there. Position is out of the cache key, so a move is a hit:
287
+ median 0.130 ms → 0.000125 ms, min 0.123 ms → 0.000041 ms. The cold path is
288
+ unchanged (min 0.123 ms → 0.110 ms for a full miss).
289
+
290
+ This is not a rendering change. Alignment, wrapping, tracking, kerning and
291
+ decoration placement read widths and pen deltas, never an absolute coordinate,
292
+ and nothing in the walk rounds or snaps — checked over 247,572 coordinates
293
+ spanning three alignments, four wrap widths, mixed sizes, positive and negative
294
+ tracking, and fractional positions. The only differences were float64 rounding
295
+ from folding the position into the accumulator early, none of them survived the
296
+ conversion to the float32 vertex buffers, and they favor the new code: at a
297
+ position of 1e6 the old path computed 28.800000000046566 where this one gives
298
+ 28.8. The 37-test Playwright visual suite is unchanged.
299
+
300
+ `layoutRuns` and `cachedLayoutRuns` lose their `origin` parameter, and the
301
+ `LayoutRunsOrigin` type is gone. Neither is exported from the package. Callers
302
+ of the public `textLineBoxes` and `measureTextBounds` see no change.
303
+
304
+ - 0a40c29: Add `scene.renderOrderNodes()`, and scan it in the area hit-test.
305
+
306
+ `renderOrder()` hands back ids, and almost every caller immediately resolves
307
+ each one back to a node — a map lookup per node, per query, for a node the
308
+ traversal had in hand and dropped. `renderOrderNodes()` is the same
309
+ layer-major sequence as the nodes themselves. It is a snapshot, freshly built
310
+ per call, exactly like `renderOrder()`.
311
+
312
+ `hitTestArea` (marquee and lasso) now scans it, and reads `pose.kind` inline
313
+ instead of through the `isPathLike` predicate. Together those recover the
314
+ 5–17% the AABB memo cost rect scenes and take a good deal more besides
315
+ (`npm run bench`, `min` column, three alternating runs per build on one
316
+ machine; run-to-run scatter on these was under 3%):
317
+
318
+ | 10,000 nodes, 25% query rect | before | after |
319
+ | ---------------------------- | ------- | ------- |
320
+ | rect poses | 0.94 ms | 0.53 ms |
321
+ | 24-gon silhouettes | 1.21 ms | 0.78 ms |
322
+
323
+ `renderOrder()` itself gets a separate walk for the single-layer case, which
324
+ needs no per-layer buckets and can compare the layer id rather than index it:
325
+ 10,000 nodes over one layer 0.27 ms → 0.19 ms, with multi-layer scenes
326
+ unchanged. `toJSON()` rides the nodes walk and skips its lookups too.
327
+
328
+ `Scene` gains a method, so a hand-written stand-in for a scene needs to
329
+ implement it; scenes from `createScene` and `sceneFromJSON` already do.
330
+
331
+ - Updated dependencies [e367165]
332
+ - @weasel-js/gestures@1.0.1
333
+ - @weasel-js/font@1.0.1
334
+ - @weasel-js/geom@1.0.1
335
+ - @weasel-js/history@1.0.1
336
+ - @weasel-js/modes@1.0.1
337
+
338
+ ## 1.0.0
339
+
340
+ ### Minor Changes
341
+
342
+ - ffd9713: Chrome hit-tests are axis-aware under non-uniform zoom.
343
+
344
+ Chrome declares its hit zones in screen pixels and paints in screen space, but
345
+ the hit-test converted those pixels to world units through `meanScale`, a
346
+ single geometric mean of the two axis scales. Under per-axis zoom that made the
347
+ pickable region larger than the painted one on the squashed axis and smaller on
348
+ the stretched one — an 8px handle at `scale: { x: 4, y: 1 }` was grabbable 16
349
+ screen px to the side and only 4 px above.
350
+
351
+ New primitives in `core/viewport/pxExtent`, all exported: `pxExtent(px, scale)`
352
+ for a per-axis world length, and `withinPxBox` / `withinPxRadius` to compare a
353
+ world-space delta in screen space directly, which can't drift from the paint at
354
+ all.
355
+
356
+ `AffordanceRegion`'s `point` hit is now that screen-space comparison, so a
357
+ handle's hit region is the square you see — which also fixes it under a rotated
358
+ target, where the old local-frame test was a tilted rectangle on screen. The
359
+ annulus band floor (`minBandPx`) and the annulus paint inset (`insetPx`) are
360
+ per-axis, keeping the ring you can see the ring you can grab. The pen's
361
+ close-hit radius is a screen-space circle. Snap-guide tolerances — the shared
362
+ guide strategy, insert, resize, and alignment — are per-axis, which is exact
363
+ rather than approximate there: a vertical guide is matched by a horizontal
364
+ distance, so it answers to `scale.x` alone.
365
+
366
+ `matchAlignment`'s `worldTolerance` parameter is now `{ x, y }` rather than a
367
+ number. Pass the same value twice for a world-space tolerance.
368
+
369
+ `meanScale` stays, for the two things that legitimately have no per-axis
370
+ answer: hairline stroke widths, where the renderer takes one width, and painted
371
+ chrome placement, whose per-axis form doesn't separate under a rotated target.
372
+ Its doc now says so.
373
+
374
+ - 8853e73: Affordance hits are claims, and an exclusive claim outranks the scope tier.
375
+
376
+ `AffordanceHit` gains `strength`, and `owner` naming what produced the hit. Kit
377
+ chrome claims `'shared'`, which is what it always did: compete on scope and
378
+ specificity. Registered layers, whose hits previously flattened to a bare kind
379
+ string and a payload, now return a `LayerHit` that can also carry `cursor` and
380
+ `strength`, so a consumer's own chrome says the things kit chrome already said
381
+ through `AffordanceRegion`. `owner` is groundwork — nothing binds on it yet, and
382
+ today only diagnostics read it; target it with `kindOf` or `affordance:<kind>`.
383
+
384
+ **Behavior change.** When a press carries an exclusive claim, only bindings
385
+ whose `target` consults the affordance — a `kindOf` predicate, or the
386
+ `affordance:<kind>` string form — are candidates. Scope ordering applies within
387
+ that filtered set, unchanged. Body-class targets (`'empty'`, `'selected-body'`,
388
+ `'unselected-body'`) and `kind:` targets resolve from the body classification
389
+ and never see the affordance, so they no longer win presses on chrome floating
390
+ over the body they name.
391
+
392
+ This is the dispatcher rule the previous release's changeset said was the real
393
+ fix. `select`'s marquee no longer needs its hand-written predicate declining
394
+ chrome affordances, and it is deleted; `rect`, `ellipse`, `polygon`, `star`,
395
+ `hand`, `pen` and `lasso` keep their bare `{ kind: 'drag' }` bindings and stop
396
+ swallowing drags on HUD chrome anyway, which is the point — seven copies of a
397
+ predicate was the alternative. (`select`'s _click_ binding keeps a predicate of
398
+ its own, for an unrelated reason: resize and rotate bind only drag, so a click
399
+ exactly on a handle would otherwise clear the selection.)
400
+
401
+ The filter is hard: if an exclusive claim leaves nothing eligible, the press
402
+ does nothing. A dev-only warning names the owner the first time that happens for
403
+ each owner, because the failure is otherwise silent.
404
+
405
+ **What a claim does not reach.** Only gestures that carry an affordance are
406
+ filtered, and keyboard events never do. Which pointer-family gestures carry one
407
+ is settled by the per-kind claim work later in this release — see "A widget
408
+ declares which gestures it consumes." The filter outranks hotkey scope as well
409
+ as active scope, so holding space to pan no longer pans while the pointer is
410
+ over HUD chrome.
411
+
412
+ `@weasel-js/hud` claims exclusively, and `Widget` gains `cursorAt(x, y)`, which
413
+ resolves a cursor per point rather than from hover state; `hud.window()`
414
+ implements it, so hovering a resize band shows `nwse-resize` instead of the
415
+ active tool's cursor. A widget can also stay transparent to input — `rect`,
416
+ `text`, `label` and `image` are decoration — so a backdrop widget no longer
417
+ eats presses meant for the canvas or for widgets beneath it. That occlusion
418
+ predates this release, but an exclusive claim would have widened it from "HUD
419
+ elements occlude each other" to "HUD elements kill every tool underneath."
420
+
421
+ `WindowWidget.cursor` is removed. Nothing read it; `cursorAt` replaces it.
422
+
423
+ - 43482ce: A registry entry declares what it contributes and when it is eligible.
424
+
425
+ `Contribution` is the entry type: `bindings`, `actions`, an `overlay`, a
426
+ `presentation`, each optional and independent. `Tool<TScratch>` is now the
427
+ **focus-declaring case** of one — it keeps only what a mode the user switches
428
+ into needs (`initScratch`, the lifecycle hooks, the preview hooks, a `cursor`
429
+ closing over its own scratch). An entry that only routes input declares only
430
+ bindings and actions, which is what `@weasel-js/hud` always was.
431
+
432
+ `Eligibility` is a set of conditions rather than one value, because one entry
433
+ holds several: the hand tool is palette-selectable _and_ space-held. `focus`,
434
+ `offhand: HotkeyTrigger`, `always`, `claimed`, plus `capabilities` as a modality
435
+ filter — `@weasel-js/modes` now reads its tags from there. The scope tier a
436
+ binding matches at is derived from whichever condition is live, ordered to match
437
+ the dispatcher's existing hotkey > active > ambient walk. Nothing about that
438
+ walk changed; what changed is that an entry lands in a tier because of what it
439
+ declares about itself rather than which argument a consumer passed it in.
440
+
441
+ `useContributions` is the assembly point, and `useTools` is a shim over it with
442
+ its shape unchanged.
443
+
444
+ **Breaking — `@weasel-js/hud`:** `createHudTool` / `useHudTool` are now
445
+ `createHudContribution` / `useHudContribution`, with no alias. The old names were
446
+ half of the same misstatement as the `as unknown as Tool<null>` cast they
447
+ required; keeping them would preserve exactly what this corrects.
448
+
449
+ **Breaking — `DispatcherContext.ambientToolIds` is removed.** A host driving the
450
+ dispatcher directly declares `eligibility: { always: true }` on always-on entries
451
+ instead. `useTools` consumers are unaffected; the shim sets it.
452
+
453
+ **Behavior — a declared held-key trigger now wires itself.** `ToolDef.hotkey` was
454
+ read by the inspector and wired nothing; the engagement lived in a host-side
455
+ registration keyed by tool id (`BUILTIN_OFFHAND_ACTIONS`, now gone). Assembly
456
+ registers the consolidated `tool.offhand` action from the declarations, so a tool
457
+ that wants space declares it — `useHandTool` does. A host that also registered
458
+ `tool.offhand` by hand should stop.
459
+
460
+ **Behavior — `useTools` returns shallow copies** for ambient entries and
461
+ `hotkey`-declaring tools, since it adds the declaration they were missing.
462
+ Registry tools from `defineTool` are returned unchanged.
463
+
464
+ **Behavior — route-conflict reporting now also sees action `defaultBinding`s.**
465
+ The dispatcher always matched against them; the reporter never saw them, so a
466
+ tool binding colliding with an action default went unreported. It no longer does.
467
+ Dispatch is unchanged.
468
+
469
+ Also added: `mergeContributions(...bundles)`, which concatenates and throws on a
470
+ duplicate id rather than silently dropping one — the recorded plugin/bundling v1,
471
+ whose deferral condition was "≥2 plugin-shaped features in flight."
472
+
473
+ **Not done, so the seam is stated rather than implied:**
474
+ `EligibilityState.heldTriggers` exists and `liveScope` honors it, but nothing
475
+ populates it. `tool.offhand`'s invoker still reports engagement by pushing a tool
476
+ _id_, which `engagedIds` reads, so the declaration registers the binding while
477
+ the id carries the tier. Retiring that means changing `tool.offhand`'s contract.
478
+
479
+ - 9ed1139: Gradient fills that stay attached to their shape, and an editor for them.
480
+
481
+ Gradient geometry was interpreted in screen space: `draw.ts` set the shader's
482
+ `u_worldInv` to the identity matrix, with a comment saying a later step would
483
+ wire the real view inverse. Nothing did. Every gradient therefore slid across
484
+ its own geometry under pan and zoom, which is why the gradient demo shipped
485
+ with pan and zoom disabled. Fine for a viewport-fixed wash, useless for a
486
+ paint on a shape.
487
+
488
+ Gradient paints now carry `units`, mirroring SVG's `gradientUnits`:
489
+
490
+ - `'bounds'` — fractions of the painted node's box, `0..1` per axis (SVG
491
+ `objectBoundingBox`). Resolved by the node painter, so the paint follows the
492
+ node through moves, resizes and rotation.
493
+ - `'local'` — the frame the geometry was handed to the renderer in.
494
+ - `'world'` — scene coordinates; the paint holds still while geometry moves
495
+ through it (SVG `userSpaceOnUse`).
496
+ - `'screen'` — surface pixels, and the default, so every existing gradient
497
+ keeps the behavior it had. WeaselDraw's workspace tint wants exactly this.
498
+
499
+ `WeaselRenderer.render` takes an optional view matrix for `'world'`, and falls
500
+ back to screen space without one. `fillInPoseFrame` resolves `'bounds'` against
501
+ a box and `fillToBoundsFrame` inverts it; `mat3.invert` is new alongside them.
502
+ Supporting helpers: `sampleGradientStops`, `withGradientKind`,
503
+ `gradientGeometry`, `gradientForBounds`.
504
+
505
+ `setFill` takes a whole `paint` as well as a `color`, so a gradient edit is one
506
+ undo entry like any color edit. A `color` no longer tries to inherit alpha from
507
+ a fill that is a gradient.
508
+
509
+ New in `@weasel-js/ui`: `<GradientEditor>` (kind switch, stop strip, per-stop
510
+ color) and `<GradientHandles>` (on-canvas endpoint / center / radius / angle
511
+ handles, positioned through consumer-supplied `toScreen` / `toLocal` so it
512
+ needs no view or scene of its own). Both split live `onInput` from committed
513
+ `onChange`.
514
+
515
+ Converting between kinds is lossy in ways the data makes unavoidable: a radial
516
+ gradient stores no angle, so a round trip through one leaves the segment
517
+ horizontal, and a conic stores no radius, so a round trip through one resets
518
+ the segment's length.
519
+
520
+ `'bounds'` is not a frame you can do polar math in: `x` and `y` are fractions
521
+ of two different lengths, so a circle in it is an ellipse on screen.
522
+ `<GradientHandles>` therefore takes a gradient already resolved by
523
+ `fillInPoseFrame`, and consumers convert edits back with `fillToBoundsFrame`.
524
+
525
+ - 6aaa469: Group resize scales a rotated child along its own axes.
526
+
527
+ Resizing a group applied the group's per-axis scale straight to each leaf's
528
+ axis-aligned `width` and `height` and carried `rotation` through untouched.
529
+ That is correct when `src`/`dst` are already in the leaf's own frame — the
530
+ single-leaf path, where the drag delta is projected into that frame before the
531
+ anchor math runs — but in a group they are world-frame, and a rotated leaf's
532
+ local axes are not the axes being scaled. At 90° a horizontal stretch grew the
533
+ leaf's `width`, when its world-horizontal extent is its `height`.
534
+
535
+ New `remapRotatedLeaf(pose, src, dst)` in `interactions/actions/resize/geometry`
536
+ applies the group affine to the leaf's local frame and drops the shear, which
537
+ the `{x, y, width, height, rotation}` pose model cannot represent. The centre
538
+ moves exactly; each local axis takes the length of its own image; the rotation
539
+ follows the image of the local x-axis. `resizeAction` uses it on the group path
540
+ for any leaf with a rotation, and nowhere else — the unrotated leaf and the
541
+ single-leaf path are byte-identical to before.
542
+
543
+ Rotation and `width` compose exactly under repeated application; only `height`
544
+ drifts, since the perpendicular of a mapped axis is not the image of the
545
+ perpendicular — that gap is exactly the dropped shear. It does not accumulate
546
+ during a drag: every move remaps from the gesture's start poses, not from the
547
+ previous preview.
548
+
549
+ - 40dd97d: A widget declares which gestures it consumes, and a claim bars only those.
550
+
551
+ A HUD widget could be pressed, dragged and hovered and nothing else. The gap
552
+ was in the dispatcher: `affordanceAt` ran on `pointerdown` and hover only, so
553
+ `doubleclick`, `contextmenu` and `wheel` carried no affordance and no binding
554
+ could gate on one. All four now do — `doubleclick` replays it from its press,
555
+ `contextmenu` classifies its own position (a secondary button never reaches
556
+ `onPointerDown`, so there is nothing to replay from) and gains world
557
+ coordinates it never carried, and `wheel` classifies the point under the
558
+ cursor. The immediate-invoker param bag, which forwarded position for `click`
559
+ and `pointerdown` only, now covers `contextmenu`, `longpress` and the
560
+ affordance on all four.
561
+
562
+ **Behavior change, and the reason for the rest of this entry.** An exclusive
563
+ claim previously meant _this pixel is mine_. Under that rule, giving `wheel` an
564
+ affordance would have killed scroll-to-zoom over every floating panel. It now
565
+ means _these gestures are mine_: `LayerHit` and `AffordanceHit` gain
566
+ `claimedKinds`, a set over the new `ClaimableGesture` union (`'pointer'` —
567
+ covering `pointerDown` / `click` / `drag`, one press protocol — plus
568
+ `'doubleClick'`, `'contextMenu'`, `'longPress'`, `'wheel'`). Omitting it bars
569
+ everything, which is what an exclusive claim did before.
570
+
571
+ `Widget.claims` is that set. Absent means every kind but `wheel`, so
572
+ right-clicking or double-clicking HUD chrome stops acting on the scene
573
+ underneath while scroll-to-zoom over a panel is unchanged; a widget that wants
574
+ the wheel asks for it. `claims: []` is decoration and replaces `claimsPointer`,
575
+ which is removed. `HudPointerEvent` gains `doubleclick`, `contextmenu`,
576
+ `longpress` and `wheel` arms.
577
+
578
+ `PointerClaim` and `onPointer`'s return type are removed. The return was
579
+ discarded at every call site, and it cannot be made live: the claim filter runs
580
+ at match time, before any widget is consulted, so there is no later moment at
581
+ which returning `'pass'` could restore a binding the filter already dropped.
582
+
583
+ **Two matcher fixes this depends on.** `doubleClick` and `contextMenu` passed
584
+ the DOM target to `matchTarget` where `click`, `drag` and `longPress` pass the
585
+ affordance; a `kindOf` predicate on either kind therefore received an element
586
+ no predicate in the kit expects. They now match the others. And the kit's body
587
+ predicates — `isBody`, `isSelectedBody`, `isUnselectedBody`, `isEmpty` — carry
588
+ `readsAffordance: false`, which `targetConsultsAffordance` honors. Each has a
589
+ `kindOf` but reads only `bodyTarget`, so the filter inferred that they consult
590
+ the hit and let them survive a claim; with `doubleclick` now carrying one,
591
+ `enterPathEdit`'s `kindOf: isBody` would otherwise have entered path-edit mode
592
+ on a double-click over chrome.
593
+
594
+ `WheelSpec` gains `target`, and `wheel` gains a route-grammar target slot —
595
+ a wheel binding could not gate on anything at all before.
596
+
597
+ - 531150f: A loupe, and the window primitive it needed.
598
+
599
+ `hud.window()` is a draggable, resizable frame drawn in WebGL over the canvas —
600
+ titlebar, eight resize bands, close box. It paints no interior. Interiors come
601
+ from a new optional `content` painter on `Widget`, which `attachHud` draws
602
+ beneath every widget frame in the same layer, clipped to `contentRect`. That
603
+ painter receives `HudContentCtx`, carrying the scene data and view the hud layer
604
+ was already handed; `HudDrawCtx` stays data-free, so widgets remain renderable
605
+ headlessly. Painter commands are in absolute canvas coordinates — the group
606
+ carries a clip, not a transform.
607
+
608
+ `createLoupe()` is the first consumer: a window whose interior shows either the
609
+ scene re-rendered through a magnified inner view, or the actual framebuffer read
610
+ back and magnified 1:1. Both modes exist because neither answers both questions
611
+ honestly — a re-render is crisp at any magnification but its antialiased edge
612
+ colors are not the colors on screen, so the hex readout samples the framebuffer
613
+ in either mode. The frame stays parked and the pointer aims it; content freezes
614
+ while the pointer is over the window, which is what keeps the borders reachable.
615
+ Aiming uses its own `pointermove` listener rather than hud hover, because hud
616
+ hover comes from the layer's `onUncapturedMove` and stops during a captured drag
617
+ — exactly when a magnifier is most wanted.
618
+
619
+ Also fixed in the HUD: `hud.drag` pumped **world** coordinates into widgets
620
+ while `hud.press` sent screen coordinates, because the dispatcher builds
621
+ move/end contexts with an empty dep bag and the `view` lookup silently fell
622
+ through. Invisible at zoom 1, and a window that jumped and tracked backwards at
623
+ any other zoom. The drag action now captures its deps at gesture start.
624
+
625
+ `ImageDrawCommand` gains `sampling: 'linear' | 'nearest'`, applied per draw at
626
+ bind time rather than at upload, since `GLImageCache` keys textures by bitmap
627
+ identity. Without `nearest`, magnifying a framebuffer readback comes back
628
+ blurred, which defeats the readback.
629
+
630
+ **Behavior change in `@weasel-js/core`:** the select tool's area-select drag now
631
+ declines presses that a registered layer's hit-test claimed. It bound
632
+ `{ kind: 'drag', target: 'empty' }`, and the string form of `target` resolves
633
+ from the body only — chrome floating over empty canvas read as empty canvas, so
634
+ area-select swallowed drags on HUD widgets. The adjacent `clearSelection` click
635
+ binding already had the correct shape. Other tools that bind a bare
636
+ `{ kind: 'drag' }` still have this hole.
637
+
638
+ - 596253e: The built-in path and shape painters accept a `FillStyle` in `data.fill`.
639
+
640
+ `kit:path` and `kit:shape` typed `data.fill` as a color string and emitted
641
+ `{ color }`, so a node could not carry a gradient or a pattern without the
642
+ consumer registering a painter of its own — even though the renderer has taken
643
+ every `FillStyle` variant since the paint model landed. The two painters were
644
+ the narrow point, not the renderer.
645
+
646
+ `data.fill` is now `string | FillStyle` (exported as `NodeFill`). A string
647
+ still means a solid color, `'none'` still skips the fill, `undefined` still
648
+ falls back to `data.color` and then to the default — and only when there is no
649
+ stroke, so a stroke-only pencil path stays unfilled. An object is used as-is.
650
+ `ink()` agrees with `paint()` on all of it, which the existing agreement test
651
+ plus a new one both check.
652
+
653
+ One behavior change beyond the widening: `kit:shape` used to paint `'none'` as
654
+ a literal color, since only `kit:path` special-cased it. It now skips the fill,
655
+ matching `kit:path` and matching what the string obviously means.
656
+
657
+ This is the kit half of gradient and texture fills. The app half — widening
658
+ WeaselDraw's own data shape, a fill-kind switch in the properties panel, a
659
+ gradient editor with on-canvas handles, and matching SVG `<linearGradient>` /
660
+ `<pattern>` export — is untouched.
661
+
662
+ - 22eafe6: Pattern fills tile, persist, and round-trip through SVG.
663
+
664
+ The `pattern` variant of `FillStyle` never tiled. `drawPathFillPattern`
665
+ borrowed the `imageFill` program while binding the path fill mesh VAO, which
666
+ enables `a_position` only — `a_uv` was never bound, so `v_uv` was the constant
667
+ `(0, 0)` and every fragment sampled texel (0, 0) of the tile. Textures also
668
+ uploaded with `CLAMP_TO_EDGE`, so correct UVs alone would have smeared rather
669
+ than repeated. The variant had no visual consumer, which is why it went
670
+ unnoticed.
671
+
672
+ `patternFill` is now its own program, taking `gradFill`'s vertex stage:
673
+ paint-space coordinates come from the screen position through `u_worldInv`
674
+ rather than a UV attribute, so the path mesh keeps its position-only layout.
675
+ `GLTextureCache.upload` takes a wrap argument and pattern textures bind
676
+ `REPEAT`.
677
+
678
+ Patterns pick up `units` alongside gradients. For a pattern it names the space
679
+ the tile's **origin and scale** live in — not geometry, which a pattern hasn't
680
+ got. `'bounds'` anchors the tile to the painted node's box, so dragging the
681
+ node carries the pattern and resizing reveals more tiles instead of stretching
682
+ them; `fillInPoseFrame` rebases it by translation only.
683
+
684
+ `TilePatternSpec` is the serializable payload — plain data naming a built-in
685
+ tile (`hatch`, `crosshatch`, `dots`, `chunks`) plus its parameters:
686
+
687
+ ```ts
688
+ { fill: 'pattern', pattern: { tile: 'hatch', color: '#0fb5a8', size: 8 }, units: 'bounds' }
689
+ ```
690
+
691
+ `resolvePatternSpec` turns one into a `TextureHandle` at paint time, memoized
692
+ on the spec's values so identical specs share a texture. The built-in painters
693
+ resolve it alongside `fillInPoseFrame`; a consumer emitting its own draw
694
+ commands resolves it at the same place it calls that one. A `TextureHandle`
695
+ payload still works untouched, but cannot be persisted or exported — prefer
696
+ the spec.
697
+
698
+ `@weasel-js/svg` serializes a tile spec as a `<pattern patternUnits=
699
+ "userSpaceOnUse">` whose children come from the same tile description that
700
+ rasterizes the texture, so the vector and raster forms cannot drift. The spec
701
+ rides along on `data-weasel-tile` for lossless re-import; a hand-authored
702
+ `<pattern>` without it is dropped with a warning rather than guessed at.
703
+ `SerializeOptions.onWarn` is new, and reports paint that SVG cannot express —
704
+ a conic gradient, or a pattern carrying a `TextureHandle`.
705
+
706
+ `tilePreviewSvg` / `tilePreviewCssUrl` render a single tile as a standalone
707
+ `<svg>`, for pickers that need to show a tile outside a document.
708
+
709
+ - cd23624: A text run can turn off a flag its node sets.
710
+
711
+ Run-level `bold` / `italic` / `underline` / `strikethrough` are additive over
712
+ the node's `TextStyle` — a run turns a flag on, never off. So "select a word
713
+ inside an underlined node and hit U" was unrepresentable, and the character bar
714
+ could only refuse.
715
+
716
+ New `setFlagOverRange(runs, style, start, end, key, value)` in
717
+ `features/text/runs/flagRange`, exported alongside `nodeHasFlag`. Turning a
718
+ flag on, or off in a node that doesn't set it, is the ordinary additive write
719
+ and returns the style untouched. Turning it off in a node that _does_ set it
720
+ clears the node flag and raises it on the runs outside the range: identical
721
+ rendered result, expressible edit, and `StyledRun` unchanged — so nothing a
722
+ document can already contain changes meaning.
723
+
724
+ That is the answer to the recorded question of tri-state versus
725
+ normalize-on-write, and it is not close. A tri-state run flag cannot cover
726
+ `bold` or `italic`: those are booleans on a run but `fontWeight` and
727
+ `fontStyle` on the node, so a run's `false` has no node-level boolean to
728
+ override. Tri-state fixes two of the four flags; this fixes all four, and
729
+ without widening the persisted shape.
730
+
731
+ One case is declined rather than approximated. `run.bold` resolves to exactly
732
+ 700 everywhere, so a node at `fontWeight: 900` cannot have its weight pushed
733
+ onto its runs without lightening the text that was _not_ edited. That returns
734
+ `applied: false` and writes nothing; a control should disable rather than
735
+ silently downgrade.
736
+
737
+ `useTextEdit` takes an optional `setStyle(id, style)` for this — the hook could
738
+ read the node style but had nowhere to write one back. Omit it and the toggle
739
+ declines exactly as it does today.
740
+
741
+ ### Patch Changes
742
+
743
+ - Updated dependencies [43482ce]
744
+ - Updated dependencies [40dd97d]
745
+ - @weasel-js/modes@1.0.0
746
+ - @weasel-js/gestures@1.0.0
747
+ - @weasel-js/font@1.0.0
748
+ - @weasel-js/geom@1.0.0
749
+ - @weasel-js/history@1.0.0
750
+
3
751
  ## 0.8.0
4
752
 
5
753
  ### Minor Changes