@weasel-js/core 1.4.3 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +1299 -2197
  2. package/README.md +118 -75
  3. package/dist/{pointSnapToGrid-D1bRdq-j.d.ts → autoPoseDescriptor-CvjflWJK.d.ts} +35 -17
  4. package/dist/{chunk-ORZNKVXM.js → chunk-BDWAA634.js} +4 -22
  5. package/dist/chunk-BDWAA634.js.map +1 -0
  6. package/dist/{chunk-WYPSGIYS.js → chunk-MG7OXCAI.js} +4149 -5316
  7. package/dist/chunk-MG7OXCAI.js.map +1 -0
  8. package/dist/{chunk-BLJNRMKB.js → chunk-MQI4PIX3.js} +3 -3
  9. package/dist/chunk-MQI4PIX3.js.map +1 -0
  10. package/dist/{chunk-SBFC6J3C.js → chunk-UCPV7JXC.js} +201 -256
  11. package/dist/chunk-UCPV7JXC.js.map +1 -0
  12. package/dist/clipboard.d.ts +2 -3
  13. package/dist/clone.d.ts +3 -2
  14. package/dist/depSchema-nMqj_qTM.d.ts +3490 -0
  15. package/dist/{grid-Bw8cSde-.d.ts → grid-BrIa38gG.d.ts} +7 -23
  16. package/dist/index.d.ts +2134 -1210
  17. package/dist/index.js +4 -5
  18. package/dist/insert.d.ts +4 -4
  19. package/dist/insert.js +1 -1
  20. package/dist/move.d.ts +5 -6
  21. package/dist/move.js +3 -6
  22. package/dist/move.js.map +1 -1
  23. package/dist/{options-DYfUlZxk.d.ts → options-BDyCnrp8.d.ts} +3 -2
  24. package/dist/poseDescriptor-CGOgIgf8.d.ts +134 -0
  25. package/dist/renderer.d.ts +10 -4
  26. package/dist/renderer.js +4 -5
  27. package/dist/resize.d.ts +10 -11
  28. package/dist/resize.js +2 -2
  29. package/dist/routing.d.ts +1 -142
  30. package/dist/routing.js +1 -1
  31. package/dist/routing.js.map +1 -1
  32. package/dist/{types-BJqsTlXl.d.ts → types-DMyo7dnM.d.ts} +12 -41
  33. package/dist/{types-CoVTbo_y.d.ts → types-DtjCJA5r.d.ts} +10 -9
  34. package/package.json +13 -10
  35. package/dist/DrawCommand-CD-ug3d9.d.ts +0 -332
  36. package/dist/builtins-CtGORLCG.d.ts +0 -740
  37. package/dist/chunk-BLJNRMKB.js.map +0 -1
  38. package/dist/chunk-LEURURX3.js +0 -561
  39. package/dist/chunk-LEURURX3.js.map +0 -1
  40. package/dist/chunk-ORZNKVXM.js.map +0 -1
  41. package/dist/chunk-SBFC6J3C.js.map +0 -1
  42. package/dist/chunk-WYPSGIYS.js.map +0 -1
  43. package/dist/geometry-CHR36Ub_.d.ts +0 -114
  44. package/dist/path-JEV2c5If.d.ts +0 -48
  45. package/dist/registry-DnoTWGED.d.ts +0 -4003
  46. package/dist/types-BHK2dkMu.d.ts +0 -172
  47. package/dist/types-H7o6MaPo.d.ts +0 -512
  48. package/dist/view-DSQgxBJB.d.ts +0 -63
