@weasel-js/core 1.4.4 → 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 +937 -2202
  2. package/README.md +118 -75
  3. package/dist/{autoPoseDescriptor-DF1SnnSx.d.ts → autoPoseDescriptor-CvjflWJK.d.ts} +29 -26
  4. package/dist/{chunk-2VXGHUVL.js → chunk-BDWAA634.js} +4 -22
  5. package/dist/chunk-BDWAA634.js.map +1 -0
  6. package/dist/{chunk-R3AWPTLZ.js → chunk-MG7OXCAI.js} +2760 -4491
  7. package/dist/chunk-MG7OXCAI.js.map +1 -0
  8. package/dist/{chunk-PRGBGMH3.js → chunk-MQI4PIX3.js} +3 -3
  9. package/dist/chunk-MQI4PIX3.js.map +1 -0
  10. package/dist/{chunk-WPM42WJP.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-0Pbn5B2C.d.ts → grid-BrIa38gG.d.ts} +7 -10
  16. package/dist/index.d.ts +1996 -1229
  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-DbYLImvq.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 -12
  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-ei3UMl9R.d.ts → types-DMyo7dnM.d.ts} +12 -41
  33. package/dist/{types-DEALFt5F.d.ts → types-DtjCJA5r.d.ts} +9 -3
  34. package/package.json +13 -10
  35. package/dist/DrawCommand-CD-ug3d9.d.ts +0 -332
  36. package/dist/builtins-BXFBXegF.d.ts +0 -840
  37. package/dist/chunk-2VXGHUVL.js.map +0 -1
  38. package/dist/chunk-BL65SHCX.js +0 -573
  39. package/dist/chunk-BL65SHCX.js.map +0 -1
  40. package/dist/chunk-PRGBGMH3.js.map +0 -1
  41. package/dist/chunk-R3AWPTLZ.js.map +0 -1
  42. package/dist/chunk-WPM42WJP.js.map +0 -1
  43. package/dist/geometry-6fCNhAux.d.ts +0 -114
  44. package/dist/path-JEV2c5If.d.ts +0 -48
  45. package/dist/registry-BY-wI9gm.d.ts +0 -4003
  46. package/dist/types-BHK2dkMu.d.ts +0 -172
  47. package/dist/types-bcc7jcUy.d.ts +0 -594
  48. package/dist/view-DSQgxBJB.d.ts +0 -63
package/CHANGELOG.md CHANGED
@@ -1,5 +1,586 @@
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
+
3
584
  ## 1.4.4
4
585
 
5
586
  ### Patch Changes
@@ -1279,1951 +1860,158 @@
1279
1860
  - @weasel-js/modes@1.4.0
1280
1861
  - @weasel-js/paint@1.4.0
1281
1862
 
1282
- ## 1.4.0-pre.1
1863
+ ## 1.3.0
1283
1864
 
1284
- ### Patch Changes
1865
+ ### Minor Changes
1285
1866
 
1286
- - 36b6ee7: Add `@weasel-js/cursor` and give three tools real cursors.
1287
-
1288
- A cursor glyph is SVG path `d` strings tagged with a paint role plus a hotspot
1289
- in glyph units — the one geometry form both a data-URI baker and a `Path2D`
1290
- painter consume without translating. `bakeCursor` renders one to a
1291
- `url(data:image/svg+xml,…)` string with the hotspot scaled to integer CSS px,
1292
- and `cursorFor` memoizes that per name and size. The pencil, pen and eyedropper
1293
- tools now show their own glyph instead of a shared `crosshair`.
1294
-
1295
- Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
1296
- cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
1297
- throws above 128 CSS px rather than emitting a cursor the browser would drop
1298
- 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.
1299
1869
 
1300
- Glyph geometry is authored in `scripts/glyphs/` and generated to resolved
1301
- literals by `npm run gen:cursors`; `npm run proof:cursors` renders the baked
1302
- 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.
1303
1875
 
1304
- New API: `bakeCursor`, `cursorFor`, `GLYPHS`, `haloFitsInBox`, `CursorGlyph`,
1305
- `CursorPath`, `CursorGlyphName`, `BakeOptions`, and the register constants
1306
- `CURSOR_INK`, `CURSOR_HALO`, `CURSOR_HALO_WIDTH`, `CURSOR_MAX_CSS_PX`.
1307
- - Updated dependencies [36b6ee7]
1308
- - @weasel-js/cursor@1.4.0-pre.1
1309
- - @weasel-js/font@1.4.0-pre.1
1310
- - @weasel-js/geom@1.4.0-pre.1
1311
- - @weasel-js/gestures@1.4.0-pre.1
1312
- - @weasel-js/history@1.4.0-pre.1
1313
- - @weasel-js/modes@1.4.0-pre.1
1314
- - @weasel-js/paint@1.4.0-pre.1
1315
- - @weasel-js/text@1.4.0-pre.1
1316
-
1317
- ## 1.4.0-pre.0
1318
-
1319
- ### Minor Changes
1320
-
1321
- - 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.
1322
1879
 
1323
- `<SceneCanvas paintInto={{ canvas, x, y }} inputElement={el}>` paints into a
1324
- rect of a canvas you own and takes pointer input from an element you own, so N
1325
- canvases share one GL context and one buffer. Each needs its own
1326
- `<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`.
1327
1882
 
1328
- The ref handle names both elements: `element` is where input, focus and the
1329
- cursor live, and is now typed `HTMLElement` because detached it is not a canvas;
1330
- `surface` is where pixels land. Attached, they are the same `<canvas>` and
1331
- `element` keeps working as before. The HUDs render when detached too, anchored
1332
- 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.
1333
1885
 
1334
- Breaking, narrowly: `createLoupe`'s `element` option is now `canvas`, with an
1335
- optional `input` for the element aim is measured against.
1336
- `CanvasExtensionApi.element` no longer satisfies an `HTMLCanvasElement` — read
1337
- `surface` for pixels. And `clientToWorld`'s first parameter widens to
1338
- `HTMLElement`, which stops compiling for a consumer who annotated that parameter
1339
- 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`.
1340
1889
 
1341
- <!-- 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" -->
1342
1891
 
1343
1892
  ### Patch Changes
1344
1893
 
1345
- - 5295c34: Draw on a lab's instrument: the `annotations` capability gets its overlay.
1894
+ - 52c7b2a: Depend on `font` and `core` as exact peers
1346
1895
 
1347
- An instrument that declares `annotations` now gets a drawing surface on every
1348
- target it names — weasel tools, weasel selection, marks that pan and zoom with
1349
- what they mark — plus a palette (select, freehand, line, arrow, rectangle,
1350
- ellipse, text) and its own tool slot. `useAnnotations()` reaches the store from
1351
- the instrument's render or from a chrome contribution, and re-renders its
1352
- caller as marks change.
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.
1353
1901
 
1354
- The lab's shared surface grew the buffer that makes this possible: one
1355
- `<canvas>` over `.lk-lab__body`, and `SurfaceHandle.registerPainter`, which is
1356
- how a resize of that buffer reaches every tile rather than the one that moved.
1357
- `getContainer()` names the element tile rects are measured against.
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.
1358
1908
 
1359
- A mark is a weasel scene node in a scene of its own per target — a pane's
1360
- hit-test, marquee and paint walk the whole scene they are handed, so one shared
1361
- scene would put a neighbour's marks under the pointer. An annotation's id is
1362
- therefore `<target>/<node>`, and `createAnnotationStore` takes `targets` alone
1363
- plus an optional `restore`; `SerializedAnnotations` carries `scenes`, keyed by
1364
- target. Marks still do not survive a reload — the storage slot is the next arc.
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.
1365
1912
 
1366
- Core adds `ArrowIcon` to the built-in tool glyphs.
1367
- - 2fbf611: Give a canvas its own provider scope with `<WeaselProvider isolate>`
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
1368
1919
 
1369
- An actions registry holds exactly one dispatcher, so a second `<SceneCanvas>`
1370
- joining a scope displaced the first and took its input away. Worse, the
1371
- detach was unconditional: whichever canvas unmounted — or merely re-rendered
1372
- with a new dispatcher identity — cleared the slot for the one still on screen.
1373
- The symptom was a canvas that stopped responding, naming neither canvas nor the
1374
- registry they shared.
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.
1375
1924
 
1376
- `isolate` mounts every provider unconditionally instead of deferring to one
1377
- already in scope, so canvases that merely coexist get a scope each. This is the
1378
- shape consumers had already reached for by hand: `AnimationDemo` and
1379
- `BooleanOpsDemo` both mounted raw `ActionsProvider` / `SelectionContextProvider`
1380
- / `DepRegistryProvider` to shadow the ambient scope, and both now say `isolate`
1381
- instead.
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.
1382
1932
 
1383
- `setDispatcher` and `setDepRegistry` return a release that clears the slot only
1384
- while the caller still holds it, so a departing canvas can no longer disable a
1385
- surviving one. A second dispatcher claiming an occupied registry warns once,
1386
- naming `isolate` as the fix.
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.
1387
1935
 
1388
- Two canvases still cannot *share* one registry: a toolbar outside both has
1389
- nothing to say which one it drives. That needs a focused-canvas concept and is
1390
- not in this change.
1391
- - 7a0c568: Tell an event handler how late its crossing is
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.
1392
1939
 
1393
- `EventTrack`'s `fire` took no arguments, so a handler could only ask its own
1394
- clock for "now" — when the frame was processed, not when the playhead crossed
1395
- the edge. That held footstep scheduling in the side-scroller at frame
1396
- resolution against an audio engine built for sample resolution: a measured peak
1397
- spread of 33–47 ms on the looping run cycle.
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.
1398
1949
 
1399
- `fire(lateBy)` reports the gap between the crossing and the frame carrying it,
1400
- in timeline ms. It is never negative, including on the loop seam, where the
1401
- outgoing lap's tail fires after the playhead has already wrapped — the case
1402
- that makes a handler comparing against `handle.time()` read a negative
1403
- lateness. A nested timeline's events report the same figure as a top-level
1404
- one's; the track's offset cancels.
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
1405
1953
 
1406
- Nothing has to change to compile: a zero-argument function is assignable to the
1407
- new signature.
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.
1408
1958
 
1409
- `lateBy` is a delta, not a clock reading, so events from two different
1410
- timelines still cannot be ordered against each other. That would need the
1411
- animator's virtual clock made public, which this does not do.
1959
+ Camera animation is a general surface, not a zoom flag. Three ways in, one
1960
+ runner behind them:
1412
1961
 
1413
- `SideScrollerDemo` now places each footfall a fixed budget after its true
1414
- crossing, so which frame happened to notice a contact turns into constant
1415
- latency rather than audible spread.
1416
- - a7fa697: Add an anchored-placement solver and keep HUD windows on their host.
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`.
1417
1971
 
1418
- `@weasel-js/geom` gains `placeRect` and `clampRectWithin`. `placeRect` resolves an
1419
- overlay against an anchor: it picks a side, flips to the opposite one when the
1420
- preferred side has no room, and slides along the alignment axis to stay inside a
1421
- boundary. `clampRectWithin` is the containment half on its own — move a rect the
1422
- shortest distance that puts it inside a boundary, keeping its size. Both are pure
1423
- and take an explicit boundary rect, so a boundary that does not start at the
1424
- origin resolves correctly.
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.
1425
1978
 
1426
- A HUD window could previously be dragged fully off its host with no way to
1427
- recover it: `createWindow` clamped size but never position. Move drags and
1428
- `setBounds` now keep the window on the host. Resize drags are deliberately left
1429
- alone, so pulling an edge past the host does not fight the gesture.
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.
1430
1988
 
1431
- `@weasel-js/core` gains `hostAnchorRect`, `hostAnchorCss` and `useHostAnchor`,
1432
- which hold a fixed-position panel against a host element's corner and keep it
1433
- inside the viewport. The corner is an alignment per axis rather than a fixed
1434
- one, and `useHostAnchor` takes a function that resolves the host, so a host held
1435
- in a ref and one found by selector work the same way.
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
1436
1995
 
1437
- `hostAnchorCss` pins whichever edges the alignment names. That is not cosmetic:
1438
- a panel whose width tracks its content holds the anchored edge still and grows
1439
- away from it, so pinning the wrong edge makes the anchored corner drift on every
1440
- content change.
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
1441
2001
 
