@weasel-js/core 1.2.0 → 1.3.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 (59) hide show
  1. package/CHANGELOG.md +2982 -0
  2. package/dist/{DrawCommand-uNkv5TjO.d.ts → DrawCommand-BnZyQaDc.d.ts} +6 -189
  3. package/dist/chunk-2KKYDDDD.js +121 -0
  4. package/dist/chunk-2KKYDDDD.js.map +1 -0
  5. package/dist/{chunk-J7LUY47N.js → chunk-3FB44DBC.js} +3 -3
  6. package/dist/{chunk-J7LUY47N.js.map → chunk-3FB44DBC.js.map} +1 -1
  7. package/dist/{chunk-CKADTCHK.js → chunk-3ZV53ZW2.js} +51 -14
  8. package/dist/chunk-3ZV53ZW2.js.map +1 -0
  9. package/dist/{chunk-ADWOB5K2.js → chunk-67KE7SDP.js} +4 -4
  10. package/dist/{chunk-ADWOB5K2.js.map → chunk-67KE7SDP.js.map} +1 -1
  11. package/dist/{chunk-CSMF654J.js → chunk-AQS4JGRA.js} +50 -7
  12. package/dist/chunk-AQS4JGRA.js.map +1 -0
  13. package/dist/{chunk-HGF5X6MZ.js → chunk-BJN5ZRIJ.js} +2 -2
  14. package/dist/chunk-BJN5ZRIJ.js.map +1 -0
  15. package/dist/{chunk-4Z6J4IVH.js → chunk-F74NVEHT.js} +14 -46
  16. package/dist/chunk-F74NVEHT.js.map +1 -0
  17. package/dist/{chunk-UGFFCMQP.js → chunk-MXFSHJOM.js} +4 -4
  18. package/dist/{chunk-UGFFCMQP.js.map → chunk-MXFSHJOM.js.map} +1 -1
  19. package/dist/{chunk-7KYLIC3E.js → chunk-N3T7D6AS.js} +16611 -15749
  20. package/dist/chunk-N3T7D6AS.js.map +1 -0
  21. package/dist/{chunk-BHVYVFGV.js → chunk-ZB7UYJVG.js} +3 -3
  22. package/dist/{chunk-BHVYVFGV.js.map → chunk-ZB7UYJVG.js.map} +1 -1
  23. package/dist/clipboard.d.ts +3 -3
  24. package/dist/clipboard.js +3 -3
  25. package/dist/clone.d.ts +2 -2
  26. package/dist/clone.js +3 -3
  27. package/dist/{geometry-C56YbnfD.d.ts → geometry-Dtt_k6Dq.d.ts} +1 -1
  28. package/dist/{grid-DqOi0Vgi.d.ts → grid-nnXU4VjN.d.ts} +1 -1
  29. package/dist/index.d.ts +1510 -1292
  30. package/dist/index.js +10 -10
  31. package/dist/insert.d.ts +3 -3
  32. package/dist/insert.js +1 -1
  33. package/dist/move.d.ts +4 -4
  34. package/dist/move.js +3 -3
  35. package/dist/{options-C6HYCKP7.d.ts → options-CdFl510T.d.ts} +1 -1
  36. package/dist/{path-B6MMiodD.d.ts → path-JEV2c5If.d.ts} +2 -13
  37. package/dist/patterns-builtin.d.ts +1 -1
  38. package/dist/patterns-builtin.js +1 -1
  39. package/dist/{pointSnapToGrid-BBtn5bus.d.ts → pointSnapToGrid-Dmthv94u.d.ts} +2 -2
  40. package/dist/{registry-l3JX0Z6b.d.ts → registry-BU1_3L_C.d.ts} +629 -34
  41. package/dist/renderer.d.ts +38 -37
  42. package/dist/renderer.js +10 -10
  43. package/dist/resize.d.ts +4 -4
  44. package/dist/resize.js +2 -2
  45. package/dist/routing.d.ts +35 -9
  46. package/dist/routing.js +1 -1
  47. package/dist/{stroke-DaxnQ-u7.d.ts → stroke-5pBwOm9s.d.ts} +41 -4
  48. package/dist/{types-C2R2bjxf.d.ts → types-DIQAisSG.d.ts} +99 -5
  49. package/dist/{types-DbysOO-2.d.ts → types-XBcDEp3Y.d.ts} +14 -9
  50. package/dist/{types-BhrifbZ1.d.ts → types-miXHGrZM.d.ts} +1 -1
  51. package/package.json +9 -7
  52. package/dist/chunk-4Z6J4IVH.js.map +0 -1
  53. package/dist/chunk-7KYLIC3E.js.map +0 -1
  54. package/dist/chunk-CKADTCHK.js.map +0 -1
  55. package/dist/chunk-CSMF654J.js.map +0 -1
  56. package/dist/chunk-GVCNT7UH.js +0 -47
  57. package/dist/chunk-GVCNT7UH.js.map +0 -1
  58. package/dist/chunk-HGF5X6MZ.js.map +0 -1
  59. package/dist/paint-types-1fUdZUaH.d.ts +0 -266