package/CHANGELOG.md CHANGED
@@ -1,5 +1,953 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 9190fc9: Follow-ups a 3D lab turned up while driving core's dispatcher over a WebGL
8
+ viewport. Each one is a place the kit assumed its own 2D renderer.
9
+
10
+ **`classifyTarget` and `affordanceAt` now take the world point their types
11
+ promise.** Both were handed the raw client point at every dispatcher call site,
12
+ so `<SceneCanvas>` and `<CanvasView>` each wrapped their thunk in the same
13
+ `clientToWorld` they also passed the dispatcher, and a consumer hit-tested in
14
+ one space while reading `ctx.world` in another. The conversion happens once now,
15
+ where the event arrives. Behavior-identical for both kit consumers; a consumer
16
+ passing no `clientToWorld` sees identity. **If you pass either option to
17
+ `useGestureDispatcher` yourself and convert coordinates inside it, remove your
18
+ conversion.**
19
+
20
+ **`InvocationCtx.screen` carries a screen point, and is optional.** It was
21
+ filled from the same field as `ctx.world`, so it had never been screen-space.
22
+ It now comes from the event's client coordinates and is absent where the event
23
+ carries none — a keystroke, a UI-driven trigger, a synthetic probe. A
24
+ view-mutating drag still wants `drag.screenDelta`. A click's `ctx.world` is the
25
+ click's own position rather than the origin.
26
+
27
+ **`scene.history` publishes the `History` a `Scene` already owned.** The kit's
28
+ `undo`/`redo` actions resolve a `history` dep and a consumer had nothing to give
29
+ them, so `<SceneCanvas>` cast the Scene itself through `unknown`. It is a façade
30
+ rather than the private engine: mutating members route through the scene's own
31
+ wrappers, so an action-driven undo bumps the version and notifies subscribers.
32
+
33
+ **`resolveOverlays` is the overlay half of the in-flight gesture channel.**
34
+ `resolvePreviews` already answered for the ghosts a gesture displaces; this
35
+ answers for the chrome it draws that is no node at all — a marquee rect, a lasso
36
+ trail, an insert outline — in world geometry, with every degenerate case
37
+ dropped. `insertPreviewExtent` is exported alongside it.
38
+
39
+ **Every overlay variant is now geometry, and the layer owns the paint.**
40
+ `OngoingOverlay`'s `'commands'` variant — arbitrary `DrawCommand[]`, which only
41
+ core's own 2D renderer could execute — **is gone**, along with the `opaque` flag
42
+ on the resolved form and the `action.commands` chrome id. Its two producers
43
+ publish the new `'polyline'` variant instead: a run of world points plus a
44
+ one-word `OverlayRole` (`'cut'` for `slice`, `'connector'` for
45
+ `@weasel-js/diagram`'s `connect`) that a painter maps to a stroke, falling back
46
+ to plain chrome for a role it does not know. `useDispatcherOverlayLayer` draws
47
+ both exactly as they were drawn before, and
48
+ `DispatcherOverlayStyle.roles` is where a consumer restyles one.
49
+ **`ConnectActionOptions.stroke` is removed** — an action no longer names a
50
+ paint; use `roles: { connector: … }` on the layer's style.
51
+ **If you produced a `'commands'` overlay**, publish a `'polyline'` for a line,
52
+ or paint it from a render layer of your own.
53
+
54
+ **labkit stacks two surface buffers around the trial DOM.** The shared buffer
55
+ sat over the trials, which is right for a mark annotating an instrument and
56
+ wrong for an opaque renderer that buries its own pane. `useSurfaceCanvas('under')`
57
+ asks for the lower buffer; the default is unchanged. **`SurfaceCanvasContext`
58
+ now carries `{ over, under }` rather than one canvas** — a consumer providing it
59
+ directly must update the value.
60
+
61
+ **labkit labs get their own chrome regions.** Every region was per-trial, so a
62
+ lab-level control had nowhere to go and `LabPalette` existed by casting a
63
+ two-field object through `as unknown as TrialChromeContext`. `<Lab labChrome>`
64
+ takes contributions shaped exactly like a trial's, against a real lab context.
65
+
66
+ `docs/extending.md` now states the contract for mounting tools outside
67
+ `<SceneCanvas>`, including the half that was written down wrong: capability
68
+ eligibility resolves through `RuleCtx.allowedCapabilities` and `getRuleCtx`, not
69
+ the `activeTool` dep.
70
+ - a2feeb0: The default actions now work in a scene where a container's pose is a frame.
71
+ Each one read a stored pose as though it were world, which is right only under
72
+ the identity composition every consumer ships today; under any other one a
73
+ selection inside a rotated container aligned to the wrong edge, orbited the
74
+ wrong pivot, and grew along the wrong axis.
75
+
76
+ `resize`, `rotate`, `flip`, `align`, `distribute` and `clone` now compose the
77
+ poses they measure up to world, and rebase every pose they write — preview,
78
+ override and committed op alike — into the frame the node stores it in. An
79
+ action that reads world and writes world looks right for one gesture and drifts
80
+ on the next, so `scenePoseFrame` pairs the two directions and each action's
81
+ tests run against a rotated-container fixture where local and world differ.
82
+
83
+ `align`, `distribute` and `flip` also read through `effectivePose` now, so they
84
+ see a gesture's in-flight override instead of the pose underneath it.
85
+
86
+ `group` and `ungroup` re-express every member across the change of frame, so
87
+ neither moves anything on screen. The container takes the world envelope of its
88
+ members' ink (`unionAABB`, so a turned member contributes what it covers rather
89
+ than the box it was posed in). Its pose is derived from its members only under
90
+ an identity composition: anywhere else that derivation reads poses expressed in
91
+ the container's own frame to compute that frame, which is circular, so the
92
+ container keeps the authored envelope until that has its own answer.
93
+
94
+ `useAlign` and `useDistribute` take the same seam — an optional `getParent` on
95
+ the adapter and an optional `composition` in the options. Both default to the
96
+ absolute-pose behavior they have today.
97
+ - 3ecc1be: Dragging a path anchor now edits the anchor on a canvas with no mode registry.
98
+ The drag used to move the whole shape: anchors sit on the selected body, so
99
+ `move`'s ambient binding matched the same press as `editAnchors` and won on
100
+ registration order. `move` now declines a press that hit an anchor or control
101
+ handle, and the drag falls through to `editAnchors`. Canvases that pass
102
+ `getActiveMode` were unaffected, because path-edit mode already filters `move`
103
+ out.
104
+ - dd48085: A window blur now cancels every pointer held on the canvas. A drag in flight
105
+ ends with reason `'cancel'` rather than waiting for a release that may never
106
+ arrive, and a press that had not yet become a drag is dropped without a click.
107
+ - efaf707: A loupe can now be edited through. `createLoupe({ views: api, interactive: true })` makes the lens a view on the canvas: a press inside it selects the node it magnifies, and a drag moves that node by the pointer travel divided by the magnification. With `views` alone the lens paints the canvas's own stack but keeps its clicks as an eyedropper; with `source` alone it is the picture it was. A lens given `views` reads back from its canvas's `getSurfaceRect()`, so over a `paintInto` pane it samples that pane without a `region`.
108
+
109
+ Views gained what that needed. `SceneCanvasApi.addView` declares a `<CanvasView>` from outside React, and its `paint: false` hands the drawing to a host such as a HUD window. `<CanvasView view>` accepts a thunk for a camera derived from the canvas's, and `interactive={false}` makes a view paint-only. A registered layer painted over a view — a HUD window over a panel — now takes presses there instead of the view beneath, and a view no longer hit-tests registered layers it does not paint.
110
+
111
+ `hud.window({ interior: 'pass' })` gives the interior's input to what it shows while the frame stays chrome.
112
+ - 7586835: `@weasel-js/geom` is now the single definition of the geometry both packages
113
+ were carrying, and `@weasel-js/core` imports it.
114
+
115
+ Two of core's command-stream walks had the pen wrong after a `Z`: `boundsOfPath`
116
+ measured a following curve from the last point drawn rather than the subpath
117
+ start, and `extractPolylines` flattened one from there. Both are fixed by
118
+ `forEachSegment`, which now returns the pen where SVG says `Z` leaves it, and
119
+ which also reports the command index and stops when its visitor returns `false`
120
+ — the three things core's own walks needed. Ten walks, six Bernstein
121
+ evaluations, an even-odd ray cast and four rect-corner literals now go through
122
+ geom.
123
+
124
+ `pathPoseDescriptor.remapBounds` scaled a zero-extent source axis by `0`, which
125
+ collapsed a flat path onto the destination origin and left a transform that
126
+ could not be inverted. It uses geom's `boxToBox`, which translates that axis, as
127
+ `scalePathToBounds` already did.
128
+
129
+ Moved into geom so core no longer keeps a second copy: bezier flattening
130
+ (`flattenQuadratic` and both arc-length variants included) and `pathCrop`.
131
+ geom's boolean adapter picks up core's ring nesting, which pre-tests bounding
132
+ boxes and votes over three sample vertices where geom probed one — a hole
133
+ sharing a vertex with its container was misclassified.
134
+
135
+ `rectToContour` now emits the four corners with the closing edge implicit,
136
+ matching what `pointInPolygon` documents and what every call site wants. The
137
+ repeated first vertex it used to emit is a zero-length closing segment for
138
+ anything that strokes the result.
139
+ - 6385c68: A container's pose can now define a **frame**: a child's stored pose is
140
+ expressed in it, so rotating a container rotates its contents and moving one
141
+ carries them without touching their poses. Opt in with
142
+ `<SceneCanvas poseComposition={RIGID_POSE_COMPOSITION}>` or
143
+ `sceneToAdapter(scene, { poseComposition })`. Omit it and nothing changes —
144
+ poses stay absolute, exactly as before.
145
+
146
+ Before this, nesting contributed a clip chain and nothing else. `getPose` was
147
+ documented as returning a local pose while every render walk, both pick
148
+ sources, all selection chrome and the clipboard treated it as world, which
149
+ agreed only because no consumer ever supplied a composition.
150
+
151
+ `getPose` still returns the stored pose. `getWorldPose` on the scene adapter is
152
+ the composed reading, and is what picking, chrome, `getNodeAtPoint` and the
153
+ clipboard consume. `composeRigidPose` / `decomposeRigidPose` and the
154
+ `RECT_POSE_COMPOSITION` / `RIGID_POSE_COMPOSITION` strategies are exported.
155
+
156
+ `PoseComposition` gains a required `closure` field naming the transforms it
157
+ represents exactly. This is a breaking change to that interface for anyone
158
+ constructing one by hand; nothing in the repo did. `RectPose` carries no scale
159
+ factor, so the strategy that ships is rigid — translate and rotate — and an
160
+ anisotropically scaled parent is outside what a pose can hold, since it turns a
161
+ rotated child into a parallelogram.
162
+
163
+ `sceneToAdapter` throws when given both `poseComposition` and
164
+ `cascadeContainerPose`. The cascade translates every descendant when a
165
+ container moves, which is what absolute poses need and would move a framed
166
+ child twice.
167
+
168
+ `useNodeOverlayFrame` read the authored pose, so an overlay ignored gesture
169
+ overrides and derived poses. It reads the effective pose now.
170
+
171
+ The default actions — resize, rotate, group, clone, flip, align, distribute —
172
+ read the strategy as a `poseComposition` dep alongside `poseDescriptor`, and
173
+ `<SceneCanvas>` publishes it through the new `usePoseCompositionDepSource`.
174
+ A consumer wiring actions without `SceneCanvas` has to publish that dep itself;
175
+ without it the actions fall back to identity and write world poses into a
176
+ framed scene.
177
+
178
+ Design: `docs/superpowers/specs/2026-09-10-group-as-frame-design.md`.
179
+ - 2f1ddd0: `@weasel-js/kernel3d` is a new package: poses, an orbit camera, ray picking and screen-projected chrome geometry over core's scene graph and dispatcher. It hosts a renderer rather than owning one — a consumer brings its own and the kernel hands it poses — and it takes core as a peer, the same tier `svg`, `diagram` and `loupe` sit in.
180
+
181
+ Core took no diff for it. `Scene` is generic over its pose and holds a `Pose3` with no adapter; a 3D host passes the dispatcher an identity `clientToWorld` so `ctx.world` stays two numbers and each dep rebuilds the ray from the camera it closes over; tools transfer untouched. The two things that do not transfer are stated rather than guessed: `ViewApi` has no orientation, so the kernel declares a `camera3d` dep of its own, and `PoseDescriptor.remapBounds`/`fromBounds` throw, because a screen rectangle does not name a 3D pose without a depth.
182
+
183
+ `@weasel-js/geom` gains a `./3d` subpath — vectors, quaternions, 4x4 matrices, ray/AABB and ray/plane intersection, and `transformAabb`. Dependency-free like the rest of the package, and immutable tuples rather than classes, so a pose survives `structuredClone` with its methods intact because it never had any.
184
+
185
+ Two corrections to code promoted out of the 3D lab. `projectAabbToScreen` now clips each of the box's twelve edges against the near plane instead of dropping the corners behind it; the old behaviour reported a box too small for anything straddling the near plane, and reported almost nothing for a solid the camera sits inside. And the seam that says how big a node is now asks for its world box rather than a local one to transform: a sphere's box is the same under every rotation, and no transform of a local box reproduces that.
186
+
187
+ `sceneFromJSON`'s `options` argument is now optional. Every field in it already was, so the natural one-argument call did not compile.
188
+
189
+ Also new: a test that a quaternion pose survives `toJSON` and `sceneFromJSON` with its rotation intact. The claim that `Scene` is dimension-neutral had only ever been run against `setPose` and undo.
190
+ - ea285a2: `LayerRecord.locked` now does something. A locked layer still paints, but its
191
+ nodes are out of reach:
192
+
193
+ - A click, marquee, lasso, double-click text edit, the `nodeAtPoint` dep (drop
194
+ targets, the eyedropper) and Select All all pass over them. A pick query that
195
+ only samples paint can take them back with `includeLocked: true`.
196
+ - The scene's selection never holds one: `setSelection` drops them, and locking
197
+ a layer drops its nodes from the selection. Undoing the lock puts them back.
198
+ - Every node mutation on them throws: `add` onto the layer or under a locked
199
+ container, `setPose`, `update`, `remove`/`removeMany` (including a cascade
200
+ that reaches one), `setLayer` from or onto the layer, `setDependsOn`, `move`
201
+ and `reorder`. Kit actions commit through those, so delete, nudge, move,
202
+ resize, rotate, group, ungroup, reorder, cut and the paint actions refuse too.
203
+ - A lock covers a container's whole subtree, whatever layers its descendants
204
+ are tagged to. There is no per-node lock.
205
+
206
+ `scene.isLocked(id)` answers the question, and `scene.unlocked(fn)` runs a
207
+ programmatic edit with the guard lifted. Undo and redo never need it. The
208
+ layer operations — the lock toggle itself, visibility, rename, reorder and
209
+ `removeLayer` — are not gated.
210
+
211
+ `scene.applyBatch`, `scene.batch` and `scene.history.apply`/`applyOps` are now
212
+ all-or-nothing: an op that throws partway reverts everything the call already
213
+ applied and records no undo entry. Before, the ops ahead of the throw stayed
214
+ applied with no entry to undo them.
215
+ - c758b4d: The loupe reads the pixels it is aimed at. Two fixes:
216
+
217
+ - **Its color comes off the frame after the aim.** `loupe.color` and `onColorChange` were read at aim time, which returns the frame before the aim. They now settle on the next frame to land. `pick()` still answers immediately, and now returns `null` if no frame has landed yet. On `@weasel-js/loupe`, a `LoupeSurface` that offers `subscribeFrame` gets this deferred sampling; a surface without it is sampled at aim time, as before.
218
+ - **It works over a pane of a shared canvas.** `CanvasExtensionApi.getSurfaceRect()` returns the rect of `surface` the canvas paints into: the pane's rect under `paintInto`, otherwise the whole canvas. Pass it as `createLoupe`'s new `region` option. The readback then offsets the aim by the pane's origin and stays inside the pane. Before, the loupe over a `paintInto` pane magnified whatever sat at the same offset from the shared canvas's corner.
219
+
220
+ Anything that implements `CanvasExtensionApi` by hand now has to supply `getSurfaceRect`.
221
+ - a41a83a: `<MinimapCanvas>` takes an `animator` prop and forwards it to its `<SceneViewCanvas>`, so the minimap repaints on the animator's ticks and paints its vertex-color overrides. Pass the main canvas's animator and the minimap shows the same animated colors instead of each node's stored ones.
222
+ - 794b4ff: A number pref can name how its value is shown. `ToolPrefNumber.format` is
223
+ `'plain'` or `'compact'`, and labkit sets it with
224
+ `f.number(0).range(0, 2_000_000).format('compact')`. A compact readout keeps a
225
+ value's precision below a thousand and abbreviates above it at one decimal:
226
+ `950`, `40.0K`, `2.0M`.
227
+
228
+ `SliderRow` takes the same choice as `notation`, and its readout reads typed text
229
+ through the new `parseNumber`: thousands commas and a `k`/`m`/`b`/`t` suffix are
230
+ accepted, so `2.5m` commits 2,500,000. An emptied readout now reverts instead of
231
+ committing zero. `formatCompact` and `parseNumber` are exported beside
232
+ `formatNumber`.
233
+
234
+ **A slider readout is no longer narrower than its own values.** The box was a
235
+ fixed width, so a six-digit value lost a digit and read as a smaller number. It
236
+ now widens to fit the longer of its formatted `min` and `max`, and rows whose
237
+ values already fit keep their width. `--wzl-property-readout-w` still sets the
238
+ floor.
239
+
240
+ `NumberRow` and `PrefsForm` ignore the format: one edits through a native number
241
+ input that cannot display `2.0M`, and the other's sliders show no value.
242
+ - b65f4df: `RuleCtx` carries a zoom, not a `View`.
243
+
244
+ `zoomAtLeast` is the only selector that ever read the viewport, and one number
245
+ is all it needs. A host whose viewport is a camera had no `View` to hand over,
246
+ so it could not build a rule context at all — and a dispatcher with no
247
+ `getRuleCtx` skips every eligibility rule silently rather than failing.
248
+
249
+ `RuleCtx.view: View` is now `RuleCtx.zoom?: number`, `BuildRuleCtxArgs` the
250
+ same, and `zoomAtLeast` declines when no zoom is reported. `viewZoom(view)` is
251
+ exported from `@weasel-js/core` for the 2D callers that now pass it; the legacy
252
+ `ChromeCtx` shape still carries a `View` and `resolveVisibility` converts.
253
+ - 90f0bd8: Every 2D affine inversion now uses `@weasel-js/geom`'s `invert` and its singularity rule, which judges the determinant against the matrix's own scale.
254
+
255
+ - SVG import now keeps a transform under a uniformly tiny parent scale, such as `scale(0.0000001)`. It used to call that parent singular and bake the child's rotation into the wrong space. A parent that really is singular now drops the child's transform with a warning, and so does a large parent whose determinant is only rounding. `@weasel-js/svg` now depends on `@weasel-js/geom`.
256
+ - A gradient or pattern measured in `units: 'local'` or `'world'` now draws nothing when that space has no inverse, for example under a group that scales an axis to zero. It used to draw as if untransformed. `mat3.invert` returns `null` for such a matrix instead of the identity, and `PaintBindContext.spaceInverse` now returns `Mat3 | null`, so a registered paint kind should return `null` from `bind` when it gets `null`. Both are type-level breaking changes.
257
+ - In the custom-shader vertex prelude, `v_world` now reads the world origin when the view has no inverse, instead of a scaled mapping that looked plausible and was wrong.
258
+ - `useNodeOverlayFrame`'s `toLocal` now keeps the last mapping that had an inverse while a live view flattens an axis. It used to hand the overlay point back unchanged.
259
+ - b5b8b69: A layer one view hides is now gone from that view for input too, not only for
260
+ paint.
261
+
262
+ - `<SceneCanvas>` documents `layerVisibility` / `layerOrder` as its own props.
263
+ They already reached the painter; now a click, a marquee, a lasso, Cmd+A and
264
+ hover in that canvas pass over a scene layer it hides (keyed
265
+ `scene:<layerId>`). Another view of the same scene still paints and takes it.
266
+ The scene's own `LayerRecord.visible` still applies underneath and cannot be
267
+ overridden.
268
+ - `<CanvasView>` (and `SceneCanvasApi.addView`) take the same two props,
269
+ applied after `layers` narrows the stack, for its paint and its picking. It
270
+ now also honors a layer's `defaultVisible`, as the surface does.
271
+ - `<SceneViewCanvas>`, `<MinimapCanvas>`, `renderSceneToCanvas` and
272
+ `buildSceneViewCommands` take `layerVisibility` / `layerOrder` keyed the same
273
+ way, so the main canvas's map can be passed straight to its minimap.
274
+ - `useSelectTool` takes `alphaOf` and `layerIsPainted`, so a consumer with an
275
+ adapter and no `Scene` can make faded or unpainted nodes unclickable.
276
+ - `ViewApi` gains an optional `layerIsPainted`; `selectAll`, `areaSelect` and
277
+ `lassoSelect` now require the `view` dep and pass it to
278
+ `AreaSelectDep.hitTestArea` / `LassoSelectDep.hitTestArea` / `hitTestLasso`
279
+ as a new optional last argument. A marquee or lasso on a canvas with
280
+ `alphaFor` also skips nodes painted at alpha 0, as a click already did.
281
+ - b2f2d45: Let a `PoseDescriptor` see the node it is describing.
282
+
283
+ Every method on the descriptor took a pose and nothing else, so two shapes
284
+ sharing a pose type — a sphere and a box both posed by position/rotation/scale —
285
+ were indistinguishable to it. New optional `forNode(node)` returns a descriptor
286
+ specialized to one node; `poseDescriptorForNode(descriptor, node)` is the
287
+ accessor, and returns the descriptor unchanged when it declares no
288
+ specialization.
289
+
290
+ This widens API. No existing signature changed, so a descriptor that ignores the
291
+ node compiles and behaves exactly as before. The kit calls `forNode` from the
292
+ sites that already hold a node — marquee and lasso hit-testing, select-tool
293
+ picking, selection chrome bounds, the container-pose cascade, the minimap union,
294
+ `arrayAdapter`, and the move and rotate actions. Sites holding only an id keep
295
+ reading the unspecialized descriptor.
296
+ - 6f5ff46: Pose geometry is supplied once. `<SceneCanvas poseDescriptor={…}>` tells every
297
+ built-in action, the selection chrome, picking and area select how to read and
298
+ rewrite this scene's poses; it defaults to `AUTO_POSE_DESCRIPTOR` (rect and
299
+ `Path` poses). A pose of any other shape now works end to end — before, dragging
300
+ one into a container wrote `NaN` into it.
301
+
302
+ Breaking:
303
+
304
+ - `PoseProjection` is renamed `PoseDescriptor`, and gains a required
305
+ `fromBounds(bounds, template)` and an optional `withRotation(pose, rotation)`.
306
+ - `ResizePose` and `AlignBounds` are removed; use `Bounds`.
307
+ - `RotateGeometry`, `AlignBoundsProjection` and `RECT_ALIGN_PROJECTION` are
308
+ removed.
309
+ - Removed options, replaced by the descriptor: `selectTool.resize.geometry` and
310
+ `useResizePolicy({ projection })` (use `<SceneCanvas poseDescriptor>`);
311
+ `UseRotateOptions.geometry` and `UseMoveOptions.translatePose` (both were
312
+ unread); `poseBounds` on `useSelectTool`, `arrayAdapter`, `sceneToAdapter`,
313
+ `MinimapCanvas` and `nestedHitTester` (use their `poseDescriptor` option);
314
+ `arrayAdapter`'s `intersectsRect` and `translatePose`; the selection overlay's
315
+ `getBounds` and `fromBounds`; the alignment behaviors' `projection`.
316
+ - `Canvas`'s `geometry` prop is renamed `poseDescriptor`. `SceneCanvas`'s own
317
+ `geometry` prop — the `pickEvery` / `boundsOf` hit-test overrides — is a
318
+ different prop and keeps its name.
319
+ - `computeFitView`'s fourth argument is a `PoseDescriptor`, not a bounds
320
+ function.
321
+ - `sceneToAdapter`'s `cascadeContainerPose` is a boolean; the cascade translates
322
+ through the descriptor.
323
+ - The kit's built-in painters only draw rect poses. A node with any other pose
324
+ needs its own painter.
325
+ - `Scene` has a read-only `registry`. For a custom pose kind,
326
+ `unionOfChildrenVia(descriptor)` builds the container-union function to
327
+ register under `UNION_OF_CHILDREN`.
328
+ - edd5b39: `createPoseFeed(scene)` is the channel a renderer weasel does not own uses to
329
+ keep its objects in step with a `Scene`. It publishes `added` / `removed` /
330
+ `changed` with effective poses, so a retained renderer mutates only what moved
331
+ instead of rebuilding every node's draw record on any change.
332
+
333
+ It reads the scene's two clocks separately. Committed edits bump
334
+ `Scene.getVersion()` and cost one `O(n)` walk of three reference comparisons per
335
+ node — the scene mutates node objects in place and swaps their `pose` and `data`
336
+ references, so it is those the feed snapshots. A drag lives in `Scene.overrides`,
337
+ which names the ids it touched and costs `O(changed)`, so the walk never runs on
338
+ the hot path. A `FeedNode` carries the effective pose beside the node's committed
339
+ one, so a host can draw both without a second channel.
340
+
341
+ The delta carries no order: a host that draws in order re-reads
342
+ `renderOrderNodes()`, which is cached until a structural edit. The feed does not
343
+ coalesce notifications either — that is the host scheduler's job.
344
+
345
+ The 3D lab is the first host.
346
+ - 2e2041b: `resolvePreviews(sources, scene)` reads what an in-flight gesture is proposing,
347
+ with no renderer in it.
348
+
349
+ An ongoing action publishes interim poses on its handle rather than writing them
350
+ to the scene, and until now `<SceneCanvas>`'s ghost layer was the only thing that
351
+ knew how to read them: which ids are in flight, whose preview wins when two
352
+ sources name the same id, which previewed nodes are roots and which are their
353
+ previewed children, and which are merely displaced rather than dragged. None of
354
+ that is about drawing. A consumer with its own renderer needed all of it and had
355
+ to rebuild it from `Dispatcher.getInFlightHandles()`.
356
+
357
+ It returns the previewing subtrees as roots, each carrying the committed node
358
+ beside the interim pose and data; `flattenPreviews` walks them parents-first.
359
+ `usePreviewGhostLayer` now draws from it, and the 3D lab under
360
+ `packages/labkit/examples/3d-lab` reads its drag ghosts through it instead of
361
+ its own copy.
362
+
363
+ The overlay channel — marquee, lasso, insert preview — is not covered: those
364
+ arrive as `DrawCommand[]`, which is core's own 2D renderer vocabulary.
365
+ - 65806bc: Fix eleven latent routing faults surfaced by the extraction's correctness pass.
366
+ All predate the move into `@weasel-js/routing`.
367
+
368
+ - Dispatcher and dep-registry ownership are stacks rather than single slots, so
369
+ with two canvases under one `<ActionsProvider>` the one still on screen keeps
370
+ its wiring when the other unmounts. `begin()` no longer returns `null`
371
+ permanently after that.
372
+ - An offhand hotkey hold now releases its own tool instead of whatever is on
373
+ top of the hold stack, so overlapping holds released out of order disengage
374
+ the right tool.
375
+ - `reportDeadClaim` no longer reads `process.env` bare. A consumer loading the
376
+ published ESM in a runtime with no `process` got a `ReferenceError` out of the
377
+ pointerdown listener on any unmatched exclusive claim.
378
+ - `ctx.drag.points` accumulates every pointermove vertex for actions that
379
+ declare no `onMove`. Such an action previously saw only the press point,
380
+ committing a one-vertex path.
381
+ - `ContributionsApi.entries` tracks the entry list rather than the entry list as
382
+ it stood when the focused tool last changed.
383
+ - The dev-only route-conflict reporter compares ambient entries against each
384
+ other (it never did), keeps two collisions on different predicates apart, and
385
+ checks a lone action against itself.
386
+ - `inFlightCursor` reports the most recently started gesture's cursor, agreeing
387
+ with `getActiveAction`; the dispatcher breaks same-specificity hotkey ties in
388
+ favor of the newest hold, agreeing with `ToolsApi.hotkeyEngaged`.
389
+ - **`Eligibility.capabilities` now gates.** It never did: `liveScope`
390
+ short-circuits when no `allows` predicate is supplied, and none was. A tool
391
+ declaring `capabilities` whose action carries no `eligible` rule kept routing
392
+ input in a mode that forbids those tags. The dispatcher builds the predicate
393
+ from the `RuleCtx` it already holds — so this changes behavior only for
394
+ consumers that wired the modes system, which is where the declaration was
395
+ meant to take effect.
396
+ - A multitouch handle is ended when the finger count changes rather than left in
397
+ flight, so a third finger landing mid-pinch no longer commits two gestures on
398
+ the final lift.
399
+ - The route-conflict reporter buckets each key alternative separately, so
400
+ `key: ['h','H']` is reported as colliding with `key: 'H'`. `RegistryEntry`
401
+ gains an optional `argAlternatives` carrying them.
402
+ - a614be4: Extract binding-to-action routing into `@weasel-js/routing`.
403
+
404
+ The gesture dispatcher, the action registry and invoker, tool and contribution
405
+ declaration, the route grammar's reflection surface, and the eligibility rule
406
+ algebra now live in their own package beside `@weasel-js/gestures` and
407
+ `@weasel-js/history`. It ships two entry points: the pure dispatcher on the main
408
+ entry — no React, no DOM — and the React seam that pumps browser events into it
409
+ behind `@weasel-js/routing/react`, with React an optional peer. A kernel that
410
+ drives routing itself can take the first without the second.
411
+
412
+ `@weasel-js/core` depends on the new package and re-exports every symbol that
413
+ moved, so **no existing import changes**, including `@weasel-js/core/routing`.
414
+ A consumer that adds its own dependency still writes
415
+ `declare module '@weasel-js/core'`; the merge carries through core's re-export.
416
+
417
+ `createPaintedCursorState` and its types move to `@weasel-js/cursor`, where the
418
+ cursor they hold is declared. `@weasel-js/core` re-exports them unchanged.
419
+ - ef60ff6: The routing layer's types now state where the dispatch boundary runs, instead of leaving it implicit inside two large interfaces.
420
+
421
+ `Action` splits into `ActionDispatch` — bindings, deps, invoker, scope, gates, cursors — and `ActionPresentation`: label, icon, group, shortcut. `Contribution` splits the same way into `ContributionRouting` and `ContributionChrome`. Both composed types keep every field they had, so nothing that authors an action or a contribution changes.
422
+
423
+ New alongside them: `BindingSource`, the id-plus-`defaultBinding` shape `actionBindings` reads; and `ActionSource`, the single `list()` method the gesture dispatcher consults a registry through. `actionBindings`, `BoundGesture`, `evaluateEnabled`, `ActionEnabledResult`, `SliceDep`, `ClipboardDep` and `TextEditDep` are re-exported from the same barrel entries as before, from new homes. `ClaimableGesture` now lives in `@weasel-js/gestures` beside `GestureName`, and is still exported from `@weasel-js/core`.
424
+
425
+ `_resetEnabledWarnsForTests` is removed. It had no callers and was never on the public barrel.
426
+
427
+ Under this, core's import graph loses a 15-file strongly connected component spanning contributions, tools, actions, the dep schema and the dispatcher. Nothing in routing, tools or contributions is in an import cycle now.
428
+
429
+ The consumer smoke test gained a check that a consumer's own `declare module '@weasel-js/core'` dep merges into `DepSchema` against the published declarations, and stopped reading a subpath import such as `@weasel-js/geom/booleans` as an undeclared package.
430
+ - 269d432: Animated vertex colors now reach nodes the scene paints itself. `<SceneCanvas animator>` paints `animator.colorOverrides` — what `tweenVertexColors`, `springVertexColors`, `cycleVertexColors` and `staggerVertexColors` write — onto the scene's nodes: the built-in path, shape and derived-path painters apply them, and a custom `drawOne` receives them as the new `NodePaintCtx.vertexColors`. Before, only `createPathLayer` read the registry, so a default-painted node ignored every color animation.
431
+
432
+ The same colors reach detached renders: `<SceneViewCanvas>` takes an `animator` prop, and `renderSceneToCanvas`, `renderSceneToPixels` and `buildSceneViewCommands` take the registry as `colorOverrides`. `ColorOverrideRegistry` gains `has(id)` and `resolve(id, channel, base, tMs)`, the one resolution `createPathLayer` and the scene walks now share.
433
+
434
+ Path nodes also paint fill vertex colors from `data.vertexColors`, the same field `PathDrawCommand` uses.
435
+ - 486f631: `staggerVertexColors` no longer jumps straight to its end colors. It published a function-form override that read its timestamp as time since the stagger began, but `createPathLayer` calls a function-form override with `performance.now()`, so on screen every anchor finished the moment the stagger started. The helper now publishes a plain color array on each tick, the same way `tweenVertexColors` does.
436
+ - 0f374d8: `kit:text` nodes with `align: 'center'` or `'right'` now align within
437
+ `pose.width`. They were anchored on `pose.x`, so centered text hung half outside
438
+ the left edge of its box and right-aligned text ended at that edge.
439
+
440
+ Alignment has its own width, separate from the wrap width:
441
+ `LayoutRunsOpts.alignWidth` and `TextDrawCommand.width`, both defaulting to
442
+ `maxWidth`, and a trailing `width` argument on `textCommand` /
443
+ `textCommandFromRuns`. The painter passes its pose width there and still does
444
+ not wrap. `textLineBoxes` and `caretIndexAt` align within `pose.width` even at
445
+ `maxWidth: Infinity`, so the silhouette and the caret follow the paint. A
446
+ `layoutRuns` call or text command that sets no alignment width lays out exactly
447
+ as before.
448
+ - d25a09d: `useSceneTextEdit`'s editing overlay now follows the canvas's pan and zoom
449
+ without being handed a `view`. It reads the camera from the weasel canvas
450
+ mounted inside the `container` it is given — the canvas a double-click landed
451
+ on, when there are several — so the overlay's text sits on the glyphs it
452
+ replaces at any zoom. Passing `view` still works and still wins.
453
+
454
+ The overlay is also clipped to that canvas's box, the same place the canvas
455
+ clips its glyphs. A zoomed text node used to paint its editor across the page
456
+ beside the canvas. Clipping the container with `overflow: hidden` was no fix:
457
+ the browser scrolls a `hidden` box to keep the caret in view, so typing past
458
+ the edge dragged the canvas sideways. The clip cannot scroll.
459
+
460
+ `useTextEdit` gains the underlying option, `getClipRect`: a box in container
461
+ pixels to clip the overlay to, re-read every frame. The overlay now mounts
462
+ inside a clip box of its own, one level below `container`, rather than as the
463
+ container's direct child. With no clip rect, nothing is clipped.
464
+ - deb9e79: Text wraps only where its style says so, and everything that lays a text node
465
+ out now agrees. `TextStyle.wrap` (default `false`) breaks lines between words
466
+ at the pose width; without it a line runs as long as its text and the width
467
+ only resolves `align`.
468
+
469
+ Before this, `kit:text` never wrapped while `createTextLayer`, `textLineBoxes`,
470
+ `caretIndexAt`, `fitTextPose` and the edit overlay all wrapped at the pose
471
+ width. Opening an edit on a `kit:text` line longer than its box reflowed it,
472
+ and a double-click could put the caret on a line the canvas never drew.
473
+
474
+ **Breaking:**
475
+
476
+ - `createTextLayer` and `fitTextPose` (`axis: 'height'`) no longer wrap unless
477
+ the style sets `wrap: true`. Add it to text that should keep wrapping.
478
+ - `TextLineBoxesOpts.maxWidth` and `caretIndexAt`'s `opts` argument are gone,
479
+ along with the `CaretIndexAtOpts` type. Both read `style.wrap`.
480
+ - The edit overlay is `white-space: pre` for unwrapped text, sized to its
481
+ content, and never breaks inside a word in either mode.
482
+
483
+ New: `layoutTextPose` and `textPoseLayoutInput` in `@weasel-js/text`, and
484
+ `textCommandFromPose` in `@weasel-js/core`, which `kit:text` and
485
+ `createTextLayer` both emit. `textLineBoxes` and `caretIndexAt` now resolve
486
+ `align: 'start' | 'end'` against `direction` as the painters do, and
487
+ `useSceneTextEdit` maps a double-click through the node's `verticalAlign`
488
+ (`getVerticalAlign` for custom data), which it used to ignore. SVG export
489
+ writes `data-weasel-wrap="true"` and import reads it back.
490
+ - 830cf7e: A timeline can now book its events ahead of the frame against an outside clock,
491
+ so a sound lands at its true sub-frame time rather than on whichever frame
492
+ noticed the crossing. Pass `booking: { clock }` to `animator.timeline()` and give
493
+ an event a `book(when)` handler; `clock` is anything with `now()` in ms, and an
494
+ `AudioEngine` from `@weasel-js/audio` is one:
495
+
496
+ ```ts
497
+ animator.timeline({
498
+ booking: { clock: engine },
499
+ tracks: [{ kind: 'event', events: [{ t: 500, book: (when) => engine.play(hit, { when }) }] }],
500
+ });
501
+ ```
502
+
503
+ Each event is booked once per crossing, up to `lookahead` clock ms before its
504
+ edge (default 100). Returning a handle with `stop()` — a `VoiceHandle` is one —
505
+ lets a pause, seek, time-scale change, loop change, `edit` or cancel retract the
506
+ booking while the clock has not reached it; playback books it again wherever it
507
+ next reaches the event. A seek never books the span it skips. An event first
508
+ reached after its edge books at `clock.now()`, or is skipped once it is later
509
+ than `maxLate`. The frame clock's mapping onto the booking clock is smoothed per
510
+ frame, so per-frame read jitter does not reach `when`, and resynced when the two
511
+ jump apart.
512
+
513
+ `TimelineEvent.fire` is now optional, since an event may only book. Reading
514
+ `event.fire` directly needs a check for `undefined`.
515
+ - 50d2881: A number pref with a display unit now reads a unit typed into it: `0.25turn` or
516
+ `30°` in the rotation field stores π/2 or π/6, and `12mm` in a field showing
517
+ centimeters stores what 1.2cm is.
518
+
519
+ **`prefUnit(system, displayUnit, { precision?, suffix? })`** builds a
520
+ `ToolPrefNumberUnit` from a `UnitSystem`, so a leaf no longer hand-writes its
521
+ conversion. `ToolPrefNumberUnit` gains `accepts`, the units a person may type
522
+ and the factor each scales by. `ANGLE_RADIANS` joins the unit tables, and
523
+ `rotationDegreesUnit` is built from it.
524
+
525
+ **`parseNumber(text, units?)`** reads a trailing unit, longest name first, and
526
+ a unit beats a magnitude suffix: with meters accepted, `2m` is two meters.
527
+
528
+ **`UnitField`** is a text field for a number that can carry a unit. Both
529
+ `SelectionPanel` and `PrefsForm` edit a unit leaf through it; a leaf with no
530
+ unit keeps `NumberField`.
531
+ - a5f738a: Resolve a screen rectangle at a pose's own depth, and stop deriving a group's
532
+ bounds from poses the kit cannot read.
533
+
534
+ `kernel3d`'s `PoseDescriptor.remapBounds` and `fromBounds` threw: a rectangle on
535
+ screen names a pose only once something says how far away it is. Both now
536
+ resolve it on the plane through the pose they were handed, facing the camera, so
537
+ neither changes depth. A resize scales uniformly — two screen extents cannot
538
+ name three — and `fromBounds` returns a world-axis-aligned box whose third
539
+ extent is the mean of the two the rectangle gives it.
540
+
541
+ `core`'s `unionOfChildren`, which every scene carries under
542
+ `kit:unionOfChildren`, read its members as rects with no check and produced a
543
+ box of `NaN` in a scene posed otherwise. It now declines, and the container
544
+ keeps its authored pose; `unionOfChildrenVia(descriptor)` remains the way to
545
+ make such a container track its members.
546
+ - ab90aa7: Views now clamp zoom to a positive floor. A view's zoom is always finite and at
547
+ least `ZOOM_FLOOR` (1e-9); a zoom of 0, a negative one, `NaN` or `Infinity`
548
+ becomes the floor, and a non-finite position becomes 0. Dev builds warn once
549
+ when that happens. A negative `View.scale` axis is still a flipped (y-up) axis
550
+ and keeps its sign.
551
+
552
+ The rule lives in `normalizeZoom`, with `normalizeView` applying it to a `View`,
553
+ and every place a view enters the kit goes through it: `<Canvas>` and
554
+ `<SceneCanvas>` (the `view` and `defaultView` props, `setView`, the `view` dep),
555
+ `<CanvasView>` (including a thunked `view`), `<SceneViewCanvas>`,
556
+ `<MinimapCanvas>`, `createViewportLayer`, camera animation targets, `zoomAt`,
557
+ `fitViewToBounds` and `fitZoom`. In labkit, `CanvasStack`, `Stage`, `usePanZoom`,
558
+ `zoomAt`, `centerOn`, `ZoomControl`, a trial's zoom chrome and `as2DView` do the
559
+ same through the new `normalize2DView` and `withZoom`. A loupe's magnification
560
+ follows the same rule.
561
+
562
+ So `screenToWorld`, `canvasCoords` and affordance hit-testing stay finite
563
+ without handling a zero zoom themselves. `pxExtent` no longer guards a zero
564
+ axis, which a view can no longer have, and labkit's `zoomAt` now treats a
565
+ non-finite opening zoom as the floor rather than as 1.
566
+ - Updated dependencies [7586835]
567
+ - Updated dependencies [2f1ddd0]
568
+ - Updated dependencies [b65f4df]
569
+ - Updated dependencies [aa45d32]
570
+ - Updated dependencies [65806bc]
571
+ - Updated dependencies [a614be4]
572
+ - Updated dependencies [ef60ff6]
573
+ - Updated dependencies [0f374d8]
574
+ - Updated dependencies [deb9e79]
575
+ - @weasel-js/geom@1.5.0
576
+ - @weasel-js/routing@1.5.0
577
+ - @weasel-js/cursor@1.5.0
578
+ - @weasel-js/gestures@1.5.0
579
+ - @weasel-js/text@1.5.0
580
+ - @weasel-js/font@1.5.0
581
+ - @weasel-js/history@1.5.0
582
+ - @weasel-js/paint@1.5.0
583
+
584
+ ## 1.4.4
585
+
586
+ ### Patch Changes
587
+
588
+ - 9ce6f00: Fix solid fills coming out multiplied by whatever bitmap shared their batch.
589
+ A solid vertex carried the UV of a 1x1 white texel, but the flush bound the
590
+ run's *image*, so any solid staged alongside an image quad sampled that bitmap
591
+ at its middle texel instead. A wall of thumbnails is a ground rect under an
592
+ atlas quad per cell — the shape the merged batch exists for — so its grounds
593
+ came out tinted by the atlas: white drew olive. Reported from outside the repo
594
+ against a canvas2d reference; no visual baseline caught it, because in every
595
+ demo the quad covers its ground.
596
+
597
+ Every batch vertex now names the texture slot it samples. Slot 0 is always the
598
+ white texel, so a solid's `texture() * a_vertexColor` is the vertex color
599
+ exactly whatever else joins its run, and bitmaps take the slots above it.
600
+
601
+ That also lets one run hold up to seven distinct bitmaps. A document with a
602
+ handful of loose images used to break its run on every change of bitmap; now it
603
+ breaks only when the slots run out, or when one bitmap is drawn at two
604
+ MAG_FILTERs, which is state on the texture object and cannot be had both ways
605
+ in one draw.
606
+
607
+ `tests/visual/batch-pixels.spec.ts` reads the framebuffer channel by channel
608
+ rather than diffing a screenshot, which is what it takes to see a run's
609
+ composition at all.
610
+ - 6f876a7: Two canvases under one `DepRegistryProvider` no longer take each other's deps
611
+ down.
612
+
613
+ `register` set one source per name and its release did a bare `delete`. Mount a
614
+ second canvas under a shared provider and it displaced the first's `view` /
615
+ `scene` / `selection`; unmount either one and the name went dark for the canvas
616
+ still on screen.
617
+
618
+ Sources now stack per name, newest live, and a release removes its own entry
619
+ wherever it sits — so a displaced source comes back when the one above it
620
+ leaves, and a displaced source leaving disturbs nothing. Same shape as the
621
+ `ActionsProvider` registrant stack, one layer down.
622
+ - ed400a3: A derivation now receives its dependencies as `{ node, pose }` rather than as
623
+ bare poses. Breaking for anything with a `derivePath` or `derivePose`: read
624
+ `deps[0]?.pose` where it read `deps[0]`.
625
+
626
+ A connector legitimately reads more than a box — one that thickens with its
627
+ endpoint's weight, or routes only to nodes on a given layer, is answering off
628
+ `data` and `layer`. The scene has invalidated dependents on `kit:setData` and
629
+ `kit:setLayer` since those landed, and `scene.ts` said in a comment that a
630
+ derivation "is handed its dependencies' nodes, not only their poses". It was
631
+ not: the invalidation was paying for a read the signature could not perform.
632
+
633
+ `scenePoseLookup` is now `sceneDepLookup` and answers `DerivedDep` for the same
634
+ reason. `DerivedDep` is exported.
635
+ - fc00dae: Hand a derivation its dependencies' paths, and label an edge with one.
636
+
637
+ `DerivedDep` is now `{ node, pose, path }`. The path resolves on first read and
638
+ memoizes, so a route costs the same whether one node reads it or five, and a
639
+ dependency nobody asks about costs nothing. `resolveDerivedPath` moves beside
640
+ `derivedPose` in `core/scene` — a pose can now derive from a dependency's path —
641
+ and picks up the cycle guard the pose side already had.
642
+
643
+ `pointAlongPath(path, t)` is the new geometry primitive underneath: where a path
644
+ is at a fraction of its length, and which way it heads there, measured along the
645
+ flattened arc.
646
+
647
+ In `@weasel-js/diagram`, an edge label is an ordinary leaf node with
648
+ `dependsOn: [edge]` and `LABEL_DERIVE_POSE`. Its trait says where it sits —
649
+ `at: 'start' | 'mid' | 'end'` or a fraction, plus an `offset` perpendicular to
650
+ the route — and it reads the edge's resolved path rather than routing again, so
651
+ a label and its arrowhead can never disagree about where the edge went.
652
+ - 730da55: A derived node now recomputes when a dependency's `data` or `layer` changes.
653
+
654
+ `derivePath` is handed its dependencies' nodes, not only their poses — so a
655
+ connector that thickens with a node's weight, or draws only for nodes on a given
656
+ layer, is answering off `data` and `layer`. `kit:setPose` and `kit:move`
657
+ invalidated the dependent's memo; `kit:setData` and `kit:setLayer` did not, so
658
+ the derived geometry kept the old answer with nothing on screen to show it was
659
+ stale. Undo and redo were wrong the same way.
660
+
661
+ Both ops now invalidate dependents on `apply` and on `revert`, matching the two
662
+ that already did.
663
+ - 60ba9d9: A stroke marker on a derived path is drawn.
664
+
665
+ `markerStart` / `markerMid` / `markerEnd` reached the `kit:derived` painter
666
+ intact and were then dropped: the painter emitted its stroke command and
667
+ returned, where `kit:path` follows with a marker pass. So an arrowhead on a
668
+ diagram edge — the whole reason markers and derived geometry landed in the same
669
+ release — silently drew nothing. Its own `ink` had been reserving the hit-test
670
+ reach for the marker all along, which is the shape of the bug: the pointer could
671
+ already grab past the end of a line with no head on it.
672
+
673
+ A connect-authored edge now carries `markerEnd: 'arrow'` by default. An edge
674
+ runs *from* one node *to* another and a plain line does not say so;
675
+ `DEFAULT_EDGE_STROKE` is exported for a consumer overriding `commit` who wants
676
+ the rest of it.
677
+ - 5732951: The minimap's scene fit and the scene text editor read a derived pose rather
678
+ than the placeholder a derived node authors. `computeFitView`'s `'scene'` fit
679
+ frames on `documentPose`, so a derived node is framed where it actually is
680
+ while a drag still leaves the framing alone; `useSceneTextEdit` resolves both
681
+ its double-click hit test and the overlay's own projection through
682
+ `effectivePose`, so double-clicking a derived label opens the editor on it and
683
+ the box lands on the text.
684
+
685
+ Removes `UseMoveOptions.cascadeWorldPose`. Nothing has read it since the move
686
+ action started walking `scene.childrenOf` for its own cascade — it was a
687
+ documented option that silently did nothing, and two doc comments described the
688
+ behavior it used to drive.
689
+ - 2ff4824: A scene node can now derive its **pose** from its dependencies, the way it
690
+ already derived its path. New API; a group's bounds change behavior.
691
+
692
+ `Node.derivePose` takes the same `dependsOn` list as `derivePath`, serializes
693
+ through `SceneRegistry.derivePose` by key, and rides the same push
694
+ invalidation. Where a derived path is resolved at paint time and reaches only
695
+ the painter, a derived pose is what the node *is* at — it feeds bounds,
696
+ hit-testing, selection chrome, snapping and layout.
697
+
698
+ `dependsOn` gains a second form, `'children'`: "my own children, in child
699
+ order", which a fixed id list cannot express because reparenting would have to
700
+ maintain it. The two forms differ in lifetime as well as membership — deleting
701
+ a node still deletes everything that names it in `dependsOn`, but a container
702
+ outlives the children it derives from, because an emptied group is still a
703
+ group.
704
+
705
+ `groupAction` uses it, which fixes the group-bounds defect: a container's union
706
+ AABB was computed once at creation and never re-derived, so moving a member
707
+ left the group's bounds, selection chrome and hit area behind. The kit
708
+ registers the union function under `kit:unionOfChildren` and merges its own
709
+ registry entries under the consumer's, so a grouped document round-trips
710
+ through `toJSON` in any scene.
711
+
712
+ `effectivePose(scene, node)` is the one rule — override, else derived, else
713
+ authored — and now takes the scene rather than the override table alone. The
714
+ three render walks, the pick walk, and the scene, commit and gesture adapters
715
+ all resolve through it or through `documentPose`, the same answer minus the
716
+ override step for a reader that must not see an in-flight gesture. Both are
717
+ exported.
718
+
719
+ `clipFromPose`, `derivePath` and `derivePose` now share one table-driven
720
+ serialization path (`core/scene/nodeFnFields.ts`) instead of a copy per field.
721
+ - 3d89141: Layout: `layered`, `tree` and `force`, and the action that runs one.
722
+
723
+ A layout is a plain function of the graph — no scene, no ops, no history. It
724
+ hands back the new top-left for every node that **moves**, and a node already
725
+ standing where the layout wants it is absent from the answer, so re-running a
726
+ layout on an arrangement it produced writes nothing and pushes no undo entry.
727
+
728
+ Three rules keep a re-layout from scrambling a diagram someone has arranged.
729
+ There is no RNG anywhere in the path, so the same graph always lays out the same
730
+ way. Within-rank order is seeded from where the nodes already sit on the cross
731
+ axis rather than from crossing-minimization, so two branches an author dragged
732
+ into an order come back in it. And a node carrying `pinned: true` never moves,
733
+ with the rest of the layout translated to sit around it — with no pin, the
734
+ layout lands on the diagram's own bounding box rather than at the origin.
735
+
736
+ `layered` ranks by longest path, breaking cycles with a depth-first walk in node
737
+ order so a loop draws as an edge running back up the page. `tree` centers a
738
+ parent over its children's block; a graph that is not a tree still lays out,
739
+ since roots are the nodes nothing points at and anything the walk cannot reach
740
+ becomes a root of its own. `force` is an iterative relaxation seeded from the
741
+ current positions — **the one layout that is not idempotent**, since re-running
742
+ it keeps relaxing.
743
+
744
+ `buildGraph` reads the adjacency index from the same participant source the port
745
+ affordance takes, per invocation rather than maintaining one. `createLayoutAction`
746
+ rebuilds it on each press and writes the whole move as a single `scene.batch`,
747
+ carrying a container's whole subtree — `setPose` does not cascade, and a built
748
+ body would otherwise walk out from under its own label rows.
749
+
750
+ In core, `createSimulation` is the velocity-Verlet integrator with no clock
751
+ attached: `tick()` is the only thing that moves a node, so a pure function can
752
+ run a whole relaxation and read the result. `useSimulation` is now that
753
+ integrator on a frame loop and is otherwise unchanged. `SimulationCore` and
754
+ `SimulationOptions` name the halves, and forces can be handed a seeded `random`
755
+ in place of `Math.random`.
756
+ - 4a128c4: New package `@weasel-js/diagram` — the skeleton of weasel's node-link
757
+ diagramming: flowcharts, pipelines, code-flow diagrams, simple visual
758
+ programming. Arc 3 of the diagram design; edges, routing, the connect gesture
759
+ and layout are still to come.
760
+
761
+ **`DiagramNode` is a trait on an existing scene node, not a node type the
762
+ package mints.** A text block, an image, a path, a group or a plain rect all
763
+ become participants by carrying it, and nothing has to be authored through this
764
+ package to take part. Two ways to attach it, both answered by the default
765
+ reader: on the node's own `data.diagram`, or by kind — `createDiagramNodes`
766
+ takes predicates over `data` the way `createNodeRouting` does, so a class of
767
+ node takes part without being stamped one at a time.
768
+
769
+ **Ports default to the perimeter, so a node needs to say nothing to be
770
+ connectable.** `portsOf(node, pose)` resolves every port to a world point and
771
+ an outward normal, reading bounds through the pose descriptor and carrying the
772
+ node's rotation. Anchors are normalized against the bounds — `{ u, v }` from
773
+ the top-left — so a port stays where it was put when the node is resized.
774
+
775
+ **The body builder is optional**, for nodes that should look like a flowchart
776
+ box: `rect`, `diamond`, `stadium` and `parallelogram` outlines plus a `Row[]`
777
+ body of labels, fields, port rows and slots. Rows measure a floor, and the
778
+ authored pose is maxed against it rather than set to it — adding a row can grow
779
+ a node, nothing shrinks one back — which is what keeps resize, align,
780
+ distribute, guides, snapping and undo free of a special case. Text measurement
781
+ is a seam rather than an import; `canvasMeasure` adapts a 2D context.
782
+
783
+ Rows lay out in the shape's **content box**, not its bounding box.
784
+ `contentBox` reports the largest axis-aligned box inside an outline — a
785
+ diamond's inscribed rect, a parallelogram minus its lean, the flat span between
786
+ a stadium's ends — and `boxForContent` inverts it so the floor grows to suit.
787
+ Without it a diamond's label is placed against the bounding box, lands outside
788
+ the diamond, and the silhouette clip removes it: the label simply vanishes.
789
+
790
+ `registerDiagramShape` paints a node whose trait names an outline, and reports
791
+ the outline as its silhouette so picking and clipping follow the diamond rather
792
+ than its box. Rows are not painted there — a built body's rows are ordinary
793
+ scene nodes, so the kit's own text painter draws them and text editing,
794
+ selection and styling work on them unchanged.
795
+
796
+ Core exports `AUTO_POSE_DESCRIPTOR` and `isPathLike`, which were already
797
+ general-purpose but reachable only from inside the package. A peer package
798
+ computing a node's bounds needs the kit's own default descriptor rather than a
799
+ second copy of it.
800
+ - aee9d92: `createPoseOverrides` is public.
801
+
802
+ `Scene` is public and its `overrides: PoseOverrides<TPose>` is mandatory — and
803
+ load-bearing, since every ongoing gesture writes a frame to it. The factory that
804
+ builds one was internal, so a consumer assembling a scene-like object by hand
805
+ had to reimplement the table from its type. It is now exported alongside
806
+ `createScene`.
807
+ - c067221: Put every baked gradient ramp in one texture, a row each, rather than a texture
808
+ each. `GradientRampAtlas` replaces `GradientRampCache`: a stop list is baked
809
+ once into a 256-texel strip and written to a row, and the fragment shader picks
810
+ its row with `u_rampV`. Every gradient in a frame now samples the same texture
811
+ unit, which is what a gradient needs before it can take a batch texture slot
812
+ the way a bitmap or a font atlas already does.
813
+
814
+ The atlas doubles from 16 rows and stops at 1024, recycling the least recently
815
+ used row past that. An animating gradient mints a new stop list every frame, so
816
+ the old cache grew a GL texture per frame and freed none of them; the cap is
817
+ what bounds that.
818
+
819
+ `PaintBindContext.bindRamp` now returns the `v` its ramp sits at. A registered
820
+ paint kind that samples the ramp at a constant `v` reads whatever gradient
821
+ happens to own that row, so it must sample at the returned value.
822
+ - 26d40bf: Fold gradients into the shared batch. A gradient fill used to bind its own
823
+ program and break the run of solid geometry, image quads and glyphs around it;
824
+ now it stages alongside them, so a page of gradient-filled shapes is one draw
825
+ rather than one per shape.
826
+
827
+ All three kinds go, and for the same reason — not that the ramp position is
828
+ affine in position, which is true only of a linear gradient, but that the
829
+ *coordinate* the ramp position is computed from is affine in all three. So a
830
+ vertex carries that and the rasterizer's interpolation across a triangle is
831
+ exact. A linear gradient's coordinate is the ramp position itself, which is why
832
+ it needs no paint mode of its own; a radial or conic one carries a
833
+ gradient-space point, with its atlas row where a plain vertex keeps its alpha,
834
+ and the shader takes a `length` or an `atan` of it behind a branch on the flat
835
+ paint mode. Fill opacity and group alpha ride the vertices the way a solid's
836
+ already did, and fills, stroke ribbons and glyph-outline meshes all take the
837
+ route.
838
+
839
+ Per-vertex-colored and even-odd fills still take their own draw, as do patterns
840
+ and shaders.
841
+
842
+ The ramp atlas is what makes the slot arithmetic work: every gradient in a run
843
+ shares one texture slot, so a document full of them costs the same one slot a
844
+ single gradient does. Growing the atlas moves every row, so a bake that would
845
+ grow it — or recycle a row — flushes the run first.
846
+
847
+ Gradient fills now apply the group's color matrix. `gradFill` was the only
848
+ paint program that did not, and since the batch program applies it to
849
+ everything in a run, leaving it out would have made a linear gradient and a
850
+ radial one under the same group paint differently.
851
+ - b8d2940: Layout you can watch, and push against.
852
+
853
+ `usePoseRun` is the transport: each frame it asks a producer for poses,
854
+ publishes them to the scene's ephemeral override channel — the one a drag
855
+ already writes to, which `effectivePose`, derived geometry and the pick source
856
+ read — and commits the lot as one batch when the producer says it is done or
857
+ the consumer stops it. Cancel drops the frames and the document is untouched.
858
+ It runs behind `useVisibleRaf`, and it knows nothing about layout.
859
+
860
+ A node carrying an override the run did not publish belongs to another gesture:
861
+ the run never writes it, never commits it, and reports it to the producer as
862
+ pinned. Dragging a box mid-run is therefore the consumer's ordinary move tool,
863
+ with no gesture contributed by the diagram package.
864
+
865
+ `useLiveLayout` in `@weasel-js/diagram` drives it. `force` relaxes one tick a
866
+ frame off the same force list the one-shot `force` runs, holding a pinned node
867
+ with `fx`/`fy` while its neighbors answer; `layered` and `tree` ease into a
868
+ target computed once. A node or edge appearing or disappearing re-heats the run.
869
+
870
+ `SceneNode.pickable: false` makes a node transparent to the hit-test walk, so a
871
+ press lands on what is behind it. Without it the innermost hit wins and dragging
872
+ a labeled box pulls the label out of the box.
873
+ - b5e2cd9: Solid geometry and image quads now share one batch, so a wall of thumbnails —
874
+ a ground rect under an atlas quad, per cell — draws in one call rather than
875
+ one per command. The two batches used to be exclusive: staging a solid drained
876
+ the image run and staging an image drained the solid one, so a shape that
877
+ batches perfectly in either half alone paid a flush per command. Solid vertices
878
+ carry the UV of a 1x1 white texel, which makes `texture() * a_vertexColor` the
879
+ vertex color exactly, so the merge is pixel-identical rather than close.
880
+
881
+ Measured over a viewport-filling grid of those cells on an M2 Max via ANGLE
882
+ (`tests/perf/atlas-wall.spec.ts`): 600 draw commands 2.83 -> 0.10 ms, 1,650
883
+ 11.37 -> 0.20, 5,400 40.50 -> 0.58, 15,000 126.15 -> 1.50. A run still breaks
884
+ on what a run cannot carry — a second bitmap, a different MAG_FILTER, a clip
885
+ depth, a color matrix.
886
+ - 89276ee: `usePoseRun` and `useSimulation` hand their injected clock straight to
887
+ `useVisibleRaf` instead of defaulting it themselves. The gate already falls
888
+ back to `requestAnimationFrame`, so both were defaulting it twice — and the
889
+ copy in `usePoseRun` was a bare `requestAnimationFrame` in kit source, which
890
+ `check:frame-loops` fails the build on. The allowlist is back down to the gate
891
+ itself.
892
+ - 36950d8: `scene.setDependsOn(id, dependsOn)` retargets a node's dependencies as one
893
+ undoable step, so dragging an existing edge's end onto a different node no
894
+ longer means removing the edge and adding another one. Switching a container
895
+ between an id list and `'children'` goes through the same call.
896
+
897
+ Both indices move with it — the reverse dependents index that drives cascade
898
+ delete, and the `'children'` set that drives the ancestor walk — and the
899
+ retargeted node's own memo is dropped alongside its dependents', since its
900
+ derivation now reads different sources. Order is significant, because a
901
+ derivation reads its dependencies positionally; declaring what a node already
902
+ declares records no history entry.
903
+ - 4f8c6b2: Text no longer breaks a batched run. Glyphs, the rules under underlined words,
904
+ and tessellated glyph outlines all stage into the same draw as the solid
905
+ geometry and image quads around them, so a wall of captioned thumbnails is one
906
+ draw where every label used to cost two.
907
+
908
+ The batch shader carries the glyph math behind a paint mode, which packs into
909
+ the texture-slot attribute the vertex already had, so the vertex does not grow
910
+ and a wall of thumbnails costs what it did before. It runs that math on every
911
+ fragment, glyph or not, because `fwidth` in non-uniform control flow is
912
+ undefined and the derivative has to be taken before anything selects on the
913
+ mode — priced at about 1.4% of a fragment that is not a glyph. A synthetic
914
+ oblique now shears on the CPU as the batch places its corners, rather than in a
915
+ vertex shader that read the baseline from a vertex attribute.
916
+
917
+ Three things a run used to break on are gone: a second text color in the same
918
+ paragraph, a decoration whose fill differs from the glyphs it sits under, and
919
+ the difference between a baked MSDF atlas and a runtime canvas bake. What still
920
+ breaks a run is a change of synthetic-bold threshold, which is a uniform — that
921
+ one is a fallback path, since a registered bold face never sets it.
922
+
923
+ **Breaking for anyone importing the text shader sources.** `TEXT_VERT_SRC`,
924
+ `TEXT_FRAG_SRC`, `TEXT_FRAG_R8_SRC`, `TEXT_SDF_UNIFORMS` and
925
+ `TEXT_SDF_ATTRIBUTES` are removed from `@weasel-js/font`: text has no program of
926
+ its own any more. What replaces them is `GLYPH_COVERAGE_GLSL`, the snippet a
927
+ program pastes in to turn an atlas sample into coverage, alongside
928
+ `GLYPH_MODE_MSDF` and `GLYPH_MODE_R8` naming the two channel layouts.
929
+ - 1240956: `kit:text` nodes take a `verticalAlign`, and picking follows it.
930
+
931
+ Centering a glyph in its box meant nudging `pose.y` by hand and re-deriving the
932
+ nudge whenever the font size changed. The painter forwarded the pose's height
933
+ but never an alignment, so the box the renderer aligned within was always
934
+ resolved as `'top'`.
935
+
936
+ `data.verticalAlign` — `'top' | 'center' | 'bottom'`, the same spelling the draw
937
+ command already took — now reaches both halves: the paint command and the
938
+ silhouette `textLineBoxes` builds, so a centered block is grabbable where it
939
+ draws rather than where a top-aligned one would have. A node that names none
940
+ paints exactly where it did before.
941
+ - Updated dependencies [4f8c6b2]
942
+ - @weasel-js/font@1.4.4
943
+ - @weasel-js/text@1.4.4
944
+ - @weasel-js/cursor@1.4.4
945
+ - @weasel-js/geom@1.4.4
946
+ - @weasel-js/gestures@1.4.4
947
+ - @weasel-js/history@1.4.4
948
+ - @weasel-js/modes@1.4.4
949
+ - @weasel-js/paint@1.4.4
950
+
3
951
  ## 1.4.3