1442
- Four places were carrying their own copy of that anchor math and now share this
1443
- one — `CursorCoordsHud`, `PickHud`, `ModalityHud`, and WeaselDraw's
1444
- `DispatchTracePanel`, which anchors the opposite corner. None of the four
1445
- clamped, so a panel could hang off the edge when the host was scrolled or the
1446
- panel was tall.
1447
- - 2272682: `createParallaxLayer` takes an optional `getOuterView`, so a plane can derive
1448
- from a ref-driven camera. It previously derived only from the canvas's `view`
1449
- prop; a consumer keeping a 60 Hz camera out of React state pins that prop to
1450
- identity and got identity back for every `pan` value — a backdrop that silently
1451
- never moved.
2002
+ `hit-test affordances against the painted chrome state` moved the selection
2003
+ overlay to reading bounds off the chrome state at paint time, which left
2004
+ `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
2005
+ referenced only by the `layers` memo's dependency array — nothing in the body
2006
+ used them. Removing them from the array made all four dead locals, so they go
2007
+ too.
1452
2008
 
1453
- `useHandTool` no longer builds a velocity tracker and a decay loop it never
1454
- uses. `inertia` and `axis` were already inert; they are now documented as such
1455
- until the `viewport.dragPan` action implements them.
1456
- - 503b56d: Fix two path-walker bugs that produced wrong geometry with no error.
1457
-
1458
- `tessellate` treated `Z` as a no-op, so a command following a close flattened
1459
- from the last point drawn rather than from the subpath start — SVG puts the pen
1460
- back at the start. `pathDistanceToPoint` dispatched through an `if`/`else if`
1461
- chain with no final `else`, so an unrecognized command code left the coordinate
1462
- cursor unadvanced and silently misaligned every later read; it now throws.
1463
- - ac2deea: Add `polylineFromPoints` — the open counterpart to `polygonFromPoints`.
1464
-
1465
- Same geometry, without the closing edge. A freehand stroke or a measurement
1466
- line wants this; a region wants the closed one. The pencil tool's drag preview
1467
- was building its ghost with `polygonFromPoints`, so the edge from the newest
1468
- sample back to the first swept across the drawing as the stroke grew and read
1469
- as a marquee.
1470
- - 23ffb2f: `WeaselRenderer` can draw into a rect of a buffer it does not own.
1471
- `setTarget({ origin, clear })` applies a viewport and scissor inside `render()`,
1472
- so N renderers can share one WebGL context and one canvas without a frame clear
1473
- erasing a co-tenant. The rect's size is the renderer's own `width`/`height`, so
1474
- `resize()` remains the single source of it.
1475
-
1476
- Adds API. Two behaviour changes for existing callers: `render()` now
1477
- re-establishes blend, depth, cull and clear colour every frame instead of once at
1478
- construction, so a co-tenant moving that state no longer corrupts weasel's
1479
- frames; and the constructor now throws when handed a WebGL2 context whose
1480
- attributes report no stencil buffer, which previously rendered clips and even-odd
1481
- fills wrong rather than failing. A context that cannot report its attributes is
1482
- unaffected.
1483
- - 016851c: Stop a stroke with no paint from blanking the whole document.
1484
-
1485
- `SelectionPanel`'s object leaf started from `{}` when the node held no value
1486
- yet, so editing any non-paint field of `data.stroke` on an unstroked node
1487
- committed that field alone — a `Stroke` with no `paint`, which the type
1488
- forbids. The leaf's declared `default` was dead for writes; it now seeds from
1489
- it, so writing one field materializes a complete value.
1490
-
1491
- Such a stroke threw out of `fillInPoseFrame`, and the throw escaped the painter
1492
- and took the frame with it: the document page and every other node vanished,
1493
- and the canvas stayed stale until something unrelated requested a redraw — so
1494
- WeaselDraw opened on an empty workspace and only drew once the pointer moved.
1495
- `resolveNodeStroke` now reads a paintless stroke as no stroke, and the text
1496
- painter routes through it like every other painter. The frame loop no longer
1497
- loses its dirty flag when a paint throws, so one bad frame is retried rather
1498
- than stranding the surface.
1499
- - c9dd37f: Render text decorations as a toggle row, and ship a builtin font-family control
1500
-
1501
- `SelectionPanel` rendered every boolean leaf as a `Switch`, ignoring the leaf's
1502
- `control` entirely — so the three text decorations arrived as three switch rows
1503
- where every text editor puts one row of U / S / O. `ToolPrefBooleanControl` now
1504
- accepts `'toggle'`, `ToolPrefBoolean` carries a `short` label for it (the pair
1505
- takes the row's name, leaving the leaf only a glyph's worth of room), and the
1506
- panel honors both. Core's text schema asks for it: `underline`,
1507
- `strikethrough` and `overline` share a `Decoration` pair.
1508
-
1509
- A run of adjacent leaves sharing a `pair` renders as one `ToggleBar`, not one
1510
- bar per leaf — the same segmented control the `Align` row beside it already
1511
- draws. Each segment still writes only its own leaf, so flipping one decoration
1512
- never invents values for the other two. An unset toggle is left unselected
1513
- rather than dimmed: unselected is what a toggle button's off state means, and
1514
- the dimming the `Switch` path uses for the same case reads as disabled on one.
1515
- A leaf a consumer claims with its own `renderers` entry drops out of the run.
1516
-
1517
- `FontFamilySelect` moves from WeaselDraw into `@weasel-js/ui`, and
1518
- `SelectionPanel` reaches for it on a `font-family` leaf. Core's own default
1519
- text schema declares that kind, so a consumer passing no `renderers` — the
1520
- Storybook story, any app taking the defaults — got the literal
1521
- `(font-family: no renderer)` placeholder where the font picker belongs. The
1522
- control offers both tiers that can actually paint and probes substitution at
1523
- the node's own weight and style, so its label names the variant that will
1524
- render. `@weasel-js/ui` now depends on `@weasel-js/font`.
1525
- - 9a000ea: A stroked text node now gets hit reach from its stroke. `TEXT_PAINTER` declared
1526
- no `ink`, so picking fell back to a zero-outset default and a heavily outlined
1527
- glyph was unpickable across the width of its own outline.
1528
-
1529
- `kit:derived` also now evaluates ahead of `kit:path` / `kit:shape` / `kit:image`.
1530
- A derived node whose `data` happens to carry a `path`, `shape` or `image` field
1531
- was silently painted by those painters instead of from its derived path.
1532
- - 016851c: Add an editor surface for superscript, subscript and overline.
1533
-
1534
- `StyledRun.script`, `baselineShift`, `fontScale` and `overline` reached layout,
1535
- SVG and the DOM overlay but nothing could apply them. The character bar now
1536
- carries an x² / x₂ pair, an overline toggle beside B / I / U / S, and the two
1537
- primitives `script` presets — baseline shift and scale — as percentage fields
1538
- that show what the preset supplies and override just that half when typed over.
1539
- `overline` also joins the sidebar's node-level Character group. Superscript and
1540
- subscript take Cmd+Shift+= and Cmd+Shift+-; the unshifted pair is browser zoom,
1541
- which a page cannot cancel.
1542
-
1543
- A styling written at a collapsed caret now arms `useTextEdit`'s new
1544
- `pendingStyle` and applies to the next character typed, instead of being
1545
- dropped or restyling the whole node. That is what `script` needs — it has no
1546
- node-level counterpart to write to by design — and it makes the bar agree with
1547
- Cmd+B, which already behaved this way. `rangeStyle` reports the styling *at* a
1548
- collapsed caret rather than `{}`, and `toggleStyle` is public.
1549
-
1550
- Three fixes fall out of putting both paths through one implementation:
1551
- lowering a flag the node sets now works from the bar and from a collapsed
1552
- caret, not only from the keyboard over a range; a toggle reads the node's flags
1553
- as well as the runs, so Cmd+B inside a `fontWeight: 700` node clears bold
1554
- instead of adding it; and focus returns to the text after a styling control is
1555
- clicked, so typing continues in the document rather than reaching the app as
1556
- tool shortcuts.
1557
- - 8ddec11: Accept a named or cubic-bezier easing wherever a curve is taken, and let a
1558
- timeline's loop policy change after it is created.
1559
-
1560
- `easing` was a bare function everywhere, which is fine to call and impossible to
1561
- name back, show in a picker, or serialize. It now also accepts the name of a
1562
- built-in (`'easeOutBack'`) or control points (`{ bezier: [0.4, 0, 0.2, 1] }`),
1563
- resolved by `resolveEasing` at the four places a curve is actually invoked. The
1564
- union is additive, so every existing function value stays assignable. Bezier x
1565
- control points are clamped to 0..1, which is what keeps the solve monotone, and
1566
- the control-point tuple is `readonly` so an `as const` preset is assignable.
1567
-
1568
- `TimelineHandle.setLoop(loop)` sets policy and nothing else. A timeline already
1569
- parked at its duration does not restart — `rearm` declines to revive one — so
1570
- play it again by seeking to 0 and resuming. Restoring saved transport state
1571
- therefore cannot start playback as a side effect.
1572
-
1573
- Both settings now read back. `AnimationHandle.timeScale()` returns an
1574
- animation's own scale, and `Animator.timeScale()` the global one, the way
1575
- `isPaused()` already pairs with `pause()`. `TimelineHandle.loop()` returns the
1576
- policy as it stands — `true`, `false`, or the laps a finite loop has left, which
1577
- falls as they are consumed. A transport UI can drive itself off the handle
1578
- instead of mirroring what it last wrote, which drifts as soon as anything else
1579
- holding the handle sets it.
1580
- - 28894b9: Fix the viewport primitives on an axis with negative scale.
1581
-
1582
- `View.scale` is documented as pixels per world unit _per axis_, so `scale.y < 0`
1583
- is the ordinary way to spell a y-up camera. Two primitives did not read it that
1584
- way, and both failed silently rather than erroring.
1585
-
1586
- `zoomAt` clamped the signed scale against positive bounds
1587
- (`min(max, max(min, scale * factor))`), so one wheel step on a y-up view
1588
- returned `scale.y = +0.1`: the axis flipped and the zoom collapsed to the
1589
- minimum. It now bounds the magnitude and restores the sign, so a clamp limits a
1590
- flipped axis instead of unflipping it.
1591
-
1592
- `clampView` computed the visible world extent as `canvas.height / scale.y`,
1593
- which is negative on a flipped axis. That made the "is the view zoomed out past
1594
- the bounds" test never fire, and put the scroll interval on the wrong side of
1595
- the anchor — a y-up view could be panned outside its own bounds. It now takes
1596
- the extent as a magnitude and anchors the interval at the rect's far edge when
1597
- the axis is flipped.
1598
-
1599
- Found while giving labkit's instrument canvas a declarable coordinate system:
1600
- routing its wheel through `zoomAt` looked like the obvious way to stop
1601
- reimplementing fixed-point zoom, and would have been a bug.
1602
- - c4ccd0a: Zoom now has one clamp. `DEFAULT_MIN_ZOOM` / `DEFAULT_MAX_ZOOM` are exported from
1603
- `@weasel-js/core` and every zoom path defaults from them — `zoomAt`, the
1604
- `viewport.zoom` and pinch actions, `usePinchZoomTool`, `fitViewToBounds`,
1605
- `computeWheelAction` and `useZoom`.
1606
-
1607
- **Behavior change:** the three paths that carried the second, undocumented pair
1608
- now cap at 8x rather than 10x. `fitViewToBounds` could previously land at 10x and
1609
- the next pinch frame would clamp it straight back to 8x. Pass an explicit
1610
- `maxScale` / `max` to keep 10x.
1611
- - Updated dependencies [a7fa697]
1612
- - @weasel-js/geom@1.4.0-pre.0
1613
- - @weasel-js/text@1.4.0-pre.0
1614
- - @weasel-js/font@1.4.0-pre.0
1615
- - @weasel-js/gestures@1.4.0-pre.0
1616
- - @weasel-js/history@1.4.0-pre.0
1617
- - @weasel-js/modes@1.4.0-pre.0
1618
- - @weasel-js/paint@1.4.0-pre.0
1619
-
1620
- ## 1.3.0
1621
-
1622
- ### Minor Changes
1623
-
1624
- - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
1625
- into `@weasel-js/paint` — two new Tier A leaves.
1626
-
1627
- `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
1628
- measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
1629
- `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
1630
- lay out text without taking the scene graph or a React peer dependency.
1631
- `layoutRuns` is now public — it was previously reachable only from inside core.
1632
-
1633
- `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
1634
- `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
1635
- layout could not move while its fill type lived in the renderer's graph.
1636
-
1637
- `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
1638
- `Rect` moves to `@weasel-js/geom`, beside `Box`.
1639
-
1640
- Breaking for anyone importing these through core's internal paths rather than
1641
- its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
1642
-
1643
- Advances and kerning still come from a baked MSDF atlas — laying out from font
1644
- bytes alone needs the metrics seam in
1645
- `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
1646
-
1647
- <!-- 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" -->
1648
-
1649
- ### Patch Changes
1650
-
1651
- - 52c7b2a: Depend on `font` and `core` as exact peers
1652
-
1653
- `@weasel-js/font` and `@weasel-js/core` keep registries that consumer code
1654
- writes into — registered faces and glyph-ready subscribers in one, content
1655
- handlers and paint kinds and shape painters in the other. Two physical copies
1656
- in a tree are two registries, so a face registered into one while layout
1657
- resolves against the other lays out nothing and the canvas is blank.
1658
-
1659
- Exact sibling pins are what produced the duplicate: a consumer mixing two
1660
- weasel releases left npm no choice but to nest a second copy, silently. As
1661
- peers, the same mix is an `ERESOLVE` at install time. `font` is now a peer of
1662
- `core`, `hud` and `text`; `core` is now a peer of `svg`, joining `d3`, `hud`
1663
- and `ui`, whose `>=` ranges tighten to exact so no version mix resolves by
1664
- accident.
1665
-
1666
- **This can break an install that currently succeeds.** Anyone resolving a
1667
- mixed set of weasel versions by luck now gets an install error instead of a
1668
- blank canvas. That is the point, but it is a break.
1669
-
1670
- `labkit` deliberately keeps `core` as an ordinary dependency: its build aliases
1671
- every core entry point to core's built files and inlines them, so it never
1672
- resolves core at the consumer and has nothing to peer. The flip side is that
1673
- labkit ships its own copy of core's registries, so a consumer using both still
1674
- has two — this change does not address that.
1675
- - 3386d64: Align, distribute and flip use visual bounds
1676
-
1677
- These folded each member's unrotated pose box, so "Align Left" on a selection
1678
- containing a rotated shape lined up the boxes and left the rotated shape's ink
1679
- sticking out past the others. They now work on the visual bounding box, as
1680
- Figma and Illustrator do.
1681
-
1682
- Both ends moved together — expanding only the union would have made alignment
1683
- worse, since the delta runs from an edge of the union to the same edge of each
1684
- member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
1685
- reads a pose's bounds, recovers its rotation and expands via
1686
- `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
1687
- applied as a translation of the stored pose through
1688
- `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
1689
-
1690
- Flip needed only its union pivot changed: mirroring maps a centre and preserves
1691
- size, and an expanded box is concentric with the box it came from.
1692
-
1693
- `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
1694
- by its ink.
1695
- - ffafb7d: Never let an animation's virtual clock run backwards.
1696
-
1697
- `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
1698
- time, then advances its virtual clock by the difference against the timestamp
1699
- the frame loop supplies. Those two share a time origin in a browser, where the
1700
- rAF timestamp and `performance.now()` are both page-relative — but that is a
1701
- browser guarantee, not a universal one, and jsdom starts them roughly 600ms
1702
- apart. The first frame's delta then came out hugely negative and `virtualNow`
1703
- spent dozens of frames climbing back toward zero before a tween advanced at
1704
- all: a 40ms glide took 95 frames and over a second of wall time, growing worse
1705
- the longer the process had been alive.
1706
-
1707
- A frame's elapsed time is never negative, so the sample is now clamped at
1708
- zero. Under a shared origin this is a no-op.
1709
- - ba8b139: Camera animation: `viewport.animatedZoom` now does something
1710
-
1711
- `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
1712
- nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
1713
- through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
1714
- their input already delivers a sample per frame.
1715
-
1716
- Camera animation is a general surface, not a zoom flag. Three ways in, one
1717
- runner behind them:
1718
-
1719
- - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
1720
- `isAnimating`, `target`.
1721
- - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
1722
- so any action can glide the camera.
1723
- - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
1724
- `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
1725
- three are **required** members: anyone hand-implementing `SceneCanvasApi`
1726
- (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
1727
- grew `getPaintedVersion`.
1728
-
1729
- Scale interpolates geometrically and translation is derived from the screen
1730
- point the two views agree on, so a zoom stays anchored instead of drifting and
1731
- each frame changes the view by the same ratio. One animation runs at a time; any
1732
- other view write cancels it, and a cancel leaves the camera where it is rather
1733
- than jumping to the target. On an uncontrolled canvas the whole animation costs
1734
- no React render.
1735
-
1736
- **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
1737
- changes signature — it takes a `{ get, set }` view channel plus an optional
1738
- `Animator`, and `animateTo(from, to, { duration, easing })` becomes
1739
- `animate(to, { ms, easing })`. The `from` argument is gone because the runner
1740
- reads the live view, which is what lets an interrupted camera resume from where
1741
- it actually is instead of snapping back to a captured start. `cancel()` is now
1742
- `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
1743
- `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
1744
- the same reason `from` does.
1745
-
1746
- **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
1747
- `() => View | void`. Returning the target view lets Cmd+0 animate there;
1748
- returning nothing keeps the existing behavior. `animatedZoom`'s config fields
1749
- are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
1750
- animator's vocabulary.
1751
- - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
1752
-
1753
- The canvas is focusable by default (`tabIndex` 0) but exposed no way to
1754
- observe focus, so consumers driving focus-dependent chrome had to attach a
1755
- listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
1756
- so reach `SceneCanvasProps` and the canvas element unchanged.
1757
- - 67bcb05: Drop four values the canvas layer memo no longer reads
1758
-
1759
- `hit-test affordances against the painted chrome state` moved the selection
1760
- overlay to reading bounds off the chrome state at paint time, which left
1761
- `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
1762
- referenced only by the `layers` memo's dependency array — nothing in the body
1763
- used them. Removing them from the array made all four dead locals, so they go
1764
- too.
1765
-
1766
- The memo now rebuilds the layer array on layer/tool/geometry changes rather
1767
- than additionally on every selection and preview-pose change. Selection chrome
1768
- is unaffected: it repaints from chrome state, not from the identity of this
1769
- array.
1770
- - 47cbb08: A closed subpath's dash no longer seams at its start vertex
1771
-
1772
- `splitForDash` flushed the run still open when a closed subpath's walk returned
1773
- to the vertex it started from as its own open sub-polyline, so it and the run
1774
- that began there rendered as two butt-capped ribbons meeting at a point — a
1775
- notch on the corner of any dashed rectangle whose perimeter isn't a whole
1776
- multiple of the pattern. They are joined now, and the join the stroke asked for
1777
- is drawn across the seam like any other corner. A pattern whose first "on"
1778
- length covers the whole perimeter emits a closed ribbon, identical to the
1779
- undashed stroke.
1780
- - f43e9c2: A derived edge follows the drag that moves its endpoint
1781
-
1782
- `move`, `resize` and `rotate` kept their in-flight poses in action-local
1783
- scratch and published them only as `previewIds` / `previewPose`. That surface
1784
- is enough to paint a ghost and size selection chrome, but nothing that asks
1785
- the *scene* where a node is can see it — and `scenePoseLookup`, which resolves
1786
- a derived node's geometry, asks the scene. So dragging a box left its edge
1787
- anchored to the pre-drag position until the drop, when the commit invalidated
1788
- the dependents and the edge jumped.
1789
-
1790
- The three actions now also publish each frame into the scene's ephemeral pose
1791
- overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
1792
- `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
1793
- so a drag still commits as exactly one undo entry — the reason the actions
1794
- avoided per-frame scene writes in the first place was history, and this writes
1795
- no history. Entries are set once and mutated in place, published with a single
1796
- `commit()` per frame.
1797
-
1798
- Picking follows for free: the pick source resolves a derived path through its
1799
- own override-aware `poseOf`, so an edge is grabbable where it is drawn
1800
- mid-gesture rather than where it used to be.
1801
-
1802
- `clone` is deliberately untouched — its previews are the new ghosts at the
1803
- drag target, and the originals never move, so nothing derives from a changed
1804
- pose.
1805
-
1806
- Also closes the matching gap in the preview-ghost layer, which built a
1807
- container's clip with no derived path and so ghosted a derived container
1808
- without one.
1809
-
1810
- Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
1811
- on every gesture frame. It was already required by the `Scene` contract, but a
1812
- partial fake that omitted it will now throw rather than silently skip.
1813
- - bb27e83: A derived node is clickable where it paints
1814
-
1815
- A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
1816
- `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
1817
- resolved from the *dependencies'* poses, which a painter has no handle on. So
1818
- `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
1819
- as "no opinion" and answered `true` everywhere, and picking fell back to the
1820
- node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
1821
- was unpickable, and a derived container contributed no clip.
1822
-
1823
- `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
1824
- same convention `NodePaintCtx` already uses, and `kit:derived` reports the
1825
- derived path as its silhouette and its declared stroke as its `ink`.
1826
-
1827
- Resolving that path needs the scene, so it is the *source* that answers, not
1828
- the painter: `PickSource.derivedPathOf`, a matching optional argument to
1829
- `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
1830
- carried the derived path a node *paints*, and now also the clip a derived
1831
- container *imposes*, so the live canvas and the headless walk clip alike. The
1832
- bare-adapter paths supply none of them and behave exactly as before.
1833
-
1834
- The pre-filter had to move with it. `useSceneSelectTool` grew its region test
1835
- from the node's pose, which for a derived node is the wrong box entirely, so
1836
- the edge was rejected before the shape test could claim it. It now tests the
1837
- derived path when there is one — `poseContains` already reads a path-like pose
1838
- as a path, so this reuses it rather than adding a second reach calculation.
1839
-
1840
- `findShapeSilhouette` skips its memo when handed a derived path. That slot is
1841
- keyed on `(node, pose, data)` and cannot see the path, so it would serve one
1842
- caller's silhouette to a caller that passed a different one — the same reason
1843
- `kit:derived` already skips `PAINT_SLOT`.
1844
- - 6a33c3f: A node's path can be derived from other nodes' poses
1845
-
1846
- A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
1847
- through `SceneRegistry`, and the scene walks resolve its path before painting
1848
- rather than it being authored. An edge drawn between two boxes is then an
1849
- ordinary scene node — selectable, styleable, exportable — whose geometry never
1850
- enters undo history. The seam and its traps are in `docs/extending.md`.
1851
-
1852
- New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
1853
- `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
1854
- `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
1855
- `SerializedNode.derivePathKey`, both additions to the serialization format;
1856
- `NodePaintCtx.derivedPath`.
1857
-
1858
- Deleting a node now deletes everything that derives from it, transitively,
1859
- including those nodes' own subtrees, in one undo entry — so `scene.remove` can
1860
- remove nodes anywhere in the tree that the caller never named, and `removeLayer`
1861
- reaches nodes on other layers. Undo after the built-in **Delete** key does not
1862
- yet restore the cascaded nodes; see "Derived geometry follow-ups" in
1863
- `docs/TODO.md`.
1864
-
1865
- **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
1866
- context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
1867
- now a type error rather than a silent slide into the `view` slot. The same
1868
- fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
1869
- callback types, which is not a break: an existing three-parameter implementation
1870
- still satisfies them, and an existing three-argument call still compiles.
1871
-
1872
- **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
1873
- typed as a `Scene` — a test double, most likely — no longer typechecks until it
1874
- implements it.
1875
-
1876
- **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
1877
- `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
1878
- is not a descendant of the removed node and the tree has to be told about every
1879
- subtree that came out of it. A history persisted by an older build now throws
1880
- mid-undo rather than degrading. The break is deliberate; kit op payloads are not
1881
- versioned.
1882
- - c24e7de: Detached views follow pose overrides
1883
-
1884
- `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
1885
- which a pose override deliberately never bumps — so they kept painting document
1886
- poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
1887
- canvas driving a drag or a simulation silently disagreed with it.
1888
-
1889
- `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
1890
- subscribes to `scene.overrides`. A render (prop change or version bump) and an
1891
- override commit both just mark the surface dirty, and one animation frame
1892
- coalesces them — so a 60 Hz override loop repaints these views with no React
1893
- render, and a backgrounded tab stops painting them entirely. The mount paint
1894
- stays synchronous, so the first frame is still the scene rather than a blank
1895
- canvas. `<MinimapCanvas>` inherits all of this through it.
1896
-
1897
- Repaints driven by a prop change are now asynchronous: they land on the next
1898
- animation frame rather than in the layout effect of the render that caused them.
1899
- Code that renders and then reads pixels in the same tick needs to wait a frame.
1900
-
1901
- A minimap's *framing* still derives from document poses, so a node overridden
1902
- outside the document bounds paints outside the fitted frame — recomputing the
1903
- fit per frame would rescale the whole minimap throughout a settle.
1904
- - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
1905
-
1906
- `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
1907
- once instead of hiding two behind a select. Options carry an optional `short`
1908
- label — a capital or two — for the width a property row has; the full `label`
1909
- stays the accessible name, so the abbreviation never becomes the only thing
1910
- naming the option. A mixed selection selects no segment rather than picking a
1911
- winner.
1912
-
1913
- `pair` now merges fields inside an object leaf, as it already did for section
1914
- rows — a hint shouldn't mean something different for being a field of a value
1915
- rather than a sibling of one. It merges *adjacent* leaves in both places, so
1916
- the schema orders family, size, weight: size and weight pair, and family (which
1917
- sat between them) moves ahead of the pair rather than splitting it.
1918
-
1919
- A stroke's cap, join and align share one row; property rows wrap rather than
1920
- overflow when the controls in them don't fit.
1921
- - be697dc: Add ephemeral pose overrides to the scene
1922
-
1923
- `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
1924
- hit-test paths read through and that history, `toJSON()` and `getVersion()`
1925
- never see. It is additive: a scene with no overrides behaves exactly as before.
1926
-
1927
- This is where per-frame motion belongs. A 60 Hz loop previously had to write
1928
- through `setPose`, which records an undo entry (one per frame at best, batched)
1929
- and bumps the scene version, re-rendering every `useSyncExternalStore`
1930
- subscriber. It also had to allocate a fresh pose object per moving node per
1931
- frame, because the painter memo keys on pose reference. An override entry is
1932
- hoisted once and mutated in place; `overrides.commit()` publishes the frame and
1933
- invalidates the memo for the overridden nodes only.
1934
-
1935
- `commit()` is required after an in-place mutation — without it the memo serves
1936
- the previous frame's draw. Overrides are cleared when a node is removed, since
1937
- ids are reusable. To make a frame permanent, write it once through `setPose`
1938
- and clear the override; that single step is the undo entry.
1939
-
1940
- `ForceGraphDemo` now settles with zero history entries and bakes the result as
1941
- one, replacing a per-tick batch of 24 `setPose` calls.
1942
- - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
1943
-
1944
- It was the fourth site measuring text its own way: `ctx.measureText` per
1945
- character against system fonts, no kerning, `pose.text` only. Nothing masked
1946
- it the way the WebGL context masked the caret — a consumer calling it got a
1947
- box that disagreed with the paint, narrower by a kern on every pair and wrong
1948
- by the whole difference between the installed family and the registered face.
1949
- It goes through the shared layout now, so it sees kerning and per-run styling.
1950
-
1951
- **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
1952
- - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
1953
-
1954
- `requestRedraw()` marks the surface dirty and the next frame paints, so many
1955
- redraws in one tick cost one paint. The view gains an imperative path on the
1956
- canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
1957
- longer holds it in React state, so a camera moving at 60 Hz costs no renders.
1958
- Consumers passing a `view` prop stay controlled and are unaffected.
1959
-
1960
- Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
1961
- that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
1962
- gives a host the scene without a render per mutation, `useSceneTextEdit`'s
1963
- `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
1964
- `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
1965
-
1966
- Two public signatures changed. `usePinchZoomTool` takes a view getter,
1967
- `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
1968
- captured value any more. `CanvasExtensionApi` gained five required members —
1969
- `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
1970
- so external code hand-implementing that interface stops typechecking; code that
1971
- only calls through the ref is unaffected.
1972
-
1973
- Pixels and DOM can now be a frame apart, in whichever direction the change came
1974
- from. A view change leads with pixels: `setView` paints without rendering, so
1975
- DOM built from the view is stale until something re-renders it — position
1976
- world-anchored DOM from `subscribeView`. A scene change leads with DOM:
1977
- `SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
1978
- pixels land next frame — compare `getPaintedVersion()` against the version you
1979
- are about to render when chrome must be in lockstep. Do not render scene-derived
1980
- DOM inside `startTransition`: React defers it and nothing forces it to catch up.
1981
-
1982
- Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
1983
- mode is the one in-tree case — can likewise see a buffer one frame older;
1984
- `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
1985
- paints while `document.hidden` is true, `syncPaint` included, so a readback from
1986
- a background tab returns the frame from before the tab was hidden.
1987
- - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
1988
- recorded. A caller is entitled to reuse the array it uploads from, so storing
1989
- the reference recorded a value that later frames overwrote — a test reading
1990
- two frames back saw the same numbers twice and passed. Test-only surface.
1991
- - 3fb3a46: Release held keys when the window loses focus
1992
-
1993
- A window that blurs mid-hold never delivers the keyup, so every in-flight
1994
- `key-held` handle stayed engaged until that key was pressed again — holding
1995
- Space and tabbing away left the hand tool on the hotkey stack indefinitely.
1996
-
1997
- The gesture dispatcher now fires the `key-held` up phase for each held key on
1998
- window blur. Consumers that hand-rolled this reset can drop it; ongoing
1999
- invocations see a normal `onEnd`.
2000
- - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
2001
-
2002
- One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
2003
- round caps and joins, hairline weight reserved for structure, and filled
2004
- regions only where an action has a subject. Covers transport, history, view,
2005
- trial lifecycle, collection, state, instrument and status vocabulary. Import a
2006
- named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
2007
-
2008
- `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
2009
- so consumers have one import site for the whole set. `ImageIcon` was reachable
2010
- from core's icons folder but missing from its public barrel; it is exported
2011
- now.
2012
-
2013
- Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
2014
- rather than hand-placed, because arrowheads and joins that miss their terminus
2015
- are invisible at chrome size.
2016
- - 3fb3a46: Compose `before` and `after` layer chains in both directions
2017
-
2018
- `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
2019
- off an `after` anchor only followed further `after` links, and likewise for
2020
- `before`. A custom layer anchored `before: 'scene'` carrying a second custom
2021
- anchored `after` it dropped that second layer to the tail with a spurious
2022
- dangling-reference warning.
2023
-
2024
- Both walks now emit a layer's `before` chain, the layer, then its `after`
2025
- chain, so the two mix freely. Cycle detection and orphan fallback are
2026
- unchanged.
2027
- - d9f110e: Stop every frame loop while nothing can see it
2028
-
2029
- New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
2030
- whether a frame may run: nothing runs while `document.hidden`, and a loop that
2031
- names an element also stops while that element is outside the viewport. A
2032
- request made while suspended is held rather than dropped and re-armed on
2033
- resume, so a loop never polls visibility or needs restarting by hand.
2034
-
2035
- Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
2036
- `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
2037
- counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
2038
- `useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
2039
- the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
2040
- it paints only dirty layers, but a hidden tab still commits React updates and
2041
- its view/size effect marks every layer dirty.
2042
-
2043
- Loops measuring elapsed time rebase their clock through the new `onResume`
2044
- option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
2045
- meter reporting a rate nobody achieved, a tween jumping to its end value on
2046
- return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
2047
- export; nothing in the tree sets it.
2048
-
2049
- `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
2050
- in kit source, and runs in CI.
2051
- - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
2052
-
2053
- A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
2054
- `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
2055
- ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
2056
- and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
2057
- second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
2058
- where the matcher picks a single winner.
2059
-
2060
- Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
2061
- and jsdom reports an empty-string platform — not nullish, so the fallback never
2062
- fired and every mac binding in the kit was exercised only on the non-mac
2063
- branch. It reads `||` now.
2064
-
2065
- Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
2066
- alongside the `viewport.pinchZoom` action that already handled the same
2067
- gesture, applying one pinch's factor twice — the opt-in broke the path that
2068
- worked without it. SceneCanvas drives pinch through the action alone, and the
2069
- flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
2070
- the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
2071
- for real; it previously left the action running. Bare `<Canvas>` keeps the hook
2072
- as its own pinch path.
2073
- - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
2074
-
2075
- Nothing in the kit exported one, so consumers hand-rolled it — their own
2076
- `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
2077
- inverse built by projecting two points. That inverse silently drops
2078
- `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
2079
- beside the paint instead of on it.
2080
-
2081
- ```ts
2082
- useNodeOverlayFrame(scene, containerRef, nodeId, { view })
2083
- // → { box, toScreen, toLocal, width, height } | null
2084
- ```
2085
-
2086
- `box` is the node's composed world box, unrotated — the frame `toScreen` maps
2087
- from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
2088
- lives in the pose→world leg, where it belongs: a node's stored geometry and its
2089
- bounds-frame paint are pre-rotation by definition, so neither of those two
2090
- changes.
2091
-
2092
- `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
2093
- `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
2094
- `stroke` — `slot` is a prop — and commits each drag through `setFill` or
2095
- `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
2096
-
2097
- Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
2098
- `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
2099
- - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
2100
-
2101
- `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
2102
- color and `'none'` still skips the stroke; an object is a core `Stroke` whose
2103
- `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
2104
- renderer, which has accepted them on `PathDrawCommand` all along. The object
2105
- wins outright over `data.strokeWidth` rather than merging with it, the same
2106
- rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
2107
- is baked onto the pose box the way a fill is, so a gradient stroke resolves
2108
- against the box it was authored against.
2109
-
2110
- `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
2111
-
2112
- `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
2113
- strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
2114
- `'outer'` none inside, which one number could not say, so picking grabbed the
2115
- wrong side. `ink` takes an optional context carrying the view scale, so a
2116
- `{ px }` stroke width resolves to world units. A painter that still returns
2117
- `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
2118
-
2119
- `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
2120
- color pick replaces its paint and keeps width, cap, join and dash, and an
2121
- opacity drag sets the paint's `opacity`, which is the only form that works on
2122
- a gradient stroke.
2123
-
2124
- Editing UI for the rich form is not here yet — a schema-driven color control
2125
- still writes a bare string over the object, so nodes carrying one are for
2126
- programmatic authorship until `SelectionPanel` learns the union. See
2127
- `docs/proposals/2026-08-26-node-stroke-union.md`.
2128
- - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
2129
-
2130
- `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
2131
- servers there deliberately, but the renderer refused anything but a solid — so
2132
- importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
2133
- next frame. Both stroke paths now paint the ribbon through the same route a
2134
- fill takes, including under the inner/outer alignment stencil. A non-solid
2135
- even-odd fill no longer renders black.
2136
- - 5f6c28e: An object leaf's fields can be organised into groups
2137
-
2138
- `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
2139
- heads its fields under a label and contributes nothing to the path — the same
2140
- rule group keys follow at the top level of a schema, so a field inside one is
2141
- still addressed as a field of the object.
2142
-
2143
- Without it, a value with many fields renders as one undifferentiated list. A
2144
- `TextStyle` is the case that needs it: its character and paragraph fields are
2145
- one value but read as two lists.
2146
- - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
2147
-
2148
- A compound value — a stroke, a shadow, a pattern spec — could be described as
2149
- sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
2150
- It shouldn't be: each control then writes one field of a value it can only
2151
- half see, and writing a field into something that isn't an object yet corrupts
2152
- it outright.
2153
-
2154
- `ToolPrefObject` describes the value instead. Its `children` are ordinary
2155
- leaves whose paths are relative to the object, and every child edit commits
2156
- the parent object whole. A field that is itself a union declares the kind that
2157
- edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
2158
- value still held in a scalar form before a child edit lands on it, which is
2159
- how a stroke stored as a bare colour string gains a width.
2160
-
2161
- `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
2162
- Color, Width, Cap, Join and Align under one Stroke block, and the separate
2163
- `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
2164
- `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
2165
- replaced by this general one.
2166
-
2167
- `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
2168
- import, export and rendering untouched.
2169
- - 2ea772f: Selection handles are hit-tested at the size they are painted
2170
-
2171
- Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
2172
- constant, and neither `buildAffordanceAt` call site passed the option that
2173
- would have scaled it. A coarse pointer got a bigger picture and exactly the
2174
- same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
2175
- exists to provide never reached the hit-test. The slops debug overlay was a
2176
- third unscaled copy, so it drew hit regions where they were not.
2177
-
2178
- `core/device/targets.ts` now holds one base table and one accessor,
2179
- `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
2180
- through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
2181
- `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
2182
- table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
2183
-
2184
- `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
2185
- `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
2186
- reached nothing.
2187
-
2188
- `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
2189
- affordance is an annulus with a band thickness and no point radius, so the
2190
- option could only ever have been a second name for `rotationHandleDistance`,
2191
- which is live and now defaults from the same table.
2192
-
2193
- Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
2194
- device profile, so a nested view still hit-tests at the fine-pointer size.
2195
- - f77bd95: `getChildren` means one thing on an adapter
2196
-
2197
- `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
2198
- drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
2199
- the z-ordering seam where `null` means the root. Both land on the same adapter
2200
- object, so `arrayAdapter` took the first shape from its config and exposed it
2201
- under the name the ops read with the second meaning. An op asking for root
2202
- order got `[]`, which reads as "the root has no siblings", and the slot it
2203
- captured was silently lost.
2204
-
2205
- The two declarations are now one contract, and `arrayAdapter` answers the root
2206
- from its own item array rather than delegating — a consumer callback written
2207
- for node ids returns `[]` there, which cannot be told apart from a genuine
2208
- empty answer. A consumer's `getChildren` config is still only ever asked about
2209
- a node id.
2210
-
2211
- `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
2212
- than by anchor. That is unchanged, and it is why the ordinal fallback exists.
2213
- - 2ea772f: The canvas and the gradient editor now sample one gradient
2214
-
2215
- `buildGradientRamp` carried its own interpolation beside
2216
- `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
2217
- at either end and extrapolated past the first and last stop, the two picked
2218
- opposite sides of a coincident pair, and they parsed color differently — a stop
2219
- written as a CSS named color rendered on the canvas and threw in the editor.
2220
-
2221
- `sampleGradientStops` keeps its semantics and is now the only implementation.
2222
- `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
2223
- returns the color at `t`. The ramp cache builds its texels through those, so
2224
- there is no interpolation math left in the renderer.
2225
-
2226
- Two behavior changes worth naming. `resolveColor` is the surviving parser, so
2227
- gradient stops accept named and functional colors everywhere — but no longer
2228
- hex without a leading `#`, which only the editor path had tolerated and the
2229
- canvas never accepted. And `sampleGradientStops` returns normalized hex at the
2230
- endpoints instead of echoing the raw stop string, so `'red'` comes back as
2231
- `'#ff0000'`.
2232
-
2233
- **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
2234
- the element already carried the reference, the built-in serializer returned
2235
- nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
2236
- shape disappeared in a browser with no warning at all. Serialization now falls
2237
- through to the same warning the pattern path already emits when nothing can
2238
- produce a paint server. A consumer that registers a `toSvg` for
2239
- `conic-gradient` still serializes and gets no warning.
2240
- - aba8d91: Answer "can this node be hit" in one place
2241
-
2242
- Four tree walks answered it separately — the generic-adapter point pick, the
2243
- one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
2244
- marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
2245
- that had a test and disagreed on the rest, three times, silently. `pickWalk`
2246
- now owns every gate; a query supplies only its own shape test and the clip
2247
- predicate for its region.
2248
-
2249
- Behavior that changes as a result:
2250
-
2251
- - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
2252
- same number the painter does — the view's `alphaFor` times any per-node
2253
- override alpha — so a node faded out of sight stops claiming clicks. The
2254
- floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
2255
- Alpha is per view: dimming a node in one view leaves it pickable in another.
2256
- - **A layer that is not painted no longer claims pointer events.** `drawLayers`
2257
- drops any layer missing from a supplied `layerOrder`, and the chrome hit path
2258
- only consulted `layerVisibility`. Both gates now run through one
2259
- `isLayerPainted`, which is exported.
2260
- - `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
2261
- did not; its default `poseBounds` answers a path pose instead of `NaN`, which
2262
- is what the shadow walk existed to work around.
2263
- - An ancestor clip now rejects an area query that reaches into the clip where
2264
- the node is not, or reaches the node where the clip is not — the two terms
2265
- together, where one alone let false positives through.
2266
-
2267
- `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
2268
- `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
2269
- `adapterPickSource` and `ownClipOf` replace them.
2270
- - 2ea772f: A drag-to-insert reports the bounds it paints
2271
-
2272
- The painter, the commit factory and `getGestureBounds()` each sized an
2273
- in-flight insert differently. The reporter read the drag rect alone, so a
2274
- centered Alt-drag reported a half-extent of `d` against a painted circumradius
2275
- of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
2276
- star, and a pencil scribble that looped back to its start reported nothing at
2277
- all. The painter and the commit agreed on polygon and star but not on line or
2278
- pencil: the commit posed the drag AABB for a line the painter drew endpoint to
2279
- endpoint, and fell back to the drag rect for a trail under four samples.
2280
-
2281
- One function now answers it for all three. The zero-area skip in the painter
2282
- and the reporter tests the resolved extent rather than the raw drag rect, and
2283
- an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
2284
- `bounds` argument handed to a factory is unchanged.
2285
- - 3386d64: Path command opcodes derive from one table
2286
-
2287
- `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
2288
- once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
2289
- literals in the path transform, pose-rotation and pose-descriptor walkers. They
2290
- agreed, and nothing held them to each other: a sixth opcode desynchronizes two
2291
- packages' reading of the same `Uint8Array` with no exception and no type error,
2292
- and every walker misparses the coordinate stream from that command on.
2293
-
2294
- `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
2295
- `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
2296
- core re-exports them by name, so the opcode constants keep their names, values
2297
- and literal types. The three walkers moved onto `forEachSegment` rather than
2298
- onto the accessor alone — they were duplicating the coordinate-cursor advance
2299
- as well as the length, and the cursor is the half that actually misreads.
2300
-
2301
- Eight further files switch on these opcodes with inline literals. Five throw on
2302
- an unknown code; three — the path boolean adapter, the anchor-editing geometry,
2303
- and geom's own boolean adapter — have no `default` arm and would silently stop
2304
- advancing. Left as-is; they need per-command semantics, not one walker.
2305
- - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
2306
-
2307
- `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
2308
- saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
2309
- leaf had neither `encoding` nor `options[].disabled`, so a dash-array
2310
- preference did not merely fail to select — choosing an option wrote the option
2311
- string over the stored dash array. labkit's two renderers were missing the
2312
- `paint` and `object` kinds outright.
2313
-
2314
- ui's schema is now a rename re-export of core's declaration. The public `Pref*`
2315
- names are unchanged, and there is nothing left to keep in sync.
2316
-
2317
- More importantly, all four renderer switches ended in `default:`, so adding a
2318
- built-in kind produced no error at any site and simply rendered nothing —
2319
- verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
2320
- `string` so app-defined prefs can ride the same tree, which means a `never`
2321
- guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
2322
- `Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
2323
- `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
2324
- can discriminate and end in a `never`. App-defined kinds take the placeholder
2325
- path as before.
2326
-
2327
- Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
2328
- arm threads sibling values, routes through `encoding.read` / `encoding.write`,
2329
- and honors `option.disabled`. `SelectionPanel` already did all of this — it was
2330
- only the forked copy that could not express it.
2331
- - 3386d64: Dragging out a text box shows a live preview
2332
-
2333
- The set of insertable kinds and the `KitInsertShape` union sat on adjacent
2334
- lines with no linkage, and seven more sites restated one list or the other. The
2335
- drift was already live: the text tool binds `actionId: 'insert'` and commits
2336
- through the insert dep, but the runtime set never listed `text`, so a
2337
- drag-to-insert text box had no preview.
2338
-
2339
- `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
2340
- it has a built-in tool and whether it takes an insert preview. Both unions,
2341
- `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
2342
- preview gate all derive from it.
2343
-
2344
- Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
2345
- `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
2346
- same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
2347
- positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
2348
- gains `'text'`, which is the fix itself: a consumer switching exhaustively over
2349
- it gains a case, handled by the existing box arm.
2350
- - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
2351
-
2352
- The paint, the pose silhouette and the click-to-edit caret each ran their own
2353
- walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
2354
- `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
2355
- per call and the cache keyed on array identity; and the caret summed
2356
- `ctx.measureText` per character, which sees no kerning, reads system fonts
2357
- rather than the registered face, and ignores per-run styling entirely. The
2358
- caret could therefore answer with a different line, and a different glyph, than
2359
- the one under the pointer — masked in practice only because it asked a WebGL
2360
- canvas for a 2D context and got `null`, degrading silently to no caret at all.
2361
-
2362
- `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
2363
- and all three go through it. It keeps the array-identity `WeakMap` as the
2364
- renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
2365
- structure, which is what lets a caller that cannot hold a stable array hit it —
2366
- about 230× cheaper than laying out again, at roughly 4× the cost of the
2367
- identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
2368
- snapping is to the advance cells the glyphs were actually painted in.
2369
-
2370
- **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
2371
- `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
2372
- an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
2373
- draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
2374
- double-click always seeds the caret instead of falling back to editing from
2375
- offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
2376
- `_resetLayoutCacheForTests`.
2377
- - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
2378
-
2379
- `registerFontOutlines` was a paint upgrade for a family that already had an
2380
- MSDF atlas; a family with only font bytes could not resolve, so it rendered
2381
- nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
2382
- `advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
2383
- outline-only family, and `layoutRuns` reads advances, kerning and the baseline
2384
- through one source the atlas and a parsed face both satisfy. `outlineMinSize`
2385
- does not gate such a family — there is no other tier to prefer.
2386
-
2387
- This does not touch metric neutrality where it applies: a family that has an
2388
- atlas still resolves to the atlas, so registering outlines cannot move text
2389
- that was already rendering.
2390
-
2391
- Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
2392
- and UMD under `main`; Node takes the UMD build, whose named exports it cannot
2393
- detect, so `parse` was undefined and every face failed to load — silently, via
2394
- the fallback to SDF. A browser bundler reading `module` never saw it.
2395
-
2396
- Breaking for a consumer-supplied `OutlineParser`: a face must now report
2397
- metrics as well as geometry.
2398
- - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
2399
-
2400
- A kind bar over a per-kind body, driven by the paint-kind registry rather than
2401
- a fixed list, so a consumer's registered kind appears in the bar and renders
2402
- that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
2403
- the chip that showed a gradient as indeterminate and wrote a solid over it on
2404
- first touch — so the checkerboard now means a mixed selection and nothing else,
2405
- and a gradient stroke is editable rather than merely paintable.
2406
-
2407
- Switching kinds keeps a per-kind memory for the control's lifetime, so
2408
- linear -> solid -> linear comes back with its stops instead of the ramp
2409
- `withGradientKind` cannot carry.
2410
-
2411
- `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
2412
- on `@weasel-js/svg` for its tile previews.
2413
-
2414
- The bar offers **None**: "what kind of paint is this?" takes no-paint as an
2415
- answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
2416
- becomes `null`, and a stroke goes away entirely rather than keeping a width
2417
- that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
2418
- built-in kinds carry glyphs so six segments fit a property row.
2419
-
2420
- `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
2421
- and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
2422
- with its width — a whole paint editor cannot share a row with a slider.
2423
- - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
2424
- frames and serializes.
2425
-
2426
- `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
2427
- re-seeds the five built-ins, matching the kit's other module-global
2428
- registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
2429
- `Editor`), a render slot, both frame-conversion directions, and an SVG
2430
- `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
2431
- consumer's own paint as a `FillStyle` — the union itself stays closed, because
2432
- opening its discriminant would widen every built-in member.
2433
-
2434
- Three defects fall out of the same change, each of which a sixth kind hit
2435
- immediately. The renderer's fill dispatch fell off the end of its switch into
2436
- an unguarded cast to the gradient union, so an unknown kind read `stops` off a
2437
- paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
2438
- an unknown kind untouched, leaving it painting in screen space on a node that
2439
- moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
2440
- still writing the `url(#id)` that referenced it.
2441
-
2442
- Registering a kind now bumps the node memo generation, so a node painted
2443
- before the registration repaints rather than holding the frame it resolved
2444
- when the kind was unknown.
2445
- - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
2446
-
2447
- Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
2448
- `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
2449
- where `undefined` takes the painter's fallback. Two new authoring helpers keep
2450
- hand-written node data short:
2451
-
2452
- ```ts
2453
- data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
2454
- ```
2455
-
2456
- **Breaking, with no compatibility path.** A document written against the old
2457
- shapes renders wrong rather than failing, which is accepted:
2458
-
2459
- - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
2460
- and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
2461
- A node holding `fill: '#f00'` now paints the default grey.
2462
- - `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
2463
- - `data.color` — the legacy alias `kit:path` and the rect fallback read — is
2464
- deleted. The fallback painter reads `data.fill` like everything else.
2465
- - `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
2466
- - `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
2467
- else. A painter returning `{ filled, strokeWidth }` no longer type-checks
2468
- and its reach is read as zero.
2469
- - `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
2470
- the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
2471
- - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
2472
- of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
2473
- stroke into a color. SVG's `fill="none"` imports as `fill: null`.
2474
-
2475
- **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
2476
- the only slot a gradient or a pattern has, so it is the slot all of them use,
2477
- and the renderer multiplies a hex alpha by it — the two would fight if both
2478
- carried the value. `solid()` therefore moves an alpha channel out of the hex:
2479
- `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
2480
-
2481
- The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
2482
- `opacity` rather than splicing hex, so they now work on a gradient fill, which
2483
- they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
2484
- the node's existing paint through the new `paintWithColor`, keeping its opacity
2485
- unless the picked color states an alpha of its own — and `setStroke` keeps the
2486
- stroke's width, cap, join and dash instead of replacing the whole value.
2487
-
2488
- New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
2489
- `paintWithColor`, `DEFAULT_SHAPE_FILL`.
2490
-
2491
- `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
2492
- — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
2493
- writes a bare string over it — and the `data.stroke` object leaf drops its
2494
- `fromScalar`, which had nothing left to lift.
2495
- - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
2496
-
2497
- The text schema put `data.text` in a group named Text, so the section read
2498
- TEXT and the row inside it read Text — one word nested in itself — and the
2499
- style groups below it read as fields of the content string rather than as its
2500
- siblings. Content is its own section now, with the field full-width because
2501
- the section already names it.
2502
-
2503
- A group with an empty `name` renders no heading. That already worked for
2504
- sections and is now documented on `ToolPrefGroup`, since it is how a schema
2505
- says "this group organises, it doesn't name": `Character` and `Paragraph`
2506
- carry the labels, and a `Typography` heading over them named nothing new.
2507
- It stays opt-in rather than a rule that rolls up any all-group parent —
2508
- a `Border` over `Top` / `Right` / `Bottom` needs its name.
2509
-
2510
- Rows under a suppressed heading no longer indent. Depth drawn without a
2511
- visible parent put `Character` a level deeper than `Content` while being its
2512
- peer, which is the panel's own tree discipline broken by its own hand.
2513
- - 611b30e: Layers and deps answer for the view they are drawn for
2514
-
2515
- Nine lookups closed over the *surface's* state at construction, so they answered
2516
- for view zero in every view. `<CanvasView>` draws the surface's layer array
2517
- unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
2518
- a guarantee of answering for the wrong view rather than merely an unused
2519
- argument. A drag in view B ghosted in view A, the marquee painted in the wrong
2520
- view, chrome-caps resolved against the surface's selection, every Cmd+V centered
2521
- on the wrong camera, and Escape in view B cancelled view A.
2522
-
2523
- **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
2524
- and `getIsVisible()`. All three are **required members**: anyone hand-writing a
2525
- `CanvasViewHelpers` (a test double, a wrapper) has to add them.
2526
- `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
2527
- have answered for one view.
2528
-
2529
- **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
2530
- alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
2531
- is the tool half.
2532
-
2533
- **Layer options changed.** `createPathEditingOverlayLayer` and
2534
- `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
2535
- `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
2536
- now. `usePreviewGhostLayer` has lost `tools`. Both it and
2537
- `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
2538
-
2539
- **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
2540
- result accept an optional trailing `PickCamera`. A world point does not carry
2541
- the scale it was produced under and picking has no draw envelope, so the caller
2542
- that produced the point supplies it; omitting it keeps the surface camera.
2543
-
2544
- `useHoverTracking` took a `clientToWorld` thunk beside a world-space
2545
- `getNodeAtPoint` — the first resolved the view and the second did not, so hover
2546
- picked at the surface's scale inside a panel. It takes one
2547
- `nodeAtClientPoint(clientX, clientY)` now.
2548
-
2549
- Anchor-editing target state stays surface-wide; only the preview resolution on
2550
- that path is per-view.
2551
- - 9ad8cb2: Picking answers for what was painted
2552
-
2553
- Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
2554
- from something other than what the renderer drew.
2555
-
2556
- **Pose overrides were painted through and picked around.** `PoseOverride.pose`
2557
- is documented as replacing the document pose *everywhere the render and
2558
- hit-test paths read one*, and `sceneAdapter.getPose` honored it. But
2559
- `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
2560
- did the bounds resolver feeding selection chrome and the affordance
2561
- `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
2562
- overrides painted them at one place and picked them at another. `effectivePose`
2563
- is now the single rule and every one of those reads through it.
2564
-
2565
- **A clipped-away child was still clickable.** A container clips its subtree and
2566
- the renderer honors it, so a child outside the clip is not painted.
2567
- `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
2568
- `<SceneCanvas>` installs instead had no clip term at all. The new
2569
- `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
2570
- render-order scan can apply the same test.
2571
-
2572
- **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
2573
- 45° puts a corner 32 units above that box; a rubber-band over that corner was
2574
- rejected before the rotation-correct silhouette test ran, while a click on the
2575
- same pixel selected the shape.
2576
- - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
2577
- tiles, coins, enemies and flagpole still declared `data.color`, the alias
2578
- removed when node paint became an object, so every one of them rendered in
2579
- the default gray — the demo whose whole point is being the visual twin of the
2580
- immediate-mode load test.
2581
- - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
2582
-
2583
- Three paths read a pose's stored, unrotated box where the rotated extent was
2584
- wanted.
2585
-
2586
- `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
2587
- came back turned the same way — invisible on a rectangle, whose AABB is
2588
- symmetric under a sign flip, and plainly wrong on an asymmetric one, which
2589
- translated instead of mirroring. It now negates the pose's rotation.
2590
-
2591
- `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
2592
- stored edges, while the dragged selection matched against them by its ink.
2593
- `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
2594
- `deriveAlignmentGuides` reads its targets through the same projection — a new
2595
- `projection` option defaulting to the rect one, so existing callers get the fix
2596
- without a change.
2597
-
2598
- `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
2599
- rotated member ended up with a gap short by the difference; `centers` shared the
2600
- line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
2601
- `distributeHorizontalAction` / `distributeVerticalAction` also take
2602
- `params.mode`, so `gaps` is reachable from a binding rather than only from the
2603
- hook.
2604
-
2605
- Flip and distribute return different poses than before for rotated shapes.
2606
- That is the fix, but it is a behavior change for anything depending on the
2607
- old output.
2608
- - 3386d64: `@weasel-js/core/routing` exports the route-string projection
2609
-
2610
- Anything rendering a `GestureSpec` as a route string had to re-implement the
2611
- projection, and the copy in WeaselDraw's registry inspector had drifted three
2612
- ways: it answered `drop` and `paste` with no gesture name, so every binding of
2613
- either vanished from the route list; its argument lookup missed a spec field;
2614
- and it gated targets on a hand-listed set of kinds, dropping them for
2615
- `pointerDown`, `longPress` and `wheel`.
2616
-
2617
- New from the routing subpath: `routesForSpec(spec)` — every route string one
2618
- spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
2619
- map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
2620
- subpath index did not, so consumers reading `RegistryEntry.target` had no way
2621
- to compare against the sentinel its own docs name.
2622
- - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
2623
-
2624
- `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
2625
- in their control flow — `***`/`**`/`*` and a two-character escape set — so
2626
- reading or writing any other spelling meant forking both. They now take a
2627
- `RunGrammar`: a table of markers pairing a repeated delimiter with the run
2628
- flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
2629
- today's behavior. Escaping follows the grammar's own delimiters.
2630
-
2631
- Nothing changes for a caller that passes no grammar. `underline` and
2632
- `strikethrough` still have no markdown spelling and are still dropped by
2633
- `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
2634
- rather than editing the parser.
2635
- - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
2636
-
2637
- `createSelectionOverlayLayer` documents itself as equivalent to stacking the
2638
- other two, and it was not. It reads `ChromeState` off the draw envelope,
2639
- resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
2640
- visibility and suppressed ids, and takes selection and poses from the envelope
2641
- when they are omitted. The two primitives did none of that: they ignored the
2642
- draw envelope entirely, required a construction-time `getPose` cascade, and
2643
- knew nothing about the multi-selection union — so a consumer who stacked them,
2644
- on the wrapper's own promise, got chrome in the wrong place with no way to
2645
- tell.
2646
-
2647
- All three now run one body and differ only in which passes they enable, so the
2648
- promise holds by construction. `SelectionOutlineLayerOpts` and
2649
- `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
2650
- that don't apply, which makes `getSelection` and `getPose` optional on both and
2651
- adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
2652
- `SelectionHandleStyle`.
2653
- - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
2654
-
2655
- The effect depended on the whole selection-context value, and the provider
2656
- mints a new value object on every publish. So one publisher publishing refired
2657
- the effect for every other publisher in scope, each of which republished its
2658
- own ids — a newer selection got stomped back to an older one, and two
2659
- publishers holding different ids under one provider never settled at all.
2660
-
2661
- `publishSelection` is already a stable `useCallback`, so the effect now depends
2662
- on it directly. No provider change and no API change.
2663
- - 84db1f6: Close four gaps that produced wrong answers with no error
2664
-
2665
- Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
2666
- `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
2667
- `default:` arm, so a command code they did not know fell out of the switch
2668
- without advancing the coordinate cursor and every segment after it read the
2669
- wrong floats. They now throw, matching the six sibling walkers. This is a
2670
- behavior change for anyone feeding these a path built with an opcode outside
2671
- `PATH_COMMANDS`: what used to come back subtly wrong now raises.
2672
-
2673
- A `<CanvasView>` built its affordance hit-test without a device profile, so a
2674
- nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
2675
- zones against the 14px chrome the surface paints. It reads the profile
2676
- `<SceneCanvas>` publishes.
2677
-
2678
- `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
2679
- neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
2680
- records had nowhere to land: undoing a delete through the move pipeline
2681
- appended the node to the end of its parent instead of putting it back where it
2682
- was. All three are there now.
2683
-
2684
- The dev inspector's gesture panel formatted bindings with a private formatter
2685
- that reported only modifiers set to `true`. The `ingest` action marks every
2686
- modifier `'optional'`, so its drop and paste bindings rendered blank and the
2687
- action was invisible on both gestures. Both of the panel's plain-text
2688
- formatters now go through the kit's `routesForSpec`.
2689
- - 3386d64: Undoing a multi-node delete or group restores document order
2690
-
2691
- Restoring by stored index cannot survive replay: history runs a batch's
2692
- inverses in reverse, while indices captured before the mutation are only
2693
- correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
2694
- undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
2695
-
2696
- Ops now record a `Slot` — an ordinal plus the id of the following sibling at
2697
- capture. The anchor is the source of truth whenever it resolves, and it
2698
- resolves whatever else the batch has already restored. The ordinal remains as
2699
- the fallback for an adapter that can place by index but cannot enumerate
2700
- children. `before: null` means "last" and needs no sibling list; an absent
2701
- `before` means "unobserved", and the two survive `History.serialize` because
2702
- `undefined` drops out of JSON and `null` does not.
2703
-
2704
- The ops observe their own slot during `apply()` rather than taking one from the
2705
- caller, so every existing emitter gets this without a call-site change.
2706
- `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
2707
- docstring said it was sufficient on its own, which it never was.
2708
-
2709
- Adapters without an ordering seam still append, as they did before:
2710
- `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
2711
- neither that nor an `index` parameter on `insertNode`.
2712
- - 7a746df: A stroke's dash is edited as a style, not as an array
2713
-
2714
- `Stroke.dash` already rendered, imported and exported; it had no control,
2715
- because a `number[]` has no leaf kind. It doesn't need one — the thing a person
2716
- chooses is a style, and the array is how it is stored. The stroke block gains a
2717
- Solid / Dashed / Dotted / Custom bar under cap, join and align.
2718
-
2719
- `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
2720
- the option string, the counterpart of the `unit` a number leaf already has for
2721
- a value stored in a canonical unit. Both directions are handed the object the
2722
- leaf is a field of, because a dash pattern is meaningless without the width it
2723
- scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
2724
- hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
2725
- `strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
2726
- 2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
2727
- a new `disabled` option — one a control reports but refuses to author, since
2728
- there is no array behind it. `solid` is stored as no dash at all, and an object
2729
- leaf's field written as `undefined` is now removed rather than left holding it.
2730
- - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
2731
-
2732
- Nine option glyphs and four category glyphs join the icon set. The option
2733
- glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
2734
- shape rather than as a diagram of one. `align` is a circle zoomed until the
2735
- ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
2736
- the box's complement of it, and `center` is the annulus straddling the path,
2737
- so the three are one band at three offsets. The categories are the bare path
2738
- each row treats, drawn in the outlined register.
2739
-
2740
- A schema carries a glyph *id*, not a component: `ToolPrefEnum`'s options gain
2741
- `icon`, and every leaf gains one for rows whose own label is spent on a
2742
- `pair`. Core ships no icon set and cannot depend on one, so the field is a
2743
- plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
2744
- `short` where it names no glyph.
2745
-
2746
- `SelectionPanel` now honours `block` inside an object leaf, not only at the
2747
- section level. A row whose fields are all `block` drops the 64px label column
2748
- and spans the block. The default stroke schema uses both: paint and width
2749
- share one label-less row, and cap/join/align share the next.
2750
-
2751
- `align`'s options run inner, center, outer — the order the ink moves outward.
2752
- - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
2753
-
2754
- `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
2755
- through a new registry (`registerMarker`), shipping eight built-in shapes.
2756
- Unlike SVG, the stroke stops short of a filled head rather than running under
2757
- it to the tip; the distance is declared per marker, so an open V still reaches
2758
- the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
2759
- `<marker>` defs.
2760
- - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
2761
-
2762
- It accepted `{ color }` only, and merged through `paintWithColor`, which
2763
- supersedes a non-solid paint with a solid one — a gradient stroke was
2764
- unreachable even though `setStrokeOpacity` could already reach its alpha.
2765
- `paint` now wins over `color`, a color arriving later in the gesture supersedes
2766
- an earlier paint, and the stroke's width, cap, join, dash and align survive
2767
- either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
2768
- that has no color to pass.
2769
-
2770
- Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
2771
- from `DEFAULT_STROKE_COLOR`, painting the selection black where
2772
- `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
2773
- `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
2774
- where the body builds a left-edge-to-right-edge one.
2775
-
2776
- `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
2777
- core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
2778
- it — and nothing imported it.
2779
- - 81213fc: Edit a node's stroke as the union it is
2780
-
2781
- `data.stroke` holds `string | Stroke`, and the schema described it with a
2782
- `color` leaf — which reads `undefined` off the object form, shows its own
2783
- default, and writes a bare hex back over the stroke's width, cap, join and
2784
- dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
2785
- for `FillStyle`.
2786
-
2787
- A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
2788
- Its control shows whichever color the value has — the string itself, or a
2789
- solid paint's color — gives a gradient stroke the indeterminate chip rather
2790
- than claiming a color it doesn't have, and preserves the form on write.
2791
-
2792
- `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
2793
- `paint` leaf used to render as the literal text `(paint: no renderer)`.
2794
- `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
2795
- exported from `@weasel-js/ui` for consumers writing their own property
2796
- renderers against either union.
2797
-
2798
- Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
2799
- remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
2800
- for why that waits on the SVG mapping.
2801
- - 2f225d7: A thick stroke is clickable across its whole width
2802
-
2803
- `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
2804
- stroke width for an `outer` align — but the AABB pre-filter that runs before it
2805
- grew only by the pointer slop. So half a thick outer stroke's ink was
2806
- unclickable: the point was rejected before the refinement that would have
2807
- claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
2808
- was at least as generous as the refinement, which it cannot be on its own,
2809
- since it never sees the stroke. That budget is the caller's, and the comment
2810
- says so now.
2811
-
2812
- `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
2813
- declared in `px` resolved as world units and the reach was wrong at every zoom
2814
- but 1 — while the caller computed `meanScale(view.scale)` one line above.
2815
- - 68069dc: Right-to-left text lays out in visual order
2816
-
2817
- `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
2818
- analyses the paragraph, reorders each line after the wrap, and mirrors brackets
2819
- in right-to-left runs. Given none, nothing changes: text lays out logically,
2820
- exactly as before.
2821
-
2822
- `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
2823
- `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
2824
- consumer who renders no right-to-left text never installs the Unicode tables,
2825
- and a different implementation can be substituted. `@weasel-js/bidi` is a
2826
- devDependency here only, for a test that drives real Hebrew through the real
2827
- engine; types lining up is not evidence the semantics do.
2828
-
2829
- `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
2830
- across `cells`**. Cells stay in logical order — slot `i` is still character `i`
2831
- — while their x values follow the reordering. Sort on `x` for visual order, and
2832
- read a cell's extent as `[x, x + advance)` rather than reaching for the next
2833
- cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
2834
- against each cell's own extent, taking a right-to-left cell's visually-leading
2835
- half as the character's logical end.
2836
-
2837
- Kerning is a gap between two adjacent characters, and the wrap measures it
2838
- logically. Reordering can put a different pair side by side, so the gap taken
2839
- is the one belonging to whichever of the two is logically second, and none at
2840
- all across a direction boundary — where the pair never touched in the source.
2841
-
2842
- Laying out right-to-left text with no engine now warns once, naming the import.
2843
- The alternative is glyphs silently appearing reversed, which is the one real
2844
- hazard of making this opt-in.
2845
- - 5d0ff9c: Every code point on a line gets a cell
2846
-
2847
- `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
2848
- `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
2849
- `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
2850
- a consumer indexing per character no longer has to reconcile a sparse array
2851
- against the source string.
2852
-
2853
- The old arrays were documented as non-contiguous, and two causes were real:
2854
-
2855
- - A code point no tier could serve was dropped outright, taking its caret stop
2856
- with it. It now occupies a zero-advance cell. This is reachable whenever the
2857
- dynamic canvas fallback is off — which is the normal configuration for a
2858
- consumer registering its own outlines, where the outline tier has no rung
2859
- below it.
2860
- - A space opening a line — at the start of the text, or after a newline — was
2861
- discarded. It now keeps its cell and still consumes no width, so a line is
2862
- addressable per character without gaining an indent. A space that opens a
2863
- *wrapped* line was never affected: the wrap leaves it as a trailing cell on
2864
- the line before.
2865
-
2866
- Neither changes any geometry: both cells carry zero advance, zero tracking and
2867
- no kerning, so bounds, line widths and glyph positions are unchanged.
2868
-
2869
- A newline still has no cell, since it separates cells rather than being one.
2870
- `srcEnd` is what a blank line carries in its place.
2871
-
2872
- `drawsInk` is a property of the code point and the face, not of the call that
2873
- produced it: it does not flip when a dynamic bake lands or the outline
2874
- threshold is crossed, so the same text reports the same slots every time. A
2875
- zero-advance combining mark is `true` — it inks without advancing.
2876
- - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
2877
- `data.fill` and `data.stroke` — the same two leaves every other node kind
2878
- paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
2879
- `TextStyle.stroke` are gone, with no compatibility read: a document that put
2880
- its color in `style.fill` now renders in the default black rather than
2881
- erroring, so check documents that predate this.
2882
-
2883
- This fixes a real asymmetry rather than only moving fields. `data.stroke`
2884
- already reached text through a fold in the painter, but `data.fill` did not:
2885
- picking a fill color with a text node selected wrote a field nothing read, so
2886
- the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
2887
- the Appearance leaf now all mean the same thing on text as on a rect. The
2888
- duplicate `data.style.fill` control is gone from the text schema with them.
2889
-
2890
- `resolveTextStyle(style, paint)` takes the node's paint as a second argument
2891
- and is what derives the caret and selection colors, so the edit overlay
2892
- matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
2893
- the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
2894
- `data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
2895
- `createTextLayer` is painted rather than black. `SvgTextNode` gained the same
2896
- two, and SVG import and export carry text paint there instead of inside the
2897
- style. `StyledRun.fill` and `.stroke` are unchanged and still override the
2898
- node's per range — which is also where a caller with no node at all, a HUD
2899
- widget or a debug overlay, now states its color.
2900
-
2901
- `textCommandFromRuns` is exported from the package root.
2902
- - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
2903
- array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
2904
- the last paths still doing what `drawImage` stopped doing; text now costs
2905
- **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
2906
- ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
2907
- image draw. No other command kind moved.
2908
-
2909
- A text group is as many quads as it has glyphs, so unlike the image ring a
2910
- slot's buffer grows to the largest run it has seen rather than being fixed at
2911
- four vertices. The quad index pattern is a pure function of the quad count —
2912
- the pattern for N quads is a prefix of the pattern for any larger N — so one
2913
- index buffer serves every slot, grown the same way and written only when it
2914
- grows.
2915
- - c2ffa49: Alignment can resolve against reading direction
2916
-
2917
- `align` gains `start` and `end` alongside `left` / `center` / `right`, and
2918
- `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
2919
- the relative pair resolves against the direction, the absolute pair ignores it.
2920
- `resolveAlign(align, direction)` collapses one to the other and is exported for
2921
- consumers that need an edge rather than an intent.
2922
-
2923
- Direction is an input, not something this package discovers. `@weasel-js/text`
2924
- has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
2925
- what it found; nothing here sniffs an environment.
2926
-
2927
- Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
2928
- layout moves. Making `start` the default alignment is a separate call.
2929
-
2930
- `@weasel-js/svg` carries the direction through: `direction` joins the
2931
- inheritable presentation properties, and `text-anchor` is now written and read
2932
- against it. Two things were wrong before and are worth naming, because both
2933
- rendered plausible output:
2934
-
2935
- - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
2936
- a mapping that assumed three values and read the fourth as its `else`.
2937
- - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
2938
- right edge, while this model's default `align` is `left`. They agree under
2939
- `ltr` and only there, so an RTL document with no explicit anchor imported as
2940
- left-aligned.
2941
-
2942
- This is alignment and round-tripping only. Layout still walks code points in
2943
- logical order with the pen always increasing: there is no bidi reordering and
2944
- no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
2945
- renders in logical order, and Arabic still renders unjoined.
2946
- - 4c097ef: Sit every run on a line on one baseline
2947
-
2948
- Mixed-size text hung each run off the *line top* at its own ascent instead of
2949
- off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
2950
- with the big run's cap rather than standing on the line with it. Two faces with
2951
- different ascents at the same size diverged the same way. Baseline alignment is
2952
- what inline text does everywhere else, and the module header already claimed
2953
- this behavior — the walk just never implemented it.
2954
-
2955
- A line now sinks one baseline far enough to clear its tallest run's ascent and
2956
- places every glyph against it. Glyph quads derive their top from that baseline
2957
- rather than from the pen's line top, which is the whole of the change:
2958
- `qy0 = baselineY + (yoffset - metrics.base) * scale`.
2959
-
2960
- Uniform-size text — nearly all text — is unchanged, since the maximum over one
2961
- value is that value. Only lines that actually mix sizes or faces move, and they
2962
- move to where they always should have been.
2963
-
2964
- The test named "mixed-size runs share a baseline on the same line" asserted only
2965
- a quad count and passed throughout; it now asserts the baselines.
2966
- - 2b86e00: A text node's style is one value, not ten sibling paths
2967
-
2968
- `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
2969
- `TextStyle` from ten independent leaves, each control writing a field of a
2970
- value it could only half see. `data.style` is an object leaf now, with
2971
- Character and Paragraph as groups inside it — groups head their fields and
2972
- contribute nothing to the path, so a field is still a field of the style and
2973
- one commit writes the whole thing.
2974
-
2975
- An object leaf whose fields are entirely grouped no longer prints its own
2976
- heading, which would stack straight onto the first group's, and a group's
2977
- fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
2978
- into an object leaf when listing what a kind exposes — the fields are the
2979
- editable surface; the leaf is the container.
2980
-
2981
- `SelectionPanel` has a story now, which is how the two layout defects above
2982
- were found.
2983
- - d933a89: Superscript, subscript and overline for styled runs
2984
-
2985
- `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
2986
- a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
2987
- over two new primitives rather than a mechanism of its own:
2988
-
2989
- - `baselineShift` — raise (positive) or lower (negative) a run off the line's
2990
- shared baseline, in ems of the inherited font size.
2991
- - `fontScale` — a multiplier on the inherited font size, the relative
2992
- counterpart to `fontSize`. An absolute `fontSize` still wins over it.
2993
-
2994
- Naming either directly overrides that half of `script` and leaves the other
2995
- alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
2996
- ±33.3% position — Adobe's defaults, so a character panel can show percentages
2997
- its users already recognize) and are derived, not read from the font: `OS/2`
2998
- carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
2999
- them, and metrics that applied on one glyph tier and not the other would
3000
- reflow text as it crossed the size threshold.
3001
-
3002
- `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
3003
- `fontSize`, so layout never learns superscripts exist — it places a run against
3004
- a baseline and an offset. The shift moves a run's glyphs, its outline geometry
3005
- and its own decoration rules together, and deliberately does not feed back into
3006
- the line's baseline or height: a superscript rides the line rather than
3007
- reflowing it.
3008
-
3009
- `overline` joins `underline` and `strikethrough` on both `TextStyle` and
3010
- `StyledRun`, additive over the node style like the other two, and is now
3011
- available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
3012
- is unchanged — it stays silent on the decorations, as it always has been.
3013
- - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
3014
- its own final `onTick`. The last tick emitted the value and completed in one
3015
- pass, so a write made from that tick — cancelling the tween — still got the
3016
- completion callback, against the documented "not called on cancel" contract.
3017
- - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
3018
-
3019
- Two ops inverted to something narrower than what they applied, so undo
3020
- silently lost data.
3021
-
3022
- `createDeleteOp.invert()` re-inserted a single node while `apply()` called
3023
- `removeNode`, which cascades the whole subtree. Delete a container with two
3024
- children, undo, and the container came back with `children: []` while both
3025
- children were gone. The op now snapshots its descendants preorder through the
3026
- adapter's optional `getNode` / `getChildren` — the snapshot is written back
3027
- into `args`, so an op rebuilt from a serialized entry still inverts — and
3028
- re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
3029
- does not cascade, so the inverse skips any descendant the adapter still reports
3030
- as live rather than duplicating it.
3031
-
3032
- `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
3033
- instead of restoring the sibling slot and paint order changed. `ReparentArgs`
3034
- now carries `fromIndex` / `toIndex` and places through the existing
3035
- `getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
3036
- `setParent`'s signature is unchanged. Adapters without that seam no-op as
3037
- before. `groupAction` captures each member's index before mutating; `move` and
3038
- `snapToContainer` pass none and are byte-identical.
3039
-
3040
- `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
3041
- cascade, which is why nothing caught the first bug. It now runs against a
3042
- tree-backed fake.
3043
- - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
3044
-
3045
- Every union a user looks at or clicks folded each member's *unrotated* box.
3046
- Select two shapes, rotate one, and the multi-selection frame and its handles
3047
- sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
3048
- out as the target bounds for paint *and* hit-test, so the handles were both
3049
- drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
3050
- correct larger box.
3051
-
3052
- `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
3053
- and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
3054
- beside the rotation-free `unionBounds`, which stays correct for commit-time
3055
- actions that write poses back in the unrotated frame; the module says which to
3056
- reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
3057
- another name. Both new functions are exported from the package root.
3058
-
3059
- Moved onto it: `ChromeState.unionBounds`, the selection overlay's
3060
- container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
3061
- the wrong place whenever a member was rotated), and WeaselDraw's export
3062
- viewBox, which clipped rotated shapes out of the copied SVG.
3063
- - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
3064
-
3065
- `useAction` returned early on a null registry, so an action registered above
3066
- the provider — or with no provider mounted — silently never fired its
3067
- bindings. It now warns in dev, naming the action id. Runtime behavior in
3068
- production builds is unchanged.
3069
- - Updated dependencies [5c8e9e6]
3070
- - Updated dependencies [2621cbf]
3071
- - Updated dependencies [0f936da]
3072
- - Updated dependencies [4180095]
3073
- - Updated dependencies [9977908]
3074
- - Updated dependencies [52c7b2a]
3075
- - Updated dependencies [3386d64]
3076
- - Updated dependencies [c6c499d]
3077
- - Updated dependencies [20097e6]
3078
- - Updated dependencies [84db1f6]
3079
- - Updated dependencies [94f2446]
3080
- - Updated dependencies [68069dc]
3081
- - Updated dependencies [5d0ff9c]
3082
- - Updated dependencies [0bb27a5]
3083
- - Updated dependencies [c2ffa49]
3084
- - Updated dependencies [4c097ef]
3085
- - Updated dependencies [d933a89]
3086
- - @weasel-js/text@1.3.0
3087
- - @weasel-js/geom@1.3.0
3088
- - @weasel-js/font@1.3.0
3089
- - @weasel-js/gestures@1.3.0
3090
- - @weasel-js/history@1.3.0
3091
- - @weasel-js/modes@1.3.0
3092
- - @weasel-js/paint@1.3.0
3093
-
3094
- ## 2.0.0-pre.0
3095
-
3096
- ### Minor Changes
3097
-
3098
- - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
3099
- into `@weasel-js/paint` — two new Tier A leaves.
3100
-
3101
- `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
3102
- measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
3103
- `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
3104
- lay out text without taking the scene graph or a React peer dependency.
3105
- `layoutRuns` is now public — it was previously reachable only from inside core.
3106
-
3107
- `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
3108
- `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
3109
- layout could not move while its fill type lived in the renderer's graph.
3110
-
3111
- `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
3112
- `Rect` moves to `@weasel-js/geom`, beside `Box`.
3113
-
3114
- Breaking for anyone importing these through core's internal paths rather than
3115
- its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
3116
-
3117
- Advances and kerning still come from a baked MSDF atlas — laying out from font
3118
- bytes alone needs the metrics seam in
3119
- `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
3120
-
3121
- <!-- 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" -->
3122
-
3123
- ### Patch Changes
3124
-
3125
- - 3386d64: Align, distribute and flip use visual bounds
3126
-
3127
- These folded each member's unrotated pose box, so "Align Left" on a selection
3128
- containing a rotated shape lined up the boxes and left the rotated shape's ink
3129
- sticking out past the others. They now work on the visual bounding box, as
3130
- Figma and Illustrator do.
3131
-
3132
- Both ends moved together — expanding only the union would have made alignment
3133
- worse, since the delta runs from an edge of the union to the same edge of each
3134
- member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
3135
- reads a pose's bounds, recovers its rotation and expands via
3136
- `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
3137
- applied as a translation of the stored pose through
3138
- `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
3139
-
3140
- Flip needed only its union pivot changed: mirroring maps a centre and preserves
3141
- size, and an expanded box is concentric with the box it came from.
3142
-
3143
- `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
3144
- by its ink.
3145
-
3146
- - ffafb7d: Never let an animation's virtual clock run backwards.
3147
-
3148
- `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
3149
- time, then advances its virtual clock by the difference against the timestamp
3150
- the frame loop supplies. Those two share a time origin in a browser, where the
3151
- rAF timestamp and `performance.now()` are both page-relative — but that is a
3152
- browser guarantee, not a universal one, and jsdom starts them roughly 600ms
3153
- apart. The first frame's delta then came out hugely negative and `virtualNow`
3154
- spent dozens of frames climbing back toward zero before a tween advanced at
3155
- all: a 40ms glide took 95 frames and over a second of wall time, growing worse
3156
- the longer the process had been alive.
3157
-
3158
- A frame's elapsed time is never negative, so the sample is now clamped at
3159
- zero. Under a shared origin this is a no-op.
3160
-
3161
- - ba8b139: Camera animation: `viewport.animatedZoom` now does something
3162
-
3163
- `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
3164
- nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
3165
- through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
3166
- their input already delivers a sample per frame.
3167
-
3168
- Camera animation is a general surface, not a zoom flag. Three ways in, one
3169
- runner behind them:
3170
-
3171
- - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
3172
- `isAnimating`, `target`.
3173
- - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
3174
- so any action can glide the camera.
3175
- - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
3176
- `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
3177
- three are **required** members: anyone hand-implementing `SceneCanvasApi`
3178
- (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
3179
- grew `getPaintedVersion`.
3180
-
3181
- Scale interpolates geometrically and translation is derived from the screen
3182
- point the two views agree on, so a zoom stays anchored instead of drifting and
3183
- each frame changes the view by the same ratio. One animation runs at a time; any
3184
- other view write cancels it, and a cancel leaves the camera where it is rather
3185
- than jumping to the target. On an uncontrolled canvas the whole animation costs
3186
- no React render.
3187
-
3188
- **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
3189
- changes signature — it takes a `{ get, set }` view channel plus an optional
3190
- `Animator`, and `animateTo(from, to, { duration, easing })` becomes
3191
- `animate(to, { ms, easing })`. The `from` argument is gone because the runner
3192
- reads the live view, which is what lets an interrupted camera resume from where
3193
- it actually is instead of snapping back to a captured start. `cancel()` is now
3194
- `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
3195
- `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
3196
- the same reason `from` does.
3197
-
3198
- **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
3199
- `() => View | void`. Returning the target view lets Cmd+0 animate there;
3200
- returning nothing keeps the existing behavior. `animatedZoom`'s config fields
3201
- are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
3202
- animator's vocabulary.
3203
-
3204
- - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
3205
-
3206
- The canvas is focusable by default (`tabIndex` 0) but exposed no way to
3207
- observe focus, so consumers driving focus-dependent chrome had to attach a
3208
- listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
3209
- so reach `SceneCanvasProps` and the canvas element unchanged.
3210
-
3211
- - 67bcb05: Drop four values the canvas layer memo no longer reads
3212
-
3213
- `hit-test affordances against the painted chrome state` moved the selection
3214
- overlay to reading bounds off the chrome state at paint time, which left
3215
- `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
3216
- referenced only by the `layers` memo's dependency array — nothing in the body
3217
- used them. Removing them from the array made all four dead locals, so they go
3218
- too.
3219
-
3220
2009
  The memo now rebuilds the layer array on layer/tool/geometry changes rather
3221
2010
  than additionally on every selection and preview-pose change. Selection chrome
3222
2011
  is unaffected: it repaints from chrome state, not from the identity of this
3223
2012
  array.
3224
-
3225
2013
  - 47cbb08: A closed subpath's dash no longer seams at its start vertex
3226
-
2014
+
3227
2015
  `splitForDash` flushed the run still open when a closed subpath's walk returned
3228
2016
  to the vertex it started from as its own open sub-polyline, so it and the run
3229
2017
  that began there rendered as two butt-capped ribbons meeting at a point — a
@@ -3232,17 +2020,16 @@
3232
2020
  is drawn across the seam like any other corner. A pattern whose first "on"
3233
2021
  length covers the whole perimeter emits a closed ribbon, identical to the
3234
2022
  undashed stroke.
3235
-
3236
2023
  - f43e9c2: A derived edge follows the drag that moves its endpoint
3237
-
2024
+
3238
2025
  `move`, `resize` and `rotate` kept their in-flight poses in action-local
3239
2026
  scratch and published them only as `previewIds` / `previewPose`. That surface
3240
2027
  is enough to paint a ghost and size selection chrome, but nothing that asks
3241
- 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
3242
2029
  a derived node's geometry, asks the scene. So dragging a box left its edge
3243
2030
  anchored to the pre-drag position until the drop, when the commit invalidated
3244
2031
  the dependents and the edge jumped.
3245
-
2032
+
3246
2033
  The three actions now also publish each frame into the scene's ephemeral pose
3247
2034
  overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
3248
2035
  `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
@@ -3250,101 +2037,98 @@
3250
2037
  avoided per-frame scene writes in the first place was history, and this writes
3251
2038
  no history. Entries are set once and mutated in place, published with a single
3252
2039
  `commit()` per frame.
3253
-
2040
+
3254
2041
  Picking follows for free: the pick source resolves a derived path through its
3255
2042
  own override-aware `poseOf`, so an edge is grabbable where it is drawn
3256
2043
  mid-gesture rather than where it used to be.
3257
-
2044
+
3258
2045
  `clone` is deliberately untouched — its previews are the new ghosts at the
3259
2046
  drag target, and the originals never move, so nothing derives from a changed
3260
2047
  pose.
3261
-
2048
+
3262
2049
  Also closes the matching gap in the preview-ghost layer, which built a
3263
2050
  container's clip with no derived path and so ghosted a derived container
3264
2051
  without one.
3265
-
2052
+
3266
2053
  Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
3267
2054
  on every gesture frame. It was already required by the `Scene` contract, but a
3268
2055
  partial fake that omitted it will now throw rather than silently skip.
3269
-
3270
2056
  - bb27e83: A derived node is clickable where it paints
3271
-
2057
+
3272
2058
  A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
3273
2059
  `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
3274
- 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
3275
2061
  `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
3276
2062
  as "no opinion" and answered `true` everywhere, and picking fell back to the
3277
2063
  node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
3278
2064
  was unpickable, and a derived container contributed no clip.
3279
-
2065
+
3280
2066
  `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
3281
2067
  same convention `NodePaintCtx` already uses, and `kit:derived` reports the
3282
2068
  derived path as its silhouette and its declared stroke as its `ink`.
3283
-
3284
- 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
3285
2071
  the painter: `PickSource.derivedPathOf`, a matching optional argument to
3286
2072
  `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
3287
- carried the derived path a node _paints_, and now also the clip a derived
3288
- 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
3289
2075
  bare-adapter paths supply none of them and behave exactly as before.
3290
-
2076
+
3291
2077
  The pre-filter had to move with it. `useSceneSelectTool` grew its region test
3292
2078
  from the node's pose, which for a derived node is the wrong box entirely, so
3293
2079
  the edge was rejected before the shape test could claim it. It now tests the
3294
2080
  derived path when there is one — `poseContains` already reads a path-like pose
3295
2081
  as a path, so this reuses it rather than adding a second reach calculation.
3296
-
2082
+
3297
2083
  `findShapeSilhouette` skips its memo when handed a derived path. That slot is
3298
2084
  keyed on `(node, pose, data)` and cannot see the path, so it would serve one
3299
2085
  caller's silhouette to a caller that passed a different one — the same reason
3300
2086
  `kit:derived` already skips `PAINT_SLOT`.
3301
-
3302
2087
  - 6a33c3f: A node's path can be derived from other nodes' poses
3303
-
2088
+
3304
2089
  A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
3305
2090
  through `SceneRegistry`, and the scene walks resolve its path before painting
3306
2091
  rather than it being authored. An edge drawn between two boxes is then an
3307
2092
  ordinary scene node — selectable, styleable, exportable — whose geometry never
3308
2093
  enters undo history. The seam and its traps are in `docs/extending.md`.
3309
-
2094
+
3310
2095
  New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
3311
2096
  `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
3312
2097
  `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
3313
2098
  `SerializedNode.derivePathKey`, both additions to the serialization format;
3314
2099
  `NodePaintCtx.derivedPath`.
3315
-
2100
+
3316
2101
  Deleting a node now deletes everything that derives from it, transitively,
3317
2102
  including those nodes' own subtrees, in one undo entry — so `scene.remove` can
3318
2103
  remove nodes anywhere in the tree that the caller never named, and `removeLayer`
3319
2104
  reaches nodes on other layers. Undo after the built-in **Delete** key does not
3320
2105
  yet restore the cascaded nodes; see "Derived geometry follow-ups" in
3321
2106
  `docs/TODO.md`.
3322
-
2107
+
3323
2108
  **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
3324
2109
  context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
3325
2110
  now a type error rather than a silent slide into the `view` slot. The same
3326
2111
  fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
3327
2112
  callback types, which is not a break: an existing three-parameter implementation
3328
2113
  still satisfies them, and an existing three-argument call still compiles.
3329
-
2114
+
3330
2115
  **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
3331
2116
  typed as a `Scene` — a test double, most likely — no longer typechecks until it
3332
2117
  implements it.
3333
-
2118
+
3334
2119
  **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
3335
2120
  `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
3336
2121
  is not a descendant of the removed node and the tree has to be told about every
3337
2122
  subtree that came out of it. A history persisted by an older build now throws
3338
2123
  mid-undo rather than degrading. The break is deliberate; kit op payloads are not
3339
2124
  versioned.
3340
-
3341
2125
  - c24e7de: Detached views follow pose overrides
3342
-
2126
+
3343
2127
  `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
3344
2128
  which a pose override deliberately never bumps — so they kept painting document
3345
2129
  poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
3346
2130
  canvas driving a drag or a simulation silently disagreed with it.
3347
-
2131
+
3348
2132
  `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
3349
2133
  subscribes to `scene.overrides`. A render (prop change or version bump) and an
3350
2134
  override commit both just mark the surface dirty, and one animation frame
@@ -3352,39 +2136,37 @@
3352
2136
  render, and a backgrounded tab stops painting them entirely. The mount paint
3353
2137
  stays synchronous, so the first frame is still the scene rather than a blank
3354
2138
  canvas. `<MinimapCanvas>` inherits all of this through it.
3355
-
2139
+
3356
2140
  Repaints driven by a prop change are now asynchronous: they land on the next
3357
2141
  animation frame rather than in the layout effect of the render that caused them.
3358
2142
  Code that renders and then reads pixels in the same tick needs to wait a frame.
3359
-
3360
- 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
3361
2145
  outside the document bounds paints outside the fitted frame — recomputing the
3362
2146
  fit per frame would rescale the whole minimap throughout a settle.
3363
-
3364
2147
  - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
3365
-
2148
+
3366
2149
  `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
3367
2150
  once instead of hiding two behind a select. Options carry an optional `short`
3368
2151
  label — a capital or two — for the width a property row has; the full `label`
3369
2152
  stays the accessible name, so the abbreviation never becomes the only thing
3370
2153
  naming the option. A mixed selection selects no segment rather than picking a
3371
2154
  winner.
3372
-
2155
+
3373
2156
  `pair` now merges fields inside an object leaf, as it already did for section
3374
2157
  rows — a hint shouldn't mean something different for being a field of a value
3375
- 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
3376
2159
  the schema orders family, size, weight: size and weight pair, and family (which
3377
2160
  sat between them) moves ahead of the pair rather than splitting it.
3378
-
2161
+
3379
2162
  A stroke's cap, join and align share one row; property rows wrap rather than
3380
2163
  overflow when the controls in them don't fit.
3381
-
3382
2164
  - be697dc: Add ephemeral pose overrides to the scene
3383
-
2165
+
3384
2166
  `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
3385
2167
  hit-test paths read through and that history, `toJSON()` and `getVersion()`
3386
2168
  never see. It is additive: a scene with no overrides behaves exactly as before.
3387
-
2169
+
3388
2170
  This is where per-frame motion belongs. A 60 Hz loop previously had to write
3389
2171
  through `setPose`, which records an undo entry (one per frame at best, batched)
3390
2172
  and bumps the scene version, re-rendering every `useSyncExternalStore`
@@ -3392,47 +2174,45 @@
3392
2174
  frame, because the painter memo keys on pose reference. An override entry is
3393
2175
  hoisted once and mutated in place; `overrides.commit()` publishes the frame and
3394
2176
  invalidates the memo for the overridden nodes only.
3395
-
2177
+
3396
2178
  `commit()` is required after an in-place mutation — without it the memo serves
3397
2179
  the previous frame's draw. Overrides are cleared when a node is removed, since
3398
2180
  ids are reusable. To make a frame permanent, write it once through `setPose`
3399
2181
  and clear the override; that single step is the undo entry.
3400
-
2182
+
3401
2183
  `ForceGraphDemo` now settles with zero history entries and bakes the result as
3402
2184
  one, replacing a per-tick batch of 24 `setPose` calls.
3403
-
3404
2185
  - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
3405
-
2186
+
3406
2187
  It was the fourth site measuring text its own way: `ctx.measureText` per
3407
2188
  character against system fonts, no kerning, `pose.text` only. Nothing masked
3408
2189
  it the way the WebGL context masked the caret — a consumer calling it got a
3409
2190
  box that disagreed with the paint, narrower by a kern on every pair and wrong
3410
2191
  by the whole difference between the installed family and the registered face.
3411
2192
  It goes through the shared layout now, so it sees kerning and per-run styling.
3412
-
2193
+
3413
2194
  **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
3414
-
3415
2195
  - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
3416
-
2196
+
3417
2197
  `requestRedraw()` marks the surface dirty and the next frame paints, so many
3418
2198
  redraws in one tick cost one paint. The view gains an imperative path on the
3419
2199
  canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
3420
2200
  longer holds it in React state, so a camera moving at 60 Hz costs no renders.
3421
2201
  Consumers passing a `view` prop stay controlled and are unaffected.
3422
-
2202
+
3423
2203
  Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
3424
2204
  that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
3425
2205
  gives a host the scene without a render per mutation, `useSceneTextEdit`'s
3426
2206
  `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
3427
2207
  `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
3428
-
2208
+
3429
2209
  Two public signatures changed. `usePinchZoomTool` takes a view getter,
3430
2210
  `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
3431
2211
  captured value any more. `CanvasExtensionApi` gained five required members —
3432
2212
  `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
3433
2213
  so external code hand-implementing that interface stops typechecking; code that
3434
2214
  only calls through the ref is unaffected.
3435
-
2215
+
3436
2216
  Pixels and DOM can now be a frame apart, in whichever direction the change came
3437
2217
  from. A view change leads with pixels: `setView` paints without rendering, so
3438
2218
  DOM built from the view is stale until something re-renders it — position
@@ -3441,64 +2221,60 @@
3441
2221
  pixels land next frame — compare `getPaintedVersion()` against the version you
3442
2222
  are about to render when chrome must be in lockstep. Do not render scene-derived
3443
2223
  DOM inside `startTransition`: React defers it and nothing forces it to catch up.
3444
-
2224
+
3445
2225
  Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
3446
2226
  mode is the one in-tree case — can likewise see a buffer one frame older;
3447
2227
  `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
3448
2228
  paints while `document.hidden` is true, `syncPaint` included, so a readback from
3449
2229
  a background tab returns the frame from before the tab was hidden.
3450
-
3451
2230
  - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
3452
2231
  recorded. A caller is entitled to reuse the array it uploads from, so storing
3453
2232
  the reference recorded a value that later frames overwrote — a test reading
3454
2233
  two frames back saw the same numbers twice and passed. Test-only surface.
3455
2234
  - 3fb3a46: Release held keys when the window loses focus
3456
-
2235
+
3457
2236
  A window that blurs mid-hold never delivers the keyup, so every in-flight
3458
2237
  `key-held` handle stayed engaged until that key was pressed again — holding
3459
2238
  Space and tabbing away left the hand tool on the hotkey stack indefinitely.
3460
-
2239
+
3461
2240
  The gesture dispatcher now fires the `key-held` up phase for each held key on
3462
2241
  window blur. Consumers that hand-rolled this reset can drop it; ongoing
3463
2242
  invocations see a normal `onEnd`.
3464
-
3465
2243
  - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
3466
-
2244
+
3467
2245
  One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
3468
2246
  round caps and joins, hairline weight reserved for structure, and filled
3469
2247
  regions only where an action has a subject. Covers transport, history, view,
3470
2248
  trial lifecycle, collection, state, instrument and status vocabulary. Import a
3471
2249
  named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
3472
-
2250
+
3473
2251
  `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
3474
2252
  so consumers have one import site for the whole set. `ImageIcon` was reachable
3475
2253
  from core's icons folder but missing from its public barrel; it is exported
3476
2254
  now.
3477
-
2255
+
3478
2256
  Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
3479
2257
  rather than hand-placed, because arrowheads and joins that miss their terminus
3480
2258
  are invisible at chrome size.
3481
-
3482
2259
  - 3fb3a46: Compose `before` and `after` layer chains in both directions
3483
-
2260
+
3484
2261
  `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
3485
2262
  off an `after` anchor only followed further `after` links, and likewise for
3486
2263
  `before`. A custom layer anchored `before: 'scene'` carrying a second custom
3487
2264
  anchored `after` it dropped that second layer to the tail with a spurious
3488
2265
  dangling-reference warning.
3489
-
2266
+
3490
2267
  Both walks now emit a layer's `before` chain, the layer, then its `after`
3491
2268
  chain, so the two mix freely. Cycle detection and orphan fallback are
3492
2269
  unchanged.
3493
-
3494
2270
  - d9f110e: Stop every frame loop while nothing can see it
3495
-
2271
+
3496
2272
  New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
3497
2273
  whether a frame may run: nothing runs while `document.hidden`, and a loop that
3498
2274
  names an element also stops while that element is outside the viewport. A
3499
2275
  request made while suspended is held rather than dropped and re-armed on
3500
2276
  resume, so a loop never polls visibility or needs restarting by hand.
3501
-
2277
+
3502
2278
  Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
3503
2279
  `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
3504
2280
  counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
@@ -3506,30 +2282,29 @@
3506
2282
  the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
3507
2283
  it paints only dirty layers, but a hidden tab still commits React updates and
3508
2284
  its view/size effect marks every layer dirty.
3509
-
2285
+
3510
2286
  Loops measuring elapsed time rebase their clock through the new `onResume`
3511
2287
  option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
3512
2288
  meter reporting a rate nobody achieved, a tween jumping to its end value on
3513
2289
  return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
3514
2290
  export; nothing in the tree sets it.
3515
-
2291
+
3516
2292
  `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
3517
2293
  in kit source, and runs in CI.
3518
-
3519
2294
  - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
3520
-
2295
+
3521
2296
  A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
3522
2297
  `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
3523
2298
  ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
3524
2299
  and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
3525
2300
  second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
3526
2301
  where the matcher picks a single winner.
3527
-
2302
+
3528
2303
  Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
3529
2304
  and jsdom reports an empty-string platform — not nullish, so the fallback never
3530
2305
  fired and every mac binding in the kit was exercised only on the non-mac
3531
2306
  branch. It reads `||` now.
3532
-
2307
+
3533
2308
  Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
3534
2309
  alongside the `viewport.pinchZoom` action that already handled the same
3535
2310
  gesture, applying one pinch's factor twice — the opt-in broke the path that
@@ -3538,36 +2313,34 @@
3538
2313
  the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
3539
2314
  for real; it previously left the action running. Bare `<Canvas>` keeps the hook
3540
2315
  as its own pinch path.
3541
-
3542
2316
  - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
3543
-
2317
+
3544
2318
  Nothing in the kit exported one, so consumers hand-rolled it — their own
3545
2319
  `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
3546
2320
  inverse built by projecting two points. That inverse silently drops
3547
2321
  `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
3548
2322
  beside the paint instead of on it.
3549
-
2323
+
3550
2324
  ```ts
3551
- useNodeOverlayFrame(scene, containerRef, nodeId, { view });
2325
+ useNodeOverlayFrame(scene, containerRef, nodeId, { view })
3552
2326
  // → { box, toScreen, toLocal, width, height } | null
3553
2327
  ```
3554
-
2328
+
3555
2329
  `box` is the node's composed world box, unrotated — the frame `toScreen` maps
3556
2330
  from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
3557
2331
  lives in the pose→world leg, where it belongs: a node's stored geometry and its
3558
2332
  bounds-frame paint are pre-rotation by definition, so neither of those two
3559
2333
  changes.
3560
-
2334
+
3561
2335
  `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
3562
2336
  `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
3563
2337
  `stroke` — `slot` is a prop — and commits each drag through `setFill` or
3564
2338
  `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
3565
-
2339
+
3566
2340
  Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
3567
2341
  `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
3568
-
3569
2342
  - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
3570
-
2343
+
3571
2344
  `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
3572
2345
  color and `'none'` still skips the stroke; an object is a core `Stroke` whose
3573
2346
  `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
@@ -3576,99 +2349,94 @@
3576
2349
  rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
3577
2350
  is baked onto the pose box the way a fill is, so a gradient stroke resolves
3578
2351
  against the box it was authored against.
3579
-
2352
+
3580
2353
  `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
3581
-
2354
+
3582
2355
  `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
3583
- strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
2356
+ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3584
2357
  `'outer'` none inside, which one number could not say, so picking grabbed the
3585
2358
  wrong side. `ink` takes an optional context carrying the view scale, so a
3586
2359
  `{ px }` stroke width resolves to world units. A painter that still returns
3587
2360
  `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
3588
-
2361
+
3589
2362
  `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
3590
2363
  color pick replaces its paint and keeps width, cap, join and dash, and an
3591
2364
  opacity drag sets the paint's `opacity`, which is the only form that works on
3592
2365
  a gradient stroke.
3593
-
2366
+
3594
2367
  Editing UI for the rich form is not here yet — a schema-driven color control
3595
2368
  still writes a bare string over the object, so nodes carrying one are for
3596
2369
  programmatic authorship until `SelectionPanel` learns the union. See
3597
2370
  `docs/proposals/2026-08-26-node-stroke-union.md`.
3598
-
3599
2371
  - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
3600
-
2372
+
3601
2373
  `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
3602
2374
  servers there deliberately, but the renderer refused anything but a solid — so
3603
2375
  importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
3604
2376
  next frame. Both stroke paths now paint the ribbon through the same route a
3605
2377
  fill takes, including under the inner/outer alignment stencil. A non-solid
3606
2378
  even-odd fill no longer renders black.
3607
-
3608
2379
  - 5f6c28e: An object leaf's fields can be organised into groups
3609
-
2380
+
3610
2381
  `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
3611
2382
  heads its fields under a label and contributes nothing to the path — the same
3612
2383
  rule group keys follow at the top level of a schema, so a field inside one is
3613
2384
  still addressed as a field of the object.
3614
-
2385
+
3615
2386
  Without it, a value with many fields renders as one undifferentiated list. A
3616
2387
  `TextStyle` is the case that needs it: its character and paragraph fields are
3617
2388
  one value but read as two lists.
3618
-
3619
2389
  - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
3620
-
2390
+
3621
2391
  A compound value — a stroke, a shadow, a pattern spec — could be described as
3622
2392
  sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
3623
2393
  It shouldn't be: each control then writes one field of a value it can only
3624
2394
  half see, and writing a field into something that isn't an object yet corrupts
3625
2395
  it outright.
3626
-
2396
+
3627
2397
  `ToolPrefObject` describes the value instead. Its `children` are ordinary
3628
2398
  leaves whose paths are relative to the object, and every child edit commits
3629
2399
  the parent object whole. A field that is itself a union declares the kind that
3630
2400
  edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
3631
2401
  value still held in a scalar form before a child edit lands on it, which is
3632
2402
  how a stroke stored as a bare colour string gains a width.
3633
-
2403
+
3634
2404
  `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
3635
2405
  Color, Width, Cap, Join and Align under one Stroke block, and the separate
3636
2406
  `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
3637
2407
  `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
3638
2408
  replaced by this general one.
3639
-
2409
+
3640
2410
  `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
3641
2411
  import, export and rendering untouched.
3642
-
3643
2412
  - 2ea772f: Selection handles are hit-tested at the size they are painted
3644
-
2413
+
3645
2414
  Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
3646
2415
  constant, and neither `buildAffordanceAt` call site passed the option that
3647
2416
  would have scaled it. A coarse pointer got a bigger picture and exactly the
3648
2417
  same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
3649
2418
  exists to provide never reached the hit-test. The slops debug overlay was a
3650
2419
  third unscaled copy, so it drew hit regions where they were not.
3651
-
2420
+
3652
2421
  `core/device/targets.ts` now holds one base table and one accessor,
3653
2422
  `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
3654
2423
  through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
3655
2424
  `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
3656
2425
  table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
3657
-
2426
+
3658
2427
  `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
3659
2428
  `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
3660
2429
  reached nothing.
3661
-
2430
+
3662
2431
  `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
3663
2432
  affordance is an annulus with a band thickness and no point radius, so the
3664
2433
  option could only ever have been a second name for `rotationHandleDistance`,
3665
2434
  which is live and now defaults from the same table.
3666
-
2435
+
3667
2436
  Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
3668
2437
  device profile, so a nested view still hit-tests at the fine-pointer size.
3669
-
3670
2438
  - f77bd95: `getChildren` means one thing on an adapter
3671
-
2439
+
3672
2440
  `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
3673
2441
  drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
3674
2442
  the z-ordering seam where `null` means the root. Both land on the same adapter
@@ -3676,36 +2444,35 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3676
2444
  under the name the ops read with the second meaning. An op asking for root
3677
2445
  order got `[]`, which reads as "the root has no siblings", and the slot it
3678
2446
  captured was silently lost.
3679
-
2447
+
3680
2448
  The two declarations are now one contract, and `arrayAdapter` answers the root
3681
2449
  from its own item array rather than delegating — a consumer callback written
3682
2450
  for node ids returns `[]` there, which cannot be told apart from a genuine
3683
2451
  empty answer. A consumer's `getChildren` config is still only ever asked about
3684
2452
  a node id.
3685
-
2453
+
3686
2454
  `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
3687
2455
  than by anchor. That is unchanged, and it is why the ordinal fallback exists.
3688
-
3689
2456
  - 2ea772f: The canvas and the gradient editor now sample one gradient
3690
-
2457
+
3691
2458
  `buildGradientRamp` carried its own interpolation beside
3692
2459
  `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
3693
2460
  at either end and extrapolated past the first and last stop, the two picked
3694
2461
  opposite sides of a coincident pair, and they parsed color differently — a stop
3695
2462
  written as a CSS named color rendered on the canvas and threw in the editor.
3696
-
2463
+
3697
2464
  `sampleGradientStops` keeps its semantics and is now the only implementation.
3698
2465
  `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
3699
2466
  returns the color at `t`. The ramp cache builds its texels through those, so
3700
2467
  there is no interpolation math left in the renderer.
3701
-
2468
+
3702
2469
  Two behavior changes worth naming. `resolveColor` is the surviving parser, so
3703
2470
  gradient stops accept named and functional colors everywhere — but no longer
3704
2471
  hex without a leading `#`, which only the editor path had tolerated and the
3705
2472
  canvas never accepted. And `sampleGradientStops` returns normalized hex at the
3706
2473
  endpoints instead of echoing the raw stop string, so `'red'` comes back as
3707
2474
  `'#ff0000'`.
3708
-
2475
+
3709
2476
  **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
3710
2477
  the element already carried the reference, the built-in serializer returned
3711
2478
  nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
@@ -3713,18 +2480,17 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3713
2480
  through to the same warning the pattern path already emits when nothing can
3714
2481
  produce a paint server. A consumer that registers a `toSvg` for
3715
2482
  `conic-gradient` still serializes and gets no warning.
3716
-
3717
2483
  - aba8d91: Answer "can this node be hit" in one place
3718
-
2484
+
3719
2485
  Four tree walks answered it separately — the generic-adapter point pick, the
3720
2486
  one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
3721
2487
  marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
3722
2488
  that had a test and disagreed on the rest, three times, silently. `pickWalk`
3723
2489
  now owns every gate; a query supplies only its own shape test and the clip
3724
2490
  predicate for its region.
3725
-
2491
+
3726
2492
  Behavior that changes as a result:
3727
-
2493
+
3728
2494
  - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
3729
2495
  same number the painter does — the view's `alphaFor` times any per-node
3730
2496
  override alpha — so a node faded out of sight stops claiming clicks. The
@@ -3740,13 +2506,12 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3740
2506
  - An ancestor clip now rejects an area query that reaches into the clip where
3741
2507
  the node is not, or reaches the node where the clip is not — the two terms
3742
2508
  together, where one alone let false positives through.
3743
-
2509
+
3744
2510
  `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
3745
2511
  `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
3746
2512
  `adapterPickSource` and `ownClipOf` replace them.
3747
-
3748
2513
  - 2ea772f: A drag-to-insert reports the bounds it paints
3749
-
2514
+
3750
2515
  The painter, the commit factory and `getGestureBounds()` each sized an
3751
2516
  in-flight insert differently. The reporter read the drag rect alone, so a
3752
2517
  centered Alt-drag reported a half-extent of `d` against a painted circumradius
@@ -3755,45 +2520,43 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3755
2520
  all. The painter and the commit agreed on polygon and star but not on line or
3756
2521
  pencil: the commit posed the drag AABB for a line the painter drew endpoint to
3757
2522
  endpoint, and fell back to the drag rect for a trail under four samples.
3758
-
2523
+
3759
2524
  One function now answers it for all three. The zero-area skip in the painter
3760
2525
  and the reporter tests the resolved extent rather than the raw drag rect, and
3761
2526
  an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
3762
2527
  `bounds` argument handed to a factory is unchanged.
3763
-
3764
2528
  - 3386d64: Path command opcodes derive from one table
3765
-
2529
+
3766
2530
  `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
3767
2531
  once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
3768
2532
  literals in the path transform, pose-rotation and pose-descriptor walkers. They
3769
2533
  agreed, and nothing held them to each other: a sixth opcode desynchronizes two
3770
2534
  packages' reading of the same `Uint8Array` with no exception and no type error,
3771
2535
  and every walker misparses the coordinate stream from that command on.
3772
-
2536
+
3773
2537
  `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
3774
2538
  `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
3775
2539
  core re-exports them by name, so the opcode constants keep their names, values
3776
2540
  and literal types. The three walkers moved onto `forEachSegment` rather than
3777
2541
  onto the accessor alone — they were duplicating the coordinate-cursor advance
3778
2542
  as well as the length, and the cursor is the half that actually misreads.
3779
-
2543
+
3780
2544
  Eight further files switch on these opcodes with inline literals. Five throw on
3781
2545
  an unknown code; three — the path boolean adapter, the anchor-editing geometry,
3782
2546
  and geom's own boolean adapter — have no `default` arm and would silently stop
3783
2547
  advancing. Left as-is; they need per-command semantics, not one walker.
3784
-
3785
2548
  - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
3786
-
2549
+
3787
2550
  `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
3788
2551
  saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
3789
2552
  leaf had neither `encoding` nor `options[].disabled`, so a dash-array
3790
2553
  preference did not merely fail to select — choosing an option wrote the option
3791
2554
  string over the stored dash array. labkit's two renderers were missing the
3792
2555
  `paint` and `object` kinds outright.
3793
-
2556
+
3794
2557
  ui's schema is now a rename re-export of core's declaration. The public `Pref*`
3795
2558
  names are unchanged, and there is nothing left to keep in sync.
3796
-
2559
+
3797
2560
  More importantly, all four renderer switches ended in `default:`, so adding a
3798
2561
  built-in kind produced no error at any site and simply rendered nothing —
3799
2562
  verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
@@ -3803,34 +2566,32 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3803
2566
  `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
3804
2567
  can discriminate and end in a `never`. App-defined kinds take the placeholder
3805
2568
  path as before.
3806
-
2569
+
3807
2570
  Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
3808
2571
  arm threads sibling values, routes through `encoding.read` / `encoding.write`,
3809
2572
  and honors `option.disabled`. `SelectionPanel` already did all of this — it was
3810
2573
  only the forked copy that could not express it.
3811
-
3812
2574
  - 3386d64: Dragging out a text box shows a live preview
3813
-
2575
+
3814
2576
  The set of insertable kinds and the `KitInsertShape` union sat on adjacent
3815
2577
  lines with no linkage, and seven more sites restated one list or the other. The
3816
2578
  drift was already live: the text tool binds `actionId: 'insert'` and commits
3817
2579
  through the insert dep, but the runtime set never listed `text`, so a
3818
2580
  drag-to-insert text box had no preview.
3819
-
2581
+
3820
2582
  `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
3821
2583
  it has a built-in tool and whether it takes an insert preview. Both unions,
3822
2584
  `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
3823
2585
  preview gate all derive from it.
3824
-
2586
+
3825
2587
  Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
3826
2588
  `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
3827
2589
  same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
3828
2590
  positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
3829
2591
  gains `'text'`, which is the fix itself: a consumer switching exhaustively over
3830
2592
  it gains a case, handled by the existing box arm.
3831
-
3832
2593
  - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
3833
-
2594
+
3834
2595
  The paint, the pose silhouette and the click-to-edit caret each ran their own
3835
2596
  walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
3836
2597
  `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
@@ -3840,7 +2601,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3840
2601
  caret could therefore answer with a different line, and a different glyph, than
3841
2602
  the one under the pointer — masked in practice only because it asked a WebGL
3842
2603
  canvas for a 2D context and got `null`, degrading silently to no caret at all.
3843
-
2604
+
3844
2605
  `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
3845
2606
  and all three go through it. It keeps the array-identity `WeakMap` as the
3846
2607
  renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
@@ -3848,7 +2609,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3848
2609
  about 230× cheaper than laying out again, at roughly 4× the cost of the
3849
2610
  identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
3850
2611
  snapping is to the advance cells the glyphs were actually painted in.
3851
-
2612
+
3852
2613
  **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
3853
2614
  `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
3854
2615
  an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
@@ -3856,9 +2617,8 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3856
2617
  double-click always seeds the caret instead of falling back to editing from
3857
2618
  offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
3858
2619
  `_resetLayoutCacheForTests`.
3859
-
3860
2620
  - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
3861
-
2621
+
3862
2622
  `registerFontOutlines` was a paint upgrade for a family that already had an
3863
2623
  MSDF atlas; a family with only font bytes could not resolve, so it rendered
3864
2624
  nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
@@ -3866,48 +2626,46 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3866
2626
  outline-only family, and `layoutRuns` reads advances, kerning and the baseline
3867
2627
  through one source the atlas and a parsed face both satisfy. `outlineMinSize`
3868
2628
  does not gate such a family — there is no other tier to prefer.
3869
-
2629
+
3870
2630
  This does not touch metric neutrality where it applies: a family that has an
3871
2631
  atlas still resolves to the atlas, so registering outlines cannot move text
3872
2632
  that was already rendering.
3873
-
2633
+
3874
2634
  Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
3875
2635
  and UMD under `main`; Node takes the UMD build, whose named exports it cannot
3876
2636
  detect, so `parse` was undefined and every face failed to load — silently, via
3877
2637
  the fallback to SDF. A browser bundler reading `module` never saw it.
3878
-
2638
+
3879
2639
  Breaking for a consumer-supplied `OutlineParser`: a face must now report
3880
2640
  metrics as well as geometry.
3881
-
3882
2641
  - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
3883
-
2642
+
3884
2643
  A kind bar over a per-kind body, driven by the paint-kind registry rather than
3885
2644
  a fixed list, so a consumer's registered kind appears in the bar and renders
3886
2645
  that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
3887
2646
  the chip that showed a gradient as indeterminate and wrote a solid over it on
3888
2647
  first touch — so the checkerboard now means a mixed selection and nothing else,
3889
2648
  and a gradient stroke is editable rather than merely paintable.
3890
-
2649
+
3891
2650
  Switching kinds keeps a per-kind memory for the control's lifetime, so
3892
2651
  linear -> solid -> linear comes back with its stops instead of the ramp
3893
2652
  `withGradientKind` cannot carry.
3894
-
2653
+
3895
2654
  `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
3896
2655
  on `@weasel-js/svg` for its tile previews.
3897
-
2656
+
3898
2657
  The bar offers **None**: "what kind of paint is this?" takes no-paint as an
3899
2658
  answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
3900
2659
  becomes `null`, and a stroke goes away entirely rather than keeping a width
3901
2660
  that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
3902
2661
  built-in kinds carry glyphs so six segments fit a property row.
3903
-
2662
+
3904
2663
  `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
3905
2664
  and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
3906
2665
  with its width — a whole paint editor cannot share a row with a slider.
3907
-
3908
2666
  - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
3909
2667
  frames and serializes.
3910
-
2668
+
3911
2669
  `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
3912
2670
  re-seeds the five built-ins, matching the kit's other module-global
3913
2671
  registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
@@ -3915,7 +2673,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3915
2673
  `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
3916
2674
  consumer's own paint as a `FillStyle` — the union itself stays closed, because
3917
2675
  opening its discriminant would widen every built-in member.
3918
-
2676
+
3919
2677
  Three defects fall out of the same change, each of which a sixth kind hit
3920
2678
  immediately. The renderer's fill dispatch fell off the end of its switch into
3921
2679
  an unguarded cast to the gradient union, so an unknown kind read `stops` off a
@@ -3923,25 +2681,24 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3923
2681
  an unknown kind untouched, leaving it painting in screen space on a node that
3924
2682
  moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
3925
2683
  still writing the `url(#id)` that referenced it.
3926
-
2684
+
3927
2685
  Registering a kind now bumps the node memo generation, so a node painted
3928
2686
  before the registration repaints rather than holding the frame it resolved
3929
2687
  when the kind was unknown.
3930
-
3931
2688
  - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
3932
-
2689
+
3933
2690
  Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
3934
2691
  `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
3935
2692
  where `undefined` takes the painter's fallback. Two new authoring helpers keep
3936
2693
  hand-written node data short:
3937
-
2694
+
3938
2695
  ```ts
3939
2696
  data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
3940
2697
  ```
3941
-
2698
+
3942
2699
  **Breaking, with no compatibility path.** A document written against the old
3943
2700
  shapes renders wrong rather than failing, which is accepted:
3944
-
2701
+
3945
2702
  - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
3946
2703
  and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
3947
2704
  A node holding `fill: '#f00'` now paints the default grey.
@@ -3957,176 +2714,169 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
3957
2714
  - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
3958
2715
  of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
3959
2716
  stroke into a color. SVG's `fill="none"` imports as `fill: null`.
3960
-
2717
+
3961
2718
  **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
3962
2719
  the only slot a gradient or a pattern has, so it is the slot all of them use,
3963
2720
  and the renderer multiplies a hex alpha by it — the two would fight if both
3964
2721
  carried the value. `solid()` therefore moves an alpha channel out of the hex:
3965
2722
  `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
3966
-
2723
+
3967
2724
  The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
3968
2725
  `opacity` rather than splicing hex, so they now work on a gradient fill, which
3969
2726
  they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
3970
2727
  the node's existing paint through the new `paintWithColor`, keeping its opacity
3971
2728
  unless the picked color states an alpha of its own — and `setStroke` keeps the
3972
2729
  stroke's width, cap, join and dash instead of replacing the whole value.
3973
-
2730
+
3974
2731
  New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
3975
2732
  `paintWithColor`, `DEFAULT_SHAPE_FILL`.
3976
-
2733
+
3977
2734
  `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
3978
2735
  — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
3979
2736
  writes a bare string over it — and the `data.stroke` object leaf drops its
3980
2737
  `fromScalar`, which had nothing left to lift.
3981
-
3982
2738
  - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
3983
-
2739
+
3984
2740
  The text schema put `data.text` in a group named Text, so the section read
3985
2741
  TEXT and the row inside it read Text — one word nested in itself — and the
3986
2742
  style groups below it read as fields of the content string rather than as its
3987
2743
  siblings. Content is its own section now, with the field full-width because
3988
2744
  the section already names it.
3989
-
2745
+
3990
2746
  A group with an empty `name` renders no heading. That already worked for
3991
2747
  sections and is now documented on `ToolPrefGroup`, since it is how a schema
3992
2748
  says "this group organises, it doesn't name": `Character` and `Paragraph`
3993
2749
  carry the labels, and a `Typography` heading over them named nothing new.
3994
2750
  It stays opt-in rather than a rule that rolls up any all-group parent —
3995
2751
  a `Border` over `Top` / `Right` / `Bottom` needs its name.
3996
-
2752
+
3997
2753
  Rows under a suppressed heading no longer indent. Depth drawn without a
3998
2754
  visible parent put `Character` a level deeper than `Content` while being its
3999
2755
  peer, which is the panel's own tree discipline broken by its own hand.
4000
-
4001
2756
  - 611b30e: Layers and deps answer for the view they are drawn for
4002
-
4003
- 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
4004
2759
  for view zero in every view. `<CanvasView>` draws the surface's layer array
4005
2760
  unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
4006
2761
  a guarantee of answering for the wrong view rather than merely an unused
4007
2762
  argument. A drag in view B ghosted in view A, the marquee painted in the wrong
4008
2763
  view, chrome-caps resolved against the surface's selection, every Cmd+V centered
4009
2764
  on the wrong camera, and Escape in view B cancelled view A.
4010
-
2765
+
4011
2766
  **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
4012
2767
  and `getIsVisible()`. All three are **required members**: anyone hand-writing a
4013
2768
  `CanvasViewHelpers` (a test double, a wrapper) has to add them.
4014
2769
  `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
4015
2770
  have answered for one view.
4016
-
2771
+
4017
2772
  **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
4018
2773
  alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
4019
2774
  is the tool half.
4020
-
2775
+
4021
2776
  **Layer options changed.** `createPathEditingOverlayLayer` and
4022
2777
  `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
4023
2778
  `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
4024
2779
  now. `usePreviewGhostLayer` has lost `tools`. Both it and
4025
2780
  `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
4026
-
2781
+
4027
2782
  **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
4028
2783
  result accept an optional trailing `PickCamera`. A world point does not carry
4029
2784
  the scale it was produced under and picking has no draw envelope, so the caller
4030
2785
  that produced the point supplies it; omitting it keeps the surface camera.
4031
-
2786
+
4032
2787
  `useHoverTracking` took a `clientToWorld` thunk beside a world-space
4033
2788
  `getNodeAtPoint` — the first resolved the view and the second did not, so hover
4034
2789
  picked at the surface's scale inside a panel. It takes one
4035
2790
  `nodeAtClientPoint(clientX, clientY)` now.
4036
-
2791
+
4037
2792
  Anchor-editing target state stays surface-wide; only the preview resolution on
4038
2793
  that path is per-view.
4039
-
4040
2794
  - 9ad8cb2: Picking answers for what was painted
4041
-
2795
+
4042
2796
  Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
4043
2797
  from something other than what the renderer drew.
4044
-
2798
+
4045
2799
  **Pose overrides were painted through and picked around.** `PoseOverride.pose`
4046
- is documented as replacing the document pose _everywhere the render and
4047
- 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
4048
2802
  `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
4049
2803
  did the bounds resolver feeding selection chrome and the affordance
4050
2804
  `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
4051
2805
  overrides painted them at one place and picked them at another. `effectivePose`
4052
2806
  is now the single rule and every one of those reads through it.
4053
-
2807
+
4054
2808
  **A clipped-away child was still clickable.** A container clips its subtree and
4055
2809
  the renderer honors it, so a child outside the clip is not painted.
4056
2810
  `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
4057
2811
  `<SceneCanvas>` installs instead had no clip term at all. The new
4058
2812
  `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
4059
2813
  render-order scan can apply the same test.
4060
-
2814
+
4061
2815
  **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
4062
2816
  45° puts a corner 32 units above that box; a rubber-band over that corner was
4063
2817
  rejected before the rotation-correct silhouette test ran, while a click on the
4064
2818
  same pixel selected the shape.
4065
-
4066
2819
  - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
4067
2820
  tiles, coins, enemies and flagpole still declared `data.color`, the alias
4068
2821
  removed when node paint became an object, so every one of them rendered in
4069
2822
  the default gray — the demo whose whole point is being the visual twin of the
4070
2823
  immediate-mode load test.
4071
2824
  - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
4072
-
2825
+
4073
2826
  Three paths read a pose's stored, unrotated box where the rotated extent was
4074
2827
  wanted.
4075
-
2828
+
4076
2829
  `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
4077
2830
  came back turned the same way — invisible on a rectangle, whose AABB is
4078
2831
  symmetric under a sign flip, and plainly wrong on an asymmetric one, which
4079
2832
  translated instead of mirroring. It now negates the pose's rotation.
4080
-
2833
+
4081
2834
  `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
4082
2835
  stored edges, while the dragged selection matched against them by its ink.
4083
2836
  `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
4084
2837
  `deriveAlignmentGuides` reads its targets through the same projection — a new
4085
2838
  `projection` option defaulting to the rect one, so existing callers get the fix
4086
2839
  without a change.
4087
-
2840
+
4088
2841
  `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
4089
2842
  rotated member ended up with a gap short by the difference; `centers` shared the
4090
2843
  line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
4091
2844
  `distributeHorizontalAction` / `distributeVerticalAction` also take
4092
2845
  `params.mode`, so `gaps` is reachable from a binding rather than only from the
4093
2846
  hook.
4094
-
2847
+
4095
2848
  Flip and distribute return different poses than before for rotated shapes.
4096
2849
  That is the fix, but it is a behavior change for anything depending on the
4097
2850
  old output.
4098
-
4099
2851
  - 3386d64: `@weasel-js/core/routing` exports the route-string projection
4100
-
2852
+
4101
2853
  Anything rendering a `GestureSpec` as a route string had to re-implement the
4102
2854
  projection, and the copy in WeaselDraw's registry inspector had drifted three
4103
2855
  ways: it answered `drop` and `paste` with no gesture name, so every binding of
4104
2856
  either vanished from the route list; its argument lookup missed a spec field;
4105
2857
  and it gated targets on a hand-listed set of kinds, dropping them for
4106
2858
  `pointerDown`, `longPress` and `wheel`.
4107
-
2859
+
4108
2860
  New from the routing subpath: `routesForSpec(spec)` — every route string one
4109
2861
  spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
4110
2862
  map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
4111
2863
  subpath index did not, so consumers reading `RegistryEntry.target` had no way
4112
2864
  to compare against the sentinel its own docs name.
4113
-
4114
2865
  - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
4115
-
2866
+
4116
2867
  `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
4117
2868
  in their control flow — `***`/`**`/`*` and a two-character escape set — so
4118
2869
  reading or writing any other spelling meant forking both. They now take a
4119
2870
  `RunGrammar`: a table of markers pairing a repeated delimiter with the run
4120
2871
  flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
4121
2872
  today's behavior. Escaping follows the grammar's own delimiters.
4122
-
2873
+
4123
2874
  Nothing changes for a caller that passes no grammar. `underline` and
4124
2875
  `strikethrough` still have no markdown spelling and are still dropped by
4125
2876
  `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
4126
2877
  rather than editing the parser.
4127
-
4128
2878
  - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
4129
-
2879
+
4130
2880
  `createSelectionOverlayLayer` documents itself as equivalent to stacking the
4131
2881
  other two, and it was not. It reads `ChromeState` off the draw envelope,
4132
2882
  resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
@@ -4136,27 +2886,25 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4136
2886
  knew nothing about the multi-selection union — so a consumer who stacked them,
4137
2887
  on the wrapper's own promise, got chrome in the wrong place with no way to
4138
2888
  tell.
4139
-
2889
+
4140
2890
  All three now run one body and differ only in which passes they enable, so the
4141
2891
  promise holds by construction. `SelectionOutlineLayerOpts` and
4142
2892
  `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
4143
2893
  that don't apply, which makes `getSelection` and `getPose` optional on both and
4144
2894
  adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
4145
2895
  `SelectionHandleStyle`.
4146
-
4147
2896
  - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
4148
-
2897
+
4149
2898
  The effect depended on the whole selection-context value, and the provider
4150
2899
  mints a new value object on every publish. So one publisher publishing refired
4151
2900
  the effect for every other publisher in scope, each of which republished its
4152
2901
  own ids — a newer selection got stomped back to an older one, and two
4153
2902
  publishers holding different ids under one provider never settled at all.
4154
-
2903
+
4155
2904
  `publishSelection` is already a stable `useCallback`, so the effect now depends
4156
2905
  on it directly. No provider change and no API change.
4157
-
4158
2906
  - 84db1f6: Close four gaps that produced wrong answers with no error
4159
-
2907
+
4160
2908
  Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
4161
2909
  `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
4162
2910
  `default:` arm, so a command code they did not know fell out of the switch
@@ -4164,31 +2912,30 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4164
2912
  wrong floats. They now throw, matching the six sibling walkers. This is a
4165
2913
  behavior change for anyone feeding these a path built with an opcode outside
4166
2914
  `PATH_COMMANDS`: what used to come back subtly wrong now raises.
4167
-
2915
+
4168
2916
  A `<CanvasView>` built its affordance hit-test without a device profile, so a
4169
2917
  nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
4170
2918
  zones against the 14px chrome the surface paints. It reads the profile
4171
2919
  `<SceneCanvas>` publishes.
4172
-
2920
+
4173
2921
  `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
4174
2922
  neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
4175
2923
  records had nowhere to land: undoing a delete through the move pipeline
4176
2924
  appended the node to the end of its parent instead of putting it back where it
4177
2925
  was. All three are there now.
4178
-
2926
+
4179
2927
  The dev inspector's gesture panel formatted bindings with a private formatter
4180
2928
  that reported only modifiers set to `true`. The `ingest` action marks every
4181
2929
  modifier `'optional'`, so its drop and paste bindings rendered blank and the
4182
2930
  action was invisible on both gestures. Both of the panel's plain-text
4183
2931
  formatters now go through the kit's `routesForSpec`.
4184
-
4185
2932
  - 3386d64: Undoing a multi-node delete or group restores document order
4186
-
2933
+
4187
2934
  Restoring by stored index cannot survive replay: history runs a batch's
4188
2935
  inverses in reverse, while indices captured before the mutation are only
4189
2936
  correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
4190
2937
  undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
4191
-
2938
+
4192
2939
  Ops now record a `Slot` — an ordinal plus the id of the following sibling at
4193
2940
  capture. The anchor is the source of truth whenever it resolves, and it
4194
2941
  resolves whatever else the batch has already restored. The ordinal remains as
@@ -4196,23 +2943,22 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4196
2943
  children. `before: null` means "last" and needs no sibling list; an absent
4197
2944
  `before` means "unobserved", and the two survive `History.serialize` because
4198
2945
  `undefined` drops out of JSON and `null` does not.
4199
-
2946
+
4200
2947
  The ops observe their own slot during `apply()` rather than taking one from the
4201
2948
  caller, so every existing emitter gets this without a call-site change.
4202
2949
  `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
4203
2950
  docstring said it was sufficient on its own, which it never was.
4204
-
2951
+
4205
2952
  Adapters without an ordering seam still append, as they did before:
4206
2953
  `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
4207
2954
  neither that nor an `index` parameter on `insertNode`.
4208
-
4209
2955
  - 7a746df: A stroke's dash is edited as a style, not as an array
4210
-
2956
+
4211
2957
  `Stroke.dash` already rendered, imported and exported; it had no control,
4212
2958
  because a `number[]` has no leaf kind. It doesn't need one — the thing a person
4213
2959
  chooses is a style, and the array is how it is stored. The stroke block gains a
4214
2960
  Solid / Dashed / Dotted / Custom bar under cap, join and align.
4215
-
2961
+
4216
2962
  `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
4217
2963
  the option string, the counterpart of the `unit` a number leaf already has for
4218
2964
  a value stored in a canonical unit. Both directions are handed the object the
@@ -4224,9 +2970,8 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4224
2970
  a new `disabled` option — one a control reports but refuses to author, since
4225
2971
  there is no array behind it. `solid` is stored as no dash at all, and an object
4226
2972
  leaf's field written as `undefined` is now removed rather than left holding it.
4227
-
4228
2973
  - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
4229
-
2974
+
4230
2975
  Nine option glyphs and four category glyphs join the icon set. The option
4231
2976
  glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
4232
2977
  shape rather than as a diagram of one. `align` is a circle zoomed until the
@@ -4234,31 +2979,29 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4234
2979
  the box's complement of it, and `center` is the annulus straddling the path,
4235
2980
  so the three are one band at three offsets. The categories are the bare path
4236
2981
  each row treats, drawn in the outlined register.
4237
-
4238
- 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
4239
2984
  `icon`, and every leaf gains one for rows whose own label is spent on a
4240
2985
  `pair`. Core ships no icon set and cannot depend on one, so the field is a
4241
2986
  plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
4242
2987
  `short` where it names no glyph.
4243
-
2988
+
4244
2989
  `SelectionPanel` now honours `block` inside an object leaf, not only at the
4245
2990
  section level. A row whose fields are all `block` drops the 64px label column
4246
2991
  and spans the block. The default stroke schema uses both: paint and width
4247
2992
  share one label-less row, and cap/join/align share the next.
4248
-
2993
+
4249
2994
  `align`'s options run inner, center, outer — the order the ink moves outward.
4250
-
4251
2995
  - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
4252
-
2996
+
4253
2997
  `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
4254
2998
  through a new registry (`registerMarker`), shipping eight built-in shapes.
4255
2999
  Unlike SVG, the stroke stops short of a filled head rather than running under
4256
3000
  it to the tip; the distance is declared per marker, so an open V still reaches
4257
3001
  the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
4258
3002
  `<marker>` defs.
4259
-
4260
3003
  - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
4261
-
3004
+
4262
3005
  It accepted `{ color }` only, and merged through `paintWithColor`, which
4263
3006
  supersedes a non-solid paint with a solid one — a gradient stroke was
4264
3007
  unreachable even though `setStrokeOpacity` could already reach its alpha.
@@ -4266,42 +3009,40 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4266
3009
  an earlier paint, and the stroke's width, cap, join, dash and align survive
4267
3010
  either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
4268
3011
  that has no color to pass.
4269
-
3012
+
4270
3013
  Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
4271
3014
  from `DEFAULT_STROKE_COLOR`, painting the selection black where
4272
3015
  `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
4273
3016
  `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
4274
3017
  where the body builds a left-edge-to-right-edge one.
4275
-
3018
+
4276
3019
  `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
4277
3020
  core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
4278
3021
  it — and nothing imported it.
4279
-
4280
3022
  - 81213fc: Edit a node's stroke as the union it is
4281
-
3023
+
4282
3024
  `data.stroke` holds `string | Stroke`, and the schema described it with a
4283
3025
  `color` leaf — which reads `undefined` off the object form, shows its own
4284
3026
  default, and writes a bare hex back over the stroke's width, cap, join and
4285
3027
  dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
4286
3028
  for `FillStyle`.
4287
-
3029
+
4288
3030
  A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
4289
3031
  Its control shows whichever color the value has — the string itself, or a
4290
3032
  solid paint's color — gives a gradient stroke the indeterminate chip rather
4291
3033
  than claiming a color it doesn't have, and preserves the form on write.
4292
-
3034
+
4293
3035
  `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
4294
3036
  `paint` leaf used to render as the literal text `(paint: no renderer)`.
4295
3037
  `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
4296
3038
  exported from `@weasel-js/ui` for consumers writing their own property
4297
3039
  renderers against either union.
4298
-
3040
+
4299
3041
  Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
4300
3042
  remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
4301
3043
  for why that waits on the SVG mapping.
4302
-
4303
3044
  - 2f225d7: A thick stroke is clickable across its whole width
4304
-
3045
+
4305
3046
  `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
4306
3047
  stroke width for an `outer` align — but the AABB pre-filter that runs before it
4307
3048
  grew only by the pointer slop. So half a thick outer stroke's ink was
@@ -4310,25 +3051,24 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4310
3051
  was at least as generous as the refinement, which it cannot be on its own,
4311
3052
  since it never sees the stroke. That budget is the caller's, and the comment
4312
3053
  says so now.
4313
-
3054
+
4314
3055
  `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
4315
3056
  declared in `px` resolved as world units and the reach was wrong at every zoom
4316
3057
  but 1 — while the caller computed `meanScale(view.scale)` one line above.
4317
-
4318
3058
  - 68069dc: Right-to-left text lays out in visual order
4319
-
3059
+
4320
3060
  `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
4321
3061
  analyses the paragraph, reorders each line after the wrap, and mirrors brackets
4322
3062
  in right-to-left runs. Given none, nothing changes: text lays out logically,
4323
3063
  exactly as before.
4324
-
3064
+
4325
3065
  `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
4326
3066
  `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
4327
3067
  consumer who renders no right-to-left text never installs the Unicode tables,
4328
3068
  and a different implementation can be substituted. `@weasel-js/bidi` is a
4329
3069
  devDependency here only, for a test that drives real Hebrew through the real
4330
3070
  engine; types lining up is not evidence the semantics do.
4331
-
3071
+
4332
3072
  `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
4333
3073
  across `cells`**. Cells stay in logical order — slot `i` is still character `i`
4334
3074
  — while their x values follow the reordering. Sort on `x` for visual order, and
@@ -4336,26 +3076,25 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4336
3076
  cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
4337
3077
  against each cell's own extent, taking a right-to-left cell's visually-leading
4338
3078
  half as the character's logical end.
4339
-
3079
+
4340
3080
  Kerning is a gap between two adjacent characters, and the wrap measures it
4341
3081
  logically. Reordering can put a different pair side by side, so the gap taken
4342
3082
  is the one belonging to whichever of the two is logically second, and none at
4343
3083
  all across a direction boundary — where the pair never touched in the source.
4344
-
3084
+
4345
3085
  Laying out right-to-left text with no engine now warns once, naming the import.
4346
3086
  The alternative is glyphs silently appearing reversed, which is the one real
4347
3087
  hazard of making this opt-in.
4348
-
4349
3088
  - 5d0ff9c: Every code point on a line gets a cell
4350
-
3089
+
4351
3090
  `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
4352
3091
  `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
4353
3092
  `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
4354
3093
  a consumer indexing per character no longer has to reconcile a sparse array
4355
3094
  against the source string.
4356
-
3095
+
4357
3096
  The old arrays were documented as non-contiguous, and two causes were real:
4358
-
3097
+
4359
3098
  - A code point no tier could serve was dropped outright, taking its caret stop
4360
3099
  with it. It now occupies a zero-advance cell. This is reachable whenever the
4361
3100
  dynamic canvas fallback is off — which is the normal configuration for a
@@ -4364,34 +3103,33 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4364
3103
  - A space opening a line — at the start of the text, or after a newline — was
4365
3104
  discarded. It now keeps its cell and still consumes no width, so a line is
4366
3105
  addressable per character without gaining an indent. A space that opens a
4367
- _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
4368
3107
  the line before.
4369
-
3108
+
4370
3109
  Neither changes any geometry: both cells carry zero advance, zero tracking and
4371
3110
  no kerning, so bounds, line widths and glyph positions are unchanged.
4372
-
3111
+
4373
3112
  A newline still has no cell, since it separates cells rather than being one.
4374
3113
  `srcEnd` is what a blank line carries in its place.
4375
-
3114
+
4376
3115
  `drawsInk` is a property of the code point and the face, not of the call that
4377
3116
  produced it: it does not flip when a dynamic bake lands or the outline
4378
3117
  threshold is crossed, so the same text reports the same slots every time. A
4379
3118
  zero-advance combining mark is `true` — it inks without advancing.
4380
-
4381
3119
  - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
4382
3120
  `data.fill` and `data.stroke` — the same two leaves every other node kind
4383
3121
  paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
4384
3122
  `TextStyle.stroke` are gone, with no compatibility read: a document that put
4385
3123
  its color in `style.fill` now renders in the default black rather than
4386
3124
  erroring, so check documents that predate this.
4387
-
3125
+
4388
3126
  This fixes a real asymmetry rather than only moving fields. `data.stroke`
4389
3127
  already reached text through a fold in the painter, but `data.fill` did not:
4390
3128
  picking a fill color with a text node selected wrote a field nothing read, so
4391
3129
  the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
4392
3130
  the Appearance leaf now all mean the same thing on text as on a rect. The
4393
3131
  duplicate `data.style.fill` control is gone from the text schema with them.
4394
-
3132
+
4395
3133
  `resolveTextStyle(style, paint)` takes the node's paint as a second argument
4396
3134
  and is what derives the caret and selection colors, so the edit overlay
4397
3135
  matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
@@ -4402,105 +3140,100 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4402
3140
  style. `StyledRun.fill` and `.stroke` are unchanged and still override the
4403
3141
  node's per range — which is also where a caller with no node at all, a HUD
4404
3142
  widget or a debug overlay, now states its color.
4405
-
3143
+
4406
3144
  `textCommandFromRuns` is exported from the package root.
4407
-
4408
3145
  - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
4409
3146
  array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
4410
3147
  the last paths still doing what `drawImage` stopped doing; text now costs
4411
3148
  **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
4412
3149
  ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
4413
3150
  image draw. No other command kind moved.
4414
-
3151
+
4415
3152
  A text group is as many quads as it has glyphs, so unlike the image ring a
4416
3153
  slot's buffer grows to the largest run it has seen rather than being fixed at
4417
3154
  four vertices. The quad index pattern is a pure function of the quad count —
4418
3155
  the pattern for N quads is a prefix of the pattern for any larger N — so one
4419
3156
  index buffer serves every slot, grown the same way and written only when it
4420
3157
  grows.
4421
-
4422
3158
  - c2ffa49: Alignment can resolve against reading direction
4423
-
3159
+
4424
3160
  `align` gains `start` and `end` alongside `left` / `center` / `right`, and
4425
3161
  `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
4426
3162
  the relative pair resolves against the direction, the absolute pair ignores it.
4427
3163
  `resolveAlign(align, direction)` collapses one to the other and is exported for
4428
3164
  consumers that need an edge rather than an intent.
4429
-
3165
+
4430
3166
  Direction is an input, not something this package discovers. `@weasel-js/text`
4431
3167
  has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
4432
3168
  what it found; nothing here sniffs an environment.
4433
-
3169
+
4434
3170
  Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
4435
3171
  layout moves. Making `start` the default alignment is a separate call.
4436
-
3172
+
4437
3173
  `@weasel-js/svg` carries the direction through: `direction` joins the
4438
3174
  inheritable presentation properties, and `text-anchor` is now written and read
4439
3175
  against it. Two things were wrong before and are worth naming, because both
4440
3176
  rendered plausible output:
4441
-
3177
+
4442
3178
  - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
4443
3179
  a mapping that assumed three values and read the fourth as its `else`.
4444
3180
  - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
4445
3181
  right edge, while this model's default `align` is `left`. They agree under
4446
3182
  `ltr` and only there, so an RTL document with no explicit anchor imported as
4447
3183
  left-aligned.
4448
-
3184
+
4449
3185
  This is alignment and round-tripping only. Layout still walks code points in
4450
3186
  logical order with the pen always increasing: there is no bidi reordering and
4451
3187
  no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
4452
3188
  renders in logical order, and Arabic still renders unjoined.
4453
-
4454
3189
  - 4c097ef: Sit every run on a line on one baseline
4455
-
4456
- 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
4457
3192
  off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
4458
3193
  with the big run's cap rather than standing on the line with it. Two faces with
4459
3194
  different ascents at the same size diverged the same way. Baseline alignment is
4460
3195
  what inline text does everywhere else, and the module header already claimed
4461
3196
  this behavior — the walk just never implemented it.
4462
-
3197
+
4463
3198
  A line now sinks one baseline far enough to clear its tallest run's ascent and
4464
3199
  places every glyph against it. Glyph quads derive their top from that baseline
4465
3200
  rather than from the pen's line top, which is the whole of the change:
4466
3201
  `qy0 = baselineY + (yoffset - metrics.base) * scale`.
4467
-
3202
+
4468
3203
  Uniform-size text — nearly all text — is unchanged, since the maximum over one
4469
3204
  value is that value. Only lines that actually mix sizes or faces move, and they
4470
3205
  move to where they always should have been.
4471
-
3206
+
4472
3207
  The test named "mixed-size runs share a baseline on the same line" asserted only
4473
3208
  a quad count and passed throughout; it now asserts the baselines.
4474
-
4475
3209
  - 2b86e00: A text node's style is one value, not ten sibling paths
4476
-
3210
+
4477
3211
  `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
4478
3212
  `TextStyle` from ten independent leaves, each control writing a field of a
4479
3213
  value it could only half see. `data.style` is an object leaf now, with
4480
3214
  Character and Paragraph as groups inside it — groups head their fields and
4481
3215
  contribute nothing to the path, so a field is still a field of the style and
4482
3216
  one commit writes the whole thing.
4483
-
3217
+
4484
3218
  An object leaf whose fields are entirely grouped no longer prints its own
4485
3219
  heading, which would stack straight onto the first group's, and a group's
4486
3220
  fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
4487
3221
  into an object leaf when listing what a kind exposes — the fields are the
4488
3222
  editable surface; the leaf is the container.
4489
-
3223
+
4490
3224
  `SelectionPanel` has a story now, which is how the two layout defects above
4491
3225
  were found.
4492
-
4493
3226
  - d933a89: Superscript, subscript and overline for styled runs
4494
-
3227
+
4495
3228
  `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
4496
3229
  a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
4497
3230
  over two new primitives rather than a mechanism of its own:
4498
-
3231
+
4499
3232
  - `baselineShift` — raise (positive) or lower (negative) a run off the line's
4500
3233
  shared baseline, in ems of the inherited font size.
4501
3234
  - `fontScale` — a multiplier on the inherited font size, the relative
4502
3235
  counterpart to `fontSize`. An absolute `fontSize` still wins over it.
4503
-
3236
+
4504
3237
  Naming either directly overrides that half of `script` and leaves the other
4505
3238
  alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
4506
3239
  ±33.3% position — Adobe's defaults, so a character panel can show percentages
@@ -4508,28 +3241,27 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4508
3241
  carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
4509
3242
  them, and metrics that applied on one glyph tier and not the other would
4510
3243
  reflow text as it crossed the size threshold.
4511
-
3244
+
4512
3245
  `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
4513
3246
  `fontSize`, so layout never learns superscripts exist — it places a run against
4514
3247
  a baseline and an offset. The shift moves a run's glyphs, its outline geometry
4515
3248
  and its own decoration rules together, and deliberately does not feed back into
4516
3249
  the line's baseline or height: a superscript rides the line rather than
4517
3250
  reflowing it.
4518
-
3251
+
4519
3252
  `overline` joins `underline` and `strikethrough` on both `TextStyle` and
4520
3253
  `StyledRun`, additive over the node style like the other two, and is now
4521
3254
  available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
4522
3255
  is unchanged — it stays silent on the decorations, as it always has been.
4523
-
4524
3256
  - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
4525
3257
  its own final `onTick`. The last tick emitted the value and completed in one
4526
3258
  pass, so a write made from that tick — cancelling the tween — still got the
4527
3259
  completion callback, against the documented "not called on cancel" contract.
4528
3260
  - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
4529
-
3261
+
4530
3262
  Two ops inverted to something narrower than what they applied, so undo
4531
3263
  silently lost data.
4532
-
3264
+
4533
3265
  `createDeleteOp.invert()` re-inserted a single node while `apply()` called
4534
3266
  `removeNode`, which cascades the whole subtree. Delete a container with two
4535
3267
  children, undo, and the container came back with `children: []` while both
@@ -4539,7 +3271,7 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4539
3271
  re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
4540
3272
  does not cascade, so the inverse skips any descendant the adapter still reports
4541
3273
  as live rather than duplicating it.
4542
-
3274
+
4543
3275
  `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
4544
3276
  instead of restoring the sibling slot and paint order changed. `ReparentArgs`
4545
3277
  now carries `fromIndex` / `toIndex` and places through the existing
@@ -4547,39 +3279,42 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4547
3279
  `setParent`'s signature is unchanged. Adapters without that seam no-op as
4548
3280
  before. `groupAction` captures each member's index before mutating; `move` and
4549
3281
  `snapToContainer` pass none and are byte-identical.
4550
-
3282
+
4551
3283
  `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
4552
3284
  cascade, which is why nothing caught the first bug. It now runs against a
4553
3285
  tree-backed fake.
4554
-
4555
3286
  - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
4556
-
4557
- 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.
4558
3289
  Select two shapes, rotate one, and the multi-selection frame and its handles
4559
3290
  sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
4560
- 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
4561
3292
  drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
4562
3293
  correct larger box.
4563
-
3294
+
4564
3295
  `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
4565
3296
  and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
4566
3297
  beside the rotation-free `unionBounds`, which stays correct for commit-time
4567
3298
  actions that write poses back in the unrotated frame; the module says which to
4568
3299
  reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
4569
3300
  another name. Both new functions are exported from the package root.
4570
-
3301
+
4571
3302
  Moved onto it: `ChromeState.unionBounds`, the selection overlay's
4572
3303
  container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
4573
3304
  the wrong place whenever a member was rotated), and WeaselDraw's export
4574
3305
  viewBox, which clipped rotated shapes out of the copied SVG.
4575
-
4576
3306
  - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
4577
-
3307
+
4578
3308
  `useAction` returned early on a null registry, so an action registered above
4579
3309
  the provider — or with no provider mounted — silently never fired its
4580
3310
  bindings. It now warns in dev, naming the action id. Runtime behavior in
4581
3311
  production builds is unchanged.
4582
-
3312
+ - Updated dependencies [5c8e9e6]
3313
+ - Updated dependencies [2621cbf]
3314
+ - Updated dependencies [0f936da]
3315
+ - Updated dependencies [4180095]
3316
+ - Updated dependencies [9977908]
3317
+ - Updated dependencies [52c7b2a]
4583
3318
  - Updated dependencies [3386d64]
4584
3319
  - Updated dependencies [c6c499d]
4585
3320
  - Updated dependencies [20097e6]
@@ -4591,13 +3326,13 @@ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
4591
3326
  - Updated dependencies [c2ffa49]
4592
3327
  - Updated dependencies [4c097ef]
4593
3328
  - Updated dependencies [d933a89]
4594
- - @weasel-js/geom@2.0.0-pre.0
4595
- - @weasel-js/text@2.0.0-pre.0
4596
- - @weasel-js/gestures@2.0.0-pre.0
4597
- - @weasel-js/history@2.0.0-pre.0
4598
- - @weasel-js/modes@2.0.0-pre.0
4599
- - @weasel-js/paint@2.0.0-pre.0
4600
- - @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
4601
3336
 
4602
3337
  ## 1.2.0
4603
3338