package/CHANGELOG.md CHANGED
@@ -1,5 +1,2987 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
8
+ into `@weasel-js/paint` — two new Tier A leaves.
9
+
10
+ `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
11
+ measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
12
+ `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
13
+ lay out text without taking the scene graph or a React peer dependency.
14
+ `layoutRuns` is now public — it was previously reachable only from inside core.
15
+
16
+ `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
17
+ `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
18
+ layout could not move while its fill type lived in the renderer's graph.
19
+
20
+ `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
21
+ `Rect` moves to `@weasel-js/geom`, beside `Box`.
22
+
23
+ Breaking for anyone importing these through core's internal paths rather than
24
+ its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
25
+
26
+ Advances and kerning still come from a baked MSDF atlas — laying out from font
27
+ bytes alone needs the metrics seam in
28
+ `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
29
+
30
+ <!-- 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" -->
31
+
32
+ ### Patch Changes
33
+
34
+ - 52c7b2a: Depend on `font` and `core` as exact peers
35
+
36
+ `@weasel-js/font` and `@weasel-js/core` keep registries that consumer code
37
+ writes into — registered faces and glyph-ready subscribers in one, content
38
+ handlers and paint kinds and shape painters in the other. Two physical copies
39
+ in a tree are two registries, so a face registered into one while layout
40
+ resolves against the other lays out nothing and the canvas is blank.
41
+
42
+ Exact sibling pins are what produced the duplicate: a consumer mixing two
43
+ weasel releases left npm no choice but to nest a second copy, silently. As
44
+ peers, the same mix is an `ERESOLVE` at install time. `font` is now a peer of
45
+ `core`, `hud` and `text`; `core` is now a peer of `svg`, joining `d3`, `hud`
46
+ and `ui`, whose `>=` ranges tighten to exact so no version mix resolves by
47
+ accident.
48
+
49
+ **This can break an install that currently succeeds.** Anyone resolving a
50
+ mixed set of weasel versions by luck now gets an install error instead of a
51
+ blank canvas. That is the point, but it is a break.
52
+
53
+ `labkit` deliberately keeps `core` as an ordinary dependency: its build aliases
54
+ every core entry point to core's built files and inlines them, so it never
55
+ resolves core at the consumer and has nothing to peer. The flip side is that
56
+ labkit ships its own copy of core's registries, so a consumer using both still
57
+ has two — this change does not address that.
58
+ - 3386d64: Align, distribute and flip use visual bounds
59
+
60
+ These folded each member's unrotated pose box, so "Align Left" on a selection
61
+ containing a rotated shape lined up the boxes and left the rotated shape's ink
62
+ sticking out past the others. They now work on the visual bounding box, as
63
+ Figma and Illustrator do.
64
+
65
+ Both ends moved together — expanding only the union would have made alignment
66
+ worse, since the delta runs from an edge of the union to the same edge of each
67
+ member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
68
+ reads a pose's bounds, recovers its rotation and expands via
69
+ `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
70
+ applied as a translation of the stored pose through
71
+ `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
72
+
73
+ Flip needed only its union pivot changed: mirroring maps a centre and preserves
74
+ size, and an expanded box is concentric with the box it came from.
75
+
76
+ `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
77
+ by its ink.
78
+ - ffafb7d: Never let an animation's virtual clock run backwards.
79
+
80
+ `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
81
+ time, then advances its virtual clock by the difference against the timestamp
82
+ the frame loop supplies. Those two share a time origin in a browser, where the
83
+ rAF timestamp and `performance.now()` are both page-relative — but that is a
84
+ browser guarantee, not a universal one, and jsdom starts them roughly 600ms
85
+ apart. The first frame's delta then came out hugely negative and `virtualNow`
86
+ spent dozens of frames climbing back toward zero before a tween advanced at
87
+ all: a 40ms glide took 95 frames and over a second of wall time, growing worse
88
+ the longer the process had been alive.
89
+
90
+ A frame's elapsed time is never negative, so the sample is now clamped at
91
+ zero. Under a shared origin this is a no-op.
92
+ - ba8b139: Camera animation: `viewport.animatedZoom` now does something
93
+
94
+ `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
95
+ nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
96
+ through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
97
+ their input already delivers a sample per frame.
98
+
99
+ Camera animation is a general surface, not a zoom flag. Three ways in, one
100
+ runner behind them:
101
+
102
+ - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
103
+ `isAnimating`, `target`.
104
+ - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
105
+ so any action can glide the camera.
106
+ - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
107
+ `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
108
+ three are **required** members: anyone hand-implementing `SceneCanvasApi`
109
+ (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
110
+ grew `getPaintedVersion`.
111
+
112
+ Scale interpolates geometrically and translation is derived from the screen
113
+ point the two views agree on, so a zoom stays anchored instead of drifting and
114
+ each frame changes the view by the same ratio. One animation runs at a time; any
115
+ other view write cancels it, and a cancel leaves the camera where it is rather
116
+ than jumping to the target. On an uncontrolled canvas the whole animation costs
117
+ no React render.
118
+
119
+ **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
120
+ changes signature — it takes a `{ get, set }` view channel plus an optional
121
+ `Animator`, and `animateTo(from, to, { duration, easing })` becomes
122
+ `animate(to, { ms, easing })`. The `from` argument is gone because the runner
123
+ reads the live view, which is what lets an interrupted camera resume from where
124
+ it actually is instead of snapping back to a captured start. `cancel()` is now
125
+ `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
126
+ `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
127
+ the same reason `from` does.
128
+
129
+ **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
130
+ `() => View | void`. Returning the target view lets Cmd+0 animate there;
131
+ returning nothing keeps the existing behavior. `animatedZoom`'s config fields
132
+ are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
133
+ animator's vocabulary.
134
+ - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
135
+
136
+ The canvas is focusable by default (`tabIndex` 0) but exposed no way to
137
+ observe focus, so consumers driving focus-dependent chrome had to attach a
138
+ listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
139
+ so reach `SceneCanvasProps` and the canvas element unchanged.
140
+ - 67bcb05: Drop four values the canvas layer memo no longer reads
141
+
142
+ `hit-test affordances against the painted chrome state` moved the selection
143
+ overlay to reading bounds off the chrome state at paint time, which left
144
+ `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
145
+ referenced only by the `layers` memo's dependency array — nothing in the body
146
+ used them. Removing them from the array made all four dead locals, so they go
147
+ too.
148
+
149
+ The memo now rebuilds the layer array on layer/tool/geometry changes rather
150
+ than additionally on every selection and preview-pose change. Selection chrome
151
+ is unaffected: it repaints from chrome state, not from the identity of this
152
+ array.
153
+ - 47cbb08: A closed subpath's dash no longer seams at its start vertex
154
+
155
+ `splitForDash` flushed the run still open when a closed subpath's walk returned
156
+ to the vertex it started from as its own open sub-polyline, so it and the run
157
+ that began there rendered as two butt-capped ribbons meeting at a point — a
158
+ notch on the corner of any dashed rectangle whose perimeter isn't a whole
159
+ multiple of the pattern. They are joined now, and the join the stroke asked for
160
+ is drawn across the seam like any other corner. A pattern whose first "on"
161
+ length covers the whole perimeter emits a closed ribbon, identical to the
162
+ undashed stroke.
163
+ - f43e9c2: A derived edge follows the drag that moves its endpoint
164
+
165
+ `move`, `resize` and `rotate` kept their in-flight poses in action-local
166
+ scratch and published them only as `previewIds` / `previewPose`. That surface
167
+ is enough to paint a ghost and size selection chrome, but nothing that asks
168
+ the *scene* where a node is can see it — and `scenePoseLookup`, which resolves
169
+ a derived node's geometry, asks the scene. So dragging a box left its edge
170
+ anchored to the pre-drag position until the drop, when the commit invalidated
171
+ the dependents and the edge jumped.
172
+
173
+ The three actions now also publish each frame into the scene's ephemeral pose
174
+ overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
175
+ `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
176
+ so a drag still commits as exactly one undo entry — the reason the actions
177
+ avoided per-frame scene writes in the first place was history, and this writes
178
+ no history. Entries are set once and mutated in place, published with a single
179
+ `commit()` per frame.
180
+
181
+ Picking follows for free: the pick source resolves a derived path through its
182
+ own override-aware `poseOf`, so an edge is grabbable where it is drawn
183
+ mid-gesture rather than where it used to be.
184
+
185
+ `clone` is deliberately untouched — its previews are the new ghosts at the
186
+ drag target, and the originals never move, so nothing derives from a changed
187
+ pose.
188
+
189
+ Also closes the matching gap in the preview-ghost layer, which built a
190
+ container's clip with no derived path and so ghosted a derived container
191
+ without one.
192
+
193
+ Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
194
+ on every gesture frame. It was already required by the `Scene` contract, but a
195
+ partial fake that omitted it will now throw rather than silently skip.
196
+ - bb27e83: A derived node is clickable where it paints
197
+
198
+ A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
199
+ `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
200
+ resolved from the *dependencies'* poses, which a painter has no handle on. So
201
+ `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
202
+ as "no opinion" and answered `true` everywhere, and picking fell back to the
203
+ node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
204
+ was unpickable, and a derived container contributed no clip.
205
+
206
+ `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
207
+ same convention `NodePaintCtx` already uses, and `kit:derived` reports the
208
+ derived path as its silhouette and its declared stroke as its `ink`.
209
+
210
+ Resolving that path needs the scene, so it is the *source* that answers, not
211
+ the painter: `PickSource.derivedPathOf`, a matching optional argument to
212
+ `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
213
+ carried the derived path a node *paints*, and now also the clip a derived
214
+ container *imposes*, so the live canvas and the headless walk clip alike. The
215
+ bare-adapter paths supply none of them and behave exactly as before.
216
+
217
+ The pre-filter had to move with it. `useSceneSelectTool` grew its region test
218
+ from the node's pose, which for a derived node is the wrong box entirely, so
219
+ the edge was rejected before the shape test could claim it. It now tests the
220
+ derived path when there is one — `poseContains` already reads a path-like pose
221
+ as a path, so this reuses it rather than adding a second reach calculation.
222
+
223
+ `findShapeSilhouette` skips its memo when handed a derived path. That slot is
224
+ keyed on `(node, pose, data)` and cannot see the path, so it would serve one
225
+ caller's silhouette to a caller that passed a different one — the same reason
226
+ `kit:derived` already skips `PAINT_SLOT`.
227
+ - 6a33c3f: A node's path can be derived from other nodes' poses
228
+
229
+ A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
230
+ through `SceneRegistry`, and the scene walks resolve its path before painting
231
+ rather than it being authored. An edge drawn between two boxes is then an
232
+ ordinary scene node — selectable, styleable, exportable — whose geometry never
233
+ enters undo history. The seam and its traps are in `docs/extending.md`.
234
+
235
+ New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
236
+ `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
237
+ `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
238
+ `SerializedNode.derivePathKey`, both additions to the serialization format;
239
+ `NodePaintCtx.derivedPath`.
240
+
241
+ Deleting a node now deletes everything that derives from it, transitively,
242
+ including those nodes' own subtrees, in one undo entry — so `scene.remove` can
243
+ remove nodes anywhere in the tree that the caller never named, and `removeLayer`
244
+ reaches nodes on other layers. Undo after the built-in **Delete** key does not
245
+ yet restore the cascaded nodes; see "Derived geometry follow-ups" in
246
+ `docs/TODO.md`.
247
+
248
+ **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
249
+ context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
250
+ now a type error rather than a silent slide into the `view` slot. The same
251
+ fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
252
+ callback types, which is not a break: an existing three-parameter implementation
253
+ still satisfies them, and an existing three-argument call still compiles.
254
+
255
+ **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
256
+ typed as a `Scene` — a test double, most likely — no longer typechecks until it
257
+ implements it.
258
+
259
+ **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
260
+ `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
261
+ is not a descendant of the removed node and the tree has to be told about every
262
+ subtree that came out of it. A history persisted by an older build now throws
263
+ mid-undo rather than degrading. The break is deliberate; kit op payloads are not
264
+ versioned.
265
+ - c24e7de: Detached views follow pose overrides
266
+
267
+ `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
268
+ which a pose override deliberately never bumps — so they kept painting document
269
+ poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
270
+ canvas driving a drag or a simulation silently disagreed with it.
271
+
272
+ `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
273
+ subscribes to `scene.overrides`. A render (prop change or version bump) and an
274
+ override commit both just mark the surface dirty, and one animation frame
275
+ coalesces them — so a 60 Hz override loop repaints these views with no React
276
+ render, and a backgrounded tab stops painting them entirely. The mount paint
277
+ stays synchronous, so the first frame is still the scene rather than a blank
278
+ canvas. `<MinimapCanvas>` inherits all of this through it.
279
+
280
+ Repaints driven by a prop change are now asynchronous: they land on the next
281
+ animation frame rather than in the layout effect of the render that caused them.
282
+ Code that renders and then reads pixels in the same tick needs to wait a frame.
283
+
284
+ A minimap's *framing* still derives from document poses, so a node overridden
285
+ outside the document bounds paints outside the fitted frame — recomputing the
286
+ fit per frame would rescale the whole minimap throughout a settle.
287
+ - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
288
+
289
+ `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
290
+ once instead of hiding two behind a select. Options carry an optional `short`
291
+ label — a capital or two — for the width a property row has; the full `label`
292
+ stays the accessible name, so the abbreviation never becomes the only thing
293
+ naming the option. A mixed selection selects no segment rather than picking a
294
+ winner.
295
+
296
+ `pair` now merges fields inside an object leaf, as it already did for section
297
+ rows — a hint shouldn't mean something different for being a field of a value
298
+ rather than a sibling of one. It merges *adjacent* leaves in both places, so
299
+ the schema orders family, size, weight: size and weight pair, and family (which
300
+ sat between them) moves ahead of the pair rather than splitting it.
301
+
302
+ A stroke's cap, join and align share one row; property rows wrap rather than
303
+ overflow when the controls in them don't fit.
304
+ - be697dc: Add ephemeral pose overrides to the scene
305
+
306
+ `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
307
+ hit-test paths read through and that history, `toJSON()` and `getVersion()`
308
+ never see. It is additive: a scene with no overrides behaves exactly as before.
309
+
310
+ This is where per-frame motion belongs. A 60 Hz loop previously had to write
311
+ through `setPose`, which records an undo entry (one per frame at best, batched)
312
+ and bumps the scene version, re-rendering every `useSyncExternalStore`
313
+ subscriber. It also had to allocate a fresh pose object per moving node per
314
+ frame, because the painter memo keys on pose reference. An override entry is
315
+ hoisted once and mutated in place; `overrides.commit()` publishes the frame and
316
+ invalidates the memo for the overridden nodes only.
317
+
318
+ `commit()` is required after an in-place mutation — without it the memo serves
319
+ the previous frame's draw. Overrides are cleared when a node is removed, since
320
+ ids are reusable. To make a frame permanent, write it once through `setPose`
321
+ and clear the override; that single step is the undo entry.
322
+
323
+ `ForceGraphDemo` now settles with zero history entries and bakes the result as
324
+ one, replacing a per-tick batch of 24 `setPose` calls.
325
+ - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
326
+
327
+ It was the fourth site measuring text its own way: `ctx.measureText` per
328
+ character against system fonts, no kerning, `pose.text` only. Nothing masked
329
+ it the way the WebGL context masked the caret — a consumer calling it got a
330
+ box that disagreed with the paint, narrower by a kern on every pair and wrong
331
+ by the whole difference between the installed family and the registered face.
332
+ It goes through the shared layout now, so it sees kerning and per-run styling.
333
+
334
+ **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
335
+ - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
336
+
337
+ `requestRedraw()` marks the surface dirty and the next frame paints, so many
338
+ redraws in one tick cost one paint. The view gains an imperative path on the
339
+ canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
340
+ longer holds it in React state, so a camera moving at 60 Hz costs no renders.
341
+ Consumers passing a `view` prop stay controlled and are unaffected.
342
+
343
+ Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
344
+ that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
345
+ gives a host the scene without a render per mutation, `useSceneTextEdit`'s
346
+ `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
347
+ `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
348
+
349
+ Two public signatures changed. `usePinchZoomTool` takes a view getter,
350
+ `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
351
+ captured value any more. `CanvasExtensionApi` gained five required members —
352
+ `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
353
+ so external code hand-implementing that interface stops typechecking; code that
354
+ only calls through the ref is unaffected.
355
+
356
+ Pixels and DOM can now be a frame apart, in whichever direction the change came
357
+ from. A view change leads with pixels: `setView` paints without rendering, so
358
+ DOM built from the view is stale until something re-renders it — position
359
+ world-anchored DOM from `subscribeView`. A scene change leads with DOM:
360
+ `SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
361
+ pixels land next frame — compare `getPaintedVersion()` against the version you
362
+ are about to render when chrome must be in lockstep. Do not render scene-derived
363
+ DOM inside `startTransition`: React defers it and nothing forces it to catch up.
364
+
365
+ Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
366
+ mode is the one in-tree case — can likewise see a buffer one frame older;
367
+ `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
368
+ paints while `document.hidden` is true, `syncPaint` included, so a readback from
369
+ a background tab returns the frame from before the tab was hidden.
370
+ - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
371
+ recorded. A caller is entitled to reuse the array it uploads from, so storing
372
+ the reference recorded a value that later frames overwrote — a test reading
373
+ two frames back saw the same numbers twice and passed. Test-only surface.
374
+ - 3fb3a46: Release held keys when the window loses focus
375
+
376
+ A window that blurs mid-hold never delivers the keyup, so every in-flight
377
+ `key-held` handle stayed engaged until that key was pressed again — holding
378
+ Space and tabbing away left the hand tool on the hotkey stack indefinitely.
379
+
380
+ The gesture dispatcher now fires the `key-held` up phase for each held key on
381
+ window blur. Consumers that hand-rolled this reset can drop it; ongoing
382
+ invocations see a normal `onEnd`.
383
+ - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
384
+
385
+ One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
386
+ round caps and joins, hairline weight reserved for structure, and filled
387
+ regions only where an action has a subject. Covers transport, history, view,
388
+ trial lifecycle, collection, state, instrument and status vocabulary. Import a
389
+ named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
390
+
391
+ `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
392
+ so consumers have one import site for the whole set. `ImageIcon` was reachable
393
+ from core's icons folder but missing from its public barrel; it is exported
394
+ now.
395
+
396
+ Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
397
+ rather than hand-placed, because arrowheads and joins that miss their terminus
398
+ are invisible at chrome size.
399
+ - 3fb3a46: Compose `before` and `after` layer chains in both directions
400
+
401
+ `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
402
+ off an `after` anchor only followed further `after` links, and likewise for
403
+ `before`. A custom layer anchored `before: 'scene'` carrying a second custom
404
+ anchored `after` it dropped that second layer to the tail with a spurious
405
+ dangling-reference warning.
406
+
407
+ Both walks now emit a layer's `before` chain, the layer, then its `after`
408
+ chain, so the two mix freely. Cycle detection and orphan fallback are
409
+ unchanged.
410
+ - d9f110e: Stop every frame loop while nothing can see it
411
+
412
+ New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
413
+ whether a frame may run: nothing runs while `document.hidden`, and a loop that
414
+ names an element also stops while that element is outside the viewport. A
415
+ request made while suspended is held rather than dropped and re-armed on
416
+ resume, so a loop never polls visibility or needs restarting by hand.
417
+
418
+ Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
419
+ `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
420
+ counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
421
+ `useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
422
+ the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
423
+ it paints only dirty layers, but a hidden tab still commits React updates and
424
+ its view/size effect marks every layer dirty.
425
+
426
+ Loops measuring elapsed time rebase their clock through the new `onResume`
427
+ option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
428
+ meter reporting a rate nobody achieved, a tween jumping to its end value on
429
+ return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
430
+ export; nothing in the tree sets it.
431
+
432
+ `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
433
+ in kit source, and runs in CI.
434
+ - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
435
+
436
+ A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
437
+ `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
438
+ ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
439
+ and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
440
+ second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
441
+ where the matcher picks a single winner.
442
+
443
+ Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
444
+ and jsdom reports an empty-string platform — not nullish, so the fallback never
445
+ fired and every mac binding in the kit was exercised only on the non-mac
446
+ branch. It reads `||` now.
447
+
448
+ Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
449
+ alongside the `viewport.pinchZoom` action that already handled the same
450
+ gesture, applying one pinch's factor twice — the opt-in broke the path that
451
+ worked without it. SceneCanvas drives pinch through the action alone, and the
452
+ flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
453
+ the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
454
+ for real; it previously left the action running. Bare `<Canvas>` keeps the hook
455
+ as its own pinch path.
456
+ - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
457
+
458
+ Nothing in the kit exported one, so consumers hand-rolled it — their own
459
+ `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
460
+ inverse built by projecting two points. That inverse silently drops
461
+ `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
462
+ beside the paint instead of on it.
463
+
464
+ ```ts
465
+ useNodeOverlayFrame(scene, containerRef, nodeId, { view })
466
+ // → { box, toScreen, toLocal, width, height } | null
467
+ ```
468
+
469
+ `box` is the node's composed world box, unrotated — the frame `toScreen` maps
470
+ from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
471
+ lives in the pose→world leg, where it belongs: a node's stored geometry and its
472
+ bounds-frame paint are pre-rotation by definition, so neither of those two
473
+ changes.
474
+
475
+ `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
476
+ `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
477
+ `stroke` — `slot` is a prop — and commits each drag through `setFill` or
478
+ `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
479
+
480
+ Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
481
+ `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
482
+ - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
483
+
484
+ `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
485
+ color and `'none'` still skips the stroke; an object is a core `Stroke` whose
486
+ `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
487
+ renderer, which has accepted them on `PathDrawCommand` all along. The object
488
+ wins outright over `data.strokeWidth` rather than merging with it, the same
489
+ rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
490
+ is baked onto the pose box the way a fill is, so a gradient stroke resolves
491
+ against the box it was authored against.
492
+
493
+ `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
494
+
495
+ `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
496
+ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
497
+ `'outer'` none inside, which one number could not say, so picking grabbed the
498
+ wrong side. `ink` takes an optional context carrying the view scale, so a
499
+ `{ px }` stroke width resolves to world units. A painter that still returns
500
+ `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
501
+
502
+ `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
503
+ color pick replaces its paint and keeps width, cap, join and dash, and an
504
+ opacity drag sets the paint's `opacity`, which is the only form that works on
505
+ a gradient stroke.
506
+
507
+ Editing UI for the rich form is not here yet — a schema-driven color control
508
+ still writes a bare string over the object, so nodes carrying one are for
509
+ programmatic authorship until `SelectionPanel` learns the union. See
510
+ `docs/proposals/2026-08-26-node-stroke-union.md`.
511
+ - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
512
+
513
+ `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
514
+ servers there deliberately, but the renderer refused anything but a solid — so
515
+ importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
516
+ next frame. Both stroke paths now paint the ribbon through the same route a
517
+ fill takes, including under the inner/outer alignment stencil. A non-solid
518
+ even-odd fill no longer renders black.
519
+ - 5f6c28e: An object leaf's fields can be organised into groups
520
+
521
+ `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
522
+ heads its fields under a label and contributes nothing to the path — the same
523
+ rule group keys follow at the top level of a schema, so a field inside one is
524
+ still addressed as a field of the object.
525
+
526
+ Without it, a value with many fields renders as one undifferentiated list. A
527
+ `TextStyle` is the case that needs it: its character and paragraph fields are
528
+ one value but read as two lists.
529
+ - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
530
+
531
+ A compound value — a stroke, a shadow, a pattern spec — could be described as
532
+ sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
533
+ It shouldn't be: each control then writes one field of a value it can only
534
+ half see, and writing a field into something that isn't an object yet corrupts
535
+ it outright.
536
+
537
+ `ToolPrefObject` describes the value instead. Its `children` are ordinary
538
+ leaves whose paths are relative to the object, and every child edit commits
539
+ the parent object whole. A field that is itself a union declares the kind that
540
+ edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
541
+ value still held in a scalar form before a child edit lands on it, which is
542
+ how a stroke stored as a bare colour string gains a width.
543
+
544
+ `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
545
+ Color, Width, Cap, Join and Align under one Stroke block, and the separate
546
+ `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
547
+ `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
548
+ replaced by this general one.
549
+
550
+ `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
551
+ import, export and rendering untouched.
552
+ - 2ea772f: Selection handles are hit-tested at the size they are painted
553
+
554
+ Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
555
+ constant, and neither `buildAffordanceAt` call site passed the option that
556
+ would have scaled it. A coarse pointer got a bigger picture and exactly the
557
+ same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
558
+ exists to provide never reached the hit-test. The slops debug overlay was a
559
+ third unscaled copy, so it drew hit regions where they were not.
560
+
561
+ `core/device/targets.ts` now holds one base table and one accessor,
562
+ `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
563
+ through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
564
+ `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
565
+ table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
566
+
567
+ `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
568
+ `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
569
+ reached nothing.
570
+
571
+ `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
572
+ affordance is an annulus with a band thickness and no point radius, so the
573
+ option could only ever have been a second name for `rotationHandleDistance`,
574
+ which is live and now defaults from the same table.
575
+
576
+ Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
577
+ device profile, so a nested view still hit-tests at the fine-pointer size.
578
+ - f77bd95: `getChildren` means one thing on an adapter
579
+
580
+ `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
581
+ drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
582
+ the z-ordering seam where `null` means the root. Both land on the same adapter
583
+ object, so `arrayAdapter` took the first shape from its config and exposed it
584
+ under the name the ops read with the second meaning. An op asking for root
585
+ order got `[]`, which reads as "the root has no siblings", and the slot it
586
+ captured was silently lost.
587
+
588
+ The two declarations are now one contract, and `arrayAdapter` answers the root
589
+ from its own item array rather than delegating — a consumer callback written
590
+ for node ids returns `[]` there, which cannot be told apart from a genuine
591
+ empty answer. A consumer's `getChildren` config is still only ever asked about
592
+ a node id.
593
+
594
+ `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
595
+ than by anchor. That is unchanged, and it is why the ordinal fallback exists.
596
+ - 2ea772f: The canvas and the gradient editor now sample one gradient
597
+
598
+ `buildGradientRamp` carried its own interpolation beside
599
+ `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
600
+ at either end and extrapolated past the first and last stop, the two picked
601
+ opposite sides of a coincident pair, and they parsed color differently — a stop
602
+ written as a CSS named color rendered on the canvas and threw in the editor.
603
+
604
+ `sampleGradientStops` keeps its semantics and is now the only implementation.
605
+ `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
606
+ returns the color at `t`. The ramp cache builds its texels through those, so
607
+ there is no interpolation math left in the renderer.
608
+
609
+ Two behavior changes worth naming. `resolveColor` is the surviving parser, so
610
+ gradient stops accept named and functional colors everywhere — but no longer
611
+ hex without a leading `#`, which only the editor path had tolerated and the
612
+ canvas never accepted. And `sampleGradientStops` returns normalized hex at the
613
+ endpoints instead of echoing the raw stop string, so `'red'` comes back as
614
+ `'#ff0000'`.
615
+
616
+ **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
617
+ the element already carried the reference, the built-in serializer returned
618
+ nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
619
+ shape disappeared in a browser with no warning at all. Serialization now falls
620
+ through to the same warning the pattern path already emits when nothing can
621
+ produce a paint server. A consumer that registers a `toSvg` for
622
+ `conic-gradient` still serializes and gets no warning.
623
+ - aba8d91: Answer "can this node be hit" in one place
624
+
625
+ Four tree walks answered it separately — the generic-adapter point pick, the
626
+ one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
627
+ marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
628
+ that had a test and disagreed on the rest, three times, silently. `pickWalk`
629
+ now owns every gate; a query supplies only its own shape test and the clip
630
+ predicate for its region.
631
+
632
+ Behavior that changes as a result:
633
+
634
+ - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
635
+ same number the painter does — the view's `alphaFor` times any per-node
636
+ override alpha — so a node faded out of sight stops claiming clicks. The
637
+ floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
638
+ Alpha is per view: dimming a node in one view leaves it pickable in another.
639
+ - **A layer that is not painted no longer claims pointer events.** `drawLayers`
640
+ drops any layer missing from a supplied `layerOrder`, and the chrome hit path
641
+ only consulted `layerVisibility`. Both gates now run through one
642
+ `isLayerPainted`, which is exported.
643
+ - `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
644
+ did not; its default `poseBounds` answers a path pose instead of `NaN`, which
645
+ is what the shadow walk existed to work around.
646
+ - An ancestor clip now rejects an area query that reaches into the clip where
647
+ the node is not, or reaches the node where the clip is not — the two terms
648
+ together, where one alone let false positives through.
649
+
650
+ `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
651
+ `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
652
+ `adapterPickSource` and `ownClipOf` replace them.
653
+ - 2ea772f: A drag-to-insert reports the bounds it paints
654
+
655
+ The painter, the commit factory and `getGestureBounds()` each sized an
656
+ in-flight insert differently. The reporter read the drag rect alone, so a
657
+ centered Alt-drag reported a half-extent of `d` against a painted circumradius
658
+ of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
659
+ star, and a pencil scribble that looped back to its start reported nothing at
660
+ all. The painter and the commit agreed on polygon and star but not on line or
661
+ pencil: the commit posed the drag AABB for a line the painter drew endpoint to
662
+ endpoint, and fell back to the drag rect for a trail under four samples.
663
+
664
+ One function now answers it for all three. The zero-area skip in the painter
665
+ and the reporter tests the resolved extent rather than the raw drag rect, and
666
+ an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
667
+ `bounds` argument handed to a factory is unchanged.
668
+ - 3386d64: Path command opcodes derive from one table
669
+
670
+ `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
671
+ once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
672
+ literals in the path transform, pose-rotation and pose-descriptor walkers. They
673
+ agreed, and nothing held them to each other: a sixth opcode desynchronizes two
674
+ packages' reading of the same `Uint8Array` with no exception and no type error,
675
+ and every walker misparses the coordinate stream from that command on.
676
+
677
+ `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
678
+ `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
679
+ core re-exports them by name, so the opcode constants keep their names, values
680
+ and literal types. The three walkers moved onto `forEachSegment` rather than
681
+ onto the accessor alone — they were duplicating the coordinate-cursor advance
682
+ as well as the length, and the cursor is the half that actually misreads.
683
+
684
+ Eight further files switch on these opcodes with inline literals. Five throw on
685
+ an unknown code; three — the path boolean adapter, the anchor-editing geometry,
686
+ and geom's own boolean adapter — have no `default` arm and would silently stop
687
+ advancing. Left as-is; they need per-command semantics, not one walker.
688
+ - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
689
+
690
+ `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
691
+ saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
692
+ leaf had neither `encoding` nor `options[].disabled`, so a dash-array
693
+ preference did not merely fail to select — choosing an option wrote the option
694
+ string over the stored dash array. labkit's two renderers were missing the
695
+ `paint` and `object` kinds outright.
696
+
697
+ ui's schema is now a rename re-export of core's declaration. The public `Pref*`
698
+ names are unchanged, and there is nothing left to keep in sync.
699
+
700
+ More importantly, all four renderer switches ended in `default:`, so adding a
701
+ built-in kind produced no error at any site and simply rendered nothing —
702
+ verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
703
+ `string` so app-defined prefs can ride the same tree, which means a `never`
704
+ guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
705
+ `Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
706
+ `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
707
+ can discriminate and end in a `never`. App-defined kinds take the placeholder
708
+ path as before.
709
+
710
+ Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
711
+ arm threads sibling values, routes through `encoding.read` / `encoding.write`,
712
+ and honors `option.disabled`. `SelectionPanel` already did all of this — it was
713
+ only the forked copy that could not express it.
714
+ - 3386d64: Dragging out a text box shows a live preview
715
+
716
+ The set of insertable kinds and the `KitInsertShape` union sat on adjacent
717
+ lines with no linkage, and seven more sites restated one list or the other. The
718
+ drift was already live: the text tool binds `actionId: 'insert'` and commits
719
+ through the insert dep, but the runtime set never listed `text`, so a
720
+ drag-to-insert text box had no preview.
721
+
722
+ `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
723
+ it has a built-in tool and whether it takes an insert preview. Both unions,
724
+ `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
725
+ preview gate all derive from it.
726
+
727
+ Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
728
+ `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
729
+ same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
730
+ positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
731
+ gains `'text'`, which is the fix itself: a consumer switching exhaustively over
732
+ it gains a case, handled by the existing box arm.
733
+ - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
734
+
735
+ The paint, the pose silhouette and the click-to-edit caret each ran their own
736
+ walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
737
+ `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
738
+ per call and the cache keyed on array identity; and the caret summed
739
+ `ctx.measureText` per character, which sees no kerning, reads system fonts
740
+ rather than the registered face, and ignores per-run styling entirely. The
741
+ caret could therefore answer with a different line, and a different glyph, than
742
+ the one under the pointer — masked in practice only because it asked a WebGL
743
+ canvas for a 2D context and got `null`, degrading silently to no caret at all.
744
+
745
+ `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
746
+ and all three go through it. It keeps the array-identity `WeakMap` as the
747
+ renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
748
+ structure, which is what lets a caller that cannot hold a stable array hit it —
749
+ about 230× cheaper than laying out again, at roughly 4× the cost of the
750
+ identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
751
+ snapping is to the advance cells the glyphs were actually painted in.
752
+
753
+ **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
754
+ `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
755
+ an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
756
+ draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
757
+ double-click always seeds the caret instead of falling back to editing from
758
+ offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
759
+ `_resetLayoutCacheForTests`.
760
+ - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
761
+
762
+ `registerFontOutlines` was a paint upgrade for a family that already had an
763
+ MSDF atlas; a family with only font bytes could not resolve, so it rendered
764
+ nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
765
+ `advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
766
+ outline-only family, and `layoutRuns` reads advances, kerning and the baseline
767
+ through one source the atlas and a parsed face both satisfy. `outlineMinSize`
768
+ does not gate such a family — there is no other tier to prefer.
769
+
770
+ This does not touch metric neutrality where it applies: a family that has an
771
+ atlas still resolves to the atlas, so registering outlines cannot move text
772
+ that was already rendering.
773
+
774
+ Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
775
+ and UMD under `main`; Node takes the UMD build, whose named exports it cannot
776
+ detect, so `parse` was undefined and every face failed to load — silently, via
777
+ the fallback to SDF. A browser bundler reading `module` never saw it.
778
+
779
+ Breaking for a consumer-supplied `OutlineParser`: a face must now report
780
+ metrics as well as geometry.
781
+ - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
782
+
783
+ A kind bar over a per-kind body, driven by the paint-kind registry rather than
784
+ a fixed list, so a consumer's registered kind appears in the bar and renders
785
+ that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
786
+ the chip that showed a gradient as indeterminate and wrote a solid over it on
787
+ first touch — so the checkerboard now means a mixed selection and nothing else,
788
+ and a gradient stroke is editable rather than merely paintable.
789
+
790
+ Switching kinds keeps a per-kind memory for the control's lifetime, so
791
+ linear -> solid -> linear comes back with its stops instead of the ramp
792
+ `withGradientKind` cannot carry.
793
+
794
+ `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
795
+ on `@weasel-js/svg` for its tile previews.
796
+
797
+ The bar offers **None**: "what kind of paint is this?" takes no-paint as an
798
+ answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
799
+ becomes `null`, and a stroke goes away entirely rather than keeping a width
800
+ that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
801
+ built-in kinds carry glyphs so six segments fit a property row.
802
+
803
+ `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
804
+ and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
805
+ with its width — a whole paint editor cannot share a row with a slider.
806
+ - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
807
+ frames and serializes.
808
+
809
+ `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
810
+ re-seeds the five built-ins, matching the kit's other module-global
811
+ registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
812
+ `Editor`), a render slot, both frame-conversion directions, and an SVG
813
+ `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
814
+ consumer's own paint as a `FillStyle` — the union itself stays closed, because
815
+ opening its discriminant would widen every built-in member.
816
+
817
+ Three defects fall out of the same change, each of which a sixth kind hit
818
+ immediately. The renderer's fill dispatch fell off the end of its switch into
819
+ an unguarded cast to the gradient union, so an unknown kind read `stops` off a
820
+ paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
821
+ an unknown kind untouched, leaving it painting in screen space on a node that
822
+ moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
823
+ still writing the `url(#id)` that referenced it.
824
+
825
+ Registering a kind now bumps the node memo generation, so a node painted
826
+ before the registration repaints rather than holding the frame it resolved
827
+ when the kind was unknown.
828
+ - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
829
+
830
+ Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
831
+ `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
832
+ where `undefined` takes the painter's fallback. Two new authoring helpers keep
833
+ hand-written node data short:
834
+
835
+ ```ts
836
+ data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
837
+ ```
838
+
839
+ **Breaking, with no compatibility path.** A document written against the old
840
+ shapes renders wrong rather than failing, which is accepted:
841
+
842
+ - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
843
+ and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
844
+ A node holding `fill: '#f00'` now paints the default grey.
845
+ - `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
846
+ - `data.color` — the legacy alias `kit:path` and the rect fallback read — is
847
+ deleted. The fallback painter reads `data.fill` like everything else.
848
+ - `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
849
+ - `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
850
+ else. A painter returning `{ filled, strokeWidth }` no longer type-checks
851
+ and its reach is read as zero.
852
+ - `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
853
+ the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
854
+ - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
855
+ of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
856
+ stroke into a color. SVG's `fill="none"` imports as `fill: null`.
857
+
858
+ **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
859
+ the only slot a gradient or a pattern has, so it is the slot all of them use,
860
+ and the renderer multiplies a hex alpha by it — the two would fight if both
861
+ carried the value. `solid()` therefore moves an alpha channel out of the hex:
862
+ `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
863
+
864
+ The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
865
+ `opacity` rather than splicing hex, so they now work on a gradient fill, which
866
+ they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
867
+ the node's existing paint through the new `paintWithColor`, keeping its opacity
868
+ unless the picked color states an alpha of its own — and `setStroke` keeps the
869
+ stroke's width, cap, join and dash instead of replacing the whole value.
870
+
871
+ New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
872
+ `paintWithColor`, `DEFAULT_SHAPE_FILL`.
873
+
874
+ `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
875
+ — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
876
+ writes a bare string over it — and the `data.stroke` object leaf drops its
877
+ `fromScalar`, which had nothing left to lift.
878
+ - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
879
+
880
+ The text schema put `data.text` in a group named Text, so the section read
881
+ TEXT and the row inside it read Text — one word nested in itself — and the
882
+ style groups below it read as fields of the content string rather than as its
883
+ siblings. Content is its own section now, with the field full-width because
884
+ the section already names it.
885
+
886
+ A group with an empty `name` renders no heading. That already worked for
887
+ sections and is now documented on `ToolPrefGroup`, since it is how a schema
888
+ says "this group organises, it doesn't name": `Character` and `Paragraph`
889
+ carry the labels, and a `Typography` heading over them named nothing new.
890
+ It stays opt-in rather than a rule that rolls up any all-group parent —
891
+ a `Border` over `Top` / `Right` / `Bottom` needs its name.
892
+
893
+ Rows under a suppressed heading no longer indent. Depth drawn without a
894
+ visible parent put `Character` a level deeper than `Content` while being its
895
+ peer, which is the panel's own tree discipline broken by its own hand.
896
+ - 611b30e: Layers and deps answer for the view they are drawn for
897
+
898
+ Nine lookups closed over the *surface's* state at construction, so they answered
899
+ for view zero in every view. `<CanvasView>` draws the surface's layer array
900
+ unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
901
+ a guarantee of answering for the wrong view rather than merely an unused
902
+ argument. A drag in view B ghosted in view A, the marquee painted in the wrong
903
+ view, chrome-caps resolved against the surface's selection, every Cmd+V centered
904
+ on the wrong camera, and Escape in view B cancelled view A.
905
+
906
+ **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
907
+ and `getIsVisible()`. All three are **required members**: anyone hand-writing a
908
+ `CanvasViewHelpers` (a test double, a wrapper) has to add them.
909
+ `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
910
+ have answered for one view.
911
+
912
+ **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
913
+ alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
914
+ is the tool half.
915
+
916
+ **Layer options changed.** `createPathEditingOverlayLayer` and
917
+ `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
918
+ `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
919
+ now. `usePreviewGhostLayer` has lost `tools`. Both it and
920
+ `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
921
+
922
+ **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
923
+ result accept an optional trailing `PickCamera`. A world point does not carry
924
+ the scale it was produced under and picking has no draw envelope, so the caller
925
+ that produced the point supplies it; omitting it keeps the surface camera.
926
+
927
+ `useHoverTracking` took a `clientToWorld` thunk beside a world-space
928
+ `getNodeAtPoint` — the first resolved the view and the second did not, so hover
929
+ picked at the surface's scale inside a panel. It takes one
930
+ `nodeAtClientPoint(clientX, clientY)` now.
931
+
932
+ Anchor-editing target state stays surface-wide; only the preview resolution on
933
+ that path is per-view.
934
+ - 9ad8cb2: Picking answers for what was painted
935
+
936
+ Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
937
+ from something other than what the renderer drew.
938
+
939
+ **Pose overrides were painted through and picked around.** `PoseOverride.pose`
940
+ is documented as replacing the document pose *everywhere the render and
941
+ hit-test paths read one*, and `sceneAdapter.getPose` honored it. But
942
+ `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
943
+ did the bounds resolver feeding selection chrome and the affordance
944
+ `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
945
+ overrides painted them at one place and picked them at another. `effectivePose`
946
+ is now the single rule and every one of those reads through it.
947
+
948
+ **A clipped-away child was still clickable.** A container clips its subtree and
949
+ the renderer honors it, so a child outside the clip is not painted.
950
+ `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
951
+ `<SceneCanvas>` installs instead had no clip term at all. The new
952
+ `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
953
+ render-order scan can apply the same test.
954
+
955
+ **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
956
+ 45° puts a corner 32 units above that box; a rubber-band over that corner was
957
+ rejected before the rotation-correct silhouette test ran, while a click on the
958
+ same pixel selected the shape.
959
+ - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
960
+ tiles, coins, enemies and flagpole still declared `data.color`, the alias
961
+ removed when node paint became an object, so every one of them rendered in
962
+ the default gray — the demo whose whole point is being the visual twin of the
963
+ immediate-mode load test.
964
+ - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
965
+
966
+ Three paths read a pose's stored, unrotated box where the rotated extent was
967
+ wanted.
968
+
969
+ `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
970
+ came back turned the same way — invisible on a rectangle, whose AABB is
971
+ symmetric under a sign flip, and plainly wrong on an asymmetric one, which
972
+ translated instead of mirroring. It now negates the pose's rotation.
973
+
974
+ `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
975
+ stored edges, while the dragged selection matched against them by its ink.
976
+ `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
977
+ `deriveAlignmentGuides` reads its targets through the same projection — a new
978
+ `projection` option defaulting to the rect one, so existing callers get the fix
979
+ without a change.
980
+
981
+ `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
982
+ rotated member ended up with a gap short by the difference; `centers` shared the
983
+ line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
984
+ `distributeHorizontalAction` / `distributeVerticalAction` also take
985
+ `params.mode`, so `gaps` is reachable from a binding rather than only from the
986
+ hook.
987
+
988
+ Flip and distribute return different poses than before for rotated shapes.
989
+ That is the fix, but it is a behavior change for anything depending on the
990
+ old output.
991
+ - 3386d64: `@weasel-js/core/routing` exports the route-string projection
992
+
993
+ Anything rendering a `GestureSpec` as a route string had to re-implement the
994
+ projection, and the copy in WeaselDraw's registry inspector had drifted three
995
+ ways: it answered `drop` and `paste` with no gesture name, so every binding of
996
+ either vanished from the route list; its argument lookup missed a spec field;
997
+ and it gated targets on a hand-listed set of kinds, dropping them for
998
+ `pointerDown`, `longPress` and `wheel`.
999
+
1000
+ New from the routing subpath: `routesForSpec(spec)` — every route string one
1001
+ spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
1002
+ map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
1003
+ subpath index did not, so consumers reading `RegistryEntry.target` had no way
1004
+ to compare against the sentinel its own docs name.
1005
+ - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
1006
+
1007
+ `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
1008
+ in their control flow — `***`/`**`/`*` and a two-character escape set — so
1009
+ reading or writing any other spelling meant forking both. They now take a
1010
+ `RunGrammar`: a table of markers pairing a repeated delimiter with the run
1011
+ flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
1012
+ today's behavior. Escaping follows the grammar's own delimiters.
1013
+
1014
+ Nothing changes for a caller that passes no grammar. `underline` and
1015
+ `strikethrough` still have no markdown spelling and are still dropped by
1016
+ `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
1017
+ rather than editing the parser.
1018
+ - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
1019
+
1020
+ `createSelectionOverlayLayer` documents itself as equivalent to stacking the
1021
+ other two, and it was not. It reads `ChromeState` off the draw envelope,
1022
+ resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
1023
+ visibility and suppressed ids, and takes selection and poses from the envelope
1024
+ when they are omitted. The two primitives did none of that: they ignored the
1025
+ draw envelope entirely, required a construction-time `getPose` cascade, and
1026
+ knew nothing about the multi-selection union — so a consumer who stacked them,
1027
+ on the wrapper's own promise, got chrome in the wrong place with no way to
1028
+ tell.
1029
+
1030
+ All three now run one body and differ only in which passes they enable, so the
1031
+ promise holds by construction. `SelectionOutlineLayerOpts` and
1032
+ `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
1033
+ that don't apply, which makes `getSelection` and `getPose` optional on both and
1034
+ adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
1035
+ `SelectionHandleStyle`.
1036
+ - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
1037
+
1038
+ The effect depended on the whole selection-context value, and the provider
1039
+ mints a new value object on every publish. So one publisher publishing refired
1040
+ the effect for every other publisher in scope, each of which republished its
1041
+ own ids — a newer selection got stomped back to an older one, and two
1042
+ publishers holding different ids under one provider never settled at all.
1043
+
1044
+ `publishSelection` is already a stable `useCallback`, so the effect now depends
1045
+ on it directly. No provider change and no API change.
1046
+ - 84db1f6: Close four gaps that produced wrong answers with no error
1047
+
1048
+ Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
1049
+ `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
1050
+ `default:` arm, so a command code they did not know fell out of the switch
1051
+ without advancing the coordinate cursor and every segment after it read the
1052
+ wrong floats. They now throw, matching the six sibling walkers. This is a
1053
+ behavior change for anyone feeding these a path built with an opcode outside
1054
+ `PATH_COMMANDS`: what used to come back subtly wrong now raises.
1055
+
1056
+ A `<CanvasView>` built its affordance hit-test without a device profile, so a
1057
+ nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
1058
+ zones against the 14px chrome the surface paints. It reads the profile
1059
+ `<SceneCanvas>` publishes.
1060
+
1061
+ `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
1062
+ neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
1063
+ records had nowhere to land: undoing a delete through the move pipeline
1064
+ appended the node to the end of its parent instead of putting it back where it
1065
+ was. All three are there now.
1066
+
1067
+ The dev inspector's gesture panel formatted bindings with a private formatter
1068
+ that reported only modifiers set to `true`. The `ingest` action marks every
1069
+ modifier `'optional'`, so its drop and paste bindings rendered blank and the
1070
+ action was invisible on both gestures. Both of the panel's plain-text
1071
+ formatters now go through the kit's `routesForSpec`.
1072
+ - 3386d64: Undoing a multi-node delete or group restores document order
1073
+
1074
+ Restoring by stored index cannot survive replay: history runs a batch's
1075
+ inverses in reverse, while indices captured before the mutation are only
1076
+ correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
1077
+ undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
1078
+
1079
+ Ops now record a `Slot` — an ordinal plus the id of the following sibling at
1080
+ capture. The anchor is the source of truth whenever it resolves, and it
1081
+ resolves whatever else the batch has already restored. The ordinal remains as
1082
+ the fallback for an adapter that can place by index but cannot enumerate
1083
+ children. `before: null` means "last" and needs no sibling list; an absent
1084
+ `before` means "unobserved", and the two survive `History.serialize` because
1085
+ `undefined` drops out of JSON and `null` does not.
1086
+
1087
+ The ops observe their own slot during `apply()` rather than taking one from the
1088
+ caller, so every existing emitter gets this without a call-site change.
1089
+ `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
1090
+ docstring said it was sufficient on its own, which it never was.
1091
+
1092
+ Adapters without an ordering seam still append, as they did before:
1093
+ `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
1094
+ neither that nor an `index` parameter on `insertNode`.
1095
+ - 7a746df: A stroke's dash is edited as a style, not as an array
1096
+
1097
+ `Stroke.dash` already rendered, imported and exported; it had no control,
1098
+ because a `number[]` has no leaf kind. It doesn't need one — the thing a person
1099
+ chooses is a style, and the array is how it is stored. The stroke block gains a
1100
+ Solid / Dashed / Dotted / Custom bar under cap, join and align.
1101
+
1102
+ `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
1103
+ the option string, the counterpart of the `unit` a number leaf already has for
1104
+ a value stored in a canonical unit. Both directions are handed the object the
1105
+ leaf is a field of, because a dash pattern is meaningless without the width it
1106
+ scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
1107
+ hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
1108
+ `strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
1109
+ 2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
1110
+ a new `disabled` option — one a control reports but refuses to author, since
1111
+ there is no array behind it. `solid` is stored as no dash at all, and an object
1112
+ leaf's field written as `undefined` is now removed rather than left holding it.
1113
+ - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
1114
+
1115
+ Nine option glyphs and four category glyphs join the icon set. The option
1116
+ glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
1117
+ shape rather than as a diagram of one. `align` is a circle zoomed until the
1118
+ ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
1119
+ the box's complement of it, and `center` is the annulus straddling the path,
1120
+ so the three are one band at three offsets. The categories are the bare path
1121
+ each row treats, drawn in the outlined register.
1122
+
1123
+ A schema carries a glyph *id*, not a component: `ToolPrefEnum`'s options gain
1124
+ `icon`, and every leaf gains one for rows whose own label is spent on a
1125
+ `pair`. Core ships no icon set and cannot depend on one, so the field is a
1126
+ plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
1127
+ `short` where it names no glyph.
1128
+
1129
+ `SelectionPanel` now honours `block` inside an object leaf, not only at the
1130
+ section level. A row whose fields are all `block` drops the 64px label column
1131
+ and spans the block. The default stroke schema uses both: paint and width
1132
+ share one label-less row, and cap/join/align share the next.
1133
+
1134
+ `align`'s options run inner, center, outer — the order the ink moves outward.
1135
+ - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
1136
+
1137
+ `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
1138
+ through a new registry (`registerMarker`), shipping eight built-in shapes.
1139
+ Unlike SVG, the stroke stops short of a filled head rather than running under
1140
+ it to the tip; the distance is declared per marker, so an open V still reaches
1141
+ the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
1142
+ `<marker>` defs.
1143
+ - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
1144
+
1145
+ It accepted `{ color }` only, and merged through `paintWithColor`, which
1146
+ supersedes a non-solid paint with a solid one — a gradient stroke was
1147
+ unreachable even though `setStrokeOpacity` could already reach its alpha.
1148
+ `paint` now wins over `color`, a color arriving later in the gesture supersedes
1149
+ an earlier paint, and the stroke's width, cap, join, dash and align survive
1150
+ either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
1151
+ that has no color to pass.
1152
+
1153
+ Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
1154
+ from `DEFAULT_STROKE_COLOR`, painting the selection black where
1155
+ `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
1156
+ `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
1157
+ where the body builds a left-edge-to-right-edge one.
1158
+
1159
+ `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
1160
+ core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
1161
+ it — and nothing imported it.
1162
+ - 81213fc: Edit a node's stroke as the union it is
1163
+
1164
+ `data.stroke` holds `string | Stroke`, and the schema described it with a
1165
+ `color` leaf — which reads `undefined` off the object form, shows its own
1166
+ default, and writes a bare hex back over the stroke's width, cap, join and
1167
+ dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
1168
+ for `FillStyle`.
1169
+
1170
+ A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
1171
+ Its control shows whichever color the value has — the string itself, or a
1172
+ solid paint's color — gives a gradient stroke the indeterminate chip rather
1173
+ than claiming a color it doesn't have, and preserves the form on write.
1174
+
1175
+ `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
1176
+ `paint` leaf used to render as the literal text `(paint: no renderer)`.
1177
+ `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
1178
+ exported from `@weasel-js/ui` for consumers writing their own property
1179
+ renderers against either union.
1180
+
1181
+ Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
1182
+ remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
1183
+ for why that waits on the SVG mapping.
1184
+ - 2f225d7: A thick stroke is clickable across its whole width
1185
+
1186
+ `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
1187
+ stroke width for an `outer` align — but the AABB pre-filter that runs before it
1188
+ grew only by the pointer slop. So half a thick outer stroke's ink was
1189
+ unclickable: the point was rejected before the refinement that would have
1190
+ claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
1191
+ was at least as generous as the refinement, which it cannot be on its own,
1192
+ since it never sees the stroke. That budget is the caller's, and the comment
1193
+ says so now.
1194
+
1195
+ `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
1196
+ declared in `px` resolved as world units and the reach was wrong at every zoom
1197
+ but 1 — while the caller computed `meanScale(view.scale)` one line above.
1198
+ - 68069dc: Right-to-left text lays out in visual order
1199
+
1200
+ `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
1201
+ analyses the paragraph, reorders each line after the wrap, and mirrors brackets
1202
+ in right-to-left runs. Given none, nothing changes: text lays out logically,
1203
+ exactly as before.
1204
+
1205
+ `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
1206
+ `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
1207
+ consumer who renders no right-to-left text never installs the Unicode tables,
1208
+ and a different implementation can be substituted. `@weasel-js/bidi` is a
1209
+ devDependency here only, for a test that drives real Hebrew through the real
1210
+ engine; types lining up is not evidence the semantics do.
1211
+
1212
+ `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
1213
+ across `cells`**. Cells stay in logical order — slot `i` is still character `i`
1214
+ — while their x values follow the reordering. Sort on `x` for visual order, and
1215
+ read a cell's extent as `[x, x + advance)` rather than reaching for the next
1216
+ cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
1217
+ against each cell's own extent, taking a right-to-left cell's visually-leading
1218
+ half as the character's logical end.
1219
+
1220
+ Kerning is a gap between two adjacent characters, and the wrap measures it
1221
+ logically. Reordering can put a different pair side by side, so the gap taken
1222
+ is the one belonging to whichever of the two is logically second, and none at
1223
+ all across a direction boundary — where the pair never touched in the source.
1224
+
1225
+ Laying out right-to-left text with no engine now warns once, naming the import.
1226
+ The alternative is glyphs silently appearing reversed, which is the one real
1227
+ hazard of making this opt-in.
1228
+ - 5d0ff9c: Every code point on a line gets a cell
1229
+
1230
+ `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
1231
+ `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
1232
+ `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
1233
+ a consumer indexing per character no longer has to reconcile a sparse array
1234
+ against the source string.
1235
+
1236
+ The old arrays were documented as non-contiguous, and two causes were real:
1237
+
1238
+ - A code point no tier could serve was dropped outright, taking its caret stop
1239
+ with it. It now occupies a zero-advance cell. This is reachable whenever the
1240
+ dynamic canvas fallback is off — which is the normal configuration for a
1241
+ consumer registering its own outlines, where the outline tier has no rung
1242
+ below it.
1243
+ - A space opening a line — at the start of the text, or after a newline — was
1244
+ discarded. It now keeps its cell and still consumes no width, so a line is
1245
+ addressable per character without gaining an indent. A space that opens a
1246
+ *wrapped* line was never affected: the wrap leaves it as a trailing cell on
1247
+ the line before.
1248
+
1249
+ Neither changes any geometry: both cells carry zero advance, zero tracking and
1250
+ no kerning, so bounds, line widths and glyph positions are unchanged.
1251
+
1252
+ A newline still has no cell, since it separates cells rather than being one.
1253
+ `srcEnd` is what a blank line carries in its place.
1254
+
1255
+ `drawsInk` is a property of the code point and the face, not of the call that
1256
+ produced it: it does not flip when a dynamic bake lands or the outline
1257
+ threshold is crossed, so the same text reports the same slots every time. A
1258
+ zero-advance combining mark is `true` — it inks without advancing.
1259
+ - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
1260
+ `data.fill` and `data.stroke` — the same two leaves every other node kind
1261
+ paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
1262
+ `TextStyle.stroke` are gone, with no compatibility read: a document that put
1263
+ its color in `style.fill` now renders in the default black rather than
1264
+ erroring, so check documents that predate this.
1265
+
1266
+ This fixes a real asymmetry rather than only moving fields. `data.stroke`
1267
+ already reached text through a fold in the painter, but `data.fill` did not:
1268
+ picking a fill color with a text node selected wrote a field nothing read, so
1269
+ the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
1270
+ the Appearance leaf now all mean the same thing on text as on a rect. The
1271
+ duplicate `data.style.fill` control is gone from the text schema with them.
1272
+
1273
+ `resolveTextStyle(style, paint)` takes the node's paint as a second argument
1274
+ and is what derives the caret and selection colors, so the edit overlay
1275
+ matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
1276
+ the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
1277
+ `data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
1278
+ `createTextLayer` is painted rather than black. `SvgTextNode` gained the same
1279
+ two, and SVG import and export carry text paint there instead of inside the
1280
+ style. `StyledRun.fill` and `.stroke` are unchanged and still override the
1281
+ node's per range — which is also where a caller with no node at all, a HUD
1282
+ widget or a debug overlay, now states its color.
1283
+
1284
+ `textCommandFromRuns` is exported from the package root.
1285
+ - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
1286
+ array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
1287
+ the last paths still doing what `drawImage` stopped doing; text now costs
1288
+ **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
1289
+ ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
1290
+ image draw. No other command kind moved.
1291
+
1292
+ A text group is as many quads as it has glyphs, so unlike the image ring a
1293
+ slot's buffer grows to the largest run it has seen rather than being fixed at
1294
+ four vertices. The quad index pattern is a pure function of the quad count —
1295
+ the pattern for N quads is a prefix of the pattern for any larger N — so one
1296
+ index buffer serves every slot, grown the same way and written only when it
1297
+ grows.
1298
+ - c2ffa49: Alignment can resolve against reading direction
1299
+
1300
+ `align` gains `start` and `end` alongside `left` / `center` / `right`, and
1301
+ `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
1302
+ the relative pair resolves against the direction, the absolute pair ignores it.
1303
+ `resolveAlign(align, direction)` collapses one to the other and is exported for
1304
+ consumers that need an edge rather than an intent.
1305
+
1306
+ Direction is an input, not something this package discovers. `@weasel-js/text`
1307
+ has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
1308
+ what it found; nothing here sniffs an environment.
1309
+
1310
+ Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
1311
+ layout moves. Making `start` the default alignment is a separate call.
1312
+
1313
+ `@weasel-js/svg` carries the direction through: `direction` joins the
1314
+ inheritable presentation properties, and `text-anchor` is now written and read
1315
+ against it. Two things were wrong before and are worth naming, because both
1316
+ rendered plausible output:
1317
+
1318
+ - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
1319
+ a mapping that assumed three values and read the fourth as its `else`.
1320
+ - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
1321
+ right edge, while this model's default `align` is `left`. They agree under
1322
+ `ltr` and only there, so an RTL document with no explicit anchor imported as
1323
+ left-aligned.
1324
+
1325
+ This is alignment and round-tripping only. Layout still walks code points in
1326
+ logical order with the pen always increasing: there is no bidi reordering and
1327
+ no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
1328
+ renders in logical order, and Arabic still renders unjoined.
1329
+ - 4c097ef: Sit every run on a line on one baseline
1330
+
1331
+ Mixed-size text hung each run off the *line top* at its own ascent instead of
1332
+ off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
1333
+ with the big run's cap rather than standing on the line with it. Two faces with
1334
+ different ascents at the same size diverged the same way. Baseline alignment is
1335
+ what inline text does everywhere else, and the module header already claimed
1336
+ this behavior — the walk just never implemented it.
1337
+
1338
+ A line now sinks one baseline far enough to clear its tallest run's ascent and
1339
+ places every glyph against it. Glyph quads derive their top from that baseline
1340
+ rather than from the pen's line top, which is the whole of the change:
1341
+ `qy0 = baselineY + (yoffset - metrics.base) * scale`.
1342
+
1343
+ Uniform-size text — nearly all text — is unchanged, since the maximum over one
1344
+ value is that value. Only lines that actually mix sizes or faces move, and they
1345
+ move to where they always should have been.
1346
+
1347
+ The test named "mixed-size runs share a baseline on the same line" asserted only
1348
+ a quad count and passed throughout; it now asserts the baselines.
1349
+ - 2b86e00: A text node's style is one value, not ten sibling paths
1350
+
1351
+ `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
1352
+ `TextStyle` from ten independent leaves, each control writing a field of a
1353
+ value it could only half see. `data.style` is an object leaf now, with
1354
+ Character and Paragraph as groups inside it — groups head their fields and
1355
+ contribute nothing to the path, so a field is still a field of the style and
1356
+ one commit writes the whole thing.
1357
+
1358
+ An object leaf whose fields are entirely grouped no longer prints its own
1359
+ heading, which would stack straight onto the first group's, and a group's
1360
+ fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
1361
+ into an object leaf when listing what a kind exposes — the fields are the
1362
+ editable surface; the leaf is the container.
1363
+
1364
+ `SelectionPanel` has a story now, which is how the two layout defects above
1365
+ were found.
1366
+ - d933a89: Superscript, subscript and overline for styled runs
1367
+
1368
+ `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
1369
+ a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
1370
+ over two new primitives rather than a mechanism of its own:
1371
+
1372
+ - `baselineShift` — raise (positive) or lower (negative) a run off the line's
1373
+ shared baseline, in ems of the inherited font size.
1374
+ - `fontScale` — a multiplier on the inherited font size, the relative
1375
+ counterpart to `fontSize`. An absolute `fontSize` still wins over it.
1376
+
1377
+ Naming either directly overrides that half of `script` and leaves the other
1378
+ alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
1379
+ ±33.3% position — Adobe's defaults, so a character panel can show percentages
1380
+ its users already recognize) and are derived, not read from the font: `OS/2`
1381
+ carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
1382
+ them, and metrics that applied on one glyph tier and not the other would
1383
+ reflow text as it crossed the size threshold.
1384
+
1385
+ `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
1386
+ `fontSize`, so layout never learns superscripts exist — it places a run against
1387
+ a baseline and an offset. The shift moves a run's glyphs, its outline geometry
1388
+ and its own decoration rules together, and deliberately does not feed back into
1389
+ the line's baseline or height: a superscript rides the line rather than
1390
+ reflowing it.
1391
+
1392
+ `overline` joins `underline` and `strikethrough` on both `TextStyle` and
1393
+ `StyledRun`, additive over the node style like the other two, and is now
1394
+ available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
1395
+ is unchanged — it stays silent on the decorations, as it always has been.
1396
+ - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
1397
+ its own final `onTick`. The last tick emitted the value and completed in one
1398
+ pass, so a write made from that tick — cancelling the tween — still got the
1399
+ completion callback, against the documented "not called on cancel" contract.
1400
+ - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
1401
+
1402
+ Two ops inverted to something narrower than what they applied, so undo
1403
+ silently lost data.
1404
+
1405
+ `createDeleteOp.invert()` re-inserted a single node while `apply()` called
1406
+ `removeNode`, which cascades the whole subtree. Delete a container with two
1407
+ children, undo, and the container came back with `children: []` while both
1408
+ children were gone. The op now snapshots its descendants preorder through the
1409
+ adapter's optional `getNode` / `getChildren` — the snapshot is written back
1410
+ into `args`, so an op rebuilt from a serialized entry still inverts — and
1411
+ re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
1412
+ does not cascade, so the inverse skips any descendant the adapter still reports
1413
+ as live rather than duplicating it.
1414
+
1415
+ `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
1416
+ instead of restoring the sibling slot and paint order changed. `ReparentArgs`
1417
+ now carries `fromIndex` / `toIndex` and places through the existing
1418
+ `getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
1419
+ `setParent`'s signature is unchanged. Adapters without that seam no-op as
1420
+ before. `groupAction` captures each member's index before mutating; `move` and
1421
+ `snapToContainer` pass none and are byte-identical.
1422
+
1423
+ `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
1424
+ cascade, which is why nothing caught the first bug. It now runs against a
1425
+ tree-backed fake.
1426
+ - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
1427
+
1428
+ Every union a user looks at or clicks folded each member's *unrotated* box.
1429
+ Select two shapes, rotate one, and the multi-selection frame and its handles
1430
+ sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
1431
+ out as the target bounds for paint *and* hit-test, so the handles were both
1432
+ drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
1433
+ correct larger box.
1434
+
1435
+ `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
1436
+ and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
1437
+ beside the rotation-free `unionBounds`, which stays correct for commit-time
1438
+ actions that write poses back in the unrotated frame; the module says which to
1439
+ reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
1440
+ another name. Both new functions are exported from the package root.
1441
+
1442
+ Moved onto it: `ChromeState.unionBounds`, the selection overlay's
1443
+ container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
1444
+ the wrong place whenever a member was rotated), and WeaselDraw's export
1445
+ viewBox, which clipped rotated shapes out of the copied SVG.
1446
+ - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
1447
+
1448
+ `useAction` returned early on a null registry, so an action registered above
1449
+ the provider — or with no provider mounted — silently never fired its
1450
+ bindings. It now warns in dev, naming the action id. Runtime behavior in
1451
+ production builds is unchanged.
1452
+ - Updated dependencies [5c8e9e6]
1453
+ - Updated dependencies [2621cbf]
1454
+ - Updated dependencies [0f936da]
1455
+ - Updated dependencies [4180095]
1456
+ - Updated dependencies [9977908]
1457
+ - Updated dependencies [52c7b2a]
1458
+ - Updated dependencies [3386d64]
1459
+ - Updated dependencies [c6c499d]
1460
+ - Updated dependencies [20097e6]
1461
+ - Updated dependencies [84db1f6]
1462
+ - Updated dependencies [94f2446]
1463
+ - Updated dependencies [68069dc]
1464
+ - Updated dependencies [5d0ff9c]
1465
+ - Updated dependencies [0bb27a5]
1466
+ - Updated dependencies [c2ffa49]
1467
+ - Updated dependencies [4c097ef]
1468
+ - Updated dependencies [d933a89]
1469
+ - @weasel-js/text@1.3.0
1470
+ - @weasel-js/geom@1.3.0
1471
+ - @weasel-js/font@1.3.0
1472
+ - @weasel-js/gestures@1.3.0
1473
+ - @weasel-js/history@1.3.0
1474
+ - @weasel-js/modes@1.3.0
1475
+ - @weasel-js/paint@1.3.0
1476
+
1477
+ ## 2.0.0-pre.0
1478
+
1479
+ ### Minor Changes
1480
+
1481
+ - bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
1482
+ into `@weasel-js/paint` — two new Tier A leaves.
1483
+
1484
+ `@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
1485
+ measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
1486
+ `@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
1487
+ lay out text without taking the scene graph or a React peer dependency.
1488
+ `layoutRuns` is now public — it was previously reachable only from inside core.
1489
+
1490
+ `@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
1491
+ `TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
1492
+ layout could not move while its fill type lived in the renderer's graph.
1493
+
1494
+ `@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
1495
+ `Rect` moves to `@weasel-js/geom`, beside `Box`.
1496
+
1497
+ Breaking for anyone importing these through core's internal paths rather than
1498
+ its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
1499
+
1500
+ Advances and kerning still come from a baked MSDF atlas — laying out from font
1501
+ bytes alone needs the metrics seam in
1502
+ `docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
1503
+
1504
+ <!-- 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" -->
1505
+
1506
+ ### Patch Changes
1507
+
1508
+ - 3386d64: Align, distribute and flip use visual bounds
1509
+
1510
+ These folded each member's unrotated pose box, so "Align Left" on a selection
1511
+ containing a rotated shape lined up the boxes and left the rotated shape's ink
1512
+ sticking out past the others. They now work on the visual bounding box, as
1513
+ Figma and Illustrator do.
1514
+
1515
+ Both ends moved together — expanding only the union would have made alignment
1516
+ worse, since the delta runs from an edge of the union to the same edge of each
1517
+ member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
1518
+ reads a pose's bounds, recovers its rotation and expands via
1519
+ `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
1520
+ applied as a translation of the stored pose through
1521
+ `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
1522
+
1523
+ Flip needed only its union pivot changed: mirroring maps a centre and preserves
1524
+ size, and an expanded box is concentric with the box it came from.
1525
+
1526
+ `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
1527
+ by its ink.
1528
+
1529
+ - ffafb7d: Never let an animation's virtual clock run backwards.
1530
+
1531
+ `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
1532
+ time, then advances its virtual clock by the difference against the timestamp
1533
+ the frame loop supplies. Those two share a time origin in a browser, where the
1534
+ rAF timestamp and `performance.now()` are both page-relative — but that is a
1535
+ browser guarantee, not a universal one, and jsdom starts them roughly 600ms
1536
+ apart. The first frame's delta then came out hugely negative and `virtualNow`
1537
+ spent dozens of frames climbing back toward zero before a tween advanced at
1538
+ all: a 40ms glide took 95 frames and over a second of wall time, growing worse
1539
+ the longer the process had been alive.
1540
+
1541
+ A frame's elapsed time is never negative, so the sample is now clamped at
1542
+ zero. Under a shared origin this is a no-op.
1543
+
1544
+ - ba8b139: Camera animation: `viewport.animatedZoom` now does something
1545
+
1546
+ `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
1547
+ nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
1548
+ through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
1549
+ their input already delivers a sample per frame.
1550
+
1551
+ Camera animation is a general surface, not a zoom flag. Three ways in, one
1552
+ runner behind them:
1553
+
1554
+ - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
1555
+ `isAnimating`, `target`.
1556
+ - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
1557
+ so any action can glide the camera.
1558
+ - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
1559
+ `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
1560
+ three are **required** members: anyone hand-implementing `SceneCanvasApi`
1561
+ (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
1562
+ grew `getPaintedVersion`.
1563
+
1564
+ Scale interpolates geometrically and translation is derived from the screen
1565
+ point the two views agree on, so a zoom stays anchored instead of drifting and
1566
+ each frame changes the view by the same ratio. One animation runs at a time; any
1567
+ other view write cancels it, and a cancel leaves the camera where it is rather
1568
+ than jumping to the target. On an uncontrolled canvas the whole animation costs
1569
+ no React render.
1570
+
1571
+ **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
1572
+ changes signature — it takes a `{ get, set }` view channel plus an optional
1573
+ `Animator`, and `animateTo(from, to, { duration, easing })` becomes
1574
+ `animate(to, { ms, easing })`. The `from` argument is gone because the runner
1575
+ reads the live view, which is what lets an interrupted camera resume from where
1576
+ it actually is instead of snapping back to a captured start. `cancel()` is now
1577
+ `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
1578
+ `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
1579
+ the same reason `from` does.
1580
+
1581
+ **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
1582
+ `() => View | void`. Returning the target view lets Cmd+0 animate there;
1583
+ returning nothing keeps the existing behavior. `animatedZoom`'s config fields
1584
+ are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
1585
+ animator's vocabulary.
1586
+
1587
+ - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
1588
+
1589
+ The canvas is focusable by default (`tabIndex` 0) but exposed no way to
1590
+ observe focus, so consumers driving focus-dependent chrome had to attach a
1591
+ listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
1592
+ so reach `SceneCanvasProps` and the canvas element unchanged.
1593
+
1594
+ - 67bcb05: Drop four values the canvas layer memo no longer reads
1595
+
1596
+ `hit-test affordances against the painted chrome state` moved the selection
1597
+ overlay to reading bounds off the chrome state at paint time, which left
1598
+ `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
1599
+ referenced only by the `layers` memo's dependency array — nothing in the body
1600
+ used them. Removing them from the array made all four dead locals, so they go
1601
+ too.
1602
+
1603
+ The memo now rebuilds the layer array on layer/tool/geometry changes rather
1604
+ than additionally on every selection and preview-pose change. Selection chrome
1605
+ is unaffected: it repaints from chrome state, not from the identity of this
1606
+ array.
1607
+
1608
+ - 47cbb08: A closed subpath's dash no longer seams at its start vertex
1609
+
1610
+ `splitForDash` flushed the run still open when a closed subpath's walk returned
1611
+ to the vertex it started from as its own open sub-polyline, so it and the run
1612
+ that began there rendered as two butt-capped ribbons meeting at a point — a
1613
+ notch on the corner of any dashed rectangle whose perimeter isn't a whole
1614
+ multiple of the pattern. They are joined now, and the join the stroke asked for
1615
+ is drawn across the seam like any other corner. A pattern whose first "on"
1616
+ length covers the whole perimeter emits a closed ribbon, identical to the
1617
+ undashed stroke.
1618
+
1619
+ - f43e9c2: A derived edge follows the drag that moves its endpoint
1620
+
1621
+ `move`, `resize` and `rotate` kept their in-flight poses in action-local
1622
+ scratch and published them only as `previewIds` / `previewPose`. That surface
1623
+ is enough to paint a ghost and size selection chrome, but nothing that asks
1624
+ the _scene_ where a node is can see it — and `scenePoseLookup`, which resolves
1625
+ a derived node's geometry, asks the scene. So dragging a box left its edge
1626
+ anchored to the pre-drag position until the drop, when the commit invalidated
1627
+ the dependents and the edge jumped.
1628
+
1629
+ The three actions now also publish each frame into the scene's ephemeral pose
1630
+ overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
1631
+ `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
1632
+ so a drag still commits as exactly one undo entry — the reason the actions
1633
+ avoided per-frame scene writes in the first place was history, and this writes
1634
+ no history. Entries are set once and mutated in place, published with a single
1635
+ `commit()` per frame.
1636
+
1637
+ Picking follows for free: the pick source resolves a derived path through its
1638
+ own override-aware `poseOf`, so an edge is grabbable where it is drawn
1639
+ mid-gesture rather than where it used to be.
1640
+
1641
+ `clone` is deliberately untouched — its previews are the new ghosts at the
1642
+ drag target, and the originals never move, so nothing derives from a changed
1643
+ pose.
1644
+
1645
+ Also closes the matching gap in the preview-ghost layer, which built a
1646
+ container's clip with no derived path and so ghosted a derived container
1647
+ without one.
1648
+
1649
+ Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
1650
+ on every gesture frame. It was already required by the `Scene` contract, but a
1651
+ partial fake that omitted it will now throw rather than silently skip.
1652
+
1653
+ - bb27e83: A derived node is clickable where it paints
1654
+
1655
+ A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
1656
+ `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
1657
+ resolved from the _dependencies'_ poses, which a painter has no handle on. So
1658
+ `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
1659
+ as "no opinion" and answered `true` everywhere, and picking fell back to the
1660
+ node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
1661
+ was unpickable, and a derived container contributed no clip.
1662
+
1663
+ `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
1664
+ same convention `NodePaintCtx` already uses, and `kit:derived` reports the
1665
+ derived path as its silhouette and its declared stroke as its `ink`.
1666
+
1667
+ Resolving that path needs the scene, so it is the _source_ that answers, not
1668
+ the painter: `PickSource.derivedPathOf`, a matching optional argument to
1669
+ `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
1670
+ carried the derived path a node _paints_, and now also the clip a derived
1671
+ container _imposes_, so the live canvas and the headless walk clip alike. The
1672
+ bare-adapter paths supply none of them and behave exactly as before.
1673
+
1674
+ The pre-filter had to move with it. `useSceneSelectTool` grew its region test
1675
+ from the node's pose, which for a derived node is the wrong box entirely, so
1676
+ the edge was rejected before the shape test could claim it. It now tests the
1677
+ derived path when there is one — `poseContains` already reads a path-like pose
1678
+ as a path, so this reuses it rather than adding a second reach calculation.
1679
+
1680
+ `findShapeSilhouette` skips its memo when handed a derived path. That slot is
1681
+ keyed on `(node, pose, data)` and cannot see the path, so it would serve one
1682
+ caller's silhouette to a caller that passed a different one — the same reason
1683
+ `kit:derived` already skips `PAINT_SLOT`.
1684
+
1685
+ - 6a33c3f: A node's path can be derived from other nodes' poses
1686
+
1687
+ A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
1688
+ through `SceneRegistry`, and the scene walks resolve its path before painting
1689
+ rather than it being authored. An edge drawn between two boxes is then an
1690
+ ordinary scene node — selectable, styleable, exportable — whose geometry never
1691
+ enters undo history. The seam and its traps are in `docs/extending.md`.
1692
+
1693
+ New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
1694
+ `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
1695
+ `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
1696
+ `SerializedNode.derivePathKey`, both additions to the serialization format;
1697
+ `NodePaintCtx.derivedPath`.
1698
+
1699
+ Deleting a node now deletes everything that derives from it, transitively,
1700
+ including those nodes' own subtrees, in one undo entry — so `scene.remove` can
1701
+ remove nodes anywhere in the tree that the caller never named, and `removeLayer`
1702
+ reaches nodes on other layers. Undo after the built-in **Delete** key does not
1703
+ yet restore the cascaded nodes; see "Derived geometry follow-ups" in
1704
+ `docs/TODO.md`.
1705
+
1706
+ **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
1707
+ context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
1708
+ now a type error rather than a silent slide into the `view` slot. The same
1709
+ fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
1710
+ callback types, which is not a break: an existing three-parameter implementation
1711
+ still satisfies them, and an existing three-argument call still compiles.
1712
+
1713
+ **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
1714
+ typed as a `Scene` — a test double, most likely — no longer typechecks until it
1715
+ implements it.
1716
+
1717
+ **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
1718
+ `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
1719
+ is not a descendant of the removed node and the tree has to be told about every
1720
+ subtree that came out of it. A history persisted by an older build now throws
1721
+ mid-undo rather than degrading. The break is deliberate; kit op payloads are not
1722
+ versioned.
1723
+
1724
+ - c24e7de: Detached views follow pose overrides
1725
+
1726
+ `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
1727
+ which a pose override deliberately never bumps — so they kept painting document
1728
+ poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
1729
+ canvas driving a drag or a simulation silently disagreed with it.
1730
+
1731
+ `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
1732
+ subscribes to `scene.overrides`. A render (prop change or version bump) and an
1733
+ override commit both just mark the surface dirty, and one animation frame
1734
+ coalesces them — so a 60 Hz override loop repaints these views with no React
1735
+ render, and a backgrounded tab stops painting them entirely. The mount paint
1736
+ stays synchronous, so the first frame is still the scene rather than a blank
1737
+ canvas. `<MinimapCanvas>` inherits all of this through it.
1738
+
1739
+ Repaints driven by a prop change are now asynchronous: they land on the next
1740
+ animation frame rather than in the layout effect of the render that caused them.
1741
+ Code that renders and then reads pixels in the same tick needs to wait a frame.
1742
+
1743
+ A minimap's _framing_ still derives from document poses, so a node overridden
1744
+ outside the document bounds paints outside the fitted frame — recomputing the
1745
+ fit per frame would rescale the whole minimap throughout a settle.
1746
+
1747
+ - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
1748
+
1749
+ `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
1750
+ once instead of hiding two behind a select. Options carry an optional `short`
1751
+ label — a capital or two — for the width a property row has; the full `label`
1752
+ stays the accessible name, so the abbreviation never becomes the only thing
1753
+ naming the option. A mixed selection selects no segment rather than picking a
1754
+ winner.
1755
+
1756
+ `pair` now merges fields inside an object leaf, as it already did for section
1757
+ rows — a hint shouldn't mean something different for being a field of a value
1758
+ rather than a sibling of one. It merges _adjacent_ leaves in both places, so
1759
+ the schema orders family, size, weight: size and weight pair, and family (which
1760
+ sat between them) moves ahead of the pair rather than splitting it.
1761
+
1762
+ A stroke's cap, join and align share one row; property rows wrap rather than
1763
+ overflow when the controls in them don't fit.
1764
+
1765
+ - be697dc: Add ephemeral pose overrides to the scene
1766
+
1767
+ `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
1768
+ hit-test paths read through and that history, `toJSON()` and `getVersion()`
1769
+ never see. It is additive: a scene with no overrides behaves exactly as before.
1770
+
1771
+ This is where per-frame motion belongs. A 60 Hz loop previously had to write
1772
+ through `setPose`, which records an undo entry (one per frame at best, batched)
1773
+ and bumps the scene version, re-rendering every `useSyncExternalStore`
1774
+ subscriber. It also had to allocate a fresh pose object per moving node per
1775
+ frame, because the painter memo keys on pose reference. An override entry is
1776
+ hoisted once and mutated in place; `overrides.commit()` publishes the frame and
1777
+ invalidates the memo for the overridden nodes only.
1778
+
1779
+ `commit()` is required after an in-place mutation — without it the memo serves
1780
+ the previous frame's draw. Overrides are cleared when a node is removed, since
1781
+ ids are reusable. To make a frame permanent, write it once through `setPose`
1782
+ and clear the override; that single step is the undo entry.
1783
+
1784
+ `ForceGraphDemo` now settles with zero history entries and bakes the result as
1785
+ one, replacing a per-tick batch of 24 `setPose` calls.
1786
+
1787
+ - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
1788
+
1789
+ It was the fourth site measuring text its own way: `ctx.measureText` per
1790
+ character against system fonts, no kerning, `pose.text` only. Nothing masked
1791
+ it the way the WebGL context masked the caret — a consumer calling it got a
1792
+ box that disagreed with the paint, narrower by a kern on every pair and wrong
1793
+ by the whole difference between the installed family and the registered face.
1794
+ It goes through the shared layout now, so it sees kerning and per-run styling.
1795
+
1796
+ **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
1797
+
1798
+ - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
1799
+
1800
+ `requestRedraw()` marks the surface dirty and the next frame paints, so many
1801
+ redraws in one tick cost one paint. The view gains an imperative path on the
1802
+ canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
1803
+ longer holds it in React state, so a camera moving at 60 Hz costs no renders.
1804
+ Consumers passing a `view` prop stay controlled and are unaffected.
1805
+
1806
+ Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
1807
+ that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
1808
+ gives a host the scene without a render per mutation, `useSceneTextEdit`'s
1809
+ `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
1810
+ `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
1811
+
1812
+ Two public signatures changed. `usePinchZoomTool` takes a view getter,
1813
+ `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
1814
+ captured value any more. `CanvasExtensionApi` gained five required members —
1815
+ `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
1816
+ so external code hand-implementing that interface stops typechecking; code that
1817
+ only calls through the ref is unaffected.
1818
+
1819
+ Pixels and DOM can now be a frame apart, in whichever direction the change came
1820
+ from. A view change leads with pixels: `setView` paints without rendering, so
1821
+ DOM built from the view is stale until something re-renders it — position
1822
+ world-anchored DOM from `subscribeView`. A scene change leads with DOM:
1823
+ `SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
1824
+ pixels land next frame — compare `getPaintedVersion()` against the version you
1825
+ are about to render when chrome must be in lockstep. Do not render scene-derived
1826
+ DOM inside `startTransition`: React defers it and nothing forces it to catch up.
1827
+
1828
+ Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
1829
+ mode is the one in-tree case — can likewise see a buffer one frame older;
1830
+ `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
1831
+ paints while `document.hidden` is true, `syncPaint` included, so a readback from
1832
+ a background tab returns the frame from before the tab was hidden.
1833
+
1834
+ - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
1835
+ recorded. A caller is entitled to reuse the array it uploads from, so storing
1836
+ the reference recorded a value that later frames overwrote — a test reading
1837
+ two frames back saw the same numbers twice and passed. Test-only surface.
1838
+ - 3fb3a46: Release held keys when the window loses focus
1839
+
1840
+ A window that blurs mid-hold never delivers the keyup, so every in-flight
1841
+ `key-held` handle stayed engaged until that key was pressed again — holding
1842
+ Space and tabbing away left the hand tool on the hotkey stack indefinitely.
1843
+
1844
+ The gesture dispatcher now fires the `key-held` up phase for each held key on
1845
+ window blur. Consumers that hand-rolled this reset can drop it; ongoing
1846
+ invocations see a normal `onEnd`.
1847
+
1848
+ - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
1849
+
1850
+ One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
1851
+ round caps and joins, hairline weight reserved for structure, and filled
1852
+ regions only where an action has a subject. Covers transport, history, view,
1853
+ trial lifecycle, collection, state, instrument and status vocabulary. Import a
1854
+ named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
1855
+
1856
+ `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
1857
+ so consumers have one import site for the whole set. `ImageIcon` was reachable
1858
+ from core's icons folder but missing from its public barrel; it is exported
1859
+ now.
1860
+
1861
+ Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
1862
+ rather than hand-placed, because arrowheads and joins that miss their terminus
1863
+ are invisible at chrome size.
1864
+
1865
+ - 3fb3a46: Compose `before` and `after` layer chains in both directions
1866
+
1867
+ `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
1868
+ off an `after` anchor only followed further `after` links, and likewise for
1869
+ `before`. A custom layer anchored `before: 'scene'` carrying a second custom
1870
+ anchored `after` it dropped that second layer to the tail with a spurious
1871
+ dangling-reference warning.
1872
+
1873
+ Both walks now emit a layer's `before` chain, the layer, then its `after`
1874
+ chain, so the two mix freely. Cycle detection and orphan fallback are
1875
+ unchanged.
1876
+
1877
+ - d9f110e: Stop every frame loop while nothing can see it
1878
+
1879
+ New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
1880
+ whether a frame may run: nothing runs while `document.hidden`, and a loop that
1881
+ names an element also stops while that element is outside the viewport. A
1882
+ request made while suspended is held rather than dropped and re-armed on
1883
+ resume, so a loop never polls visibility or needs restarting by hand.
1884
+
1885
+ Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
1886
+ `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
1887
+ counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
1888
+ `useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
1889
+ the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
1890
+ it paints only dirty layers, but a hidden tab still commits React updates and
1891
+ its view/size effect marks every layer dirty.
1892
+
1893
+ Loops measuring elapsed time rebase their clock through the new `onResume`
1894
+ option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
1895
+ meter reporting a rate nobody achieved, a tween jumping to its end value on
1896
+ return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
1897
+ export; nothing in the tree sets it.
1898
+
1899
+ `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
1900
+ in kit source, and runs in CI.
1901
+
1902
+ - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
1903
+
1904
+ A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
1905
+ `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
1906
+ ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
1907
+ and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
1908
+ second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
1909
+ where the matcher picks a single winner.
1910
+
1911
+ Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
1912
+ and jsdom reports an empty-string platform — not nullish, so the fallback never
1913
+ fired and every mac binding in the kit was exercised only on the non-mac
1914
+ branch. It reads `||` now.
1915
+
1916
+ Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
1917
+ alongside the `viewport.pinchZoom` action that already handled the same
1918
+ gesture, applying one pinch's factor twice — the opt-in broke the path that
1919
+ worked without it. SceneCanvas drives pinch through the action alone, and the
1920
+ flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
1921
+ the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
1922
+ for real; it previously left the action running. Bare `<Canvas>` keeps the hook
1923
+ as its own pinch path.
1924
+
1925
+ - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
1926
+
1927
+ Nothing in the kit exported one, so consumers hand-rolled it — their own
1928
+ `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
1929
+ inverse built by projecting two points. That inverse silently drops
1930
+ `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
1931
+ beside the paint instead of on it.
1932
+
1933
+ ```ts
1934
+ useNodeOverlayFrame(scene, containerRef, nodeId, { view });
1935
+ // → { box, toScreen, toLocal, width, height } | null
1936
+ ```
1937
+
1938
+ `box` is the node's composed world box, unrotated — the frame `toScreen` maps
1939
+ from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
1940
+ lives in the pose→world leg, where it belongs: a node's stored geometry and its
1941
+ bounds-frame paint are pre-rotation by definition, so neither of those two
1942
+ changes.
1943
+
1944
+ `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
1945
+ `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
1946
+ `stroke` — `slot` is a prop — and commits each drag through `setFill` or
1947
+ `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
1948
+
1949
+ Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
1950
+ `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
1951
+
1952
+ - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
1953
+
1954
+ `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
1955
+ color and `'none'` still skips the stroke; an object is a core `Stroke` whose
1956
+ `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
1957
+ renderer, which has accepted them on `PathDrawCommand` all along. The object
1958
+ wins outright over `data.strokeWidth` rather than merging with it, the same
1959
+ rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
1960
+ is baked onto the pose box the way a fill is, so a gradient stroke resolves
1961
+ against the box it was authored against.
1962
+
1963
+ `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
1964
+
1965
+ `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
1966
+ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
1967
+ `'outer'` none inside, which one number could not say, so picking grabbed the
1968
+ wrong side. `ink` takes an optional context carrying the view scale, so a
1969
+ `{ px }` stroke width resolves to world units. A painter that still returns
1970
+ `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
1971
+
1972
+ `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
1973
+ color pick replaces its paint and keeps width, cap, join and dash, and an
1974
+ opacity drag sets the paint's `opacity`, which is the only form that works on
1975
+ a gradient stroke.
1976
+
1977
+ Editing UI for the rich form is not here yet — a schema-driven color control
1978
+ still writes a bare string over the object, so nodes carrying one are for
1979
+ programmatic authorship until `SelectionPanel` learns the union. See
1980
+ `docs/proposals/2026-08-26-node-stroke-union.md`.
1981
+
1982
+ - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
1983
+
1984
+ `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
1985
+ servers there deliberately, but the renderer refused anything but a solid — so
1986
+ importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
1987
+ next frame. Both stroke paths now paint the ribbon through the same route a
1988
+ fill takes, including under the inner/outer alignment stencil. A non-solid
1989
+ even-odd fill no longer renders black.
1990
+
1991
+ - 5f6c28e: An object leaf's fields can be organised into groups
1992
+
1993
+ `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
1994
+ heads its fields under a label and contributes nothing to the path — the same
1995
+ rule group keys follow at the top level of a schema, so a field inside one is
1996
+ still addressed as a field of the object.
1997
+
1998
+ Without it, a value with many fields renders as one undifferentiated list. A
1999
+ `TextStyle` is the case that needs it: its character and paragraph fields are
2000
+ one value but read as two lists.
2001
+
2002
+ - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
2003
+
2004
+ A compound value — a stroke, a shadow, a pattern spec — could be described as
2005
+ sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
2006
+ It shouldn't be: each control then writes one field of a value it can only
2007
+ half see, and writing a field into something that isn't an object yet corrupts
2008
+ it outright.
2009
+
2010
+ `ToolPrefObject` describes the value instead. Its `children` are ordinary
2011
+ leaves whose paths are relative to the object, and every child edit commits
2012
+ the parent object whole. A field that is itself a union declares the kind that
2013
+ edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
2014
+ value still held in a scalar form before a child edit lands on it, which is
2015
+ how a stroke stored as a bare colour string gains a width.
2016
+
2017
+ `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
2018
+ Color, Width, Cap, Join and Align under one Stroke block, and the separate
2019
+ `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
2020
+ `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
2021
+ replaced by this general one.
2022
+
2023
+ `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
2024
+ import, export and rendering untouched.
2025
+
2026
+ - 2ea772f: Selection handles are hit-tested at the size they are painted
2027
+
2028
+ Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
2029
+ constant, and neither `buildAffordanceAt` call site passed the option that
2030
+ would have scaled it. A coarse pointer got a bigger picture and exactly the
2031
+ same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
2032
+ exists to provide never reached the hit-test. The slops debug overlay was a
2033
+ third unscaled copy, so it drew hit regions where they were not.
2034
+
2035
+ `core/device/targets.ts` now holds one base table and one accessor,
2036
+ `targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
2037
+ through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
2038
+ `ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
2039
+ table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
2040
+
2041
+ `buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
2042
+ `selectTool.handleHitRadius` now actually reaches the hit-test — it previously
2043
+ reached nothing.
2044
+
2045
+ `useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
2046
+ affordance is an annulus with a band thickness and no point radius, so the
2047
+ option could only ever have been a second name for `rotationHandleDistance`,
2048
+ which is live and now defaults from the same table.
2049
+
2050
+ Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
2051
+ device profile, so a nested view still hit-tests at the fine-pointer size.
2052
+
2053
+ - f77bd95: `getChildren` means one thing on an adapter
2054
+
2055
+ `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
2056
+ drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
2057
+ the z-ordering seam where `null` means the root. Both land on the same adapter
2058
+ object, so `arrayAdapter` took the first shape from its config and exposed it
2059
+ under the name the ops read with the second meaning. An op asking for root
2060
+ order got `[]`, which reads as "the root has no siblings", and the slot it
2061
+ captured was silently lost.
2062
+
2063
+ The two declarations are now one contract, and `arrayAdapter` answers the root
2064
+ from its own item array rather than delegating — a consumer callback written
2065
+ for node ids returns `[]` there, which cannot be told apart from a genuine
2066
+ empty answer. A consumer's `getChildren` config is still only ever asked about
2067
+ a node id.
2068
+
2069
+ `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
2070
+ than by anchor. That is unchanged, and it is why the ordinal fallback exists.
2071
+
2072
+ - 2ea772f: The canvas and the gradient editor now sample one gradient
2073
+
2074
+ `buildGradientRamp` carried its own interpolation beside
2075
+ `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
2076
+ at either end and extrapolated past the first and last stop, the two picked
2077
+ opposite sides of a coincident pair, and they parsed color differently — a stop
2078
+ written as a CSS named color rendered on the canvas and threw in the editor.
2079
+
2080
+ `sampleGradientStops` keeps its semantics and is now the only implementation.
2081
+ `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
2082
+ returns the color at `t`. The ramp cache builds its texels through those, so
2083
+ there is no interpolation math left in the renderer.
2084
+
2085
+ Two behavior changes worth naming. `resolveColor` is the surviving parser, so
2086
+ gradient stops accept named and functional colors everywhere — but no longer
2087
+ hex without a leading `#`, which only the editor path had tolerated and the
2088
+ canvas never accepted. And `sampleGradientStops` returns normalized hex at the
2089
+ endpoints instead of echoing the raw stop string, so `'red'` comes back as
2090
+ `'#ff0000'`.
2091
+
2092
+ **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
2093
+ the element already carried the reference, the built-in serializer returned
2094
+ nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
2095
+ shape disappeared in a browser with no warning at all. Serialization now falls
2096
+ through to the same warning the pattern path already emits when nothing can
2097
+ produce a paint server. A consumer that registers a `toSvg` for
2098
+ `conic-gradient` still serializes and gets no warning.
2099
+
2100
+ - aba8d91: Answer "can this node be hit" in one place
2101
+
2102
+ Four tree walks answered it separately — the generic-adapter point pick, the
2103
+ one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
2104
+ marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
2105
+ that had a test and disagreed on the rest, three times, silently. `pickWalk`
2106
+ now owns every gate; a query supplies only its own shape test and the clip
2107
+ predicate for its region.
2108
+
2109
+ Behavior that changes as a result:
2110
+
2111
+ - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
2112
+ same number the painter does — the view's `alphaFor` times any per-node
2113
+ override alpha — so a node faded out of sight stops claiming clicks. The
2114
+ floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
2115
+ Alpha is per view: dimming a node in one view leaves it pickable in another.
2116
+ - **A layer that is not painted no longer claims pointer events.** `drawLayers`
2117
+ drops any layer missing from a supplied `layerOrder`, and the chrome hit path
2118
+ only consulted `layerVisibility`. Both gates now run through one
2119
+ `isLayerPainted`, which is exported.
2120
+ - `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
2121
+ did not; its default `poseBounds` answers a path pose instead of `NaN`, which
2122
+ is what the shadow walk existed to work around.
2123
+ - An ancestor clip now rejects an area query that reaches into the clip where
2124
+ the node is not, or reaches the node where the clip is not — the two terms
2125
+ together, where one alone let false positives through.
2126
+
2127
+ `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
2128
+ `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
2129
+ `adapterPickSource` and `ownClipOf` replace them.
2130
+
2131
+ - 2ea772f: A drag-to-insert reports the bounds it paints
2132
+
2133
+ The painter, the commit factory and `getGestureBounds()` each sized an
2134
+ in-flight insert differently. The reporter read the drag rect alone, so a
2135
+ centered Alt-drag reported a half-extent of `d` against a painted circumradius
2136
+ of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
2137
+ star, and a pencil scribble that looped back to its start reported nothing at
2138
+ all. The painter and the commit agreed on polygon and star but not on line or
2139
+ pencil: the commit posed the drag AABB for a line the painter drew endpoint to
2140
+ endpoint, and fell back to the drag rect for a trail under four samples.
2141
+
2142
+ One function now answers it for all three. The zero-area skip in the painter
2143
+ and the reporter tests the resolved extent rather than the raw drag rect, and
2144
+ an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
2145
+ `bounds` argument handed to a factory is unchanged.
2146
+
2147
+ - 3386d64: Path command opcodes derive from one table
2148
+
2149
+ `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
2150
+ once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
2151
+ literals in the path transform, pose-rotation and pose-descriptor walkers. They
2152
+ agreed, and nothing held them to each other: a sixth opcode desynchronizes two
2153
+ packages' reading of the same `Uint8Array` with no exception and no type error,
2154
+ and every walker misparses the coordinate stream from that command on.
2155
+
2156
+ `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
2157
+ `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
2158
+ core re-exports them by name, so the opcode constants keep their names, values
2159
+ and literal types. The three walkers moved onto `forEachSegment` rather than
2160
+ onto the accessor alone — they were duplicating the coordinate-cursor advance
2161
+ as well as the length, and the cursor is the half that actually misreads.
2162
+
2163
+ Eight further files switch on these opcodes with inline literals. Five throw on
2164
+ an unknown code; three — the path boolean adapter, the anchor-editing geometry,
2165
+ and geom's own boolean adapter — have no `default` arm and would silently stop
2166
+ advancing. Left as-is; they need per-command semantics, not one walker.
2167
+
2168
+ - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
2169
+
2170
+ `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
2171
+ saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
2172
+ leaf had neither `encoding` nor `options[].disabled`, so a dash-array
2173
+ preference did not merely fail to select — choosing an option wrote the option
2174
+ string over the stored dash array. labkit's two renderers were missing the
2175
+ `paint` and `object` kinds outright.
2176
+
2177
+ ui's schema is now a rename re-export of core's declaration. The public `Pref*`
2178
+ names are unchanged, and there is nothing left to keep in sync.
2179
+
2180
+ More importantly, all four renderer switches ended in `default:`, so adding a
2181
+ built-in kind produced no error at any site and simply rendered nothing —
2182
+ verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
2183
+ `string` so app-defined prefs can ride the same tree, which means a `never`
2184
+ guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
2185
+ `Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
2186
+ `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
2187
+ can discriminate and end in a `never`. App-defined kinds take the placeholder
2188
+ path as before.
2189
+
2190
+ Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
2191
+ arm threads sibling values, routes through `encoding.read` / `encoding.write`,
2192
+ and honors `option.disabled`. `SelectionPanel` already did all of this — it was
2193
+ only the forked copy that could not express it.
2194
+
2195
+ - 3386d64: Dragging out a text box shows a live preview
2196
+
2197
+ The set of insertable kinds and the `KitInsertShape` union sat on adjacent
2198
+ lines with no linkage, and seven more sites restated one list or the other. The
2199
+ drift was already live: the text tool binds `actionId: 'insert'` and commits
2200
+ through the insert dep, but the runtime set never listed `text`, so a
2201
+ drag-to-insert text box had no preview.
2202
+
2203
+ `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
2204
+ it has a built-in tool and whether it takes an insert preview. Both unions,
2205
+ `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
2206
+ preview gate all derive from it.
2207
+
2208
+ Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
2209
+ `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
2210
+ same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
2211
+ positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
2212
+ gains `'text'`, which is the fix itself: a consumer switching exhaustively over
2213
+ it gains a case, handled by the existing box arm.
2214
+
2215
+ - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
2216
+
2217
+ The paint, the pose silhouette and the click-to-edit caret each ran their own
2218
+ walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
2219
+ `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
2220
+ per call and the cache keyed on array identity; and the caret summed
2221
+ `ctx.measureText` per character, which sees no kerning, reads system fonts
2222
+ rather than the registered face, and ignores per-run styling entirely. The
2223
+ caret could therefore answer with a different line, and a different glyph, than
2224
+ the one under the pointer — masked in practice only because it asked a WebGL
2225
+ canvas for a 2D context and got `null`, degrading silently to no caret at all.
2226
+
2227
+ `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
2228
+ and all three go through it. It keeps the array-identity `WeakMap` as the
2229
+ renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
2230
+ structure, which is what lets a caller that cannot hold a stable array hit it —
2231
+ about 230× cheaper than laying out again, at roughly 4× the cost of the
2232
+ identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
2233
+ snapping is to the advance cells the glyphs were actually painted in.
2234
+
2235
+ **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
2236
+ `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
2237
+ an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
2238
+ draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
2239
+ double-click always seeds the caret instead of falling back to editing from
2240
+ offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
2241
+ `_resetLayoutCacheForTests`.
2242
+
2243
+ - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
2244
+
2245
+ `registerFontOutlines` was a paint upgrade for a family that already had an
2246
+ MSDF atlas; a family with only font bytes could not resolve, so it rendered
2247
+ nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
2248
+ `advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
2249
+ outline-only family, and `layoutRuns` reads advances, kerning and the baseline
2250
+ through one source the atlas and a parsed face both satisfy. `outlineMinSize`
2251
+ does not gate such a family — there is no other tier to prefer.
2252
+
2253
+ This does not touch metric neutrality where it applies: a family that has an
2254
+ atlas still resolves to the atlas, so registering outlines cannot move text
2255
+ that was already rendering.
2256
+
2257
+ Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
2258
+ and UMD under `main`; Node takes the UMD build, whose named exports it cannot
2259
+ detect, so `parse` was undefined and every face failed to load — silently, via
2260
+ the fallback to SDF. A browser bundler reading `module` never saw it.
2261
+
2262
+ Breaking for a consumer-supplied `OutlineParser`: a face must now report
2263
+ metrics as well as geometry.
2264
+
2265
+ - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
2266
+
2267
+ A kind bar over a per-kind body, driven by the paint-kind registry rather than
2268
+ a fixed list, so a consumer's registered kind appears in the bar and renders
2269
+ that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
2270
+ the chip that showed a gradient as indeterminate and wrote a solid over it on
2271
+ first touch — so the checkerboard now means a mixed selection and nothing else,
2272
+ and a gradient stroke is editable rather than merely paintable.
2273
+
2274
+ Switching kinds keeps a per-kind memory for the control's lifetime, so
2275
+ linear -> solid -> linear comes back with its stops instead of the ramp
2276
+ `withGradientKind` cannot carry.
2277
+
2278
+ `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
2279
+ on `@weasel-js/svg` for its tile previews.
2280
+
2281
+ The bar offers **None**: "what kind of paint is this?" takes no-paint as an
2282
+ answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
2283
+ becomes `null`, and a stroke goes away entirely rather than keeping a width
2284
+ that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
2285
+ built-in kinds carry glyphs so six segments fit a property row.
2286
+
2287
+ `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
2288
+ and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
2289
+ with its width — a whole paint editor cannot share a row with a slider.
2290
+
2291
+ - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
2292
+ frames and serializes.
2293
+
2294
+ `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
2295
+ re-seeds the five built-ins, matching the kit's other module-global
2296
+ registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
2297
+ `Editor`), a render slot, both frame-conversion directions, and an SVG
2298
+ `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
2299
+ consumer's own paint as a `FillStyle` — the union itself stays closed, because
2300
+ opening its discriminant would widen every built-in member.
2301
+
2302
+ Three defects fall out of the same change, each of which a sixth kind hit
2303
+ immediately. The renderer's fill dispatch fell off the end of its switch into
2304
+ an unguarded cast to the gradient union, so an unknown kind read `stops` off a
2305
+ paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
2306
+ an unknown kind untouched, leaving it painting in screen space on a node that
2307
+ moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
2308
+ still writing the `url(#id)` that referenced it.
2309
+
2310
+ Registering a kind now bumps the node memo generation, so a node painted
2311
+ before the registration repaints rather than holding the frame it resolved
2312
+ when the kind was unknown.
2313
+
2314
+ - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
2315
+
2316
+ Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
2317
+ `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
2318
+ where `undefined` takes the painter's fallback. Two new authoring helpers keep
2319
+ hand-written node data short:
2320
+
2321
+ ```ts
2322
+ data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
2323
+ ```
2324
+
2325
+ **Breaking, with no compatibility path.** A document written against the old
2326
+ shapes renders wrong rather than failing, which is accepted:
2327
+
2328
+ - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
2329
+ and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
2330
+ A node holding `fill: '#f00'` now paints the default grey.
2331
+ - `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
2332
+ - `data.color` — the legacy alias `kit:path` and the rect fallback read — is
2333
+ deleted. The fallback painter reads `data.fill` like everything else.
2334
+ - `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
2335
+ - `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
2336
+ else. A painter returning `{ filled, strokeWidth }` no longer type-checks
2337
+ and its reach is read as zero.
2338
+ - `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
2339
+ the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
2340
+ - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
2341
+ of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
2342
+ stroke into a color. SVG's `fill="none"` imports as `fill: null`.
2343
+
2344
+ **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
2345
+ the only slot a gradient or a pattern has, so it is the slot all of them use,
2346
+ and the renderer multiplies a hex alpha by it — the two would fight if both
2347
+ carried the value. `solid()` therefore moves an alpha channel out of the hex:
2348
+ `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
2349
+
2350
+ The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
2351
+ `opacity` rather than splicing hex, so they now work on a gradient fill, which
2352
+ they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
2353
+ the node's existing paint through the new `paintWithColor`, keeping its opacity
2354
+ unless the picked color states an alpha of its own — and `setStroke` keeps the
2355
+ stroke's width, cap, join and dash instead of replacing the whole value.
2356
+
2357
+ New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
2358
+ `paintWithColor`, `DEFAULT_SHAPE_FILL`.
2359
+
2360
+ `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
2361
+ — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
2362
+ writes a bare string over it — and the `data.stroke` object leaf drops its
2363
+ `fromScalar`, which had nothing left to lift.
2364
+
2365
+ - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
2366
+
2367
+ The text schema put `data.text` in a group named Text, so the section read
2368
+ TEXT and the row inside it read Text — one word nested in itself — and the
2369
+ style groups below it read as fields of the content string rather than as its
2370
+ siblings. Content is its own section now, with the field full-width because
2371
+ the section already names it.
2372
+
2373
+ A group with an empty `name` renders no heading. That already worked for
2374
+ sections and is now documented on `ToolPrefGroup`, since it is how a schema
2375
+ says "this group organises, it doesn't name": `Character` and `Paragraph`
2376
+ carry the labels, and a `Typography` heading over them named nothing new.
2377
+ It stays opt-in rather than a rule that rolls up any all-group parent —
2378
+ a `Border` over `Top` / `Right` / `Bottom` needs its name.
2379
+
2380
+ Rows under a suppressed heading no longer indent. Depth drawn without a
2381
+ visible parent put `Character` a level deeper than `Content` while being its
2382
+ peer, which is the panel's own tree discipline broken by its own hand.
2383
+
2384
+ - 611b30e: Layers and deps answer for the view they are drawn for
2385
+
2386
+ Nine lookups closed over the _surface's_ state at construction, so they answered
2387
+ for view zero in every view. `<CanvasView>` draws the surface's layer array
2388
+ unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
2389
+ a guarantee of answering for the wrong view rather than merely an unused
2390
+ argument. A drag in view B ghosted in view A, the marquee painted in the wrong
2391
+ view, chrome-caps resolved against the surface's selection, every Cmd+V centered
2392
+ on the wrong camera, and Escape in view B cancelled view A.
2393
+
2394
+ **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
2395
+ and `getIsVisible()`. All three are **required members**: anyone hand-writing a
2396
+ `CanvasViewHelpers` (a test double, a wrapper) has to add them.
2397
+ `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
2398
+ have answered for one view.
2399
+
2400
+ **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
2401
+ alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
2402
+ is the tool half.
2403
+
2404
+ **Layer options changed.** `createPathEditingOverlayLayer` and
2405
+ `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
2406
+ `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
2407
+ now. `usePreviewGhostLayer` has lost `tools`. Both it and
2408
+ `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
2409
+
2410
+ **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
2411
+ result accept an optional trailing `PickCamera`. A world point does not carry
2412
+ the scale it was produced under and picking has no draw envelope, so the caller
2413
+ that produced the point supplies it; omitting it keeps the surface camera.
2414
+
2415
+ `useHoverTracking` took a `clientToWorld` thunk beside a world-space
2416
+ `getNodeAtPoint` — the first resolved the view and the second did not, so hover
2417
+ picked at the surface's scale inside a panel. It takes one
2418
+ `nodeAtClientPoint(clientX, clientY)` now.
2419
+
2420
+ Anchor-editing target state stays surface-wide; only the preview resolution on
2421
+ that path is per-view.
2422
+
2423
+ - 9ad8cb2: Picking answers for what was painted
2424
+
2425
+ Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
2426
+ from something other than what the renderer drew.
2427
+
2428
+ **Pose overrides were painted through and picked around.** `PoseOverride.pose`
2429
+ is documented as replacing the document pose _everywhere the render and
2430
+ hit-test paths read one_, and `sceneAdapter.getPose` honored it. But
2431
+ `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
2432
+ did the bounds resolver feeding selection chrome and the affordance
2433
+ `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
2434
+ overrides painted them at one place and picked them at another. `effectivePose`
2435
+ is now the single rule and every one of those reads through it.
2436
+
2437
+ **A clipped-away child was still clickable.** A container clips its subtree and
2438
+ the renderer honors it, so a child outside the clip is not painted.
2439
+ `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
2440
+ `<SceneCanvas>` installs instead had no clip term at all. The new
2441
+ `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
2442
+ render-order scan can apply the same test.
2443
+
2444
+ **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
2445
+ 45° puts a corner 32 units above that box; a rubber-band over that corner was
2446
+ rejected before the rotation-correct silhouette test ran, while a click on the
2447
+ same pixel selected the shape.
2448
+
2449
+ - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
2450
+ tiles, coins, enemies and flagpole still declared `data.color`, the alias
2451
+ removed when node paint became an object, so every one of them rendered in
2452
+ the default gray — the demo whose whole point is being the visual twin of the
2453
+ immediate-mode load test.
2454
+ - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
2455
+
2456
+ Three paths read a pose's stored, unrotated box where the rotated extent was
2457
+ wanted.
2458
+
2459
+ `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
2460
+ came back turned the same way — invisible on a rectangle, whose AABB is
2461
+ symmetric under a sign flip, and plainly wrong on an asymmetric one, which
2462
+ translated instead of mirroring. It now negates the pose's rotation.
2463
+
2464
+ `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
2465
+ stored edges, while the dragged selection matched against them by its ink.
2466
+ `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
2467
+ `deriveAlignmentGuides` reads its targets through the same projection — a new
2468
+ `projection` option defaulting to the rect one, so existing callers get the fix
2469
+ without a change.
2470
+
2471
+ `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
2472
+ rotated member ended up with a gap short by the difference; `centers` shared the
2473
+ line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
2474
+ `distributeHorizontalAction` / `distributeVerticalAction` also take
2475
+ `params.mode`, so `gaps` is reachable from a binding rather than only from the
2476
+ hook.
2477
+
2478
+ Flip and distribute return different poses than before for rotated shapes.
2479
+ That is the fix, but it is a behavior change for anything depending on the
2480
+ old output.
2481
+
2482
+ - 3386d64: `@weasel-js/core/routing` exports the route-string projection
2483
+
2484
+ Anything rendering a `GestureSpec` as a route string had to re-implement the
2485
+ projection, and the copy in WeaselDraw's registry inspector had drifted three
2486
+ ways: it answered `drop` and `paste` with no gesture name, so every binding of
2487
+ either vanished from the route list; its argument lookup missed a spec field;
2488
+ and it gated targets on a hand-listed set of kinds, dropping them for
2489
+ `pointerDown`, `longPress` and `wheel`.
2490
+
2491
+ New from the routing subpath: `routesForSpec(spec)` — every route string one
2492
+ spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
2493
+ map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
2494
+ subpath index did not, so consumers reading `RegistryEntry.target` had no way
2495
+ to compare against the sentinel its own docs name.
2496
+
2497
+ - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
2498
+
2499
+ `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
2500
+ in their control flow — `***`/`**`/`*` and a two-character escape set — so
2501
+ reading or writing any other spelling meant forking both. They now take a
2502
+ `RunGrammar`: a table of markers pairing a repeated delimiter with the run
2503
+ flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
2504
+ today's behavior. Escaping follows the grammar's own delimiters.
2505
+
2506
+ Nothing changes for a caller that passes no grammar. `underline` and
2507
+ `strikethrough` still have no markdown spelling and are still dropped by
2508
+ `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
2509
+ rather than editing the parser.
2510
+
2511
+ - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
2512
+
2513
+ `createSelectionOverlayLayer` documents itself as equivalent to stacking the
2514
+ other two, and it was not. It reads `ChromeState` off the draw envelope,
2515
+ resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
2516
+ visibility and suppressed ids, and takes selection and poses from the envelope
2517
+ when they are omitted. The two primitives did none of that: they ignored the
2518
+ draw envelope entirely, required a construction-time `getPose` cascade, and
2519
+ knew nothing about the multi-selection union — so a consumer who stacked them,
2520
+ on the wrapper's own promise, got chrome in the wrong place with no way to
2521
+ tell.
2522
+
2523
+ All three now run one body and differ only in which passes they enable, so the
2524
+ promise holds by construction. `SelectionOutlineLayerOpts` and
2525
+ `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
2526
+ that don't apply, which makes `getSelection` and `getPose` optional on both and
2527
+ adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
2528
+ `SelectionHandleStyle`.
2529
+
2530
+ - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
2531
+
2532
+ The effect depended on the whole selection-context value, and the provider
2533
+ mints a new value object on every publish. So one publisher publishing refired
2534
+ the effect for every other publisher in scope, each of which republished its
2535
+ own ids — a newer selection got stomped back to an older one, and two
2536
+ publishers holding different ids under one provider never settled at all.
2537
+
2538
+ `publishSelection` is already a stable `useCallback`, so the effect now depends
2539
+ on it directly. No provider change and no API change.
2540
+
2541
+ - 84db1f6: Close four gaps that produced wrong answers with no error
2542
+
2543
+ Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
2544
+ `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
2545
+ `default:` arm, so a command code they did not know fell out of the switch
2546
+ without advancing the coordinate cursor and every segment after it read the
2547
+ wrong floats. They now throw, matching the six sibling walkers. This is a
2548
+ behavior change for anyone feeding these a path built with an opcode outside
2549
+ `PATH_COMMANDS`: what used to come back subtly wrong now raises.
2550
+
2551
+ A `<CanvasView>` built its affordance hit-test without a device profile, so a
2552
+ nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
2553
+ zones against the 14px chrome the surface paints. It reads the profile
2554
+ `<SceneCanvas>` publishes.
2555
+
2556
+ `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
2557
+ neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
2558
+ records had nowhere to land: undoing a delete through the move pipeline
2559
+ appended the node to the end of its parent instead of putting it back where it
2560
+ was. All three are there now.
2561
+
2562
+ The dev inspector's gesture panel formatted bindings with a private formatter
2563
+ that reported only modifiers set to `true`. The `ingest` action marks every
2564
+ modifier `'optional'`, so its drop and paste bindings rendered blank and the
2565
+ action was invisible on both gestures. Both of the panel's plain-text
2566
+ formatters now go through the kit's `routesForSpec`.
2567
+
2568
+ - 3386d64: Undoing a multi-node delete or group restores document order
2569
+
2570
+ Restoring by stored index cannot survive replay: history runs a batch's
2571
+ inverses in reverse, while indices captured before the mutation are only
2572
+ correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
2573
+ undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
2574
+
2575
+ Ops now record a `Slot` — an ordinal plus the id of the following sibling at
2576
+ capture. The anchor is the source of truth whenever it resolves, and it
2577
+ resolves whatever else the batch has already restored. The ordinal remains as
2578
+ the fallback for an adapter that can place by index but cannot enumerate
2579
+ children. `before: null` means "last" and needs no sibling list; an absent
2580
+ `before` means "unobserved", and the two survive `History.serialize` because
2581
+ `undefined` drops out of JSON and `null` does not.
2582
+
2583
+ The ops observe their own slot during `apply()` rather than taking one from the
2584
+ caller, so every existing emitter gets this without a call-site change.
2585
+ `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
2586
+ docstring said it was sufficient on its own, which it never was.
2587
+
2588
+ Adapters without an ordering seam still append, as they did before:
2589
+ `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
2590
+ neither that nor an `index` parameter on `insertNode`.
2591
+
2592
+ - 7a746df: A stroke's dash is edited as a style, not as an array
2593
+
2594
+ `Stroke.dash` already rendered, imported and exported; it had no control,
2595
+ because a `number[]` has no leaf kind. It doesn't need one — the thing a person
2596
+ chooses is a style, and the array is how it is stored. The stroke block gains a
2597
+ Solid / Dashed / Dotted / Custom bar under cap, join and align.
2598
+
2599
+ `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
2600
+ the option string, the counterpart of the `unit` a number leaf already has for
2601
+ a value stored in a canonical unit. Both directions are handed the object the
2602
+ leaf is a field of, because a dash pattern is meaningless without the width it
2603
+ scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
2604
+ hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
2605
+ `strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
2606
+ 2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
2607
+ a new `disabled` option — one a control reports but refuses to author, since
2608
+ there is no array behind it. `solid` is stored as no dash at all, and an object
2609
+ leaf's field written as `undefined` is now removed rather than left holding it.
2610
+
2611
+ - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
2612
+
2613
+ Nine option glyphs and four category glyphs join the icon set. The option
2614
+ glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
2615
+ shape rather than as a diagram of one. `align` is a circle zoomed until the
2616
+ ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
2617
+ the box's complement of it, and `center` is the annulus straddling the path,
2618
+ so the three are one band at three offsets. The categories are the bare path
2619
+ each row treats, drawn in the outlined register.
2620
+
2621
+ A schema carries a glyph _id_, not a component: `ToolPrefEnum`'s options gain
2622
+ `icon`, and every leaf gains one for rows whose own label is spent on a
2623
+ `pair`. Core ships no icon set and cannot depend on one, so the field is a
2624
+ plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
2625
+ `short` where it names no glyph.
2626
+
2627
+ `SelectionPanel` now honours `block` inside an object leaf, not only at the
2628
+ section level. A row whose fields are all `block` drops the 64px label column
2629
+ and spans the block. The default stroke schema uses both: paint and width
2630
+ share one label-less row, and cap/join/align share the next.
2631
+
2632
+ `align`'s options run inner, center, outer — the order the ink moves outward.
2633
+
2634
+ - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
2635
+
2636
+ `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
2637
+ through a new registry (`registerMarker`), shipping eight built-in shapes.
2638
+ Unlike SVG, the stroke stops short of a filled head rather than running under
2639
+ it to the tip; the distance is declared per marker, so an open V still reaches
2640
+ the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
2641
+ `<marker>` defs.
2642
+
2643
+ - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
2644
+
2645
+ It accepted `{ color }` only, and merged through `paintWithColor`, which
2646
+ supersedes a non-solid paint with a solid one — a gradient stroke was
2647
+ unreachable even though `setStrokeOpacity` could already reach its alpha.
2648
+ `paint` now wins over `color`, a color arriving later in the gesture supersedes
2649
+ an earlier paint, and the stroke's width, cap, join, dash and align survive
2650
+ either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
2651
+ that has no color to pass.
2652
+
2653
+ Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
2654
+ from `DEFAULT_STROKE_COLOR`, painting the selection black where
2655
+ `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
2656
+ `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
2657
+ where the body builds a left-edge-to-right-edge one.
2658
+
2659
+ `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
2660
+ core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
2661
+ it — and nothing imported it.
2662
+
2663
+ - 81213fc: Edit a node's stroke as the union it is
2664
+
2665
+ `data.stroke` holds `string | Stroke`, and the schema described it with a
2666
+ `color` leaf — which reads `undefined` off the object form, shows its own
2667
+ default, and writes a bare hex back over the stroke's width, cap, join and
2668
+ dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
2669
+ for `FillStyle`.
2670
+
2671
+ A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
2672
+ Its control shows whichever color the value has — the string itself, or a
2673
+ solid paint's color — gives a gradient stroke the indeterminate chip rather
2674
+ than claiming a color it doesn't have, and preserves the form on write.
2675
+
2676
+ `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
2677
+ `paint` leaf used to render as the literal text `(paint: no renderer)`.
2678
+ `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
2679
+ exported from `@weasel-js/ui` for consumers writing their own property
2680
+ renderers against either union.
2681
+
2682
+ Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
2683
+ remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
2684
+ for why that waits on the SVG mapping.
2685
+
2686
+ - 2f225d7: A thick stroke is clickable across its whole width
2687
+
2688
+ `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
2689
+ stroke width for an `outer` align — but the AABB pre-filter that runs before it
2690
+ grew only by the pointer slop. So half a thick outer stroke's ink was
2691
+ unclickable: the point was rejected before the refinement that would have
2692
+ claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
2693
+ was at least as generous as the refinement, which it cannot be on its own,
2694
+ since it never sees the stroke. That budget is the caller's, and the comment
2695
+ says so now.
2696
+
2697
+ `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
2698
+ declared in `px` resolved as world units and the reach was wrong at every zoom
2699
+ but 1 — while the caller computed `meanScale(view.scale)` one line above.
2700
+
2701
+ - 68069dc: Right-to-left text lays out in visual order
2702
+
2703
+ `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
2704
+ analyses the paragraph, reorders each line after the wrap, and mirrors brackets
2705
+ in right-to-left runs. Given none, nothing changes: text lays out logically,
2706
+ exactly as before.
2707
+
2708
+ `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
2709
+ `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
2710
+ consumer who renders no right-to-left text never installs the Unicode tables,
2711
+ and a different implementation can be substituted. `@weasel-js/bidi` is a
2712
+ devDependency here only, for a test that drives real Hebrew through the real
2713
+ engine; types lining up is not evidence the semantics do.
2714
+
2715
+ `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
2716
+ across `cells`**. Cells stay in logical order — slot `i` is still character `i`
2717
+ — while their x values follow the reordering. Sort on `x` for visual order, and
2718
+ read a cell's extent as `[x, x + advance)` rather than reaching for the next
2719
+ cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
2720
+ against each cell's own extent, taking a right-to-left cell's visually-leading
2721
+ half as the character's logical end.
2722
+
2723
+ Kerning is a gap between two adjacent characters, and the wrap measures it
2724
+ logically. Reordering can put a different pair side by side, so the gap taken
2725
+ is the one belonging to whichever of the two is logically second, and none at
2726
+ all across a direction boundary — where the pair never touched in the source.
2727
+
2728
+ Laying out right-to-left text with no engine now warns once, naming the import.
2729
+ The alternative is glyphs silently appearing reversed, which is the one real
2730
+ hazard of making this opt-in.
2731
+
2732
+ - 5d0ff9c: Every code point on a line gets a cell
2733
+
2734
+ `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
2735
+ `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
2736
+ `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
2737
+ a consumer indexing per character no longer has to reconcile a sparse array
2738
+ against the source string.
2739
+
2740
+ The old arrays were documented as non-contiguous, and two causes were real:
2741
+
2742
+ - A code point no tier could serve was dropped outright, taking its caret stop
2743
+ with it. It now occupies a zero-advance cell. This is reachable whenever the
2744
+ dynamic canvas fallback is off — which is the normal configuration for a
2745
+ consumer registering its own outlines, where the outline tier has no rung
2746
+ below it.
2747
+ - A space opening a line — at the start of the text, or after a newline — was
2748
+ discarded. It now keeps its cell and still consumes no width, so a line is
2749
+ addressable per character without gaining an indent. A space that opens a
2750
+ _wrapped_ line was never affected: the wrap leaves it as a trailing cell on
2751
+ the line before.
2752
+
2753
+ Neither changes any geometry: both cells carry zero advance, zero tracking and
2754
+ no kerning, so bounds, line widths and glyph positions are unchanged.
2755
+
2756
+ A newline still has no cell, since it separates cells rather than being one.
2757
+ `srcEnd` is what a blank line carries in its place.
2758
+
2759
+ `drawsInk` is a property of the code point and the face, not of the call that
2760
+ produced it: it does not flip when a dynamic bake lands or the outline
2761
+ threshold is crossed, so the same text reports the same slots every time. A
2762
+ zero-advance combining mark is `true` — it inks without advancing.
2763
+
2764
+ - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
2765
+ `data.fill` and `data.stroke` — the same two leaves every other node kind
2766
+ paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
2767
+ `TextStyle.stroke` are gone, with no compatibility read: a document that put
2768
+ its color in `style.fill` now renders in the default black rather than
2769
+ erroring, so check documents that predate this.
2770
+
2771
+ This fixes a real asymmetry rather than only moving fields. `data.stroke`
2772
+ already reached text through a fold in the painter, but `data.fill` did not:
2773
+ picking a fill color with a text node selected wrote a field nothing read, so
2774
+ the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
2775
+ the Appearance leaf now all mean the same thing on text as on a rect. The
2776
+ duplicate `data.style.fill` control is gone from the text schema with them.
2777
+
2778
+ `resolveTextStyle(style, paint)` takes the node's paint as a second argument
2779
+ and is what derives the caret and selection colors, so the edit overlay
2780
+ matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
2781
+ the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
2782
+ `data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
2783
+ `createTextLayer` is painted rather than black. `SvgTextNode` gained the same
2784
+ two, and SVG import and export carry text paint there instead of inside the
2785
+ style. `StyledRun.fill` and `.stroke` are unchanged and still override the
2786
+ node's per range — which is also where a caller with no node at all, a HUD
2787
+ widget or a debug overlay, now states its color.
2788
+
2789
+ `textCommandFromRuns` is exported from the package root.
2790
+
2791
+ - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
2792
+ array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
2793
+ the last paths still doing what `drawImage` stopped doing; text now costs
2794
+ **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
2795
+ ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
2796
+ image draw. No other command kind moved.
2797
+
2798
+ A text group is as many quads as it has glyphs, so unlike the image ring a
2799
+ slot's buffer grows to the largest run it has seen rather than being fixed at
2800
+ four vertices. The quad index pattern is a pure function of the quad count —
2801
+ the pattern for N quads is a prefix of the pattern for any larger N — so one
2802
+ index buffer serves every slot, grown the same way and written only when it
2803
+ grows.
2804
+
2805
+ - c2ffa49: Alignment can resolve against reading direction
2806
+
2807
+ `align` gains `start` and `end` alongside `left` / `center` / `right`, and
2808
+ `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
2809
+ the relative pair resolves against the direction, the absolute pair ignores it.
2810
+ `resolveAlign(align, direction)` collapses one to the other and is exported for
2811
+ consumers that need an edge rather than an intent.
2812
+
2813
+ Direction is an input, not something this package discovers. `@weasel-js/text`
2814
+ has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
2815
+ what it found; nothing here sniffs an environment.
2816
+
2817
+ Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
2818
+ layout moves. Making `start` the default alignment is a separate call.
2819
+
2820
+ `@weasel-js/svg` carries the direction through: `direction` joins the
2821
+ inheritable presentation properties, and `text-anchor` is now written and read
2822
+ against it. Two things were wrong before and are worth naming, because both
2823
+ rendered plausible output:
2824
+
2825
+ - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
2826
+ a mapping that assumed three values and read the fourth as its `else`.
2827
+ - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
2828
+ right edge, while this model's default `align` is `left`. They agree under
2829
+ `ltr` and only there, so an RTL document with no explicit anchor imported as
2830
+ left-aligned.
2831
+
2832
+ This is alignment and round-tripping only. Layout still walks code points in
2833
+ logical order with the pen always increasing: there is no bidi reordering and
2834
+ no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
2835
+ renders in logical order, and Arabic still renders unjoined.
2836
+
2837
+ - 4c097ef: Sit every run on a line on one baseline
2838
+
2839
+ Mixed-size text hung each run off the _line top_ at its own ascent instead of
2840
+ off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
2841
+ with the big run's cap rather than standing on the line with it. Two faces with
2842
+ different ascents at the same size diverged the same way. Baseline alignment is
2843
+ what inline text does everywhere else, and the module header already claimed
2844
+ this behavior — the walk just never implemented it.
2845
+
2846
+ A line now sinks one baseline far enough to clear its tallest run's ascent and
2847
+ places every glyph against it. Glyph quads derive their top from that baseline
2848
+ rather than from the pen's line top, which is the whole of the change:
2849
+ `qy0 = baselineY + (yoffset - metrics.base) * scale`.
2850
+
2851
+ Uniform-size text — nearly all text — is unchanged, since the maximum over one
2852
+ value is that value. Only lines that actually mix sizes or faces move, and they
2853
+ move to where they always should have been.
2854
+
2855
+ The test named "mixed-size runs share a baseline on the same line" asserted only
2856
+ a quad count and passed throughout; it now asserts the baselines.
2857
+
2858
+ - 2b86e00: A text node's style is one value, not ten sibling paths
2859
+
2860
+ `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
2861
+ `TextStyle` from ten independent leaves, each control writing a field of a
2862
+ value it could only half see. `data.style` is an object leaf now, with
2863
+ Character and Paragraph as groups inside it — groups head their fields and
2864
+ contribute nothing to the path, so a field is still a field of the style and
2865
+ one commit writes the whole thing.
2866
+
2867
+ An object leaf whose fields are entirely grouped no longer prints its own
2868
+ heading, which would stack straight onto the first group's, and a group's
2869
+ fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
2870
+ into an object leaf when listing what a kind exposes — the fields are the
2871
+ editable surface; the leaf is the container.
2872
+
2873
+ `SelectionPanel` has a story now, which is how the two layout defects above
2874
+ were found.
2875
+
2876
+ - d933a89: Superscript, subscript and overline for styled runs
2877
+
2878
+ `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
2879
+ a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
2880
+ over two new primitives rather than a mechanism of its own:
2881
+
2882
+ - `baselineShift` — raise (positive) or lower (negative) a run off the line's
2883
+ shared baseline, in ems of the inherited font size.
2884
+ - `fontScale` — a multiplier on the inherited font size, the relative
2885
+ counterpart to `fontSize`. An absolute `fontSize` still wins over it.
2886
+
2887
+ Naming either directly overrides that half of `script` and leaves the other
2888
+ alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
2889
+ ±33.3% position — Adobe's defaults, so a character panel can show percentages
2890
+ its users already recognize) and are derived, not read from the font: `OS/2`
2891
+ carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
2892
+ them, and metrics that applied on one glyph tier and not the other would
2893
+ reflow text as it crossed the size threshold.
2894
+
2895
+ `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
2896
+ `fontSize`, so layout never learns superscripts exist — it places a run against
2897
+ a baseline and an offset. The shift moves a run's glyphs, its outline geometry
2898
+ and its own decoration rules together, and deliberately does not feed back into
2899
+ the line's baseline or height: a superscript rides the line rather than
2900
+ reflowing it.
2901
+
2902
+ `overline` joins `underline` and `strikethrough` on both `TextStyle` and
2903
+ `StyledRun`, additive over the node style like the other two, and is now
2904
+ available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
2905
+ is unchanged — it stays silent on the decorations, as it always has been.
2906
+
2907
+ - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
2908
+ its own final `onTick`. The last tick emitted the value and completed in one
2909
+ pass, so a write made from that tick — cancelling the tween — still got the
2910
+ completion callback, against the documented "not called on cancel" contract.
2911
+ - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
2912
+
2913
+ Two ops inverted to something narrower than what they applied, so undo
2914
+ silently lost data.
2915
+
2916
+ `createDeleteOp.invert()` re-inserted a single node while `apply()` called
2917
+ `removeNode`, which cascades the whole subtree. Delete a container with two
2918
+ children, undo, and the container came back with `children: []` while both
2919
+ children were gone. The op now snapshots its descendants preorder through the
2920
+ adapter's optional `getNode` / `getChildren` — the snapshot is written back
2921
+ into `args`, so an op rebuilt from a serialized entry still inverts — and
2922
+ re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
2923
+ does not cascade, so the inverse skips any descendant the adapter still reports
2924
+ as live rather than duplicating it.
2925
+
2926
+ `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
2927
+ instead of restoring the sibling slot and paint order changed. `ReparentArgs`
2928
+ now carries `fromIndex` / `toIndex` and places through the existing
2929
+ `getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
2930
+ `setParent`'s signature is unchanged. Adapters without that seam no-op as
2931
+ before. `groupAction` captures each member's index before mutating; `move` and
2932
+ `snapToContainer` pass none and are byte-identical.
2933
+
2934
+ `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
2935
+ cascade, which is why nothing caught the first bug. It now runs against a
2936
+ tree-backed fake.
2937
+
2938
+ - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
2939
+
2940
+ Every union a user looks at or clicks folded each member's _unrotated_ box.
2941
+ Select two shapes, rotate one, and the multi-selection frame and its handles
2942
+ sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
2943
+ out as the target bounds for paint _and_ hit-test, so the handles were both
2944
+ drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
2945
+ correct larger box.
2946
+
2947
+ `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
2948
+ and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
2949
+ beside the rotation-free `unionBounds`, which stays correct for commit-time
2950
+ actions that write poses back in the unrotated frame; the module says which to
2951
+ reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
2952
+ another name. Both new functions are exported from the package root.
2953
+
2954
+ Moved onto it: `ChromeState.unionBounds`, the selection overlay's
2955
+ container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
2956
+ the wrong place whenever a member was rotated), and WeaselDraw's export
2957
+ viewBox, which clipped rotated shapes out of the copied SVG.
2958
+
2959
+ - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
2960
+
2961
+ `useAction` returned early on a null registry, so an action registered above
2962
+ the provider — or with no provider mounted — silently never fired its
2963
+ bindings. It now warns in dev, naming the action id. Runtime behavior in
2964
+ production builds is unchanged.
2965
+
2966
+ - Updated dependencies [3386d64]
2967
+ - Updated dependencies [c6c499d]
2968
+ - Updated dependencies [20097e6]
2969
+ - Updated dependencies [84db1f6]
2970
+ - Updated dependencies [94f2446]
2971
+ - Updated dependencies [68069dc]
2972
+ - Updated dependencies [5d0ff9c]
2973
+ - Updated dependencies [0bb27a5]
2974
+ - Updated dependencies [c2ffa49]
2975
+ - Updated dependencies [4c097ef]
2976
+ - Updated dependencies [d933a89]
2977
+ - @weasel-js/geom@2.0.0-pre.0
2978
+ - @weasel-js/text@2.0.0-pre.0
2979
+ - @weasel-js/gestures@2.0.0-pre.0
2980
+ - @weasel-js/history@2.0.0-pre.0
2981
+ - @weasel-js/modes@2.0.0-pre.0
2982
+ - @weasel-js/paint@2.0.0-pre.0
2983
+ - @weasel-js/font@2.0.0-pre.0
2984
+
3
2985
  ## 1.2.0
4
2986
 
5
2987
  ### Patch Changes