4
952
 
5
953
  ### Patch Changes
@@ -912,1951 +1860,158 @@
912
1860
  - @weasel-js/modes@1.4.0
913
1861
  - @weasel-js/paint@1.4.0
914
1862
 
915
- ## 1.4.0-pre.1
1863
+ ## 1.3.0
916
1864
 
917
- ### Patch Changes
1865
+ ### Minor Changes
918
1866
 
919
- - 36b6ee7: Add `@weasel-js/cursor` and give three tools real cursors.
920
-
921
- A cursor glyph is SVG path `d` strings tagged with a paint role plus a hotspot
922
- in glyph units — the one geometry form both a data-URI baker and a `Path2D`
923
- painter consume without translating. `bakeCursor` renders one to a
924
- `url(data:image/svg+xml,…)` string with the hotspot scaled to integer CSS px,
925
- and `cursorFor` memoizes that per name and size. The pencil, pen and eyedropper
926
- tools now show their own glyph instead of a shared `crosshair`.
927
-
928
- Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
929
- cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
930
- throws above 128 CSS px rather than emitting a cursor the browser would drop
931
- silently — that size is where a later painted tier will take over.
1867
+ - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
1868
+ into `@weasel-js/paint` — two new Tier A leaves.
932
1869
 
933
- Glyph geometry is authored in `scripts/glyphs/` and generated to resolved
934
- literals by `npm run gen:cursors`; `npm run proof:cursors` renders the baked
935
- assets over three backgrounds for inspection.
1870
+ `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
1871
+ measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
1872
+ `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
1873
+ lay out text without taking the scene graph or a React peer dependency.
1874
+ `layoutRuns` is now public — it was previously reachable only from inside core.
936
1875
 
937
- New API: `bakeCursor`, `cursorFor`, `GLYPHS`, `haloFitsInBox`, `CursorGlyph`,
938
- `CursorPath`, `CursorGlyphName`, `BakeOptions`, and the register constants
939
- `CURSOR_INK`, `CURSOR_HALO`, `CURSOR_HALO_WIDTH`, `CURSOR_MAX_CSS_PX`.
940
- - Updated dependencies [36b6ee7]
941
- - @weasel-js/cursor@1.4.0-pre.1
942
- - @weasel-js/font@1.4.0-pre.1
943
- - @weasel-js/geom@1.4.0-pre.1
944
- - @weasel-js/gestures@1.4.0-pre.1
945
- - @weasel-js/history@1.4.0-pre.1
946
- - @weasel-js/modes@1.4.0-pre.1
947
- - @weasel-js/paint@1.4.0-pre.1
948
- - @weasel-js/text@1.4.0-pre.1
949
-
950
- ## 1.4.0-pre.0
951
-
952
- ### Minor Changes
953
-
954
- - 1214ff5: Split a canvas's paint target from its input target.
1876
+ `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
1877
+ `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
1878
+ layout could not move while its fill type lived in the renderer's graph.
955
1879
 
956
- `<SceneCanvas paintInto={{ canvas, x, y }} inputElement={el}>` paints into a
957
- rect of a canvas you own and takes pointer input from an element you own, so N
958
- canvases share one GL context and one buffer. Each needs its own
959
- `<WeaselProvider isolate>`.
1880
+ `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
1881
+ `Rect` moves to `@weasel-js/geom`, beside `Box`.
960
1882
 
961
- The ref handle names both elements: `element` is where input, focus and the
962
- cursor live, and is now typed `HTMLElement` because detached it is not a canvas;
963
- `surface` is where pixels land. Attached, they are the same `<canvas>` and
964
- `element` keeps working as before. The HUDs render when detached too, anchored
965
- to the input box rather than to the shared surface every pane sits in.
1883
+ Breaking for anyone importing these through core's internal paths rather than
1884
+ its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
966
1885
 
967
- Breaking, narrowly: `createLoupe`'s `element` option is now `canvas`, with an
968
- optional `input` for the element aim is measured against.
969
- `CanvasExtensionApi.element` no longer satisfies an `HTMLCanvasElement` — read
970
- `surface` for pixels. And `clientToWorld`'s first parameter widens to
971
- `HTMLElement`, which stops compiling for a consumer who annotated that parameter
972
- as `HTMLCanvasElement`; one who let it infer is unaffected.
1886
+ Advances and kerning still come from a baked MSDF atlas — laying out from font
1887
+ bytes alone needs the metrics seam in
1888
+ `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
973
1889
 
974
- <!-- bump-approved: minor: Mike — the labkit annotations arcs 1-4 (a shared drawing surface, a mark store, the overlay, and capture/export) plus this split of a canvas's paint target from its input target, on top of ~30 patch changesets carrying new public surface across core, ui and labkit; called explicitly in conversation on 2026-09-03: "we were going to cut a 1.4.0-pre release" -->
1890
+ <!-- bump-approved: minor: Mike — two new published packages (@weasel-js/text, @weasel-js/paint) and layoutRuns promoted to public API, on top of ~50 patch changesets carrying new public surface across core, ui and labkit; called explicitly in conversation on 2026-08-29: "tag a minor release and push" -->
975
1891
 
976
1892
  ### Patch Changes
977
1893
 
978
- - 5295c34: Draw on a lab's instrument: the `annotations` capability gets its overlay.
979
-
980
- An instrument that declares `annotations` now gets a drawing surface on every
981
- target it names — weasel tools, weasel selection, marks that pan and zoom with
982
- what they mark — plus a palette (select, freehand, line, arrow, rectangle,
983
- ellipse, text) and its own tool slot. `useAnnotations()` reaches the store from
984
- the instrument's render or from a chrome contribution, and re-renders its
985
- caller as marks change.
1894
+ - 52c7b2a: Depend on `font` and `core` as exact peers
986
1895
 
987
- The lab's shared surface grew the buffer that makes this possible: one
988
- `<canvas>` over `.lk-lab__body`, and `SurfaceHandle.registerPainter`, which is
989
- how a resize of that buffer reaches every tile rather than the one that moved.
990
- `getContainer()` names the element tile rects are measured against.
1896
+ `@weasel-js/font` and `@weasel-js/core` keep registries that consumer code
1897
+ writes into — registered faces and glyph-ready subscribers in one, content
1898
+ handlers and paint kinds and shape painters in the other. Two physical copies
1899
+ in a tree are two registries, so a face registered into one while layout
1900
+ resolves against the other lays out nothing and the canvas is blank.
991
1901
 
992
- A mark is a weasel scene node in a scene of its own per target — a pane's
993
- hit-test, marquee and paint walk the whole scene they are handed, so one shared
994
- scene would put a neighbour's marks under the pointer. An annotation's id is
995
- therefore `<target>/<node>`, and `createAnnotationStore` takes `targets` alone
996
- plus an optional `restore`; `SerializedAnnotations` carries `scenes`, keyed by
997
- target. Marks still do not survive a reload — the storage slot is the next arc.
1902
+ Exact sibling pins are what produced the duplicate: a consumer mixing two
1903
+ weasel releases left npm no choice but to nest a second copy, silently. As
1904
+ peers, the same mix is an `ERESOLVE` at install time. `font` is now a peer of
1905
+ `core`, `hud` and `text`; `core` is now a peer of `svg`, joining `d3`, `hud`
1906
+ and `ui`, whose `>=` ranges tighten to exact so no version mix resolves by
1907
+ accident.
998
1908
 
999
- Core adds `ArrowIcon` to the built-in tool glyphs.
1000
- - 2fbf611: Give a canvas its own provider scope with `<WeaselProvider isolate>`
1909
+ **This can break an install that currently succeeds.** Anyone resolving a
1910
+ mixed set of weasel versions by luck now gets an install error instead of a
1911
+ blank canvas. That is the point, but it is a break.
1001
1912
 
1002
- An actions registry holds exactly one dispatcher, so a second `<SceneCanvas>`
1003
- joining a scope displaced the first and took its input away. Worse, the
1004
- detach was unconditional: whichever canvas unmounted — or merely re-rendered
1005
- with a new dispatcher identity — cleared the slot for the one still on screen.
1006
- The symptom was a canvas that stopped responding, naming neither canvas nor the
1007
- registry they shared.
1913
+ `labkit` deliberately keeps `core` as an ordinary dependency: its build aliases
1914
+ every core entry point to core's built files and inlines them, so it never
1915
+ resolves core at the consumer and has nothing to peer. The flip side is that
1916
+ labkit ships its own copy of core's registries, so a consumer using both still
1917
+ has two — this change does not address that.
1918
+ - 3386d64: Align, distribute and flip use visual bounds
1008
1919
 
1009
- `isolate` mounts every provider unconditionally instead of deferring to one
1010
- already in scope, so canvases that merely coexist get a scope each. This is the
1011
- shape consumers had already reached for by hand: `AnimationDemo` and
1012
- `BooleanOpsDemo` both mounted raw `ActionsProvider` / `SelectionContextProvider`
1013
- / `DepRegistryProvider` to shadow the ambient scope, and both now say `isolate`
1014
- instead.
1920
+ These folded each member's unrotated pose box, so "Align Left" on a selection
1921
+ containing a rotated shape lined up the boxes and left the rotated shape's ink
1922
+ sticking out past the others. They now work on the visual bounding box, as
1923
+ Figma and Illustrator do.
1015
1924
 
1016
- `setDispatcher` and `setDepRegistry` return a release that clears the slot only
1017
- while the caller still holds it, so a departing canvas can no longer disable a
1018
- surviving one. A second dispatcher claiming an occupied registry warns once,
1019
- naming `isolate` as the fix.
1925
+ Both ends moved together — expanding only the union would have made alignment
1926
+ worse, since the delta runs from an edge of the union to the same edge of each
1927
+ member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
1928
+ reads a pose's bounds, recovers its rotation and expands via
1929
+ `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
1930
+ applied as a translation of the stored pose through
1931
+ `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
1020
1932
 
1021
- Two canvases still cannot *share* one registry: a toolbar outside both has
1022
- nothing to say which one it drives. That needs a focused-canvas concept and is
1023
- not in this change.
1024
- - 7a0c568: Tell an event handler how late its crossing is
1933
+ Flip needed only its union pivot changed: mirroring maps a centre and preserves
1934
+ size, and an expanded box is concentric with the box it came from.
1025
1935
 
1026
- `EventTrack`'s `fire` took no arguments, so a handler could only ask its own
1027
- clock for "now" — when the frame was processed, not when the playhead crossed
1028
- the edge. That held footstep scheduling in the side-scroller at frame
1029
- resolution against an audio engine built for sample resolution: a measured peak
1030
- spread of 33–47 ms on the looping run cycle.
1936
+ `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
1937
+ by its ink.
1938
+ - ffafb7d: Never let an animation's virtual clock run backwards.
1031
1939
 
1032
- `fire(lateBy)` reports the gap between the crossing and the frame carrying it,
1033
- in timeline ms. It is never negative, including on the loop seam, where the
1034
- outgoing lap's tail fires after the playhead has already wrapped — the case
1035
- that makes a handler comparing against `handle.time()` read a negative
1036
- lateness. A nested timeline's events report the same figure as a top-level
1037
- one's; the track's offset cancels.
1940
+ `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
1941
+ time, then advances its virtual clock by the difference against the timestamp
1942
+ the frame loop supplies. Those two share a time origin in a browser, where the
1943
+ rAF timestamp and `performance.now()` are both page-relative — but that is a
1944
+ browser guarantee, not a universal one, and jsdom starts them roughly 600ms
1945
+ apart. The first frame's delta then came out hugely negative and `virtualNow`
1946
+ spent dozens of frames climbing back toward zero before a tween advanced at
1947
+ all: a 40ms glide took 95 frames and over a second of wall time, growing worse
1948
+ the longer the process had been alive.
1038
1949
 
1039
- Nothing has to change to compile: a zero-argument function is assignable to the
1040
- new signature.
1950
+ A frame's elapsed time is never negative, so the sample is now clamped at
1951
+ zero. Under a shared origin this is a no-op.
1952
+ - ba8b139: Camera animation: `viewport.animatedZoom` now does something
1041
1953
 
1042
- `lateBy` is a delta, not a clock reading, so events from two different
1043
- timelines still cannot be ordered against each other. That would need the
1044
- animator's virtual clock made public, which this does not do.
1954
+ `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
1955
+ nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
1956
+ through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
1957
+ their input already delivers a sample per frame.
1045
1958
 
1046
- `SideScrollerDemo` now places each footfall a fixed budget after its true
1047
- crossing, so which frame happened to notice a contact turns into constant
1048
- latency rather than audible spread.
1049
- - a7fa697: Add an anchored-placement solver and keep HUD windows on their host.
1959
+ Camera animation is a general surface, not a zoom flag. Three ways in, one
1960
+ runner behind them:
1050
1961
 
1051
- `@weasel-js/geom` gains `placeRect` and `clampRectWithin`. `placeRect` resolves an
1052
- overlay against an anchor: it picks a side, flips to the opposite one when the
1053
- preferred side has no room, and slides along the alignment axis to stay inside a
1054
- boundary. `clampRectWithin` is the containment half on its own — move a rect the
1055
- shortest distance that puts it inside a boundary, keeping its size. Both are pure
1056
- and take an explicit boundary rect, so a boundary that does not start at the
1057
- origin resolves correctly.
1962
+ - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
1963
+ `isAnimating`, `target`.
1964
+ - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
1965
+ so any action can glide the camera.
1966
+ - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
1967
+ `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
1968
+ three are **required** members: anyone hand-implementing `SceneCanvasApi`
1969
+ (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
1970
+ grew `getPaintedVersion`.
1058
1971
 
1059
- A HUD window could previously be dragged fully off its host with no way to
1060
- recover it: `createWindow` clamped size but never position. Move drags and
1061
- `setBounds` now keep the window on the host. Resize drags are deliberately left
1062
- alone, so pulling an edge past the host does not fight the gesture.
1972
+ Scale interpolates geometrically and translation is derived from the screen
1973
+ point the two views agree on, so a zoom stays anchored instead of drifting and
1974
+ each frame changes the view by the same ratio. One animation runs at a time; any
1975
+ other view write cancels it, and a cancel leaves the camera where it is rather
1976
+ than jumping to the target. On an uncontrolled canvas the whole animation costs
1977
+ no React render.
1063
1978
 
1064
- `@weasel-js/core` gains `hostAnchorRect`, `hostAnchorCss` and `useHostAnchor`,
1065
- which hold a fixed-position panel against a host element's corner and keep it
1066
- inside the viewport. The corner is an alignment per axis rather than a fixed
1067
- one, and `useHostAnchor` takes a function that resolves the host, so a host held
1068
- in a ref and one found by selector work the same way.
1979
+ **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
1980
+ changes signature — it takes a `{ get, set }` view channel plus an optional
1981
+ `Animator`, and `animateTo(from, to, { duration, easing })` becomes
1982
+ `animate(to, { ms, easing })`. The `from` argument is gone because the runner
1983
+ reads the live view, which is what lets an interrupted camera resume from where
1984
+ it actually is instead of snapping back to a captured start. `cancel()` is now
1985
+ `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
1986
+ `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
1987
+ the same reason `from` does.
1069
1988
 
1070
- `hostAnchorCss` pins whichever edges the alignment names. That is not cosmetic:
1071
- a panel whose width tracks its content holds the anchored edge still and grows
1072
- away from it, so pinning the wrong edge makes the anchored corner drift on every
1073
- content change.
1989
+ **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
1990
+ `() => View | void`. Returning the target view lets Cmd+0 animate there;
1991
+ returning nothing keeps the existing behavior. `animatedZoom`'s config fields
1992
+ are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
1993
+ animator's vocabulary.
1994
+ - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
1074
1995
 
1075
- Four places were carrying their own copy of that anchor math and now share this
1076
- one — `CursorCoordsHud`, `PickHud`, `ModalityHud`, and WeaselDraw's
1077
- `DispatchTracePanel`, which anchors the opposite corner. None of the four
1078
- clamped, so a panel could hang off the edge when the host was scrolled or the
1079
- panel was tall.
1080
- - 2272682: `createParallaxLayer` takes an optional `getOuterView`, so a plane can derive
1081
- from a ref-driven camera. It previously derived only from the canvas's `view`
1082
- prop; a consumer keeping a 60 Hz camera out of React state pins that prop to
1083
- identity and got identity back for every `pan` value — a backdrop that silently
1084
- never moved.
1996
+ The canvas is focusable by default (`tabIndex` 0) but exposed no way to
1997
+ observe focus, so consumers driving focus-dependent chrome had to attach a
1998
+ listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
1999
+ so reach `SceneCanvasProps` and the canvas element unchanged.
2000
+ - 67bcb05: Drop four values the canvas layer memo no longer reads
1085
2001
 
1086
- `useHandTool` no longer builds a velocity tracker and a decay loop it never
1087
- uses. `inertia` and `axis` were already inert; they are now documented as such
1088
- until the `viewport.dragPan` action implements them.
1089
- - 503b56d: Fix two path-walker bugs that produced wrong geometry with no error.
1090
-
1091
- `tessellate` treated `Z` as a no-op, so a command following a close flattened
1092
- from the last point drawn rather than from the subpath start — SVG puts the pen
1093
- back at the start. `pathDistanceToPoint` dispatched through an `if`/`else if`
1094
- chain with no final `else`, so an unrecognized command code left the coordinate
1095
- cursor unadvanced and silently misaligned every later read; it now throws.
1096
- - ac2deea: Add `polylineFromPoints` — the open counterpart to `polygonFromPoints`.
1097
-
1098
- Same geometry, without the closing edge. A freehand stroke or a measurement
1099
- line wants this; a region wants the closed one. The pencil tool's drag preview
1100
- was building its ghost with `polygonFromPoints`, so the edge from the newest
1101
- sample back to the first swept across the drawing as the stroke grew and read
1102
- as a marquee.
1103
- - 23ffb2f: `WeaselRenderer` can draw into a rect of a buffer it does not own.
1104
- `setTarget({ origin, clear })` applies a viewport and scissor inside `render()`,
1105
- so N renderers can share one WebGL context and one canvas without a frame clear
1106
- erasing a co-tenant. The rect's size is the renderer's own `width`/`height`, so
1107
- `resize()` remains the single source of it.
1108
-
1109
- Adds API. Two behaviour changes for existing callers: `render()` now
1110
- re-establishes blend, depth, cull and clear colour every frame instead of once at
1111
- construction, so a co-tenant moving that state no longer corrupts weasel's
1112
- frames; and the constructor now throws when handed a WebGL2 context whose
1113
- attributes report no stencil buffer, which previously rendered clips and even-odd
1114
- fills wrong rather than failing. A context that cannot report its attributes is
1115
- unaffected.
1116
- - 016851c: Stop a stroke with no paint from blanking the whole document.
1117
-
1118
- `SelectionPanel`'s object leaf started from `{}` when the node held no value
1119
- yet, so editing any non-paint field of `data.stroke` on an unstroked node
1120
- committed that field alone — a `Stroke` with no `paint`, which the type
1121
- forbids. The leaf's declared `default` was dead for writes; it now seeds from
1122
- it, so writing one field materializes a complete value.
1123
-
1124
- Such a stroke threw out of `fillInPoseFrame`, and the throw escaped the painter
1125
- and took the frame with it: the document page and every other node vanished,
1126
- and the canvas stayed stale until something unrelated requested a redraw — so
1127
- WeaselDraw opened on an empty workspace and only drew once the pointer moved.
1128
- `resolveNodeStroke` now reads a paintless stroke as no stroke, and the text
1129
- painter routes through it like every other painter. The frame loop no longer
1130
- loses its dirty flag when a paint throws, so one bad frame is retried rather
1131
- than stranding the surface.
1132
- - c9dd37f: Render text decorations as a toggle row, and ship a builtin font-family control
1133
-
1134
- `SelectionPanel` rendered every boolean leaf as a `Switch`, ignoring the leaf's
1135
- `control` entirely — so the three text decorations arrived as three switch rows
1136
- where every text editor puts one row of U / S / O. `ToolPrefBooleanControl` now
1137
- accepts `'toggle'`, `ToolPrefBoolean` carries a `short` label for it (the pair
1138
- takes the row's name, leaving the leaf only a glyph's worth of room), and the
1139
- panel honors both. Core's text schema asks for it: `underline`,
1140
- `strikethrough` and `overline` share a `Decoration` pair.
1141
-
1142
- A run of adjacent leaves sharing a `pair` renders as one `ToggleBar`, not one
1143
- bar per leaf — the same segmented control the `Align` row beside it already
1144
- draws. Each segment still writes only its own leaf, so flipping one decoration
1145
- never invents values for the other two. An unset toggle is left unselected
1146
- rather than dimmed: unselected is what a toggle button's off state means, and
1147
- the dimming the `Switch` path uses for the same case reads as disabled on one.
1148
- A leaf a consumer claims with its own `renderers` entry drops out of the run.
1149
-
1150
- `FontFamilySelect` moves from WeaselDraw into `@weasel-js/ui`, and
1151
- `SelectionPanel` reaches for it on a `font-family` leaf. Core's own default
1152
- text schema declares that kind, so a consumer passing no `renderers` — the
1153
- Storybook story, any app taking the defaults — got the literal
1154
- `(font-family: no renderer)` placeholder where the font picker belongs. The
1155
- control offers both tiers that can actually paint and probes substitution at
1156
- the node's own weight and style, so its label names the variant that will
1157
- render. `@weasel-js/ui` now depends on `@weasel-js/font`.
1158
- - 9a000ea: A stroked text node now gets hit reach from its stroke. `TEXT_PAINTER` declared
1159
- no `ink`, so picking fell back to a zero-outset default and a heavily outlined
1160
- glyph was unpickable across the width of its own outline.
1161
-
1162
- `kit:derived` also now evaluates ahead of `kit:path` / `kit:shape` / `kit:image`.
1163
- A derived node whose `data` happens to carry a `path`, `shape` or `image` field
1164
- was silently painted by those painters instead of from its derived path.
1165
- - 016851c: Add an editor surface for superscript, subscript and overline.
1166
-
1167
- `StyledRun.script`, `baselineShift`, `fontScale` and `overline` reached layout,
1168
- SVG and the DOM overlay but nothing could apply them. The character bar now
1169
- carries an x² / x₂ pair, an overline toggle beside B / I / U / S, and the two
1170
- primitives `script` presets — baseline shift and scale — as percentage fields
1171
- that show what the preset supplies and override just that half when typed over.
1172
- `overline` also joins the sidebar's node-level Character group. Superscript and
1173
- subscript take Cmd+Shift+= and Cmd+Shift+-; the unshifted pair is browser zoom,
1174
- which a page cannot cancel.
1175
-
1176
- A styling written at a collapsed caret now arms `useTextEdit`'s new
1177
- `pendingStyle` and applies to the next character typed, instead of being
1178
- dropped or restyling the whole node. That is what `script` needs — it has no
1179
- node-level counterpart to write to by design — and it makes the bar agree with
1180
- Cmd+B, which already behaved this way. `rangeStyle` reports the styling *at* a
1181
- collapsed caret rather than `{}`, and `toggleStyle` is public.
1182
-
1183
- Three fixes fall out of putting both paths through one implementation:
1184
- lowering a flag the node sets now works from the bar and from a collapsed
1185
- caret, not only from the keyboard over a range; a toggle reads the node's flags
1186
- as well as the runs, so Cmd+B inside a `fontWeight: 700` node clears bold
1187
- instead of adding it; and focus returns to the text after a styling control is
1188
- clicked, so typing continues in the document rather than reaching the app as
1189
- tool shortcuts.
1190
- - 8ddec11: Accept a named or cubic-bezier easing wherever a curve is taken, and let a
1191
- timeline's loop policy change after it is created.
1192
-
1193
- `easing` was a bare function everywhere, which is fine to call and impossible to
1194
- name back, show in a picker, or serialize. It now also accepts the name of a
1195
- built-in (`'easeOutBack'`) or control points (`{ bezier: [0.4, 0, 0.2, 1] }`),
1196
- resolved by `resolveEasing` at the four places a curve is actually invoked. The
1197
- union is additive, so every existing function value stays assignable. Bezier x
1198
- control points are clamped to 0..1, which is what keeps the solve monotone, and
1199
- the control-point tuple is `readonly` so an `as const` preset is assignable.
1200
-
1201
- `TimelineHandle.setLoop(loop)` sets policy and nothing else. A timeline already
1202
- parked at its duration does not restart — `rearm` declines to revive one — so
1203
- play it again by seeking to 0 and resuming. Restoring saved transport state
1204
- therefore cannot start playback as a side effect.
1205
-
1206
- Both settings now read back. `AnimationHandle.timeScale()` returns an
1207
- animation's own scale, and `Animator.timeScale()` the global one, the way
1208
- `isPaused()` already pairs with `pause()`. `TimelineHandle.loop()` returns the
1209
- policy as it stands — `true`, `false`, or the laps a finite loop has left, which
1210
- falls as they are consumed. A transport UI can drive itself off the handle
1211
- instead of mirroring what it last wrote, which drifts as soon as anything else
1212
- holding the handle sets it.
1213
- - 28894b9: Fix the viewport primitives on an axis with negative scale.
1214
-
1215
- `View.scale` is documented as pixels per world unit _per axis_, so `scale.y < 0`
1216
- is the ordinary way to spell a y-up camera. Two primitives did not read it that
1217
- way, and both failed silently rather than erroring.
1218
-
1219
- `zoomAt` clamped the signed scale against positive bounds
1220
- (`min(max, max(min, scale * factor))`), so one wheel step on a y-up view
1221
- returned `scale.y = +0.1`: the axis flipped and the zoom collapsed to the
1222
- minimum. It now bounds the magnitude and restores the sign, so a clamp limits a
1223
- flipped axis instead of unflipping it.
1224
-
1225
- `clampView` computed the visible world extent as `canvas.height / scale.y`,
1226
- which is negative on a flipped axis. That made the "is the view zoomed out past
1227
- the bounds" test never fire, and put the scroll interval on the wrong side of
1228
- the anchor — a y-up view could be panned outside its own bounds. It now takes
1229
- the extent as a magnitude and anchors the interval at the rect's far edge when
1230
- the axis is flipped.
1231
-
1232
- Found while giving labkit's instrument canvas a declarable coordinate system:
1233
- routing its wheel through `zoomAt` looked like the obvious way to stop
1234
- reimplementing fixed-point zoom, and would have been a bug.
1235
- - c4ccd0a: Zoom now has one clamp. `DEFAULT_MIN_ZOOM` / `DEFAULT_MAX_ZOOM` are exported from
1236
- `@weasel-js/core` and every zoom path defaults from them — `zoomAt`, the
1237
- `viewport.zoom` and pinch actions, `usePinchZoomTool`, `fitViewToBounds`,
1238
- `computeWheelAction` and `useZoom`.
1239
-
1240
- **Behavior change:** the three paths that carried the second, undocumented pair
1241
- now cap at 8x rather than 10x. `fitViewToBounds` could previously land at 10x and
1242
- the next pinch frame would clamp it straight back to 8x. Pass an explicit
1243
- `maxScale` / `max` to keep 10x.
1244
- - Updated dependencies [a7fa697]
1245
- - @weasel-js/geom@1.4.0-pre.0
1246
- - @weasel-js/text@1.4.0-pre.0
1247
- - @weasel-js/font@1.4.0-pre.0
1248
- - @weasel-js/gestures@1.4.0-pre.0
1249
- - @weasel-js/history@1.4.0-pre.0
1250
- - @weasel-js/modes@1.4.0-pre.0
1251
- - @weasel-js/paint@1.4.0-pre.0
1252
-
1253
- ## 1.3.0
1254
-
1255
- ### Minor Changes
1256
-
1257
- - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
1258
- into `@weasel-js/paint` — two new Tier A leaves.
1259
-
1260
- `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
1261
- measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
1262
- `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
1263
- lay out text without taking the scene graph or a React peer dependency.
1264
- `layoutRuns` is now public — it was previously reachable only from inside core.
1265
-
1266
- `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
1267
- `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
1268
- layout could not move while its fill type lived in the renderer's graph.
1269
-
1270
- `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
1271
- `Rect` moves to `@weasel-js/geom`, beside `Box`.
1272
-
1273
- Breaking for anyone importing these through core's internal paths rather than
1274
- its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
1275
-
1276
- Advances and kerning still come from a baked MSDF atlas — laying out from font
1277
- bytes alone needs the metrics seam in
1278
- `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
1279
-
1280
- <!-- bump-approved: minor: Mike — two new published packages (@weasel-js/text, @weasel-js/paint) and layoutRuns promoted to public API, on top of ~50 patch changesets carrying new public surface across core, ui and labkit; called explicitly in conversation on 2026-08-29: "tag a minor release and push" -->
1281
-
1282
- ### Patch Changes
1283
-
1284
- - 52c7b2a: Depend on `font` and `core` as exact peers
1285
-
1286
- `@weasel-js/font` and `@weasel-js/core` keep registries that consumer code
1287
- writes into — registered faces and glyph-ready subscribers in one, content
1288
- handlers and paint kinds and shape painters in the other. Two physical copies
1289
- in a tree are two registries, so a face registered into one while layout
1290
- resolves against the other lays out nothing and the canvas is blank.
1291
-
1292
- Exact sibling pins are what produced the duplicate: a consumer mixing two
1293
- weasel releases left npm no choice but to nest a second copy, silently. As
1294
- peers, the same mix is an `ERESOLVE` at install time. `font` is now a peer of
1295
- `core`, `hud` and `text`; `core` is now a peer of `svg`, joining `d3`, `hud`
1296
- and `ui`, whose `>=` ranges tighten to exact so no version mix resolves by
1297
- accident.
1298
-
1299
- **This can break an install that currently succeeds.** Anyone resolving a
1300
- mixed set of weasel versions by luck now gets an install error instead of a
1301
- blank canvas. That is the point, but it is a break.
1302
-
1303
- `labkit` deliberately keeps `core` as an ordinary dependency: its build aliases
1304
- every core entry point to core's built files and inlines them, so it never
1305
- resolves core at the consumer and has nothing to peer. The flip side is that
1306
- labkit ships its own copy of core's registries, so a consumer using both still
1307
- has two — this change does not address that.
1308
- - 3386d64: Align, distribute and flip use visual bounds
1309
-
1310
- These folded each member's unrotated pose box, so "Align Left" on a selection
1311
- containing a rotated shape lined up the boxes and left the rotated shape's ink
1312
- sticking out past the others. They now work on the visual bounding box, as
1313
- Figma and Illustrator do.
1314
-
1315
- Both ends moved together — expanding only the union would have made alignment
1316
- worse, since the delta runs from an edge of the union to the same edge of each
1317
- member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
1318
- reads a pose's bounds, recovers its rotation and expands via
1319
- `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
1320
- applied as a translation of the stored pose through
1321
- `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
1322
-
1323
- Flip needed only its union pivot changed: mirroring maps a centre and preserves
1324
- size, and an expanded box is concentric with the box it came from.
1325
-
1326
- `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
1327
- by its ink.
1328
- - ffafb7d: Never let an animation's virtual clock run backwards.
1329
-
1330
- `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
1331
- time, then advances its virtual clock by the difference against the timestamp
1332
- the frame loop supplies. Those two share a time origin in a browser, where the
1333
- rAF timestamp and `performance.now()` are both page-relative — but that is a
1334
- browser guarantee, not a universal one, and jsdom starts them roughly 600ms
1335
- apart. The first frame's delta then came out hugely negative and `virtualNow`
1336
- spent dozens of frames climbing back toward zero before a tween advanced at
1337
- all: a 40ms glide took 95 frames and over a second of wall time, growing worse
1338
- the longer the process had been alive.
1339
-
1340
- A frame's elapsed time is never negative, so the sample is now clamped at
1341
- zero. Under a shared origin this is a no-op.
1342
- - ba8b139: Camera animation: `viewport.animatedZoom` now does something
1343
-
1344
- `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
1345
- nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
1346
- through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
1347
- their input already delivers a sample per frame.
1348
-
1349
- Camera animation is a general surface, not a zoom flag. Three ways in, one
1350
- runner behind them:
1351
-
1352
- - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
1353
- `isAnimating`, `target`.
1354
- - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
1355
- so any action can glide the camera.
1356
- - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
1357
- `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
1358
- three are **required** members: anyone hand-implementing `SceneCanvasApi`
1359
- (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
1360
- grew `getPaintedVersion`.
1361
-
1362
- Scale interpolates geometrically and translation is derived from the screen
1363
- point the two views agree on, so a zoom stays anchored instead of drifting and
1364
- each frame changes the view by the same ratio. One animation runs at a time; any
1365
- other view write cancels it, and a cancel leaves the camera where it is rather
1366
- than jumping to the target. On an uncontrolled canvas the whole animation costs
1367
- no React render.
1368
-
1369
- **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
1370
- changes signature — it takes a `{ get, set }` view channel plus an optional
1371
- `Animator`, and `animateTo(from, to, { duration, easing })` becomes
1372
- `animate(to, { ms, easing })`. The `from` argument is gone because the runner
1373
- reads the live view, which is what lets an interrupted camera resume from where
1374
- it actually is instead of snapping back to a captured start. `cancel()` is now
1375
- `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
1376
- `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
1377
- the same reason `from` does.
1378
-
1379
- **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
1380
- `() => View | void`. Returning the target view lets Cmd+0 animate there;
1381
- returning nothing keeps the existing behavior. `animatedZoom`'s config fields
1382
- are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
1383
- animator's vocabulary.
1384
- - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
1385
-
1386
- The canvas is focusable by default (`tabIndex` 0) but exposed no way to
1387
- observe focus, so consumers driving focus-dependent chrome had to attach a
1388
- listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
1389
- so reach `SceneCanvasProps` and the canvas element unchanged.
1390
- - 67bcb05: Drop four values the canvas layer memo no longer reads
1391
-
1392
- `hit-test affordances against the painted chrome state` moved the selection
1393
- overlay to reading bounds off the chrome state at paint time, which left
1394
- `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
1395
- referenced only by the `layers` memo's dependency array — nothing in the body
1396
- used them. Removing them from the array made all four dead locals, so they go
1397
- too.
1398
-
1399
- The memo now rebuilds the layer array on layer/tool/geometry changes rather
1400
- than additionally on every selection and preview-pose change. Selection chrome
1401
- is unaffected: it repaints from chrome state, not from the identity of this
1402
- array.
1403
- - 47cbb08: A closed subpath's dash no longer seams at its start vertex
1404
-
1405
- `splitForDash` flushed the run still open when a closed subpath's walk returned
1406
- to the vertex it started from as its own open sub-polyline, so it and the run
1407
- that began there rendered as two butt-capped ribbons meeting at a point — a
1408
- notch on the corner of any dashed rectangle whose perimeter isn't a whole
1409
- multiple of the pattern. They are joined now, and the join the stroke asked for
1410
- is drawn across the seam like any other corner. A pattern whose first "on"
1411
- length covers the whole perimeter emits a closed ribbon, identical to the
1412
- undashed stroke.
1413
- - f43e9c2: A derived edge follows the drag that moves its endpoint
1414
-
1415
- `move`, `resize` and `rotate` kept their in-flight poses in action-local
1416
- scratch and published them only as `previewIds` / `previewPose`. That surface
1417
- is enough to paint a ghost and size selection chrome, but nothing that asks
1418
- the *scene* where a node is can see it — and `scenePoseLookup`, which resolves
1419
- a derived node's geometry, asks the scene. So dragging a box left its edge
1420
- anchored to the pre-drag position until the drop, when the commit invalidated
1421
- the dependents and the edge jumped.
1422
-
1423
- The three actions now also publish each frame into the scene's ephemeral pose
1424
- overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
1425
- `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
1426
- so a drag still commits as exactly one undo entry — the reason the actions
1427
- avoided per-frame scene writes in the first place was history, and this writes
1428
- no history. Entries are set once and mutated in place, published with a single
1429
- `commit()` per frame.
1430
-
1431
- Picking follows for free: the pick source resolves a derived path through its
1432
- own override-aware `poseOf`, so an edge is grabbable where it is drawn
1433
- mid-gesture rather than where it used to be.
1434
-
1435
- `clone` is deliberately untouched — its previews are the new ghosts at the
1436
- drag target, and the originals never move, so nothing derives from a changed
1437
- pose.
1438
-
1439
- Also closes the matching gap in the preview-ghost layer, which built a
1440
- container's clip with no derived path and so ghosted a derived container
1441
- without one.
1442
-
1443
- Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
1444
- on every gesture frame. It was already required by the `Scene` contract, but a
1445
- partial fake that omitted it will now throw rather than silently skip.
1446
- - bb27e83: A derived node is clickable where it paints
1447
-
1448
- A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
1449
- `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
1450
- resolved from the *dependencies'* poses, which a painter has no handle on. So
1451
- `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
1452
- as "no opinion" and answered `true` everywhere, and picking fell back to the
1453
- node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
1454
- was unpickable, and a derived container contributed no clip.
1455
-
1456
- `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
1457
- same convention `NodePaintCtx` already uses, and `kit:derived` reports the
1458
- derived path as its silhouette and its declared stroke as its `ink`.
1459
-
1460
- Resolving that path needs the scene, so it is the *source* that answers, not
1461
- the painter: `PickSource.derivedPathOf`, a matching optional argument to
1462
- `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
1463
- carried the derived path a node *paints*, and now also the clip a derived
1464
- container *imposes*, so the live canvas and the headless walk clip alike. The
1465
- bare-adapter paths supply none of them and behave exactly as before.
1466
-
1467
- The pre-filter had to move with it. `useSceneSelectTool` grew its region test
1468
- from the node's pose, which for a derived node is the wrong box entirely, so
1469
- the edge was rejected before the shape test could claim it. It now tests the
1470
- derived path when there is one — `poseContains` already reads a path-like pose
1471
- as a path, so this reuses it rather than adding a second reach calculation.
1472
-
1473
- `findShapeSilhouette` skips its memo when handed a derived path. That slot is
1474
- keyed on `(node, pose, data)` and cannot see the path, so it would serve one
1475
- caller's silhouette to a caller that passed a different one — the same reason
1476
- `kit:derived` already skips `PAINT_SLOT`.
1477
- - 6a33c3f: A node's path can be derived from other nodes' poses
1478
-
1479
- A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
1480
- through `SceneRegistry`, and the scene walks resolve its path before painting
1481
- rather than it being authored. An edge drawn between two boxes is then an
1482
- ordinary scene node — selectable, styleable, exportable — whose geometry never
1483
- enters undo history. The seam and its traps are in `docs/extending.md`.
1484
-
1485
- New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
1486
- `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
1487
- `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
1488
- `SerializedNode.derivePathKey`, both additions to the serialization format;
1489
- `NodePaintCtx.derivedPath`.
1490
-
1491
- Deleting a node now deletes everything that derives from it, transitively,
1492
- including those nodes' own subtrees, in one undo entry — so `scene.remove` can
1493
- remove nodes anywhere in the tree that the caller never named, and `removeLayer`
1494
- reaches nodes on other layers. Undo after the built-in **Delete** key does not
1495
- yet restore the cascaded nodes; see "Derived geometry follow-ups" in
1496
- `docs/TODO.md`.
1497
-
1498
- **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
1499
- context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
1500
- now a type error rather than a silent slide into the `view` slot. The same
1501
- fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
1502
- callback types, which is not a break: an existing three-parameter implementation
1503
- still satisfies them, and an existing three-argument call still compiles.
1504
-
1505
- **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
1506
- typed as a `Scene` — a test double, most likely — no longer typechecks until it
1507
- implements it.
1508
-
1509
- **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
1510
- `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
1511
- is not a descendant of the removed node and the tree has to be told about every
1512
- subtree that came out of it. A history persisted by an older build now throws
1513
- mid-undo rather than degrading. The break is deliberate; kit op payloads are not
1514
- versioned.
1515
- - c24e7de: Detached views follow pose overrides
1516
-
1517
- `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
1518
- which a pose override deliberately never bumps — so they kept painting document
1519
- poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
1520
- canvas driving a drag or a simulation silently disagreed with it.
1521
-
1522
- `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
1523
- subscribes to `scene.overrides`. A render (prop change or version bump) and an
1524
- override commit both just mark the surface dirty, and one animation frame
1525
- coalesces them — so a 60 Hz override loop repaints these views with no React
1526
- render, and a backgrounded tab stops painting them entirely. The mount paint
1527
- stays synchronous, so the first frame is still the scene rather than a blank
1528
- canvas. `<MinimapCanvas>` inherits all of this through it.
1529
-
1530
- Repaints driven by a prop change are now asynchronous: they land on the next
1531
- animation frame rather than in the layout effect of the render that caused them.
1532
- Code that renders and then reads pixels in the same tick needs to wait a frame.
1533
-
1534
- A minimap's *framing* still derives from document poses, so a node overridden
1535
- outside the document bounds paints outside the fitted frame — recomputing the
1536
- fit per frame would rescale the whole minimap throughout a settle.
1537
- - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
1538
-
1539
- `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
1540
- once instead of hiding two behind a select. Options carry an optional `short`
1541
- label — a capital or two — for the width a property row has; the full `label`
1542
- stays the accessible name, so the abbreviation never becomes the only thing
1543
- naming the option. A mixed selection selects no segment rather than picking a
1544
- winner.
1545
-
1546
- `pair` now merges fields inside an object leaf, as it already did for section
1547
- rows — a hint shouldn't mean something different for being a field of a value
1548
- rather than a sibling of one. It merges *adjacent* leaves in both places, so
1549
- the schema orders family, size, weight: size and weight pair, and family (which
1550
- sat between them) moves ahead of the pair rather than splitting it.
1551
-
1552
- A stroke's cap, join and align share one row; property rows wrap rather than
1553
- overflow when the controls in them don't fit.
1554
- - be697dc: Add ephemeral pose overrides to the scene
1555
-
1556
- `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
1557
- hit-test paths read through and that history, `toJSON()` and `getVersion()`
1558
- never see. It is additive: a scene with no overrides behaves exactly as before.
1559
-
1560
- This is where per-frame motion belongs. A 60 Hz loop previously had to write
1561
- through `setPose`, which records an undo entry (one per frame at best, batched)
1562
- and bumps the scene version, re-rendering every `useSyncExternalStore`
1563
- subscriber. It also had to allocate a fresh pose object per moving node per
1564
- frame, because the painter memo keys on pose reference. An override entry is
1565
- hoisted once and mutated in place; `overrides.commit()` publishes the frame and
1566
- invalidates the memo for the overridden nodes only.
1567
-
1568
- `commit()` is required after an in-place mutation — without it the memo serves
1569
- the previous frame's draw. Overrides are cleared when a node is removed, since
1570
- ids are reusable. To make a frame permanent, write it once through `setPose`
1571
- and clear the override; that single step is the undo entry.
1572
-
1573
- `ForceGraphDemo` now settles with zero history entries and bakes the result as
1574
- one, replacing a per-tick batch of 24 `setPose` calls.
1575
- - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
1576
-
1577
- It was the fourth site measuring text its own way: `ctx.measureText` per
1578
- character against system fonts, no kerning, `pose.text` only. Nothing masked
1579
- it the way the WebGL context masked the caret — a consumer calling it got a
1580
- box that disagreed with the paint, narrower by a kern on every pair and wrong
1581
- by the whole difference between the installed family and the registered face.
1582
- It goes through the shared layout now, so it sees kerning and per-run styling.
1583
-
1584
- **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
1585
- - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
1586
-
1587
- `requestRedraw()` marks the surface dirty and the next frame paints, so many
1588
- redraws in one tick cost one paint. The view gains an imperative path on the
1589
- canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
1590
- longer holds it in React state, so a camera moving at 60 Hz costs no renders.
1591
- Consumers passing a `view` prop stay controlled and are unaffected.
1592
-
1593
- Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
1594
- that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
1595
- gives a host the scene without a render per mutation, `useSceneTextEdit`'s
1596
- `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
1597
- `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
1598
-
1599
- Two public signatures changed. `usePinchZoomTool` takes a view getter,
1600
- `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
1601
- captured value any more. `CanvasExtensionApi` gained five required members —
1602
- `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
1603
- so external code hand-implementing that interface stops typechecking; code that
1604
- only calls through the ref is unaffected.
1605
-
1606
- Pixels and DOM can now be a frame apart, in whichever direction the change came
1607
- from. A view change leads with pixels: `setView` paints without rendering, so
1608
- DOM built from the view is stale until something re-renders it — position
1609
- world-anchored DOM from `subscribeView`. A scene change leads with DOM:
1610
- `SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
1611
- pixels land next frame — compare `getPaintedVersion()` against the version you
1612
- are about to render when chrome must be in lockstep. Do not render scene-derived
1613
- DOM inside `startTransition`: React defers it and nothing forces it to catch up.
1614
-
1615
- Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
1616
- mode is the one in-tree case — can likewise see a buffer one frame older;
1617
- `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
1618
- paints while `document.hidden` is true, `syncPaint` included, so a readback from
1619
- a background tab returns the frame from before the tab was hidden.
1620
- - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
1621
- recorded. A caller is entitled to reuse the array it uploads from, so storing
1622
- the reference recorded a value that later frames overwrote — a test reading
1623
- two frames back saw the same numbers twice and passed. Test-only surface.
1624
- - 3fb3a46: Release held keys when the window loses focus
1625
-
1626
- A window that blurs mid-hold never delivers the keyup, so every in-flight
1627
- `key-held` handle stayed engaged until that key was pressed again — holding
1628
- Space and tabbing away left the hand tool on the hotkey stack indefinitely.
1629
-
1630
- The gesture dispatcher now fires the `key-held` up phase for each held key on
1631
- window blur. Consumers that hand-rolled this reset can drop it; ongoing
1632
- invocations see a normal `onEnd`.
1633
- - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
1634
-
1635
- One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
1636
- round caps and joins, hairline weight reserved for structure, and filled
1637
- regions only where an action has a subject. Covers transport, history, view,
1638
- trial lifecycle, collection, state, instrument and status vocabulary. Import a
1639
- named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
1640
-
1641
- `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
1642
- so consumers have one import site for the whole set. `ImageIcon` was reachable
1643
- from core's icons folder but missing from its public barrel; it is exported
1644
- now.
1645
-
1646
- Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
1647
- rather than hand-placed, because arrowheads and joins that miss their terminus
1648
- are invisible at chrome size.
1649
- - 3fb3a46: Compose `before` and `after` layer chains in both directions
1650
-
1651
- `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
1652
- off an `after` anchor only followed further `after` links, and likewise for
1653
- `before`. A custom layer anchored `before: 'scene'` carrying a second custom
1654
- anchored `after` it dropped that second layer to the tail with a spurious
1655
- dangling-reference warning.
1656
-
1657
- Both walks now emit a layer's `before` chain, the layer, then its `after`
1658
- chain, so the two mix freely. Cycle detection and orphan fallback are
1659
- unchanged.
1660
- - d9f110e: Stop every frame loop while nothing can see it
1661
-
1662
- New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
1663
- whether a frame may run: nothing runs while `document.hidden`, and a loop that
1664
- names an element also stops while that element is outside the viewport. A
1665
- request made while suspended is held rather than dropped and re-armed on
1666
- resume, so a loop never polls visibility or needs restarting by hand.
1667
-
1668
- Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
1669
- `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
1670
- counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
1671
- `useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
1672
- the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
1673
- it paints only dirty layers, but a hidden tab still commits React updates and
1674
- its view/size effect marks every layer dirty.
1675
-
1676
- Loops measuring elapsed time rebase their clock through the new `onResume`
1677
- option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
1678
- meter reporting a rate nobody achieved, a tween jumping to its end value on
1679
- return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
1680
- export; nothing in the tree sets it.
1681
-
1682
- `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
1683
- in kit source, and runs in CI.
1684
- - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
1685
-
1686
- A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
1687
- `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
1688
- ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
1689
- and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
1690
- second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
1691
- where the matcher picks a single winner.
1692
-
1693
- Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
1694
- and jsdom reports an empty-string platform — not nullish, so the fallback never
1695
- fired and every mac binding in the kit was exercised only on the non-mac
1696
- branch. It reads `||` now.
1697
-
1698
- Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
1699
- alongside the `viewport.pinchZoom` action that already handled the same
1700
- gesture, applying one pinch's factor twice — the opt-in broke the path that
1701
- worked without it. SceneCanvas drives pinch through the action alone, and the
1702
- flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
1703
- the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
1704
- for real; it previously left the action running. Bare `<Canvas>` keeps the hook
1705
- as its own pinch path.
1706
- - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
1707
-
1708
- Nothing in the kit exported one, so consumers hand-rolled it — their own
1709
- `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
1710
- inverse built by projecting two points. That inverse silently drops
1711
- `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
1712
- beside the paint instead of on it.
1713
-
1714
- ```ts
1715
- useNodeOverlayFrame(scene, containerRef, nodeId, { view })
1716
- // → { box, toScreen, toLocal, width, height } | null
1717
- ```
1718
-
1719
- `box` is the node's composed world box, unrotated — the frame `toScreen` maps
1720
- from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
1721
- lives in the pose→world leg, where it belongs: a node's stored geometry and its
1722
- bounds-frame paint are pre-rotation by definition, so neither of those two
1723
- changes.
1724
-
1725
- `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
1726
- `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
1727
- `stroke` — `slot` is a prop — and commits each drag through `setFill` or
1728
- `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
1729
-
1730
- Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
1731
- `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
1732
- - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
1733
-
1734
- `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
1735
- color and `'none'` still skips the stroke; an object is a core `Stroke` whose
1736
- `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
1737
- renderer, which has accepted them on `PathDrawCommand` all along. The object
1738
- wins outright over `data.strokeWidth` rather than merging with it, the same
1739
- rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
1740
- is baked onto the pose box the way a fill is, so a gradient stroke resolves
1741
- against the box it was authored against.
1742
-
1743
- `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
1744
-
1745
- `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
1746
- strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
1747
- `'outer'` none inside, which one number could not say, so picking grabbed the
1748
- wrong side. `ink` takes an optional context carrying the view scale, so a
1749
- `{ px }` stroke width resolves to world units. A painter that still returns
1750
- `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
1751
-
1752
- `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
1753
- color pick replaces its paint and keeps width, cap, join and dash, and an
1754
- opacity drag sets the paint's `opacity`, which is the only form that works on
1755
- a gradient stroke.
1756
-
1757
- Editing UI for the rich form is not here yet — a schema-driven color control
1758
- still writes a bare string over the object, so nodes carrying one are for
1759
- programmatic authorship until `SelectionPanel` learns the union. See
1760
- `docs/proposals/2026-08-26-node-stroke-union.md`.
1761
- - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
1762
-
1763
- `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
1764
- servers there deliberately, but the renderer refused anything but a solid — so
1765
- importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
1766
- next frame. Both stroke paths now paint the ribbon through the same route a
1767
- fill takes, including under the inner/outer alignment stencil. A non-solid
1768
- even-odd fill no longer renders black.
1769
- - 5f6c28e: An object leaf's fields can be organised into groups
1770
-
1771
- `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
1772
- heads its fields under a label and contributes nothing to the path — the same
1773
- rule group keys follow at the top level of a schema, so a field inside one is
1774
- still addressed as a field of the object.
1775
-
1776
- Without it, a value with many fields renders as one undifferentiated list. A
1777
- `TextStyle` is the case that needs it: its character and paragraph fields are
1778
- one value but read as two lists.
1779
- - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
1780
-
1781
- A compound value — a stroke, a shadow, a pattern spec — could be described as
1782
- sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
1783
- It shouldn't be: each control then writes one field of a value it can only
1784
- half see, and writing a field into something that isn't an object yet corrupts
1785
- it outright.
1786
-
1787
- `ToolPrefObject` describes the value instead. Its `children` are ordinary
1788
- leaves whose paths are relative to the object, and every child edit commits
1789
- the parent object whole. A field that is itself a union declares the kind that
1790
- edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
1791
- value still held in a scalar form before a child edit lands on it, which is
1792
- how a stroke stored as a bare colour string gains a width.
1793
-
1794
- `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
1795
- Color, Width, Cap, Join and Align under one Stroke block, and the separate
1796
- `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
1797
- `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
1798
- replaced by this general one.
1799
-
1800
- `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
1801
- import, export and rendering untouched.
1802
- - 2ea772f: Selection handles are hit-tested at the size they are painted
1803
-
1804
- Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
1805
- constant, and neither `buildAffordanceAt` call site passed the option that
1806
- would have scaled it. A coarse pointer got a bigger picture and exactly the
1807
- same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
1808
- exists to provide never reached the hit-test. The slops debug overlay was a
1809
- third unscaled copy, so it drew hit regions where they were not.
1810
-
1811
- `core/device/targets.ts` now holds one base table and one accessor,
1812
- `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
1813
- through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
1814
- `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
1815
- table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
1816
-
1817
- `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
1818
- `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
1819
- reached nothing.
1820
-
1821
- `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
1822
- affordance is an annulus with a band thickness and no point radius, so the
1823
- option could only ever have been a second name for `rotationHandleDistance`,
1824
- which is live and now defaults from the same table.
1825
-
1826
- Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
1827
- device profile, so a nested view still hit-tests at the fine-pointer size.
1828
- - f77bd95: `getChildren` means one thing on an adapter
1829
-
1830
- `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
1831
- drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
1832
- the z-ordering seam where `null` means the root. Both land on the same adapter
1833
- object, so `arrayAdapter` took the first shape from its config and exposed it
1834
- under the name the ops read with the second meaning. An op asking for root
1835
- order got `[]`, which reads as "the root has no siblings", and the slot it
1836
- captured was silently lost.
1837
-
1838
- The two declarations are now one contract, and `arrayAdapter` answers the root
1839
- from its own item array rather than delegating — a consumer callback written
1840
- for node ids returns `[]` there, which cannot be told apart from a genuine
1841
- empty answer. A consumer's `getChildren` config is still only ever asked about
1842
- a node id.
1843
-
1844
- `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
1845
- than by anchor. That is unchanged, and it is why the ordinal fallback exists.
1846
- - 2ea772f: The canvas and the gradient editor now sample one gradient
1847
-
1848
- `buildGradientRamp` carried its own interpolation beside
1849
- `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
1850
- at either end and extrapolated past the first and last stop, the two picked
1851
- opposite sides of a coincident pair, and they parsed color differently — a stop
1852
- written as a CSS named color rendered on the canvas and threw in the editor.
1853
-
1854
- `sampleGradientStops` keeps its semantics and is now the only implementation.
1855
- `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
1856
- returns the color at `t`. The ramp cache builds its texels through those, so
1857
- there is no interpolation math left in the renderer.
1858
-
1859
- Two behavior changes worth naming. `resolveColor` is the surviving parser, so
1860
- gradient stops accept named and functional colors everywhere — but no longer
1861
- hex without a leading `#`, which only the editor path had tolerated and the
1862
- canvas never accepted. And `sampleGradientStops` returns normalized hex at the
1863
- endpoints instead of echoing the raw stop string, so `'red'` comes back as
1864
- `'#ff0000'`.
1865
-
1866
- **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
1867
- the element already carried the reference, the built-in serializer returned
1868
- nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
1869
- shape disappeared in a browser with no warning at all. Serialization now falls
1870
- through to the same warning the pattern path already emits when nothing can
1871
- produce a paint server. A consumer that registers a `toSvg` for
1872
- `conic-gradient` still serializes and gets no warning.
1873
- - aba8d91: Answer "can this node be hit" in one place
1874
-
1875
- Four tree walks answered it separately — the generic-adapter point pick, the
1876
- one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
1877
- marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
1878
- that had a test and disagreed on the rest, three times, silently. `pickWalk`
1879
- now owns every gate; a query supplies only its own shape test and the clip
1880
- predicate for its region.
1881
-
1882
- Behavior that changes as a result:
1883
-
1884
- - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
1885
- same number the painter does — the view's `alphaFor` times any per-node
1886
- override alpha — so a node faded out of sight stops claiming clicks. The
1887
- floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
1888
- Alpha is per view: dimming a node in one view leaves it pickable in another.
1889
- - **A layer that is not painted no longer claims pointer events.** `drawLayers`
1890
- drops any layer missing from a supplied `layerOrder`, and the chrome hit path
1891
- only consulted `layerVisibility`. Both gates now run through one
1892
- `isLayerPainted`, which is exported.
1893
- - `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
1894
- did not; its default `poseBounds` answers a path pose instead of `NaN`, which
1895
- is what the shadow walk existed to work around.
1896
- - An ancestor clip now rejects an area query that reaches into the clip where
1897
- the node is not, or reaches the node where the clip is not — the two terms
1898
- together, where one alone let false positives through.
1899
-
1900
- `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
1901
- `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
1902
- `adapterPickSource` and `ownClipOf` replace them.
1903
- - 2ea772f: A drag-to-insert reports the bounds it paints
1904
-
1905
- The painter, the commit factory and `getGestureBounds()` each sized an
1906
- in-flight insert differently. The reporter read the drag rect alone, so a
1907
- centered Alt-drag reported a half-extent of `d` against a painted circumradius
1908
- of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
1909
- star, and a pencil scribble that looped back to its start reported nothing at
1910
- all. The painter and the commit agreed on polygon and star but not on line or
1911
- pencil: the commit posed the drag AABB for a line the painter drew endpoint to
1912
- endpoint, and fell back to the drag rect for a trail under four samples.
1913
-
1914
- One function now answers it for all three. The zero-area skip in the painter
1915
- and the reporter tests the resolved extent rather than the raw drag rect, and
1916
- an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
1917
- `bounds` argument handed to a factory is unchanged.
1918
- - 3386d64: Path command opcodes derive from one table
1919
-
1920
- `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
1921
- once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
1922
- literals in the path transform, pose-rotation and pose-descriptor walkers. They
1923
- agreed, and nothing held them to each other: a sixth opcode desynchronizes two
1924
- packages' reading of the same `Uint8Array` with no exception and no type error,
1925
- and every walker misparses the coordinate stream from that command on.
1926
-
1927
- `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
1928
- `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
1929
- core re-exports them by name, so the opcode constants keep their names, values
1930
- and literal types. The three walkers moved onto `forEachSegment` rather than
1931
- onto the accessor alone — they were duplicating the coordinate-cursor advance
1932
- as well as the length, and the cursor is the half that actually misreads.
1933
-
1934
- Eight further files switch on these opcodes with inline literals. Five throw on
1935
- an unknown code; three — the path boolean adapter, the anchor-editing geometry,
1936
- and geom's own boolean adapter — have no `default` arm and would silently stop
1937
- advancing. Left as-is; they need per-command semantics, not one walker.
1938
- - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
1939
-
1940
- `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
1941
- saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
1942
- leaf had neither `encoding` nor `options[].disabled`, so a dash-array
1943
- preference did not merely fail to select — choosing an option wrote the option
1944
- string over the stored dash array. labkit's two renderers were missing the
1945
- `paint` and `object` kinds outright.
1946
-
1947
- ui's schema is now a rename re-export of core's declaration. The public `Pref*`
1948
- names are unchanged, and there is nothing left to keep in sync.
1949
-
1950
- More importantly, all four renderer switches ended in `default:`, so adding a
1951
- built-in kind produced no error at any site and simply rendered nothing —
1952
- verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
1953
- `string` so app-defined prefs can ride the same tree, which means a `never`
1954
- guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
1955
- `Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
1956
- `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
1957
- can discriminate and end in a `never`. App-defined kinds take the placeholder
1958
- path as before.
1959
-
1960
- Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
1961
- arm threads sibling values, routes through `encoding.read` / `encoding.write`,
1962
- and honors `option.disabled`. `SelectionPanel` already did all of this — it was
1963
- only the forked copy that could not express it.
1964
- - 3386d64: Dragging out a text box shows a live preview
1965
-
1966
- The set of insertable kinds and the `KitInsertShape` union sat on adjacent
1967
- lines with no linkage, and seven more sites restated one list or the other. The
1968
- drift was already live: the text tool binds `actionId: 'insert'` and commits
1969
- through the insert dep, but the runtime set never listed `text`, so a
1970
- drag-to-insert text box had no preview.
1971
-
1972
- `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
1973
- it has a built-in tool and whether it takes an insert preview. Both unions,
1974
- `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
1975
- preview gate all derive from it.
1976
-
1977
- Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
1978
- `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
1979
- same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
1980
- positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
1981
- gains `'text'`, which is the fix itself: a consumer switching exhaustively over
1982
- it gains a case, handled by the existing box arm.
1983
- - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
1984
-
1985
- The paint, the pose silhouette and the click-to-edit caret each ran their own
1986
- walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
1987
- `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
1988
- per call and the cache keyed on array identity; and the caret summed
1989
- `ctx.measureText` per character, which sees no kerning, reads system fonts
1990
- rather than the registered face, and ignores per-run styling entirely. The
1991
- caret could therefore answer with a different line, and a different glyph, than
1992
- the one under the pointer — masked in practice only because it asked a WebGL
1993
- canvas for a 2D context and got `null`, degrading silently to no caret at all.
1994
-
1995
- `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
1996
- and all three go through it. It keeps the array-identity `WeakMap` as the
1997
- renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
1998
- structure, which is what lets a caller that cannot hold a stable array hit it —
1999
- about 230× cheaper than laying out again, at roughly 4× the cost of the
2000
- identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
2001
- snapping is to the advance cells the glyphs were actually painted in.
2002
-
2003
- **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
2004
- `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
2005
- an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
2006
- draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
2007
- double-click always seeds the caret instead of falling back to editing from
2008
- offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
2009
- `_resetLayoutCacheForTests`.
2010
- - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
2011
-
2012
- `registerFontOutlines` was a paint upgrade for a family that already had an
2013
- MSDF atlas; a family with only font bytes could not resolve, so it rendered
2014
- nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
2015
- `advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
2016
- outline-only family, and `layoutRuns` reads advances, kerning and the baseline
2017
- through one source the atlas and a parsed face both satisfy. `outlineMinSize`
2018
- does not gate such a family — there is no other tier to prefer.
2019
-
2020
- This does not touch metric neutrality where it applies: a family that has an
2021
- atlas still resolves to the atlas, so registering outlines cannot move text
2022
- that was already rendering.
2023
-
2024
- Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
2025
- and UMD under `main`; Node takes the UMD build, whose named exports it cannot
2026
- detect, so `parse` was undefined and every face failed to load — silently, via
2027
- the fallback to SDF. A browser bundler reading `module` never saw it.
2028
-
2029
- Breaking for a consumer-supplied `OutlineParser`: a face must now report
2030
- metrics as well as geometry.
2031
- - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
2032
-
2033
- A kind bar over a per-kind body, driven by the paint-kind registry rather than
2034
- a fixed list, so a consumer's registered kind appears in the bar and renders
2035
- that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
2036
- the chip that showed a gradient as indeterminate and wrote a solid over it on
2037
- first touch — so the checkerboard now means a mixed selection and nothing else,
2038
- and a gradient stroke is editable rather than merely paintable.
2039
-
2040
- Switching kinds keeps a per-kind memory for the control's lifetime, so
2041
- linear -> solid -> linear comes back with its stops instead of the ramp
2042
- `withGradientKind` cannot carry.
2043
-
2044
- `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
2045
- on `@weasel-js/svg` for its tile previews.
2046
-
2047
- The bar offers **None**: "what kind of paint is this?" takes no-paint as an
2048
- answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
2049
- becomes `null`, and a stroke goes away entirely rather than keeping a width
2050
- that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
2051
- built-in kinds carry glyphs so six segments fit a property row.
2052
-
2053
- `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
2054
- and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
2055
- with its width — a whole paint editor cannot share a row with a slider.
2056
- - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
2057
- frames and serializes.
2058
-
2059
- `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
2060
- re-seeds the five built-ins, matching the kit's other module-global
2061
- registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
2062
- `Editor`), a render slot, both frame-conversion directions, and an SVG
2063
- `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
2064
- consumer's own paint as a `FillStyle` — the union itself stays closed, because
2065
- opening its discriminant would widen every built-in member.
2066
-
2067
- Three defects fall out of the same change, each of which a sixth kind hit
2068
- immediately. The renderer's fill dispatch fell off the end of its switch into
2069
- an unguarded cast to the gradient union, so an unknown kind read `stops` off a
2070
- paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
2071
- an unknown kind untouched, leaving it painting in screen space on a node that
2072
- moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
2073
- still writing the `url(#id)` that referenced it.
2074
-
2075
- Registering a kind now bumps the node memo generation, so a node painted
2076
- before the registration repaints rather than holding the frame it resolved
2077
- when the kind was unknown.
2078
- - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
2079
-
2080
- Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
2081
- `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
2082
- where `undefined` takes the painter's fallback. Two new authoring helpers keep
2083
- hand-written node data short:
2084
-
2085
- ```ts
2086
- data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
2087
- ```
2088
-
2089
- **Breaking, with no compatibility path.** A document written against the old
2090
- shapes renders wrong rather than failing, which is accepted:
2091
-
2092
- - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
2093
- and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
2094
- A node holding `fill: '#f00'` now paints the default grey.
2095
- - `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
2096
- - `data.color` — the legacy alias `kit:path` and the rect fallback read — is
2097
- deleted. The fallback painter reads `data.fill` like everything else.
2098
- - `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
2099
- - `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
2100
- else. A painter returning `{ filled, strokeWidth }` no longer type-checks
2101
- and its reach is read as zero.
2102
- - `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
2103
- the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
2104
- - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
2105
- of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
2106
- stroke into a color. SVG's `fill="none"` imports as `fill: null`.
2107
-
2108
- **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
2109
- the only slot a gradient or a pattern has, so it is the slot all of them use,
2110
- and the renderer multiplies a hex alpha by it — the two would fight if both
2111
- carried the value. `solid()` therefore moves an alpha channel out of the hex:
2112
- `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
2113
-
2114
- The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
2115
- `opacity` rather than splicing hex, so they now work on a gradient fill, which
2116
- they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
2117
- the node's existing paint through the new `paintWithColor`, keeping its opacity
2118
- unless the picked color states an alpha of its own — and `setStroke` keeps the
2119
- stroke's width, cap, join and dash instead of replacing the whole value.
2120
-
2121
- New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
2122
- `paintWithColor`, `DEFAULT_SHAPE_FILL`.
2123
-
2124
- `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
2125
- — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
2126
- writes a bare string over it — and the `data.stroke` object leaf drops its
2127
- `fromScalar`, which had nothing left to lift.
2128
- - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
2129
-
2130
- The text schema put `data.text` in a group named Text, so the section read
2131
- TEXT and the row inside it read Text — one word nested in itself — and the
2132
- style groups below it read as fields of the content string rather than as its
2133
- siblings. Content is its own section now, with the field full-width because
2134
- the section already names it.
2135
-
2136
- A group with an empty `name` renders no heading. That already worked for
2137
- sections and is now documented on `ToolPrefGroup`, since it is how a schema
2138
- says "this group organises, it doesn't name": `Character` and `Paragraph`
2139
- carry the labels, and a `Typography` heading over them named nothing new.
2140
- It stays opt-in rather than a rule that rolls up any all-group parent —
2141
- a `Border` over `Top` / `Right` / `Bottom` needs its name.
2142
-
2143
- Rows under a suppressed heading no longer indent. Depth drawn without a
2144
- visible parent put `Character` a level deeper than `Content` while being its
2145
- peer, which is the panel's own tree discipline broken by its own hand.
2146
- - 611b30e: Layers and deps answer for the view they are drawn for
2147
-
2148
- Nine lookups closed over the *surface's* state at construction, so they answered
2149
- for view zero in every view. `<CanvasView>` draws the surface's layer array
2150
- unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
2151
- a guarantee of answering for the wrong view rather than merely an unused
2152
- argument. A drag in view B ghosted in view A, the marquee painted in the wrong
2153
- view, chrome-caps resolved against the surface's selection, every Cmd+V centered
2154
- on the wrong camera, and Escape in view B cancelled view A.
2155
-
2156
- **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
2157
- and `getIsVisible()`. All three are **required members**: anyone hand-writing a
2158
- `CanvasViewHelpers` (a test double, a wrapper) has to add them.
2159
- `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
2160
- have answered for one view.
2161
-
2162
- **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
2163
- alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
2164
- is the tool half.
2165
-
2166
- **Layer options changed.** `createPathEditingOverlayLayer` and
2167
- `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
2168
- `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
2169
- now. `usePreviewGhostLayer` has lost `tools`. Both it and
2170
- `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
2171
-
2172
- **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
2173
- result accept an optional trailing `PickCamera`. A world point does not carry
2174
- the scale it was produced under and picking has no draw envelope, so the caller
2175
- that produced the point supplies it; omitting it keeps the surface camera.
2176
-
2177
- `useHoverTracking` took a `clientToWorld` thunk beside a world-space
2178
- `getNodeAtPoint` — the first resolved the view and the second did not, so hover
2179
- picked at the surface's scale inside a panel. It takes one
2180
- `nodeAtClientPoint(clientX, clientY)` now.
2181
-
2182
- Anchor-editing target state stays surface-wide; only the preview resolution on
2183
- that path is per-view.
2184
- - 9ad8cb2: Picking answers for what was painted
2185
-
2186
- Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
2187
- from something other than what the renderer drew.
2188
-
2189
- **Pose overrides were painted through and picked around.** `PoseOverride.pose`
2190
- is documented as replacing the document pose *everywhere the render and
2191
- hit-test paths read one*, and `sceneAdapter.getPose` honored it. But
2192
- `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
2193
- did the bounds resolver feeding selection chrome and the affordance
2194
- `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
2195
- overrides painted them at one place and picked them at another. `effectivePose`
2196
- is now the single rule and every one of those reads through it.
2197
-
2198
- **A clipped-away child was still clickable.** A container clips its subtree and
2199
- the renderer honors it, so a child outside the clip is not painted.
2200
- `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
2201
- `<SceneCanvas>` installs instead had no clip term at all. The new
2202
- `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
2203
- render-order scan can apply the same test.
2204
-
2205
- **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
2206
- 45° puts a corner 32 units above that box; a rubber-band over that corner was
2207
- rejected before the rotation-correct silhouette test ran, while a click on the
2208
- same pixel selected the shape.
2209
- - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
2210
- tiles, coins, enemies and flagpole still declared `data.color`, the alias
2211
- removed when node paint became an object, so every one of them rendered in
2212
- the default gray — the demo whose whole point is being the visual twin of the
2213
- immediate-mode load test.
2214
- - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
2215
-
2216
- Three paths read a pose's stored, unrotated box where the rotated extent was
2217
- wanted.
2218
-
2219
- `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
2220
- came back turned the same way — invisible on a rectangle, whose AABB is
2221
- symmetric under a sign flip, and plainly wrong on an asymmetric one, which
2222
- translated instead of mirroring. It now negates the pose's rotation.
2223
-
2224
- `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
2225
- stored edges, while the dragged selection matched against them by its ink.
2226
- `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
2227
- `deriveAlignmentGuides` reads its targets through the same projection — a new
2228
- `projection` option defaulting to the rect one, so existing callers get the fix
2229
- without a change.
2230
-
2231
- `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
2232
- rotated member ended up with a gap short by the difference; `centers` shared the
2233
- line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
2234
- `distributeHorizontalAction` / `distributeVerticalAction` also take
2235
- `params.mode`, so `gaps` is reachable from a binding rather than only from the
2236
- hook.
2237
-
2238
- Flip and distribute return different poses than before for rotated shapes.
2239
- That is the fix, but it is a behavior change for anything depending on the
2240
- old output.
2241
- - 3386d64: `@weasel-js/core/routing` exports the route-string projection
2242
-
2243
- Anything rendering a `GestureSpec` as a route string had to re-implement the
2244
- projection, and the copy in WeaselDraw's registry inspector had drifted three
2245
- ways: it answered `drop` and `paste` with no gesture name, so every binding of
2246
- either vanished from the route list; its argument lookup missed a spec field;
2247
- and it gated targets on a hand-listed set of kinds, dropping them for
2248
- `pointerDown`, `longPress` and `wheel`.
2249
-
2250
- New from the routing subpath: `routesForSpec(spec)` — every route string one
2251
- spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
2252
- map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
2253
- subpath index did not, so consumers reading `RegistryEntry.target` had no way
2254
- to compare against the sentinel its own docs name.
2255
- - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
2256
-
2257
- `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
2258
- in their control flow — `***`/`**`/`*` and a two-character escape set — so
2259
- reading or writing any other spelling meant forking both. They now take a
2260
- `RunGrammar`: a table of markers pairing a repeated delimiter with the run
2261
- flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
2262
- today's behavior. Escaping follows the grammar's own delimiters.
2263
-
2264
- Nothing changes for a caller that passes no grammar. `underline` and
2265
- `strikethrough` still have no markdown spelling and are still dropped by
2266
- `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
2267
- rather than editing the parser.
2268
- - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
2269
-
2270
- `createSelectionOverlayLayer` documents itself as equivalent to stacking the
2271
- other two, and it was not. It reads `ChromeState` off the draw envelope,
2272
- resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
2273
- visibility and suppressed ids, and takes selection and poses from the envelope
2274
- when they are omitted. The two primitives did none of that: they ignored the
2275
- draw envelope entirely, required a construction-time `getPose` cascade, and
2276
- knew nothing about the multi-selection union — so a consumer who stacked them,
2277
- on the wrapper's own promise, got chrome in the wrong place with no way to
2278
- tell.
2279
-
2280
- All three now run one body and differ only in which passes they enable, so the
2281
- promise holds by construction. `SelectionOutlineLayerOpts` and
2282
- `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
2283
- that don't apply, which makes `getSelection` and `getPose` optional on both and
2284
- adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
2285
- `SelectionHandleStyle`.
2286
- - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
2287
-
2288
- The effect depended on the whole selection-context value, and the provider
2289
- mints a new value object on every publish. So one publisher publishing refired
2290
- the effect for every other publisher in scope, each of which republished its
2291
- own ids — a newer selection got stomped back to an older one, and two
2292
- publishers holding different ids under one provider never settled at all.
2293
-
2294
- `publishSelection` is already a stable `useCallback`, so the effect now depends
2295
- on it directly. No provider change and no API change.
2296
- - 84db1f6: Close four gaps that produced wrong answers with no error
2297
-
2298
- Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
2299
- `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
2300
- `default:` arm, so a command code they did not know fell out of the switch
2301
- without advancing the coordinate cursor and every segment after it read the
2302
- wrong floats. They now throw, matching the six sibling walkers. This is a
2303
- behavior change for anyone feeding these a path built with an opcode outside
2304
- `PATH_COMMANDS`: what used to come back subtly wrong now raises.
2305
-
2306
- A `<CanvasView>` built its affordance hit-test without a device profile, so a
2307
- nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
2308
- zones against the 14px chrome the surface paints. It reads the profile
2309
- `<SceneCanvas>` publishes.
2310
-
2311
- `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
2312
- neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
2313
- records had nowhere to land: undoing a delete through the move pipeline
2314
- appended the node to the end of its parent instead of putting it back where it
2315
- was. All three are there now.
2316
-
2317
- The dev inspector's gesture panel formatted bindings with a private formatter
2318
- that reported only modifiers set to `true`. The `ingest` action marks every
2319
- modifier `'optional'`, so its drop and paste bindings rendered blank and the
2320
- action was invisible on both gestures. Both of the panel's plain-text
2321
- formatters now go through the kit's `routesForSpec`.
2322
- - 3386d64: Undoing a multi-node delete or group restores document order
2323
-
2324
- Restoring by stored index cannot survive replay: history runs a batch's
2325
- inverses in reverse, while indices captured before the mutation are only
2326
- correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
2327
- undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
2328
-
2329
- Ops now record a `Slot` — an ordinal plus the id of the following sibling at
2330
- capture. The anchor is the source of truth whenever it resolves, and it
2331
- resolves whatever else the batch has already restored. The ordinal remains as
2332
- the fallback for an adapter that can place by index but cannot enumerate
2333
- children. `before: null` means "last" and needs no sibling list; an absent
2334
- `before` means "unobserved", and the two survive `History.serialize` because
2335
- `undefined` drops out of JSON and `null` does not.
2336
-
2337
- The ops observe their own slot during `apply()` rather than taking one from the
2338
- caller, so every existing emitter gets this without a call-site change.
2339
- `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
2340
- docstring said it was sufficient on its own, which it never was.
2341
-
2342
- Adapters without an ordering seam still append, as they did before:
2343
- `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
2344
- neither that nor an `index` parameter on `insertNode`.
2345
- - 7a746df: A stroke's dash is edited as a style, not as an array
2346
-
2347
- `Stroke.dash` already rendered, imported and exported; it had no control,
2348
- because a `number[]` has no leaf kind. It doesn't need one — the thing a person
2349
- chooses is a style, and the array is how it is stored. The stroke block gains a
2350
- Solid / Dashed / Dotted / Custom bar under cap, join and align.
2351
-
2352
- `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
2353
- the option string, the counterpart of the `unit` a number leaf already has for
2354
- a value stored in a canonical unit. Both directions are handed the object the
2355
- leaf is a field of, because a dash pattern is meaningless without the width it
2356
- scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
2357
- hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
2358
- `strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
2359
- 2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
2360
- a new `disabled` option — one a control reports but refuses to author, since
2361
- there is no array behind it. `solid` is stored as no dash at all, and an object
2362
- leaf's field written as `undefined` is now removed rather than left holding it.
2363
- - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
2364
-
2365
- Nine option glyphs and four category glyphs join the icon set. The option
2366
- glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
2367
- shape rather than as a diagram of one. `align` is a circle zoomed until the
2368
- ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
2369
- the box's complement of it, and `center` is the annulus straddling the path,
2370
- so the three are one band at three offsets. The categories are the bare path
2371
- each row treats, drawn in the outlined register.
2372
-
2373
- A schema carries a glyph *id*, not a component: `ToolPrefEnum`'s options gain
2374
- `icon`, and every leaf gains one for rows whose own label is spent on a
2375
- `pair`. Core ships no icon set and cannot depend on one, so the field is a
2376
- plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
2377
- `short` where it names no glyph.
2378
-
2379
- `SelectionPanel` now honours `block` inside an object leaf, not only at the
2380
- section level. A row whose fields are all `block` drops the 64px label column
2381
- and spans the block. The default stroke schema uses both: paint and width
2382
- share one label-less row, and cap/join/align share the next.
2383
-
2384
- `align`'s options run inner, center, outer — the order the ink moves outward.
2385
- - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
2386
-
2387
- `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
2388
- through a new registry (`registerMarker`), shipping eight built-in shapes.
2389
- Unlike SVG, the stroke stops short of a filled head rather than running under
2390
- it to the tip; the distance is declared per marker, so an open V still reaches
2391
- the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
2392
- `<marker>` defs.
2393
- - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
2394
-
2395
- It accepted `{ color }` only, and merged through `paintWithColor`, which
2396
- supersedes a non-solid paint with a solid one — a gradient stroke was
2397
- unreachable even though `setStrokeOpacity` could already reach its alpha.
2398
- `paint` now wins over `color`, a color arriving later in the gesture supersedes
2399
- an earlier paint, and the stroke's width, cap, join, dash and align survive
2400
- either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
2401
- that has no color to pass.
2402
-
2403
- Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
2404
- from `DEFAULT_STROKE_COLOR`, painting the selection black where
2405
- `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
2406
- `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
2407
- where the body builds a left-edge-to-right-edge one.
2408
-
2409
- `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
2410
- core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
2411
- it — and nothing imported it.
2412
- - 81213fc: Edit a node's stroke as the union it is
2413
-
2414
- `data.stroke` holds `string | Stroke`, and the schema described it with a
2415
- `color` leaf — which reads `undefined` off the object form, shows its own
2416
- default, and writes a bare hex back over the stroke's width, cap, join and
2417
- dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
2418
- for `FillStyle`.
2419
-
2420
- A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
2421
- Its control shows whichever color the value has — the string itself, or a
2422
- solid paint's color — gives a gradient stroke the indeterminate chip rather
2423
- than claiming a color it doesn't have, and preserves the form on write.
2424
-
2425
- `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
2426
- `paint` leaf used to render as the literal text `(paint: no renderer)`.
2427
- `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
2428
- exported from `@weasel-js/ui` for consumers writing their own property
2429
- renderers against either union.
2430
-
2431
- Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
2432
- remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
2433
- for why that waits on the SVG mapping.
2434
- - 2f225d7: A thick stroke is clickable across its whole width
2435
-
2436
- `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
2437
- stroke width for an `outer` align — but the AABB pre-filter that runs before it
2438
- grew only by the pointer slop. So half a thick outer stroke's ink was
2439
- unclickable: the point was rejected before the refinement that would have
2440
- claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
2441
- was at least as generous as the refinement, which it cannot be on its own,
2442
- since it never sees the stroke. That budget is the caller's, and the comment
2443
- says so now.
2444
-
2445
- `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
2446
- declared in `px` resolved as world units and the reach was wrong at every zoom
2447
- but 1 — while the caller computed `meanScale(view.scale)` one line above.
2448
- - 68069dc: Right-to-left text lays out in visual order
2449
-
2450
- `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
2451
- analyses the paragraph, reorders each line after the wrap, and mirrors brackets
2452
- in right-to-left runs. Given none, nothing changes: text lays out logically,
2453
- exactly as before.
2454
-
2455
- `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
2456
- `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
2457
- consumer who renders no right-to-left text never installs the Unicode tables,
2458
- and a different implementation can be substituted. `@weasel-js/bidi` is a
2459
- devDependency here only, for a test that drives real Hebrew through the real
2460
- engine; types lining up is not evidence the semantics do.
2461
-
2462
- `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
2463
- across `cells`**. Cells stay in logical order — slot `i` is still character `i`
2464
- — while their x values follow the reordering. Sort on `x` for visual order, and
2465
- read a cell's extent as `[x, x + advance)` rather than reaching for the next
2466
- cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
2467
- against each cell's own extent, taking a right-to-left cell's visually-leading
2468
- half as the character's logical end.
2469
-
2470
- Kerning is a gap between two adjacent characters, and the wrap measures it
2471
- logically. Reordering can put a different pair side by side, so the gap taken
2472
- is the one belonging to whichever of the two is logically second, and none at
2473
- all across a direction boundary — where the pair never touched in the source.
2474
-
2475
- Laying out right-to-left text with no engine now warns once, naming the import.
2476
- The alternative is glyphs silently appearing reversed, which is the one real
2477
- hazard of making this opt-in.
2478
- - 5d0ff9c: Every code point on a line gets a cell
2479
-
2480
- `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
2481
- `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
2482
- `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
2483
- a consumer indexing per character no longer has to reconcile a sparse array
2484
- against the source string.
2485
-
2486
- The old arrays were documented as non-contiguous, and two causes were real:
2487
-
2488
- - A code point no tier could serve was dropped outright, taking its caret stop
2489
- with it. It now occupies a zero-advance cell. This is reachable whenever the
2490
- dynamic canvas fallback is off — which is the normal configuration for a
2491
- consumer registering its own outlines, where the outline tier has no rung
2492
- below it.
2493
- - A space opening a line — at the start of the text, or after a newline — was
2494
- discarded. It now keeps its cell and still consumes no width, so a line is
2495
- addressable per character without gaining an indent. A space that opens a
2496
- *wrapped* line was never affected: the wrap leaves it as a trailing cell on
2497
- the line before.
2498
-
2499
- Neither changes any geometry: both cells carry zero advance, zero tracking and
2500
- no kerning, so bounds, line widths and glyph positions are unchanged.
2501
-
2502
- A newline still has no cell, since it separates cells rather than being one.
2503
- `srcEnd` is what a blank line carries in its place.
2504
-
2505
- `drawsInk` is a property of the code point and the face, not of the call that
2506
- produced it: it does not flip when a dynamic bake lands or the outline
2507
- threshold is crossed, so the same text reports the same slots every time. A
2508
- zero-advance combining mark is `true` — it inks without advancing.
2509
- - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
2510
- `data.fill` and `data.stroke` — the same two leaves every other node kind
2511
- paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
2512
- `TextStyle.stroke` are gone, with no compatibility read: a document that put
2513
- its color in `style.fill` now renders in the default black rather than
2514
- erroring, so check documents that predate this.
2515
-
2516
- This fixes a real asymmetry rather than only moving fields. `data.stroke`
2517
- already reached text through a fold in the painter, but `data.fill` did not:
2518
- picking a fill color with a text node selected wrote a field nothing read, so
2519
- the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
2520
- the Appearance leaf now all mean the same thing on text as on a rect. The
2521
- duplicate `data.style.fill` control is gone from the text schema with them.
2522
-
2523
- `resolveTextStyle(style, paint)` takes the node's paint as a second argument
2524
- and is what derives the caret and selection colors, so the edit overlay
2525
- matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
2526
- the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
2527
- `data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
2528
- `createTextLayer` is painted rather than black. `SvgTextNode` gained the same
2529
- two, and SVG import and export carry text paint there instead of inside the
2530
- style. `StyledRun.fill` and `.stroke` are unchanged and still override the
2531
- node's per range — which is also where a caller with no node at all, a HUD
2532
- widget or a debug overlay, now states its color.
2533
-
2534
- `textCommandFromRuns` is exported from the package root.
2535
- - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
2536
- array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
2537
- the last paths still doing what `drawImage` stopped doing; text now costs
2538
- **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
2539
- ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
2540
- image draw. No other command kind moved.
2541
-
2542
- A text group is as many quads as it has glyphs, so unlike the image ring a
2543
- slot's buffer grows to the largest run it has seen rather than being fixed at
2544
- four vertices. The quad index pattern is a pure function of the quad count —
2545
- the pattern for N quads is a prefix of the pattern for any larger N — so one
2546
- index buffer serves every slot, grown the same way and written only when it
2547
- grows.
2548
- - c2ffa49: Alignment can resolve against reading direction
2549
-
2550
- `align` gains `start` and `end` alongside `left` / `center` / `right`, and
2551
- `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
2552
- the relative pair resolves against the direction, the absolute pair ignores it.
2553
- `resolveAlign(align, direction)` collapses one to the other and is exported for
2554
- consumers that need an edge rather than an intent.
2555
-
2556
- Direction is an input, not something this package discovers. `@weasel-js/text`
2557
- has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
2558
- what it found; nothing here sniffs an environment.
2559
-
2560
- Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
2561
- layout moves. Making `start` the default alignment is a separate call.
2562
-
2563
- `@weasel-js/svg` carries the direction through: `direction` joins the
2564
- inheritable presentation properties, and `text-anchor` is now written and read
2565
- against it. Two things were wrong before and are worth naming, because both
2566
- rendered plausible output:
2567
-
2568
- - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
2569
- a mapping that assumed three values and read the fourth as its `else`.
2570
- - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
2571
- right edge, while this model's default `align` is `left`. They agree under
2572
- `ltr` and only there, so an RTL document with no explicit anchor imported as
2573
- left-aligned.
2574
-
2575
- This is alignment and round-tripping only. Layout still walks code points in
2576
- logical order with the pen always increasing: there is no bidi reordering and
2577
- no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
2578
- renders in logical order, and Arabic still renders unjoined.
2579
- - 4c097ef: Sit every run on a line on one baseline
2580
-
2581
- Mixed-size text hung each run off the *line top* at its own ascent instead of
2582
- off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
2583
- with the big run's cap rather than standing on the line with it. Two faces with
2584
- different ascents at the same size diverged the same way. Baseline alignment is
2585
- what inline text does everywhere else, and the module header already claimed
2586
- this behavior — the walk just never implemented it.
2587
-
2588
- A line now sinks one baseline far enough to clear its tallest run's ascent and
2589
- places every glyph against it. Glyph quads derive their top from that baseline
2590
- rather than from the pen's line top, which is the whole of the change:
2591
- `qy0 = baselineY + (yoffset - metrics.base) * scale`.
2592
-
2593
- Uniform-size text — nearly all text — is unchanged, since the maximum over one
2594
- value is that value. Only lines that actually mix sizes or faces move, and they
2595
- move to where they always should have been.
2596
-
2597
- The test named "mixed-size runs share a baseline on the same line" asserted only
2598
- a quad count and passed throughout; it now asserts the baselines.
2599
- - 2b86e00: A text node's style is one value, not ten sibling paths
2600
-
2601
- `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
2602
- `TextStyle` from ten independent leaves, each control writing a field of a
2603
- value it could only half see. `data.style` is an object leaf now, with
2604
- Character and Paragraph as groups inside it — groups head their fields and
2605
- contribute nothing to the path, so a field is still a field of the style and
2606
- one commit writes the whole thing.
2607
-
2608
- An object leaf whose fields are entirely grouped no longer prints its own
2609
- heading, which would stack straight onto the first group's, and a group's
2610
- fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
2611
- into an object leaf when listing what a kind exposes — the fields are the
2612
- editable surface; the leaf is the container.
2613
-
2614
- `SelectionPanel` has a story now, which is how the two layout defects above
2615
- were found.
2616
- - d933a89: Superscript, subscript and overline for styled runs
2617
-
2618
- `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
2619
- a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
2620
- over two new primitives rather than a mechanism of its own:
2621
-
2622
- - `baselineShift` — raise (positive) or lower (negative) a run off the line's
2623
- shared baseline, in ems of the inherited font size.
2624
- - `fontScale` — a multiplier on the inherited font size, the relative
2625
- counterpart to `fontSize`. An absolute `fontSize` still wins over it.
2626
-
2627
- Naming either directly overrides that half of `script` and leaves the other
2628
- alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
2629
- ±33.3% position — Adobe's defaults, so a character panel can show percentages
2630
- its users already recognize) and are derived, not read from the font: `OS/2`
2631
- carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
2632
- them, and metrics that applied on one glyph tier and not the other would
2633
- reflow text as it crossed the size threshold.
2634
-
2635
- `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
2636
- `fontSize`, so layout never learns superscripts exist — it places a run against
2637
- a baseline and an offset. The shift moves a run's glyphs, its outline geometry
2638
- and its own decoration rules together, and deliberately does not feed back into
2639
- the line's baseline or height: a superscript rides the line rather than
2640
- reflowing it.
2641
-
2642
- `overline` joins `underline` and `strikethrough` on both `TextStyle` and
2643
- `StyledRun`, additive over the node style like the other two, and is now
2644
- available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
2645
- is unchanged — it stays silent on the decorations, as it always has been.
2646
- - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
2647
- its own final `onTick`. The last tick emitted the value and completed in one
2648
- pass, so a write made from that tick — cancelling the tween — still got the
2649
- completion callback, against the documented "not called on cancel" contract.
2650
- - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
2651
-
2652
- Two ops inverted to something narrower than what they applied, so undo
2653
- silently lost data.
2654
-
2655
- `createDeleteOp.invert()` re-inserted a single node while `apply()` called
2656
- `removeNode`, which cascades the whole subtree. Delete a container with two
2657
- children, undo, and the container came back with `children: []` while both
2658
- children were gone. The op now snapshots its descendants preorder through the
2659
- adapter's optional `getNode` / `getChildren` — the snapshot is written back
2660
- into `args`, so an op rebuilt from a serialized entry still inverts — and
2661
- re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
2662
- does not cascade, so the inverse skips any descendant the adapter still reports
2663
- as live rather than duplicating it.
2664
-
2665
- `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
2666
- instead of restoring the sibling slot and paint order changed. `ReparentArgs`
2667
- now carries `fromIndex` / `toIndex` and places through the existing
2668
- `getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
2669
- `setParent`'s signature is unchanged. Adapters without that seam no-op as
2670
- before. `groupAction` captures each member's index before mutating; `move` and
2671
- `snapToContainer` pass none and are byte-identical.
2672
-
2673
- `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
2674
- cascade, which is why nothing caught the first bug. It now runs against a
2675
- tree-backed fake.
2676
- - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
2677
-
2678
- Every union a user looks at or clicks folded each member's *unrotated* box.
2679
- Select two shapes, rotate one, and the multi-selection frame and its handles
2680
- sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
2681
- out as the target bounds for paint *and* hit-test, so the handles were both
2682
- drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
2683
- correct larger box.
2684
-
2685
- `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
2686
- and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
2687
- beside the rotation-free `unionBounds`, which stays correct for commit-time
2688
- actions that write poses back in the unrotated frame; the module says which to
2689
- reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
2690
- another name. Both new functions are exported from the package root.
2691
-
2692
- Moved onto it: `ChromeState.unionBounds`, the selection overlay's
2693
- container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
2694
- the wrong place whenever a member was rotated), and WeaselDraw's export
2695
- viewBox, which clipped rotated shapes out of the copied SVG.
2696
- - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
2697
-
2698
- `useAction` returned early on a null registry, so an action registered above
2699
- the provider — or with no provider mounted — silently never fired its
2700
- bindings. It now warns in dev, naming the action id. Runtime behavior in
2701
- production builds is unchanged.
2702
- - Updated dependencies [5c8e9e6]
2703
- - Updated dependencies [2621cbf]
2704
- - Updated dependencies [0f936da]
2705
- - Updated dependencies [4180095]
2706
- - Updated dependencies [9977908]
2707
- - Updated dependencies [52c7b2a]
2708
- - Updated dependencies [3386d64]
2709
- - Updated dependencies [c6c499d]
2710
- - Updated dependencies [20097e6]
2711
- - Updated dependencies [84db1f6]
2712
- - Updated dependencies [94f2446]
2713
- - Updated dependencies [68069dc]
2714
- - Updated dependencies [5d0ff9c]
2715
- - Updated dependencies [0bb27a5]
2716
- - Updated dependencies [c2ffa49]
2717
- - Updated dependencies [4c097ef]
2718
- - Updated dependencies [d933a89]
2719
- - @weasel-js/text@1.3.0
2720
- - @weasel-js/geom@1.3.0
2721
- - @weasel-js/font@1.3.0
2722
- - @weasel-js/gestures@1.3.0
2723
- - @weasel-js/history@1.3.0
2724
- - @weasel-js/modes@1.3.0
2725
- - @weasel-js/paint@1.3.0
2726
-
2727
- ## 2.0.0-pre.0
2728
-
2729
- ### Minor Changes
2730
-
2731
- - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
2732
- into `@weasel-js/paint` — two new Tier A leaves.
2733
-
2734
- `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
2735
- measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
2736
- `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
2737
- lay out text without taking the scene graph or a React peer dependency.
2738
- `layoutRuns` is now public — it was previously reachable only from inside core.
2739
-
2740
- `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
2741
- `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
2742
- layout could not move while its fill type lived in the renderer's graph.
2743
-
2744
- `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
2745
- `Rect` moves to `@weasel-js/geom`, beside `Box`.
2746
-
2747
- Breaking for anyone importing these through core's internal paths rather than
2748
- its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
2749
-
2750
- Advances and kerning still come from a baked MSDF atlas — laying out from font
2751
- bytes alone needs the metrics seam in
2752
- `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
2753
-
2754
- <!-- bump-approved: minor: Mike — two new published packages (@weasel-js/text, @weasel-js/paint) and layoutRuns promoted to public API, on top of ~50 patch changesets carrying new public surface across core, ui and labkit; called explicitly in conversation on 2026-08-29: "tag a minor release and push" -->
2755
-
2756
- ### Patch Changes
2757
-
2758
- - 3386d64: Align, distribute and flip use visual bounds
2759
-
2760
- These folded each member's unrotated pose box, so "Align Left" on a selection
2761
- containing a rotated shape lined up the boxes and left the rotated shape's ink
2762
- sticking out past the others. They now work on the visual bounding box, as
2763
- Figma and Illustrator do.
2764
-
2765
- Both ends moved together — expanding only the union would have made alignment
2766
- worse, since the delta runs from an edge of the union to the same edge of each
2767
- member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
2768
- reads a pose's bounds, recovers its rotation and expands via
2769
- `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
2770
- applied as a translation of the stored pose through
2771
- `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
2772
-
2773
- Flip needed only its union pivot changed: mirroring maps a centre and preserves
2774
- size, and an expanded box is concentric with the box it came from.
2775
-
2776
- `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
2777
- by its ink.
2778
-
2779
- - ffafb7d: Never let an animation's virtual clock run backwards.
2780
-
2781
- `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
2782
- time, then advances its virtual clock by the difference against the timestamp
2783
- the frame loop supplies. Those two share a time origin in a browser, where the
2784
- rAF timestamp and `performance.now()` are both page-relative — but that is a
2785
- browser guarantee, not a universal one, and jsdom starts them roughly 600ms
2786
- apart. The first frame's delta then came out hugely negative and `virtualNow`
2787
- spent dozens of frames climbing back toward zero before a tween advanced at
2788
- all: a 40ms glide took 95 frames and over a second of wall time, growing worse
2789
- the longer the process had been alive.
2790
-
2791
- A frame's elapsed time is never negative, so the sample is now clamped at
2792
- zero. Under a shared origin this is a no-op.
2793
-
2794
- - ba8b139: Camera animation: `viewport.animatedZoom` now does something
2795
-
2796
- `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
2797
- nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
2798
- through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
2799
- their input already delivers a sample per frame.
2800
-
2801
- Camera animation is a general surface, not a zoom flag. Three ways in, one
2802
- runner behind them:
2803
-
2804
- - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
2805
- `isAnimating`, `target`.
2806
- - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
2807
- so any action can glide the camera.
2808
- - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
2809
- `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
2810
- three are **required** members: anyone hand-implementing `SceneCanvasApi`
2811
- (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
2812
- grew `getPaintedVersion`.
2813
-
2814
- Scale interpolates geometrically and translation is derived from the screen
2815
- point the two views agree on, so a zoom stays anchored instead of drifting and
2816
- each frame changes the view by the same ratio. One animation runs at a time; any
2817
- other view write cancels it, and a cancel leaves the camera where it is rather
2818
- than jumping to the target. On an uncontrolled canvas the whole animation costs
2819
- no React render.
2820
-
2821
- **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
2822
- changes signature — it takes a `{ get, set }` view channel plus an optional
2823
- `Animator`, and `animateTo(from, to, { duration, easing })` becomes
2824
- `animate(to, { ms, easing })`. The `from` argument is gone because the runner
2825
- reads the live view, which is what lets an interrupted camera resume from where
2826
- it actually is instead of snapping back to a captured start. `cancel()` is now
2827
- `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
2828
- `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
2829
- the same reason `from` does.
2830
-
2831
- **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
2832
- `() => View | void`. Returning the target view lets Cmd+0 animate there;
2833
- returning nothing keeps the existing behavior. `animatedZoom`'s config fields
2834
- are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
2835
- animator's vocabulary.
2836
-
2837
- - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
2838
-
2839
- The canvas is focusable by default (`tabIndex` 0) but exposed no way to
2840
- observe focus, so consumers driving focus-dependent chrome had to attach a
2841
- listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
2842
- so reach `SceneCanvasProps` and the canvas element unchanged.
2843
-
2844
- - 67bcb05: Drop four values the canvas layer memo no longer reads
2845
-
2846
2002
  `hit-test affordances against the painted chrome state` moved the selection
2847
2003
  overlay to reading bounds off the chrome state at paint time, which left
2848
2004
  `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
2849
2005
  referenced only by the `layers` memo's dependency array — nothing in the body
2850
2006
  used them. Removing them from the array made all four dead locals, so they go
2851
2007
  too.
2852
-
2008
+
2853
2009
  The memo now rebuilds the layer array on layer/tool/geometry changes rather
2854
2010
  than additionally on every selection and preview-pose change. Selection chrome
2855
2011
  is unaffected: it repaints from chrome state, not from the identity of this
2856
2012
  array.
2857
-
2858
2013
  - 47cbb08: A closed subpath's dash no longer seams at its start vertex
2859
-
2014
+
2860
2015
  `splitForDash` flushed the run still open when a closed subpath's walk returned
2861
2016
  to the vertex it started from as its own open sub-polyline, so it and the run
2862
2017
  that began there rendered as two butt-capped ribbons meeting at a point — a
@@ -2865,17 +2020,16 @@
2865
2020
  is drawn across the seam like any other corner. A pattern whose first "on"
2866
2021
  length covers the whole perimeter emits a closed ribbon, identical to the
2867
2022
  undashed stroke.
2868
-
2869
2023
  - f43e9c2: A derived edge follows the drag that moves its endpoint
2870
-
2024
+
2871
2025
  `move`, `resize` and `rotate` kept their in-flight poses in action-local
2872
2026
  scratch and published them only as `previewIds` / `previewPose`. That surface
2873
2027
  is enough to paint a ghost and size selection chrome, but nothing that asks
2874
- the _scene_ where a node is can see it — and `scenePoseLookup`, which resolves
2028
+ the *scene* where a node is can see it — and `scenePoseLookup`, which resolves
2875
2029
  a derived node's geometry, asks the scene. So dragging a box left its edge
2876
2030
  anchored to the pre-drag position until the drop, when the commit invalidated
2877
2031
  the dependents and the edge jumped.
2878
-
2032
+
2879
2033
  The three actions now also publish each frame into the scene's ephemeral pose
2880
2034
  overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
2881
2035
  `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
@@ -2883,101 +2037,98 @@
2883
2037
  avoided per-frame scene writes in the first place was history, and this writes
2884
2038
  no history. Entries are set once and mutated in place, published with a single
2885
2039
  `commit()` per frame.
2886
-
2040
+
2887
2041
  Picking follows for free: the pick source resolves a derived path through its
2888
2042
  own override-aware `poseOf`, so an edge is grabbable where it is drawn
2889
2043
  mid-gesture rather than where it used to be.
2890
-
2044
+
2891
2045
  `clone` is deliberately untouched — its previews are the new ghosts at the
2892
2046
  drag target, and the originals never move, so nothing derives from a changed
2893
2047
  pose.
2894
-
2048
+
2895
2049
  Also closes the matching gap in the preview-ghost layer, which built a
2896
2050
  container's clip with no derived path and so ghosted a derived container
2897
2051
  without one.
2898
-
2052
+
2899
2053
  Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
2900
2054
  on every gesture frame. It was already required by the `Scene` contract, but a
2901
2055
  partial fake that omitted it will now throw rather than silently skip.
2902
-
2903
2056
  - bb27e83: A derived node is clickable where it paints
2904
-
2057
+
2905
2058
  A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
2906
2059
  `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
2907
- resolved from the _dependencies'_ poses, which a painter has no handle on. So
2060
+ resolved from the *dependencies'* poses, which a painter has no handle on. So
2908
2061
  `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
2909
2062
  as "no opinion" and answered `true` everywhere, and picking fell back to the
2910
2063
  node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
2911
2064
  was unpickable, and a derived container contributed no clip.
2912
-
2065
+
2913
2066
  `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
2914
2067
  same convention `NodePaintCtx` already uses, and `kit:derived` reports the
2915
2068
  derived path as its silhouette and its declared stroke as its `ink`.
2916
-
2917
- Resolving that path needs the scene, so it is the _source_ that answers, not
2069
+
2070
+ Resolving that path needs the scene, so it is the *source* that answers, not
2918
2071
  the painter: `PickSource.derivedPathOf`, a matching optional argument to
2919
2072
  `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
2920
- carried the derived path a node _paints_, and now also the clip a derived
2921
- container _imposes_, so the live canvas and the headless walk clip alike. The
2073
+ carried the derived path a node *paints*, and now also the clip a derived
2074
+ container *imposes*, so the live canvas and the headless walk clip alike. The
2922
2075
  bare-adapter paths supply none of them and behave exactly as before.
2923
-
2076
+
2924
2077
  The pre-filter had to move with it. `useSceneSelectTool` grew its region test
2925
2078
  from the node's pose, which for a derived node is the wrong box entirely, so
2926
2079
  the edge was rejected before the shape test could claim it. It now tests the
2927
2080
  derived path when there is one — `poseContains` already reads a path-like pose
2928
2081
  as a path, so this reuses it rather than adding a second reach calculation.
2929
-
2082
+
2930
2083
  `findShapeSilhouette` skips its memo when handed a derived path. That slot is
2931
2084
  keyed on `(node, pose, data)` and cannot see the path, so it would serve one
2932
2085
  caller's silhouette to a caller that passed a different one — the same reason
2933
2086
  `kit:derived` already skips `PAINT_SLOT`.
2934
-
2935
2087
  - 6a33c3f: A node's path can be derived from other nodes' poses
2936
-
2088
+
2937
2089
  A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
2938
2090
  through `SceneRegistry`, and the scene walks resolve its path before painting
2939
2091
  rather than it being authored. An edge drawn between two boxes is then an
2940
2092
  ordinary scene node — selectable, styleable, exportable — whose geometry never
2941
2093
  enters undo history. The seam and its traps are in `docs/extending.md`.
2942
-
2094
+
2943
2095
  New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
2944
2096
  `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
2945
2097
  `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
2946
2098
  `SerializedNode.derivePathKey`, both additions to the serialization format;
2947
2099
  `NodePaintCtx.derivedPath`.
2948
-
2100
+
2949
2101
  Deleting a node now deletes everything that derives from it, transitively,
2950
2102
  including those nodes' own subtrees, in one undo entry — so `scene.remove` can
2951
2103
  remove nodes anywhere in the tree that the caller never named, and `removeLayer`
2952
2104
  reaches nodes on other layers. Undo after the built-in **Delete** key does not
2953
2105
  yet restore the cascaded nodes; see "Derived geometry follow-ups" in
2954
2106
  `docs/TODO.md`.
2955
-
2107
+
2956
2108
  **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
2957
2109
  context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
2958
2110
  now a type error rather than a silent slide into the `view` slot. The same
2959
2111
  fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
2960
2112
  callback types, which is not a break: an existing three-parameter implementation
2961
2113
  still satisfies them, and an existing three-argument call still compiles.
2962
-
2114
+
2963
2115
  **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
2964
2116
  typed as a `Scene` — a test double, most likely — no longer typechecks until it
2965
2117
  implements it.
2966
-
2118
+
2967
2119
  **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
2968
2120
  `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
2969
2121
  is not a descendant of the removed node and the tree has to be told about every
2970
2122
  subtree that came out of it. A history persisted by an older build now throws
2971
2123
  mid-undo rather than degrading. The break is deliberate; kit op payloads are not
2972
2124
  versioned.
2973
-
2974
2125
  - c24e7de: Detached views follow pose overrides
2975
-
2126
+
2976
2127
  `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
2977
2128
  which a pose override deliberately never bumps — so they kept painting document
2978
2129
  poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
2979
2130
  canvas driving a drag or a simulation silently disagreed with it.
2980
-
2131
+
2981
2132
  `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
2982
2133
  subscribes to `scene.overrides`. A render (prop change or version bump) and an
2983
2134
  override commit both just mark the surface dirty, and one animation frame
@@ -2985,39 +2136,37 @@
2985
2136
  render, and a backgrounded tab stops painting them entirely. The mount paint
2986
2137
  stays synchronous, so the first frame is still the scene rather than a blank
2987
2138
  canvas. `<MinimapCanvas>` inherits all of this through it.
2988
-
2139
+
2989
2140
  Repaints driven by a prop change are now asynchronous: they land on the next
2990
2141
  animation frame rather than in the layout effect of the render that caused them.
2991
2142
  Code that renders and then reads pixels in the same tick needs to wait a frame.
2992
-
2993
- A minimap's _framing_ still derives from document poses, so a node overridden
2143
+
2144
+ A minimap's *framing* still derives from document poses, so a node overridden
2994
2145
  outside the document bounds paints outside the fitted frame — recomputing the
2995
2146
  fit per frame would rescale the whole minimap throughout a settle.
2996
-
2997
2147
  - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
2998
-
2148
+
2999
2149
  `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
3000
2150
  once instead of hiding two behind a select. Options carry an optional `short`
3001
2151
  label — a capital or two — for the width a property row has; the full `label`
3002
2152
  stays the accessible name, so the abbreviation never becomes the only thing
3003
2153
  naming the option. A mixed selection selects no segment rather than picking a
3004
2154
  winner.
3005
-
2155
+
3006
2156
  `pair` now merges fields inside an object leaf, as it already did for section
3007
2157
  rows — a hint shouldn't mean something different for being a field of a value
3008
- rather than a sibling of one. It merges _adjacent_ leaves in both places, so
2158
+ rather than a sibling of one. It merges *adjacent* leaves in both places, so
3009
2159
  the schema orders family, size, weight: size and weight pair, and family (which
3010
2160
  sat between them) moves ahead of the pair rather than splitting it.
3011
-
2161
+
3012
2162
  A stroke's cap, join and align share one row; property rows wrap rather than
3013
2163
  overflow when the controls in them don't fit.
3014
-
3015
2164
  - be697dc: Add ephemeral pose overrides to the scene
3016
-
2165
+
3017
2166
  `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
3018
2167
  hit-test paths read through and that history, `toJSON()` and `getVersion()`
3019
2168
  never see. It is additive: a scene with no overrides behaves exactly as before.
3020
-
2169
+
3021
2170
  This is where per-frame motion belongs. A 60 Hz loop previously had to write
3022
2171
  through `setPose`, which records an undo entry (one per frame at best, batched)
3023
2172
  and bumps the scene version, re-rendering every `useSyncExternalStore`
@@ -3025,47 +2174,45 @@
3025
2174
  frame, because the painter memo keys on pose reference. An override entry is
3026
2175
  hoisted once and mutated in place; `overrides.commit()` publishes the frame and
3027
2176
  invalidates the memo for the overridden nodes only.
3028
-
2177
+
3029
2178
  `commit()` is required after an in-place mutation — without it the memo serves
3030
2179
  the previous frame's draw. Overrides are cleared when a node is removed, since
3031
2180
  ids are reusable. To make a frame permanent, write it once through `setPose`
3032
2181
  and clear the override; that single step is the undo entry.
3033
-
2182
+
3034
2183
  `ForceGraphDemo` now settles with zero history entries and bakes the result as
3035
2184
  one, replacing a per-tick batch of 24 `setPose` calls.
3036
-
3037
2185
  - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
3038
-
2186
+
3039
2187
  It was the fourth site measuring text its own way: `ctx.measureText` per
3040
2188
  character against system fonts, no kerning, `pose.text` only. Nothing masked
3041
2189
  it the way the WebGL context masked the caret — a consumer calling it got a
3042
2190
  box that disagreed with the paint, narrower by a kern on every pair and wrong
3043
2191
  by the whole difference between the installed family and the registered face.
3044
2192
  It goes through the shared layout now, so it sees kerning and per-run styling.
3045
-
2193
+
3046
2194
  **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
3047
-
3048
2195
  - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
3049
-
2196
+
3050
2197
  `requestRedraw()` marks the surface dirty and the next frame paints, so many
3051
2198
  redraws in one tick cost one paint. The view gains an imperative path on the
3052
2199
  canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
3053
2200
  longer holds it in React state, so a camera moving at 60 Hz costs no renders.
3054
2201
  Consumers passing a `view` prop stay controlled and are unaffected.
3055
-
2202
+
3056
2203
  Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
3057
2204
  that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
3058
2205
  gives a host the scene without a render per mutation, `useSceneTextEdit`'s
3059
2206
  `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
3060
2207
  `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
3061
-
2208
+
3062
2209
  Two public signatures changed. `usePinchZoomTool` takes a view getter,
3063
2210
  `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
3064
2211
  captured value any more. `CanvasExtensionApi` gained five required members —
3065
2212
  `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
3066
2213
  so external code hand-implementing that interface stops typechecking; code that
3067
2214
  only calls through the ref is unaffected.
3068
-
2215
+
3069
2216
  Pixels and DOM can now be a frame apart, in whichever direction the change came
3070
2217
  from. A view change leads with pixels: `setView` paints without rendering, so
3071
2218
  DOM built from the view is stale until something re-renders it — position
@@ -3074,64 +2221,60 @@
3074
2221
  pixels land next frame — compare `getPaintedVersion()` against the version you
3075
2222
  are about to render when chrome must be in lockstep. Do not render scene-derived
3076
2223
  DOM inside `startTransition`: React defers it and nothing forces it to catch up.
3077
-
2224
+
3078
2225
  Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
3079
2226
  mode is the one in-tree case — can likewise see a buffer one frame older;
3080
2227
  `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
3081
2228
  paints while `document.hidden` is true, `syncPaint` included, so a readback from
3082
2229
  a background tab returns the frame from before the tab was hidden.
3083
-
3084
2230
  - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
3085
2231
  recorded. A caller is entitled to reuse the array it uploads from, so storing
3086
2232
  the reference recorded a value that later frames overwrote — a test reading
3087
2233
  two frames back saw the same numbers twice and passed. Test-only surface.
3088
2234
  - 3fb3a46: Release held keys when the window loses focus
3089
-
2235
+
3090
2236
  A window that blurs mid-hold never delivers the keyup, so every in-flight
3091
2237
  `key-held` handle stayed engaged until that key was pressed again — holding
3092
2238
  Space and tabbing away left the hand tool on the hotkey stack indefinitely.
3093
-
2239
+
3094
2240
  The gesture dispatcher now fires the `key-held` up phase for each held key on
3095
2241
  window blur. Consumers that hand-rolled this reset can drop it; ongoing
3096
2242
  invocations see a normal `onEnd`.
3097
-
3098
2243
  - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
3099
-
2244
+
3100
2245
  One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
3101
2246
  round caps and joins, hairline weight reserved for structure, and filled
3102
2247
  regions only where an action has a subject. Covers transport, history, view,
3103
2248
  trial lifecycle, collection, state, instrument and status vocabulary. Import a
3104
2249
  named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
3105
-
2250
+
3106
2251
  `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
3107
2252
  so consumers have one import site for the whole set. `ImageIcon` was reachable
3108
2253
  from core's icons folder but missing from its public barrel; it is exported
3109
2254
  now.
3110
-
2255
+
3111
2256
  Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
3112
2257
  rather than hand-placed, because arrowheads and joins that miss their terminus
3113
2258
  are invisible at chrome size.
3114
-
3115
2259
  - 3fb3a46: Compose `before` and `after` layer chains in both directions
3116
-
2260
+
3117
2261
  `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
3118
2262
  off an `after` anchor only followed further `after` links, and likewise for
3119
2263
  `before`. A custom layer anchored `before: 'scene'` carrying a second custom
3120
2264
  anchored `after` it dropped that second layer to the tail with a spurious
3121
2265
  dangling-reference warning.
3122
-
2266
+
3123
2267
  Both walks now emit a layer's `before` chain, the layer, then its `after`
3124
2268
  chain, so the two mix freely. Cycle detection and orphan fallback are
3125
2269
  unchanged.
3126
-
3127
2270
  - d9f110e: Stop every frame loop while nothing can see it
3128
-
2271
+
3129
2272
  New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
3130
2273
  whether a frame may run: nothing runs while `document.hidden`, and a loop that
3131
2274
  names an element also stops while that element is outside the viewport. A
3132
2275
  request made while suspended is held rather than dropped and re-armed on
3133
2276
  resume, so a loop never polls visibility or needs restarting by hand.
3134
-
2277
+
3135
2278
  Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
3136
2279
  `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
3137
2280
  counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
@@ -3139,30 +2282,29 @@
3139
2282
  the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
3140
2283
  it paints only dirty layers, but a hidden tab still commits React updates and
3141
2284
  its view/size effect marks every layer dirty.
3142
-
2285
+
3143
2286
  Loops measuring elapsed time rebase their clock through the new `onResume`
3144
2287
  option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
3145
2288
  meter reporting a rate nobody achieved, a tween jumping to its end value on
3146
2289
  return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
3147
2290
  export; nothing in the tree sets it.
3148
-
2291
+
3149
2292
  `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
3150
2293
  in kit source, and runs in CI.
3151
-
3152
2294
  - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
3153
-
2295
+
3154
2296
  A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
3155
2297
  `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
3156
2298
  ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
3157
2299
  and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
3158
2300
  second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
3159
2301
  where the matcher picks a single winner.
3160
-
2302
+
3161
2303
  Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
3162
2304
  and jsdom reports an empty-string platform — not nullish, so the fallback never
3163
2305
  fired and every mac binding in the kit was exercised only on the non-mac
3164
2306
  branch. It reads `||` now.
3165
-
2307
+
3166
2308
  Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
3167
2309
  alongside the `viewport.pinchZoom` action that already handled the same
3168
2310
  gesture, applying one pinch's factor twice — the opt-in broke the path that
@@ -3171,36 +2313,34 @@
3171
2313
  the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
3172
2314
  for real; it previously left the action running. Bare `<Canvas>` keeps the hook
3173
2315
  as its own pinch path.
3174
-
3175
2316
  - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
3176
-
2317
+
3177
2318
  Nothing in the kit exported one, so consumers hand-rolled it — their own
3178
2319
  `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
3179
2320
  inverse built by projecting two points. That inverse silently drops
3180
2321
  `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
3181
2322
  beside the paint instead of on it.
3182
-
2323
+
3183
2324
  ```ts
3184
- useNodeOverlayFrame(scene, containerRef, nodeId, { view });
2325
+ useNodeOverlayFrame(scene, containerRef, nodeId, { view })
3185
2326
  // → { box, toScreen, toLocal, width, height } | null
3186
2327
  ```
3187
-
2328
+
3188
2329
  `box` is the node's composed world box, unrotated — the frame `toScreen` maps
3189
2330
  from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
3190
2331
  lives in the pose→world leg, where it belongs: a node's stored geometry and its
3191
2332
  bounds-frame paint are pre-rotation by definition, so neither of those two
3192
2333
  changes.
3193
-
2334
+
3194
2335
  `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
3195
2336
  `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
3196
2337
  `stroke` — `slot` is a prop — and commits each drag through `setFill` or
3197
2338
  `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
3198
-
2339
+
3199
2340
  Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
3200
2341
  `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
3201
-
3202
2342
  - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
3203
-
2343
+
3204
2344
  `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
3205
2345
  color and `'none'` still skips the stroke; an object is a core `Stroke` whose
3206
2346
  `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
@@ -3209,99 +2349,94 @@
3209
2349
  rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
3210
2350
  is baked onto the pose box the way a fill is, so a gradient stroke resolves
3211
2351
  against the box it was authored against.
3212
-
2352
+
3213
2353
  `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
3214
-
2354
+
3215
2355
  `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
3216
- strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
2356
+ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3217
2357
  `'outer'` none inside, which one number could not say, so picking grabbed the
3218
2358
  wrong side. `ink` takes an optional context carrying the view scale, so a
3219
2359
  `{ px }` stroke width resolves to world units. A painter that still returns
3220
2360
  `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
3221
-
2361
+
3222
2362
  `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
3223
2363
  color pick replaces its paint and keeps width, cap, join and dash, and an
3224
2364
  opacity drag sets the paint's `opacity`, which is the only form that works on
3225
2365
  a gradient stroke.
3226
-
2366
+
3227
2367
  Editing UI for the rich form is not here yet — a schema-driven color control
3228
2368
  still writes a bare string over the object, so nodes carrying one are for
3229
2369
  programmatic authorship until `SelectionPanel` learns the union. See
3230
2370
  `docs/proposals/2026-08-26-node-stroke-union.md`.
3231
-
3232
2371
  - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
3233
-
2372
+
3234
2373
  `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
3235
2374
  servers there deliberately, but the renderer refused anything but a solid — so
3236
2375
  importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
3237
2376
  next frame. Both stroke paths now paint the ribbon through the same route a
3238
2377
  fill takes, including under the inner/outer alignment stencil. A non-solid
3239
2378
  even-odd fill no longer renders black.
3240
-
3241
2379
  - 5f6c28e: An object leaf's fields can be organised into groups
3242
-
2380
+
3243
2381
  `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
3244
2382
  heads its fields under a label and contributes nothing to the path — the same
3245
2383
  rule group keys follow at the top level of a schema, so a field inside one is
3246
2384
  still addressed as a field of the object.
3247
-
2385
+
3248
2386
  Without it, a value with many fields renders as one undifferentiated list. A
3249
2387
  `TextStyle` is the case that needs it: its character and paragraph fields are
3250
2388
  one value but read as two lists.
3251
-
3252
2389
  - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
3253
-
2390
+
3254
2391
  A compound value — a stroke, a shadow, a pattern spec — could be described as
3255
2392
  sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
3256
2393
  It shouldn't be: each control then writes one field of a value it can only
3257
2394
  half see, and writing a field into something that isn't an object yet corrupts
3258
2395
  it outright.
3259
-
2396
+
3260
2397
  `ToolPrefObject` describes the value instead. Its `children` are ordinary
3261
2398
  leaves whose paths are relative to the object, and every child edit commits
3262
2399
  the parent object whole. A field that is itself a union declares the kind that
3263
2400
  edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
3264
2401
  value still held in a scalar form before a child edit lands on it, which is
3265
2402
  how a stroke stored as a bare colour string gains a width.
3266
-
2403
+
3267
2404
  `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
3268
2405
  Color, Width, Cap, Join and Align under one Stroke block, and the separate
3269
2406
  `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
3270
2407
  `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
3271
2408
  replaced by this general one.
3272
-
2409
+
3273
2410
  `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
3274
2411
  import, export and rendering untouched.
3275
-
3276
2412
  - 2ea772f: Selection handles are hit-tested at the size they are painted
3277
-
2413
+
3278
2414
  Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
3279
2415
  constant, and neither `buildAffordanceAt` call site passed the option that
3280
2416
  would have scaled it. A coarse pointer got a bigger picture and exactly the
3281
2417
  same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
3282
2418
  exists to provide never reached the hit-test. The slops debug overlay was a
3283
2419
  third unscaled copy, so it drew hit regions where they were not.
3284
-
2420
+
3285
2421
  `core/device/targets.ts` now holds one base table and one accessor,
3286
2422
  `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
3287
2423
  through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
3288
2424
  `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
3289
2425
  table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
3290
-
2426
+
3291
2427
  `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
3292
2428
  `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
3293
2429
  reached nothing.
3294
-
2430
+
3295
2431
  `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
3296
2432
  affordance is an annulus with a band thickness and no point radius, so the
3297
2433
  option could only ever have been a second name for `rotationHandleDistance`,
3298
2434
  which is live and now defaults from the same table.
3299
-
2435
+
3300
2436
  Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
3301
2437
  device profile, so a nested view still hit-tests at the fine-pointer size.
3302
-
3303
2438
  - f77bd95: `getChildren` means one thing on an adapter
3304
-
2439
+
3305
2440
  `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
3306
2441
  drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
3307
2442
  the z-ordering seam where `null` means the root. Both land on the same adapter
@@ -3309,36 +2444,35 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3309
2444
  under the name the ops read with the second meaning. An op asking for root
3310
2445
  order got `[]`, which reads as "the root has no siblings", and the slot it
3311
2446
  captured was silently lost.
3312
-
2447
+
3313
2448
  The two declarations are now one contract, and `arrayAdapter` answers the root
3314
2449
  from its own item array rather than delegating — a consumer callback written
3315
2450
  for node ids returns `[]` there, which cannot be told apart from a genuine
3316
2451
  empty answer. A consumer's `getChildren` config is still only ever asked about
3317
2452
  a node id.
3318
-
2453
+
3319
2454
  `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
3320
2455
  than by anchor. That is unchanged, and it is why the ordinal fallback exists.
3321
-
3322
2456
  - 2ea772f: The canvas and the gradient editor now sample one gradient
3323
-
2457
+
3324
2458
  `buildGradientRamp` carried its own interpolation beside
3325
2459
  `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
3326
2460
  at either end and extrapolated past the first and last stop, the two picked
3327
2461
  opposite sides of a coincident pair, and they parsed color differently — a stop
3328
2462
  written as a CSS named color rendered on the canvas and threw in the editor.
3329
-
2463
+
3330
2464
  `sampleGradientStops` keeps its semantics and is now the only implementation.
3331
2465
  `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
3332
2466
  returns the color at `t`. The ramp cache builds its texels through those, so
3333
2467
  there is no interpolation math left in the renderer.
3334
-
2468
+
3335
2469
  Two behavior changes worth naming. `resolveColor` is the surviving parser, so
3336
2470
  gradient stops accept named and functional colors everywhere — but no longer
3337
2471
  hex without a leading `#`, which only the editor path had tolerated and the
3338
2472
  canvas never accepted. And `sampleGradientStops` returns normalized hex at the
3339
2473
  endpoints instead of echoing the raw stop string, so `'red'` comes back as
3340
2474
  `'#ff0000'`.
3341
-
2475
+
3342
2476
  **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
3343
2477
  the element already carried the reference, the built-in serializer returned
3344
2478
  nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
@@ -3346,18 +2480,17 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3346
2480
  through to the same warning the pattern path already emits when nothing can
3347
2481
  produce a paint server. A consumer that registers a `toSvg` for
3348
2482
  `conic-gradient` still serializes and gets no warning.
3349
-
3350
2483
  - aba8d91: Answer "can this node be hit" in one place
3351
-
2484
+
3352
2485
  Four tree walks answered it separately — the generic-adapter point pick, the
3353
2486
  one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
3354
2487
  marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
3355
2488
  that had a test and disagreed on the rest, three times, silently. `pickWalk`
3356
2489
  now owns every gate; a query supplies only its own shape test and the clip
3357
2490
  predicate for its region.
3358
-
2491
+
3359
2492
  Behavior that changes as a result:
3360
-
2493
+
3361
2494
  - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
3362
2495
  same number the painter does — the view's `alphaFor` times any per-node
3363
2496
  override alpha — so a node faded out of sight stops claiming clicks. The
@@ -3373,13 +2506,12 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3373
2506
  - An ancestor clip now rejects an area query that reaches into the clip where
3374
2507
  the node is not, or reaches the node where the clip is not — the two terms
3375
2508
  together, where one alone let false positives through.
3376
-
2509
+
3377
2510
  `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
3378
2511
  `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
3379
2512
  `adapterPickSource` and `ownClipOf` replace them.
3380
-
3381
2513
  - 2ea772f: A drag-to-insert reports the bounds it paints
3382
-
2514
+
3383
2515
  The painter, the commit factory and `getGestureBounds()` each sized an
3384
2516
  in-flight insert differently. The reporter read the drag rect alone, so a
3385
2517
  centered Alt-drag reported a half-extent of `d` against a painted circumradius
@@ -3388,45 +2520,43 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3388
2520
  all. The painter and the commit agreed on polygon and star but not on line or
3389
2521
  pencil: the commit posed the drag AABB for a line the painter drew endpoint to
3390
2522
  endpoint, and fell back to the drag rect for a trail under four samples.
3391
-
2523
+
3392
2524
  One function now answers it for all three. The zero-area skip in the painter
3393
2525
  and the reporter tests the resolved extent rather than the raw drag rect, and
3394
2526
  an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
3395
2527
  `bounds` argument handed to a factory is unchanged.
3396
-
3397
2528
  - 3386d64: Path command opcodes derive from one table
3398
-
2529
+
3399
2530
  `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
3400
2531
  once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
3401
2532
  literals in the path transform, pose-rotation and pose-descriptor walkers. They
3402
2533
  agreed, and nothing held them to each other: a sixth opcode desynchronizes two
3403
2534
  packages' reading of the same `Uint8Array` with no exception and no type error,
3404
2535
  and every walker misparses the coordinate stream from that command on.
3405
-
2536
+
3406
2537
  `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
3407
2538
  `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
3408
2539
  core re-exports them by name, so the opcode constants keep their names, values
3409
2540
  and literal types. The three walkers moved onto `forEachSegment` rather than
3410
2541
  onto the accessor alone — they were duplicating the coordinate-cursor advance
3411
2542
  as well as the length, and the cursor is the half that actually misreads.
3412
-
2543
+
3413
2544
  Eight further files switch on these opcodes with inline literals. Five throw on
3414
2545
  an unknown code; three — the path boolean adapter, the anchor-editing geometry,
3415
2546
  and geom's own boolean adapter — have no `default` arm and would silently stop
3416
2547
  advancing. Left as-is; they need per-command semantics, not one walker.
3417
-
3418
2548
  - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
3419
-
2549
+
3420
2550
  `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
3421
2551
  saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
3422
2552
  leaf had neither `encoding` nor `options[].disabled`, so a dash-array
3423
2553
  preference did not merely fail to select — choosing an option wrote the option
3424
2554
  string over the stored dash array. labkit's two renderers were missing the
3425
2555
  `paint` and `object` kinds outright.
3426
-
2556
+
3427
2557
  ui's schema is now a rename re-export of core's declaration. The public `Pref*`
3428
2558
  names are unchanged, and there is nothing left to keep in sync.
3429
-
2559
+
3430
2560
  More importantly, all four renderer switches ended in `default:`, so adding a
3431
2561
  built-in kind produced no error at any site and simply rendered nothing —
3432
2562
  verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
@@ -3436,34 +2566,32 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3436
2566
  `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
3437
2567
  can discriminate and end in a `never`. App-defined kinds take the placeholder
3438
2568
  path as before.
3439
-
2569
+
3440
2570
  Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
3441
2571
  arm threads sibling values, routes through `encoding.read` / `encoding.write`,
3442
2572
  and honors `option.disabled`. `SelectionPanel` already did all of this — it was
3443
2573
  only the forked copy that could not express it.
3444
-
3445
2574
  - 3386d64: Dragging out a text box shows a live preview
3446
-
2575
+
3447
2576
  The set of insertable kinds and the `KitInsertShape` union sat on adjacent
3448
2577
  lines with no linkage, and seven more sites restated one list or the other. The
3449
2578
  drift was already live: the text tool binds `actionId: 'insert'` and commits
3450
2579
  through the insert dep, but the runtime set never listed `text`, so a
3451
2580
  drag-to-insert text box had no preview.
3452
-
2581
+
3453
2582
  `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
3454
2583
  it has a built-in tool and whether it takes an insert preview. Both unions,
3455
2584
  `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
3456
2585
  preview gate all derive from it.
3457
-
2586
+
3458
2587
  Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
3459
2588
  `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
3460
2589
  same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
3461
2590
  positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
3462
2591
  gains `'text'`, which is the fix itself: a consumer switching exhaustively over
3463
2592
  it gains a case, handled by the existing box arm.
3464
-
3465
2593
  - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
3466
-
2594
+
3467
2595
  The paint, the pose silhouette and the click-to-edit caret each ran their own
3468
2596
  walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
3469
2597
  `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
@@ -3473,7 +2601,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3473
2601
  caret could therefore answer with a different line, and a different glyph, than
3474
2602
  the one under the pointer — masked in practice only because it asked a WebGL
3475
2603
  canvas for a 2D context and got `null`, degrading silently to no caret at all.
3476
-
2604
+
3477
2605
  `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
3478
2606
  and all three go through it. It keeps the array-identity `WeakMap` as the
3479
2607
  renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
@@ -3481,7 +2609,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3481
2609
  about 230× cheaper than laying out again, at roughly 4× the cost of the
3482
2610
  identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
3483
2611
  snapping is to the advance cells the glyphs were actually painted in.
3484
-
2612
+
3485
2613
  **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
3486
2614
  `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
3487
2615
  an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
@@ -3489,9 +2617,8 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3489
2617
  double-click always seeds the caret instead of falling back to editing from
3490
2618
  offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
3491
2619
  `_resetLayoutCacheForTests`.
3492
-
3493
2620
  - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
3494
-
2621
+
3495
2622
  `registerFontOutlines` was a paint upgrade for a family that already had an
3496
2623
  MSDF atlas; a family with only font bytes could not resolve, so it rendered
3497
2624
  nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
@@ -3499,48 +2626,46 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3499
2626
  outline-only family, and `layoutRuns` reads advances, kerning and the baseline
3500
2627
  through one source the atlas and a parsed face both satisfy. `outlineMinSize`
3501
2628
  does not gate such a family — there is no other tier to prefer.
3502
-
2629
+
3503
2630
  This does not touch metric neutrality where it applies: a family that has an
3504
2631
  atlas still resolves to the atlas, so registering outlines cannot move text
3505
2632
  that was already rendering.
3506
-
2633
+
3507
2634
  Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
3508
2635
  and UMD under `main`; Node takes the UMD build, whose named exports it cannot
3509
2636
  detect, so `parse` was undefined and every face failed to load — silently, via
3510
2637
  the fallback to SDF. A browser bundler reading `module` never saw it.
3511
-
2638
+
3512
2639
  Breaking for a consumer-supplied `OutlineParser`: a face must now report
3513
2640
  metrics as well as geometry.
3514
-
3515
2641
  - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
3516
-
2642
+
3517
2643
  A kind bar over a per-kind body, driven by the paint-kind registry rather than
3518
2644
  a fixed list, so a consumer's registered kind appears in the bar and renders
3519
2645
  that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
3520
2646
  the chip that showed a gradient as indeterminate and wrote a solid over it on
3521
2647
  first touch — so the checkerboard now means a mixed selection and nothing else,
3522
2648
  and a gradient stroke is editable rather than merely paintable.
3523
-
2649
+
3524
2650
  Switching kinds keeps a per-kind memory for the control's lifetime, so
3525
2651
  linear -> solid -> linear comes back with its stops instead of the ramp
3526
2652
  `withGradientKind` cannot carry.
3527
-
2653
+
3528
2654
  `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
3529
2655
  on `@weasel-js/svg` for its tile previews.
3530
-
2656
+
3531
2657
  The bar offers **None**: "what kind of paint is this?" takes no-paint as an
3532
2658
  answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
3533
2659
  becomes `null`, and a stroke goes away entirely rather than keeping a width
3534
2660
  that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
3535
2661
  built-in kinds carry glyphs so six segments fit a property row.
3536
-
2662
+
3537
2663
  `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
3538
2664
  and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
3539
2665
  with its width — a whole paint editor cannot share a row with a slider.
3540
-
3541
2666
  - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
3542
2667
  frames and serializes.
3543
-
2668
+
3544
2669
  `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
3545
2670
  re-seeds the five built-ins, matching the kit's other module-global
3546
2671
  registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
@@ -3548,7 +2673,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3548
2673
  `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
3549
2674
  consumer's own paint as a `FillStyle` — the union itself stays closed, because
3550
2675
  opening its discriminant would widen every built-in member.
3551
-
2676
+
3552
2677
  Three defects fall out of the same change, each of which a sixth kind hit
3553
2678
  immediately. The renderer's fill dispatch fell off the end of its switch into
3554
2679
  an unguarded cast to the gradient union, so an unknown kind read `stops` off a
@@ -3556,25 +2681,24 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3556
2681
  an unknown kind untouched, leaving it painting in screen space on a node that
3557
2682
  moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
3558
2683
  still writing the `url(#id)` that referenced it.
3559
-
2684
+
3560
2685
  Registering a kind now bumps the node memo generation, so a node painted
3561
2686
  before the registration repaints rather than holding the frame it resolved
3562
2687
  when the kind was unknown.
3563
-
3564
2688
  - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
3565
-
2689
+
3566
2690
  Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
3567
2691
  `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
3568
2692
  where `undefined` takes the painter's fallback. Two new authoring helpers keep
3569
2693
  hand-written node data short:
3570
-
2694
+
3571
2695
  ```ts
3572
2696
  data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
3573
2697
  ```
3574
-
2698
+
3575
2699
  **Breaking, with no compatibility path.** A document written against the old
3576
2700
  shapes renders wrong rather than failing, which is accepted:
3577
-
2701
+
3578
2702
  - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
3579
2703
  and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
3580
2704
  A node holding `fill: '#f00'` now paints the default grey.
@@ -3590,176 +2714,169 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3590
2714
  - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
3591
2715
  of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
3592
2716
  stroke into a color. SVG's `fill="none"` imports as `fill: null`.
3593
-
2717
+
3594
2718
  **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
3595
2719
  the only slot a gradient or a pattern has, so it is the slot all of them use,
3596
2720
  and the renderer multiplies a hex alpha by it — the two would fight if both
3597
2721
  carried the value. `solid()` therefore moves an alpha channel out of the hex:
3598
2722
  `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
3599
-
2723
+
3600
2724
  The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
3601
2725
  `opacity` rather than splicing hex, so they now work on a gradient fill, which
3602
2726
  they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
3603
2727
  the node's existing paint through the new `paintWithColor`, keeping its opacity
3604
2728
  unless the picked color states an alpha of its own — and `setStroke` keeps the
3605
2729
  stroke's width, cap, join and dash instead of replacing the whole value.
3606
-
2730
+
3607
2731
  New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
3608
2732
  `paintWithColor`, `DEFAULT_SHAPE_FILL`.
3609
-
2733
+
3610
2734
  `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
3611
2735
  — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
3612
2736
  writes a bare string over it — and the `data.stroke` object leaf drops its
3613
2737
  `fromScalar`, which had nothing left to lift.
3614
-
3615
2738
  - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
3616
-
2739
+
3617
2740
  The text schema put `data.text` in a group named Text, so the section read
3618
2741
  TEXT and the row inside it read Text — one word nested in itself — and the
3619
2742
  style groups below it read as fields of the content string rather than as its
3620
2743
  siblings. Content is its own section now, with the field full-width because
3621
2744
  the section already names it.
3622
-
2745
+
3623
2746
  A group with an empty `name` renders no heading. That already worked for
3624
2747
  sections and is now documented on `ToolPrefGroup`, since it is how a schema
3625
2748
  says "this group organises, it doesn't name": `Character` and `Paragraph`
3626
2749
  carry the labels, and a `Typography` heading over them named nothing new.
3627
2750
  It stays opt-in rather than a rule that rolls up any all-group parent —
3628
2751
  a `Border` over `Top` / `Right` / `Bottom` needs its name.
3629
-
2752
+
3630
2753
  Rows under a suppressed heading no longer indent. Depth drawn without a
3631
2754
  visible parent put `Character` a level deeper than `Content` while being its
3632
2755
  peer, which is the panel's own tree discipline broken by its own hand.
3633
-
3634
2756
  - 611b30e: Layers and deps answer for the view they are drawn for
3635
-
3636
- Nine lookups closed over the _surface's_ state at construction, so they answered
2757
+
2758
+ Nine lookups closed over the *surface's* state at construction, so they answered
3637
2759
  for view zero in every view. `<CanvasView>` draws the surface's layer array
3638
2760
  unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
3639
2761
  a guarantee of answering for the wrong view rather than merely an unused
3640
2762
  argument. A drag in view B ghosted in view A, the marquee painted in the wrong
3641
2763
  view, chrome-caps resolved against the surface's selection, every Cmd+V centered
3642
2764
  on the wrong camera, and Escape in view B cancelled view A.
3643
-
2765
+
3644
2766
  **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
3645
2767
  and `getIsVisible()`. All three are **required members**: anyone hand-writing a
3646
2768
  `CanvasViewHelpers` (a test double, a wrapper) has to add them.
3647
2769
  `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
3648
2770
  have answered for one view.
3649
-
2771
+
3650
2772
  **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
3651
2773
  alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
3652
2774
  is the tool half.
3653
-
2775
+
3654
2776
  **Layer options changed.** `createPathEditingOverlayLayer` and
3655
2777
  `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
3656
2778
  `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
3657
2779
  now. `usePreviewGhostLayer` has lost `tools`. Both it and
3658
2780
  `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
3659
-
2781
+
3660
2782
  **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
3661
2783
  result accept an optional trailing `PickCamera`. A world point does not carry
3662
2784
  the scale it was produced under and picking has no draw envelope, so the caller
3663
2785
  that produced the point supplies it; omitting it keeps the surface camera.
3664
-
2786
+
3665
2787
  `useHoverTracking` took a `clientToWorld` thunk beside a world-space
3666
2788
  `getNodeAtPoint` — the first resolved the view and the second did not, so hover
3667
2789
  picked at the surface's scale inside a panel. It takes one
3668
2790
  `nodeAtClientPoint(clientX, clientY)` now.
3669
-
2791
+
3670
2792
  Anchor-editing target state stays surface-wide; only the preview resolution on
3671
2793
  that path is per-view.
3672
-
3673
2794
  - 9ad8cb2: Picking answers for what was painted
3674
-
2795
+
3675
2796
  Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
3676
2797
  from something other than what the renderer drew.
3677
-
2798
+
3678
2799
  **Pose overrides were painted through and picked around.** `PoseOverride.pose`
3679
- is documented as replacing the document pose _everywhere the render and
3680
- hit-test paths read one_, and `sceneAdapter.getPose` honored it. But
2800
+ is documented as replacing the document pose *everywhere the render and
2801
+ hit-test paths read one*, and `sceneAdapter.getPose` honored it. But
3681
2802
  `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
3682
2803
  did the bounds resolver feeding selection chrome and the affordance
3683
2804
  `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
3684
2805
  overrides painted them at one place and picked them at another. `effectivePose`
3685
2806
  is now the single rule and every one of those reads through it.
3686
-
2807
+
3687
2808
  **A clipped-away child was still clickable.** A container clips its subtree and
3688
2809
  the renderer honors it, so a child outside the clip is not painted.
3689
2810
  `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
3690
2811
  `<SceneCanvas>` installs instead had no clip term at all. The new
3691
2812
  `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
3692
2813
  render-order scan can apply the same test.
3693
-
2814
+
3694
2815
  **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
3695
2816
  45° puts a corner 32 units above that box; a rubber-band over that corner was
3696
2817
  rejected before the rotation-correct silhouette test ran, while a click on the
3697
2818
  same pixel selected the shape.
3698
-
3699
2819
  - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
3700
2820
  tiles, coins, enemies and flagpole still declared `data.color`, the alias
3701
2821
  removed when node paint became an object, so every one of them rendered in
3702
2822
  the default gray — the demo whose whole point is being the visual twin of the
3703
2823
  immediate-mode load test.
3704
2824
  - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
3705
-
2825
+
3706
2826
  Three paths read a pose's stored, unrotated box where the rotated extent was
3707
2827
  wanted.
3708
-
2828
+
3709
2829
  `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
3710
2830
  came back turned the same way — invisible on a rectangle, whose AABB is
3711
2831
  symmetric under a sign flip, and plainly wrong on an asymmetric one, which
3712
2832
  translated instead of mirroring. It now negates the pose's rotation.
3713
-
2833
+
3714
2834
  `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
3715
2835
  stored edges, while the dragged selection matched against them by its ink.
3716
2836
  `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
3717
2837
  `deriveAlignmentGuides` reads its targets through the same projection — a new
3718
2838
  `projection` option defaulting to the rect one, so existing callers get the fix
3719
2839
  without a change.
3720
-
2840
+
3721
2841
  `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
3722
2842
  rotated member ended up with a gap short by the difference; `centers` shared the
3723
2843
  line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
3724
2844
  `distributeHorizontalAction` / `distributeVerticalAction` also take
3725
2845
  `params.mode`, so `gaps` is reachable from a binding rather than only from the
3726
2846
  hook.
3727
-
2847
+
3728
2848
  Flip and distribute return different poses than before for rotated shapes.
3729
2849
  That is the fix, but it is a behavior change for anything depending on the
3730
2850
  old output.
3731
-
3732
2851
  - 3386d64: `@weasel-js/core/routing` exports the route-string projection
3733
-
2852
+
3734
2853
  Anything rendering a `GestureSpec` as a route string had to re-implement the
3735
2854
  projection, and the copy in WeaselDraw's registry inspector had drifted three
3736
2855
  ways: it answered `drop` and `paste` with no gesture name, so every binding of
3737
2856
  either vanished from the route list; its argument lookup missed a spec field;
3738
2857
  and it gated targets on a hand-listed set of kinds, dropping them for
3739
2858
  `pointerDown`, `longPress` and `wheel`.
3740
-
2859
+
3741
2860
  New from the routing subpath: `routesForSpec(spec)` — every route string one
3742
2861
  spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
3743
2862
  map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
3744
2863
  subpath index did not, so consumers reading `RegistryEntry.target` had no way
3745
2864
  to compare against the sentinel its own docs name.
3746
-
3747
2865
  - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
3748
-
2866
+
3749
2867
  `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
3750
2868
  in their control flow — `***`/`**`/`*` and a two-character escape set — so
3751
2869
  reading or writing any other spelling meant forking both. They now take a
3752
2870
  `RunGrammar`: a table of markers pairing a repeated delimiter with the run
3753
2871
  flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
3754
2872
  today's behavior. Escaping follows the grammar's own delimiters.
3755
-
2873
+
3756
2874
  Nothing changes for a caller that passes no grammar. `underline` and
3757
2875
  `strikethrough` still have no markdown spelling and are still dropped by
3758
2876
  `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
3759
2877
  rather than editing the parser.
3760
-
3761
2878
  - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
3762
-
2879
+
3763
2880
  `createSelectionOverlayLayer` documents itself as equivalent to stacking the
3764
2881
  other two, and it was not. It reads `ChromeState` off the draw envelope,
3765
2882
  resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
@@ -3769,27 +2886,25 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3769
2886
  knew nothing about the multi-selection union — so a consumer who stacked them,
3770
2887
  on the wrapper's own promise, got chrome in the wrong place with no way to
3771
2888
  tell.
3772
-
2889
+
3773
2890
  All three now run one body and differ only in which passes they enable, so the
3774
2891
  promise holds by construction. `SelectionOutlineLayerOpts` and
3775
2892
  `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
3776
2893
  that don't apply, which makes `getSelection` and `getPose` optional on both and
3777
2894
  adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
3778
2895
  `SelectionHandleStyle`.
3779
-
3780
2896
  - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
3781
-
2897
+
3782
2898
  The effect depended on the whole selection-context value, and the provider
3783
2899
  mints a new value object on every publish. So one publisher publishing refired
3784
2900
  the effect for every other publisher in scope, each of which republished its
3785
2901
  own ids — a newer selection got stomped back to an older one, and two
3786
2902
  publishers holding different ids under one provider never settled at all.
3787
-
2903
+
3788
2904
  `publishSelection` is already a stable `useCallback`, so the effect now depends
3789
2905
  on it directly. No provider change and no API change.
3790
-
3791
2906
  - 84db1f6: Close four gaps that produced wrong answers with no error
3792
-
2907
+
3793
2908
  Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
3794
2909
  `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
3795
2910
  `default:` arm, so a command code they did not know fell out of the switch
@@ -3797,31 +2912,30 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3797
2912
  wrong floats. They now throw, matching the six sibling walkers. This is a
3798
2913
  behavior change for anyone feeding these a path built with an opcode outside
3799
2914
  `PATH_COMMANDS`: what used to come back subtly wrong now raises.
3800
-
2915
+
3801
2916
  A `<CanvasView>` built its affordance hit-test without a device profile, so a
3802
2917
  nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
3803
2918
  zones against the 14px chrome the surface paints. It reads the profile
3804
2919
  `<SceneCanvas>` publishes.
3805
-
2920
+
3806
2921
  `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
3807
2922
  neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
3808
2923
  records had nowhere to land: undoing a delete through the move pipeline
3809
2924
  appended the node to the end of its parent instead of putting it back where it
3810
2925
  was. All three are there now.
3811
-
2926
+
3812
2927
  The dev inspector's gesture panel formatted bindings with a private formatter
3813
2928
  that reported only modifiers set to `true`. The `ingest` action marks every
3814
2929
  modifier `'optional'`, so its drop and paste bindings rendered blank and the
3815
2930
  action was invisible on both gestures. Both of the panel's plain-text
3816
2931
  formatters now go through the kit's `routesForSpec`.
3817
-
3818
2932
  - 3386d64: Undoing a multi-node delete or group restores document order
3819
-
2933
+
3820
2934
  Restoring by stored index cannot survive replay: history runs a batch's
3821
2935
  inverses in reverse, while indices captured before the mutation are only
3822
2936
  correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
3823
2937
  undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
3824
-
2938
+
3825
2939
  Ops now record a `Slot` — an ordinal plus the id of the following sibling at
3826
2940
  capture. The anchor is the source of truth whenever it resolves, and it
3827
2941
  resolves whatever else the batch has already restored. The ordinal remains as
@@ -3829,23 +2943,22 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3829
2943
  children. `before: null` means "last" and needs no sibling list; an absent
3830
2944
  `before` means "unobserved", and the two survive `History.serialize` because
3831
2945
  `undefined` drops out of JSON and `null` does not.
3832
-
2946
+
3833
2947
  The ops observe their own slot during `apply()` rather than taking one from the
3834
2948
  caller, so every existing emitter gets this without a call-site change.
3835
2949
  `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
3836
2950
  docstring said it was sufficient on its own, which it never was.
3837
-
2951
+
3838
2952
  Adapters without an ordering seam still append, as they did before:
3839
2953
  `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
3840
2954
  neither that nor an `index` parameter on `insertNode`.
3841
-
3842
2955
  - 7a746df: A stroke's dash is edited as a style, not as an array
3843
-
2956
+
3844
2957
  `Stroke.dash` already rendered, imported and exported; it had no control,
3845
2958
  because a `number[]` has no leaf kind. It doesn't need one — the thing a person
3846
2959
  chooses is a style, and the array is how it is stored. The stroke block gains a
3847
2960
  Solid / Dashed / Dotted / Custom bar under cap, join and align.
3848
-
2961
+
3849
2962
  `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
3850
2963
  the option string, the counterpart of the `unit` a number leaf already has for
3851
2964
  a value stored in a canonical unit. Both directions are handed the object the
@@ -3857,9 +2970,8 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3857
2970
  a new `disabled` option — one a control reports but refuses to author, since
3858
2971
  there is no array behind it. `solid` is stored as no dash at all, and an object
3859
2972
  leaf's field written as `undefined` is now removed rather than left holding it.
3860
-
3861
2973
  - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
3862
-
2974
+
3863
2975
  Nine option glyphs and four category glyphs join the icon set. The option
3864
2976
  glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
3865
2977
  shape rather than as a diagram of one. `align` is a circle zoomed until the
@@ -3867,31 +2979,29 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3867
2979
  the box's complement of it, and `center` is the annulus straddling the path,
3868
2980
  so the three are one band at three offsets. The categories are the bare path
3869
2981
  each row treats, drawn in the outlined register.
3870
-
3871
- A schema carries a glyph _id_, not a component: `ToolPrefEnum`'s options gain
2982
+
2983
+ A schema carries a glyph *id*, not a component: `ToolPrefEnum`'s options gain
3872
2984
  `icon`, and every leaf gains one for rows whose own label is spent on a
3873
2985
  `pair`. Core ships no icon set and cannot depend on one, so the field is a
3874
2986
  plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
3875
2987
  `short` where it names no glyph.
3876
-
2988
+
3877
2989
  `SelectionPanel` now honours `block` inside an object leaf, not only at the
3878
2990
  section level. A row whose fields are all `block` drops the 64px label column
3879
2991
  and spans the block. The default stroke schema uses both: paint and width
3880
2992
  share one label-less row, and cap/join/align share the next.
3881
-
2993
+
3882
2994
  `align`'s options run inner, center, outer — the order the ink moves outward.
3883
-
3884
2995
  - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
3885
-
2996
+
3886
2997
  `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
3887
2998
  through a new registry (`registerMarker`), shipping eight built-in shapes.
3888
2999
  Unlike SVG, the stroke stops short of a filled head rather than running under
3889
3000
  it to the tip; the distance is declared per marker, so an open V still reaches
3890
3001
  the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
3891
3002
  `<marker>` defs.
3892
-
3893
3003
  - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
3894
-
3004
+
3895
3005
  It accepted `{ color }` only, and merged through `paintWithColor`, which
3896
3006
  supersedes a non-solid paint with a solid one — a gradient stroke was
3897
3007
  unreachable even though `setStrokeOpacity` could already reach its alpha.
@@ -3899,42 +3009,40 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3899
3009
  an earlier paint, and the stroke's width, cap, join, dash and align survive
3900
3010
  either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
3901
3011
  that has no color to pass.
3902
-
3012
+
3903
3013
  Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
3904
3014
  from `DEFAULT_STROKE_COLOR`, painting the selection black where
3905
3015
  `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
3906
3016
  `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
3907
3017
  where the body builds a left-edge-to-right-edge one.
3908
-
3018
+
3909
3019
  `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
3910
3020
  core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
3911
3021
  it — and nothing imported it.
3912
-
3913
3022
  - 81213fc: Edit a node's stroke as the union it is
3914
-
3023
+
3915
3024
  `data.stroke` holds `string | Stroke`, and the schema described it with a
3916
3025
  `color` leaf — which reads `undefined` off the object form, shows its own
3917
3026
  default, and writes a bare hex back over the stroke's width, cap, join and
3918
3027
  dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
3919
3028
  for `FillStyle`.
3920
-
3029
+
3921
3030
  A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
3922
3031
  Its control shows whichever color the value has — the string itself, or a
3923
3032
  solid paint's color — gives a gradient stroke the indeterminate chip rather
3924
3033
  than claiming a color it doesn't have, and preserves the form on write.
3925
-
3034
+
3926
3035
  `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
3927
3036
  `paint` leaf used to render as the literal text `(paint: no renderer)`.
3928
3037
  `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
3929
3038
  exported from `@weasel-js/ui` for consumers writing their own property
3930
3039
  renderers against either union.
3931
-
3040
+
3932
3041
  Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
3933
3042
  remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
3934
3043
  for why that waits on the SVG mapping.
3935
-
3936
3044
  - 2f225d7: A thick stroke is clickable across its whole width
3937
-
3045
+
3938
3046
  `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
3939
3047
  stroke width for an `outer` align — but the AABB pre-filter that runs before it
3940
3048
  grew only by the pointer slop. So half a thick outer stroke's ink was
@@ -3943,25 +3051,24 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3943
3051
  was at least as generous as the refinement, which it cannot be on its own,
3944
3052
  since it never sees the stroke. That budget is the caller's, and the comment
3945
3053
  says so now.
3946
-
3054
+
3947
3055
  `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
3948
3056
  declared in `px` resolved as world units and the reach was wrong at every zoom
3949
3057
  but 1 — while the caller computed `meanScale(view.scale)` one line above.
3950
-
3951
3058
  - 68069dc: Right-to-left text lays out in visual order
3952
-
3059
+
3953
3060
  `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
3954
3061
  analyses the paragraph, reorders each line after the wrap, and mirrors brackets
3955
3062
  in right-to-left runs. Given none, nothing changes: text lays out logically,
3956
3063
  exactly as before.
3957
-
3064
+
3958
3065
  `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
3959
3066
  `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
3960
3067
  consumer who renders no right-to-left text never installs the Unicode tables,
3961
3068
  and a different implementation can be substituted. `@weasel-js/bidi` is a
3962
3069
  devDependency here only, for a test that drives real Hebrew through the real
3963
3070
  engine; types lining up is not evidence the semantics do.
3964
-
3071
+
3965
3072
  `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
3966
3073
  across `cells`**. Cells stay in logical order — slot `i` is still character `i`
3967
3074
  — while their x values follow the reordering. Sort on `x` for visual order, and
@@ -3969,26 +3076,25 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3969
3076
  cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
3970
3077
  against each cell's own extent, taking a right-to-left cell's visually-leading
3971
3078
  half as the character's logical end.
3972
-
3079
+
3973
3080
  Kerning is a gap between two adjacent characters, and the wrap measures it
3974
3081
  logically. Reordering can put a different pair side by side, so the gap taken
3975
3082
  is the one belonging to whichever of the two is logically second, and none at
3976
3083
  all across a direction boundary — where the pair never touched in the source.
3977
-
3084
+
3978
3085
  Laying out right-to-left text with no engine now warns once, naming the import.
3979
3086
  The alternative is glyphs silently appearing reversed, which is the one real
3980
3087
  hazard of making this opt-in.
3981
-
3982
3088
  - 5d0ff9c: Every code point on a line gets a cell
3983
-
3089
+
3984
3090
  `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
3985
3091
  `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
3986
3092
  `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
3987
3093
  a consumer indexing per character no longer has to reconcile a sparse array
3988
3094
  against the source string.
3989
-
3095
+
3990
3096
  The old arrays were documented as non-contiguous, and two causes were real:
3991
-
3097
+
3992
3098
  - A code point no tier could serve was dropped outright, taking its caret stop
3993
3099
  with it. It now occupies a zero-advance cell. This is reachable whenever the
3994
3100
  dynamic canvas fallback is off — which is the normal configuration for a
@@ -3997,34 +3103,33 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3997
3103
  - A space opening a line — at the start of the text, or after a newline — was
3998
3104
  discarded. It now keeps its cell and still consumes no width, so a line is
3999
3105
  addressable per character without gaining an indent. A space that opens a
4000
- _wrapped_ line was never affected: the wrap leaves it as a trailing cell on
3106
+ *wrapped* line was never affected: the wrap leaves it as a trailing cell on
4001
3107
  the line before.
4002
-
3108
+
4003
3109
  Neither changes any geometry: both cells carry zero advance, zero tracking and
4004
3110
  no kerning, so bounds, line widths and glyph positions are unchanged.
4005
-
3111
+
4006
3112
  A newline still has no cell, since it separates cells rather than being one.
4007
3113
  `srcEnd` is what a blank line carries in its place.
4008
-
3114
+
4009
3115
  `drawsInk` is a property of the code point and the face, not of the call that
4010
3116
  produced it: it does not flip when a dynamic bake lands or the outline
4011
3117
  threshold is crossed, so the same text reports the same slots every time. A
4012
3118
  zero-advance combining mark is `true` — it inks without advancing.
4013
-
4014
3119
  - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
4015
3120
  `data.fill` and `data.stroke` — the same two leaves every other node kind
4016
3121
  paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
4017
3122
  `TextStyle.stroke` are gone, with no compatibility read: a document that put
4018
3123
  its color in `style.fill` now renders in the default black rather than
4019
3124
  erroring, so check documents that predate this.
4020
-
3125
+
4021
3126
  This fixes a real asymmetry rather than only moving fields. `data.stroke`
4022
3127
  already reached text through a fold in the painter, but `data.fill` did not:
4023
3128
  picking a fill color with a text node selected wrote a field nothing read, so
4024
3129
  the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
4025
3130
  the Appearance leaf now all mean the same thing on text as on a rect. The
4026
3131
  duplicate `data.style.fill` control is gone from the text schema with them.
4027
-
3132
+
4028
3133
  `resolveTextStyle(style, paint)` takes the node's paint as a second argument
4029
3134
  and is what derives the caret and selection colors, so the edit overlay
4030
3135
  matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
@@ -4035,105 +3140,100 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4035
3140
  style. `StyledRun.fill` and `.stroke` are unchanged and still override the
4036
3141
  node's per range — which is also where a caller with no node at all, a HUD
4037
3142
  widget or a debug overlay, now states its color.
4038
-
3143
+
4039
3144
  `textCommandFromRuns` is exported from the package root.
4040
-
4041
3145
  - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
4042
3146
  array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
4043
3147
  the last paths still doing what `drawImage` stopped doing; text now costs
4044
3148
  **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
4045
3149
  ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
4046
3150
  image draw. No other command kind moved.
4047
-
3151
+
4048
3152
  A text group is as many quads as it has glyphs, so unlike the image ring a
4049
3153
  slot's buffer grows to the largest run it has seen rather than being fixed at
4050
3154
  four vertices. The quad index pattern is a pure function of the quad count —
4051
3155
  the pattern for N quads is a prefix of the pattern for any larger N — so one
4052
3156
  index buffer serves every slot, grown the same way and written only when it
4053
3157
  grows.
4054
-
4055
3158
  - c2ffa49: Alignment can resolve against reading direction
4056
-
3159
+
4057
3160
  `align` gains `start` and `end` alongside `left` / `center` / `right`, and
4058
3161
  `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
4059
3162
  the relative pair resolves against the direction, the absolute pair ignores it.
4060
3163
  `resolveAlign(align, direction)` collapses one to the other and is exported for
4061
3164
  consumers that need an edge rather than an intent.
4062
-
3165
+
4063
3166
  Direction is an input, not something this package discovers. `@weasel-js/text`
4064
3167
  has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
4065
3168
  what it found; nothing here sniffs an environment.
4066
-
3169
+
4067
3170
  Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
4068
3171
  layout moves. Making `start` the default alignment is a separate call.
4069
-
3172
+
4070
3173
  `@weasel-js/svg` carries the direction through: `direction` joins the
4071
3174
  inheritable presentation properties, and `text-anchor` is now written and read
4072
3175
  against it. Two things were wrong before and are worth naming, because both
4073
3176
  rendered plausible output:
4074
-
3177
+
4075
3178
  - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
4076
3179
  a mapping that assumed three values and read the fourth as its `else`.
4077
3180
  - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
4078
3181
  right edge, while this model's default `align` is `left`. They agree under
4079
3182
  `ltr` and only there, so an RTL document with no explicit anchor imported as
4080
3183
  left-aligned.
4081
-
3184
+
4082
3185
  This is alignment and round-tripping only. Layout still walks code points in
4083
3186
  logical order with the pen always increasing: there is no bidi reordering and
4084
3187
  no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
4085
3188
  renders in logical order, and Arabic still renders unjoined.
4086
-
4087
3189
  - 4c097ef: Sit every run on a line on one baseline
4088
-
4089
- Mixed-size text hung each run off the _line top_ at its own ascent instead of
3190
+
3191
+ Mixed-size text hung each run off the *line top* at its own ascent instead of
4090
3192
  off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
4091
3193
  with the big run's cap rather than standing on the line with it. Two faces with
4092
3194
  different ascents at the same size diverged the same way. Baseline alignment is
4093
3195
  what inline text does everywhere else, and the module header already claimed
4094
3196
  this behavior — the walk just never implemented it.
4095
-
3197
+
4096
3198
  A line now sinks one baseline far enough to clear its tallest run's ascent and
4097
3199
  places every glyph against it. Glyph quads derive their top from that baseline
4098
3200
  rather than from the pen's line top, which is the whole of the change:
4099
3201
  `qy0 = baselineY + (yoffset - metrics.base) * scale`.
4100
-
3202
+
4101
3203
  Uniform-size text — nearly all text — is unchanged, since the maximum over one
4102
3204
  value is that value. Only lines that actually mix sizes or faces move, and they
4103
3205
  move to where they always should have been.
4104
-
3206
+
4105
3207
  The test named "mixed-size runs share a baseline on the same line" asserted only
4106
3208
  a quad count and passed throughout; it now asserts the baselines.
4107
-
4108
3209
  - 2b86e00: A text node's style is one value, not ten sibling paths
4109
-
3210
+
4110
3211
  `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
4111
3212
  `TextStyle` from ten independent leaves, each control writing a field of a
4112
3213
  value it could only half see. `data.style` is an object leaf now, with
4113
3214
  Character and Paragraph as groups inside it — groups head their fields and
4114
3215
  contribute nothing to the path, so a field is still a field of the style and
4115
3216
  one commit writes the whole thing.
4116
-
3217
+
4117
3218
  An object leaf whose fields are entirely grouped no longer prints its own
4118
3219
  heading, which would stack straight onto the first group's, and a group's
4119
3220
  fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
4120
3221
  into an object leaf when listing what a kind exposes — the fields are the
4121
3222
  editable surface; the leaf is the container.
4122
-
3223
+
4123
3224
  `SelectionPanel` has a story now, which is how the two layout defects above
4124
3225
  were found.
4125
-
4126
3226
  - d933a89: Superscript, subscript and overline for styled runs
4127
-
3227
+
4128
3228
  `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
4129
3229
  a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
4130
3230
  over two new primitives rather than a mechanism of its own:
4131
-
3231
+
4132
3232
  - `baselineShift` — raise (positive) or lower (negative) a run off the line's
4133
3233
  shared baseline, in ems of the inherited font size.
4134
3234
  - `fontScale` — a multiplier on the inherited font size, the relative
4135
3235
  counterpart to `fontSize`. An absolute `fontSize` still wins over it.
4136
-
3236
+
4137
3237
  Naming either directly overrides that half of `script` and leaves the other
4138
3238
  alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
4139
3239
  ±33.3% position — Adobe's defaults, so a character panel can show percentages
@@ -4141,28 +3241,27 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4141
3241
  carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
4142
3242
  them, and metrics that applied on one glyph tier and not the other would
4143
3243
  reflow text as it crossed the size threshold.
4144
-
3244
+
4145
3245
  `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
4146
3246
  `fontSize`, so layout never learns superscripts exist — it places a run against
4147
3247
  a baseline and an offset. The shift moves a run's glyphs, its outline geometry
4148
3248
  and its own decoration rules together, and deliberately does not feed back into
4149
3249
  the line's baseline or height: a superscript rides the line rather than
4150
3250
  reflowing it.
4151
-
3251
+
4152
3252
  `overline` joins `underline` and `strikethrough` on both `TextStyle` and
4153
3253
  `StyledRun`, additive over the node style like the other two, and is now
4154
3254
  available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
4155
3255
  is unchanged — it stays silent on the decorations, as it always has been.
4156
-
4157
3256
  - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
4158
3257
  its own final `onTick`. The last tick emitted the value and completed in one
4159
3258
  pass, so a write made from that tick — cancelling the tween — still got the
4160
3259
  completion callback, against the documented "not called on cancel" contract.
4161
3260
  - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
4162
-
3261
+
4163
3262
  Two ops inverted to something narrower than what they applied, so undo
4164
3263
  silently lost data.
4165
-
3264
+
4166
3265
  `createDeleteOp.invert()` re-inserted a single node while `apply()` called
4167
3266
  `removeNode`, which cascades the whole subtree. Delete a container with two
4168
3267
  children, undo, and the container came back with `children: []` while both
@@ -4172,7 +3271,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4172
3271
  re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
4173
3272
  does not cascade, so the inverse skips any descendant the adapter still reports
4174
3273
  as live rather than duplicating it.
4175
-
3274
+
4176
3275
  `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
4177
3276
  instead of restoring the sibling slot and paint order changed. `ReparentArgs`
4178
3277
  now carries `fromIndex` / `toIndex` and places through the existing
@@ -4180,39 +3279,42 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4180
3279
  `setParent`'s signature is unchanged. Adapters without that seam no-op as
4181
3280
  before. `groupAction` captures each member's index before mutating; `move` and
4182
3281
  `snapToContainer` pass none and are byte-identical.
4183
-
3282
+
4184
3283
  `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
4185
3284
  cascade, which is why nothing caught the first bug. It now runs against a
4186
3285
  tree-backed fake.
4187
-
4188
3286
  - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
4189
-
4190
- Every union a user looks at or clicks folded each member's _unrotated_ box.
3287
+
3288
+ Every union a user looks at or clicks folded each member's *unrotated* box.
4191
3289
  Select two shapes, rotate one, and the multi-selection frame and its handles
4192
3290
  sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
4193
- out as the target bounds for paint _and_ hit-test, so the handles were both
3291
+ out as the target bounds for paint *and* hit-test, so the handles were both
4194
3292
  drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
4195
3293
  correct larger box.
4196
-
3294
+
4197
3295
  `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
4198
3296
  and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
4199
3297
  beside the rotation-free `unionBounds`, which stays correct for commit-time
4200
3298
  actions that write poses back in the unrotated frame; the module says which to
4201
3299
  reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
4202
3300
  another name. Both new functions are exported from the package root.
4203
-
3301
+
4204
3302
  Moved onto it: `ChromeState.unionBounds`, the selection overlay's
4205
3303
  container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
4206
3304
  the wrong place whenever a member was rotated), and WeaselDraw's export
4207
3305
  viewBox, which clipped rotated shapes out of the copied SVG.
4208
-
4209
3306
  - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
4210
-
3307
+
4211
3308
  `useAction` returned early on a null registry, so an action registered above
4212
3309
  the provider — or with no provider mounted — silently never fired its
4213
3310
  bindings. It now warns in dev, naming the action id. Runtime behavior in
4214
3311
  production builds is unchanged.
4215
-
3312
+ - Updated dependencies [5c8e9e6]
3313
+ - Updated dependencies [2621cbf]
3314
+ - Updated dependencies [0f936da]
3315
+ - Updated dependencies [4180095]
3316
+ - Updated dependencies [9977908]
3317
+ - Updated dependencies [52c7b2a]
4216
3318
  - Updated dependencies [3386d64]
4217
3319
  - Updated dependencies [c6c499d]
4218
3320
  - Updated dependencies [20097e6]
@@ -4224,13 +3326,13 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4224
3326
  - Updated dependencies [c2ffa49]
4225
3327
  - Updated dependencies [4c097ef]
4226
3328
  - Updated dependencies [d933a89]
4227
- - @weasel-js/geom@2.0.0-pre.0
4228
- - @weasel-js/text@2.0.0-pre.0
4229
- - @weasel-js/gestures@2.0.0-pre.0
4230
- - @weasel-js/history@2.0.0-pre.0
4231
- - @weasel-js/modes@2.0.0-pre.0
4232
- - @weasel-js/paint@2.0.0-pre.0
4233
- - @weasel-js/font@2.0.0-pre.0
3329
+ - @weasel-js/text@1.3.0
3330
+ - @weasel-js/geom@1.3.0
3331
+ - @weasel-js/font@1.3.0
3332
+ - @weasel-js/gestures@1.3.0
3333
+ - @weasel-js/history@1.3.0
3334
+ - @weasel-js/modes@1.3.0
3335
+ - @weasel-js/paint@1.3.0
4234
3336
 
4235
3337
  ## 1.2.0
4236
3338