@weasel-js/core 1.1.0 → 1.3.0-pre.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 (61) hide show
  1. package/CHANGELOG.md +1988 -0
  2. package/dist/{DrawCommand-BbPrSTra.d.ts → DrawCommand-BnZyQaDc.d.ts} +29 -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-J3YC3GVF.js → chunk-3ZV53ZW2.js} +51 -14
  8. package/dist/chunk-3ZV53ZW2.js.map +1 -0
  9. package/dist/{chunk-JOSUVS6C.js → chunk-67KE7SDP.js} +4 -4
  10. package/dist/chunk-67KE7SDP.js.map +1 -0
  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-3B4QEB2G.js → chunk-IFOVTYAK.js} +26291 -24809
  18. package/dist/chunk-IFOVTYAK.js.map +1 -0
  19. package/dist/{chunk-UGFFCMQP.js → chunk-MXFSHJOM.js} +4 -4
  20. package/dist/{chunk-UGFFCMQP.js.map → chunk-MXFSHJOM.js.map} +1 -1
  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 +1929 -1342
  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-DGBrjGbB.d.ts → registry-BU1_3L_C.d.ts} +691 -38
  41. package/dist/renderer.d.ts +38 -83
  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-5pBwOm9s.d.ts +190 -0
  48. package/dist/{types-vqxpCFVp.d.ts → types-DIQAisSG.d.ts} +107 -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 +8 -6
  52. package/dist/chunk-3B4QEB2G.js.map +0 -1
  53. package/dist/chunk-4Z6J4IVH.js.map +0 -1
  54. package/dist/chunk-CSMF654J.js.map +0 -1
  55. package/dist/chunk-GVCNT7UH.js +0 -47
  56. package/dist/chunk-GVCNT7UH.js.map +0 -1
  57. package/dist/chunk-HGF5X6MZ.js.map +0 -1
  58. package/dist/chunk-J3YC3GVF.js.map +0 -1
  59. package/dist/chunk-JOSUVS6C.js.map +0 -1
  60. package/dist/paint-types-CnLIzqq1.d.ts +0 -261
  61. package/dist/viewToMat3-CQW_YqDw.d.ts +0 -67
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1993 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0-pre.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
+ - 3386d64: Align, distribute and flip use visual bounds
35
+
36
+ These folded each member's unrotated pose box, so "Align Left" on a selection
37
+ containing a rotated shape lined up the boxes and left the rotated shape's ink
38
+ sticking out past the others. They now work on the visual bounding box, as
39
+ Figma and Illustrator do.
40
+
41
+ Both ends moved together — expanding only the union would have made alignment
42
+ worse, since the delta runs from an edge of the union to the same edge of each
43
+ member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
44
+ reads a pose's bounds, recovers its rotation and expands via
45
+ `axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
46
+ applied as a translation of the stored pose through
47
+ `translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
48
+
49
+ Flip needed only its union pivot changed: mirroring maps a centre and preserves
50
+ size, and an expanded box is concentric with the box it came from.
51
+
52
+ `alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
53
+ by its ink.
54
+
55
+ - ffafb7d: Never let an animation's virtual clock run backwards.
56
+
57
+ `useAnimator` seeds each animation's `lastRealNow` from `now()` at register
58
+ time, then advances its virtual clock by the difference against the timestamp
59
+ the frame loop supplies. Those two share a time origin in a browser, where the
60
+ rAF timestamp and `performance.now()` are both page-relative — but that is a
61
+ browser guarantee, not a universal one, and jsdom starts them roughly 600ms
62
+ apart. The first frame's delta then came out hugely negative and `virtualNow`
63
+ spent dozens of frames climbing back toward zero before a tween advanced at
64
+ all: a 40ms glide took 95 frames and over a second of wall time, growing worse
65
+ the longer the process had been alive.
66
+
67
+ A frame's elapsed time is never negative, so the sample is now clamped at
68
+ zero. Under a shared origin this is a no-op.
69
+
70
+ - ba8b139: Camera animation: `viewport.animatedZoom` now does something
71
+
72
+ `animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
73
+ nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
74
+ through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
75
+ their input already delivers a sample per frame.
76
+
77
+ Camera animation is a general surface, not a zoom flag. Three ways in, one
78
+ runner behind them:
79
+
80
+ - `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
81
+ `isAnimating`, `target`.
82
+ - The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
83
+ so any action can glide the camera.
84
+ - `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
85
+ `isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
86
+ three are **required** members: anyone hand-implementing `SceneCanvasApi`
87
+ (a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
88
+ grew `getPaintedVersion`.
89
+
90
+ Scale interpolates geometrically and translation is derived from the screen
91
+ point the two views agree on, so a zoom stays anchored instead of drifting and
92
+ each frame changes the view by the same ratio. One animation runs at a time; any
93
+ other view write cancels it, and a cancel leaves the camera where it is rather
94
+ than jumping to the target. On an uncontrolled canvas the whole animation costs
95
+ no React render.
96
+
97
+ **Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
98
+ changes signature — it takes a `{ get, set }` view channel plus an optional
99
+ `Animator`, and `animateTo(from, to, { duration, easing })` becomes
100
+ `animate(to, { ms, easing })`. The `from` argument is gone because the runner
101
+ reads the live view, which is what lets an interrupted camera resume from where
102
+ it actually is instead of snapping back to a captured start. `cancel()` is now
103
+ `stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
104
+ `animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
105
+ the same reason `from` does.
106
+
107
+ **Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
108
+ `() => View | void`. Returning the target view lets Cmd+0 animate there;
109
+ returning nothing keeps the existing behavior. `animatedZoom`'s config fields
110
+ are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
111
+ animator's vocabulary.
112
+
113
+ - 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
114
+
115
+ The canvas is focusable by default (`tabIndex` 0) but exposed no way to
116
+ observe focus, so consumers driving focus-dependent chrome had to attach a
117
+ listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
118
+ so reach `SceneCanvasProps` and the canvas element unchanged.
119
+
120
+ - 67bcb05: Drop four values the canvas layer memo no longer reads
121
+
122
+ `hit-test affordances against the painted chrome state` moved the selection
123
+ overlay to reading bounds off the chrome state at paint time, which left
124
+ `selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
125
+ referenced only by the `layers` memo's dependency array — nothing in the body
126
+ used them. Removing them from the array made all four dead locals, so they go
127
+ too.
128
+
129
+ The memo now rebuilds the layer array on layer/tool/geometry changes rather
130
+ than additionally on every selection and preview-pose change. Selection chrome
131
+ is unaffected: it repaints from chrome state, not from the identity of this
132
+ array.
133
+
134
+ - 47cbb08: A closed subpath's dash no longer seams at its start vertex
135
+
136
+ `splitForDash` flushed the run still open when a closed subpath's walk returned
137
+ to the vertex it started from as its own open sub-polyline, so it and the run
138
+ that began there rendered as two butt-capped ribbons meeting at a point — a
139
+ notch on the corner of any dashed rectangle whose perimeter isn't a whole
140
+ multiple of the pattern. They are joined now, and the join the stroke asked for
141
+ is drawn across the seam like any other corner. A pattern whose first "on"
142
+ length covers the whole perimeter emits a closed ribbon, identical to the
143
+ undashed stroke.
144
+
145
+ - f43e9c2: A derived edge follows the drag that moves its endpoint
146
+
147
+ `move`, `resize` and `rotate` kept their in-flight poses in action-local
148
+ scratch and published them only as `previewIds` / `previewPose`. That surface
149
+ is enough to paint a ghost and size selection chrome, but nothing that asks
150
+ the _scene_ where a node is can see it — and `scenePoseLookup`, which resolves
151
+ a derived node's geometry, asks the scene. So dragging a box left its edge
152
+ anchored to the pre-drag position until the drop, when the commit invalidated
153
+ the dependents and the edge jumped.
154
+
155
+ The three actions now also publish each frame into the scene's ephemeral pose
156
+ overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
157
+ `interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
158
+ so a drag still commits as exactly one undo entry — the reason the actions
159
+ avoided per-frame scene writes in the first place was history, and this writes
160
+ no history. Entries are set once and mutated in place, published with a single
161
+ `commit()` per frame.
162
+
163
+ Picking follows for free: the pick source resolves a derived path through its
164
+ own override-aware `poseOf`, so an edge is grabbable where it is drawn
165
+ mid-gesture rather than where it used to be.
166
+
167
+ `clone` is deliberately untouched — its previews are the new ghosts at the
168
+ drag target, and the originals never move, so nothing derives from a changed
169
+ pose.
170
+
171
+ Also closes the matching gap in the preview-ghost layer, which built a
172
+ container's clip with no derived path and so ghosted a derived container
173
+ without one.
174
+
175
+ Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
176
+ on every gesture frame. It was already required by the `Scene` contract, but a
177
+ partial fake that omitted it will now throw rather than silently skip.
178
+
179
+ - bb27e83: A derived node is clickable where it paints
180
+
181
+ A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
182
+ `NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
183
+ resolved from the _dependencies'_ poses, which a painter has no handle on. So
184
+ `kit:derived` could not report one, `shapeCoversPoint` read the resulting null
185
+ as "no opinion" and answered `true` everywhere, and picking fell back to the
186
+ node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
187
+ was unpickable, and a derived container contributed no clip.
188
+
189
+ `silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
190
+ same convention `NodePaintCtx` already uses, and `kit:derived` reports the
191
+ derived path as its silhouette and its declared stroke as its `ink`.
192
+
193
+ Resolving that path needs the scene, so it is the _source_ that answers, not
194
+ the painter: `PickSource.derivedPathOf`, a matching optional argument to
195
+ `buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
196
+ carried the derived path a node _paints_, and now also the clip a derived
197
+ container _imposes_, so the live canvas and the headless walk clip alike. The
198
+ bare-adapter paths supply none of them and behave exactly as before.
199
+
200
+ The pre-filter had to move with it. `useSceneSelectTool` grew its region test
201
+ from the node's pose, which for a derived node is the wrong box entirely, so
202
+ the edge was rejected before the shape test could claim it. It now tests the
203
+ derived path when there is one — `poseContains` already reads a path-like pose
204
+ as a path, so this reuses it rather than adding a second reach calculation.
205
+
206
+ `findShapeSilhouette` skips its memo when handed a derived path. That slot is
207
+ keyed on `(node, pose, data)` and cannot see the path, so it would serve one
208
+ caller's silhouette to a caller that passed a different one — the same reason
209
+ `kit:derived` already skips `PAINT_SLOT`.
210
+
211
+ - 6a33c3f: A node's path can be derived from other nodes' poses
212
+
213
+ A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
214
+ through `SceneRegistry`, and the scene walks resolve its path before painting
215
+ rather than it being authored. An edge drawn between two boxes is then an
216
+ ordinary scene node — selectable, styleable, exportable — whose geometry never
217
+ enters undo history. The seam and its traps are in `docs/extending.md`.
218
+
219
+ New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
220
+ `NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
221
+ `SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
222
+ `SerializedNode.derivePathKey`, both additions to the serialization format;
223
+ `NodePaintCtx.derivedPath`.
224
+
225
+ Deleting a node now deletes everything that derives from it, transitively,
226
+ including those nodes' own subtrees, in one undo entry — so `scene.remove` can
227
+ remove nodes anywhere in the tree that the caller never named, and `removeLayer`
228
+ reaches nodes on other layers. Undo after the built-in **Delete** key does not
229
+ yet restore the cascaded nodes; see "Derived geometry follow-ups" in
230
+ `docs/TODO.md`.
231
+
232
+ **Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
233
+ context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
234
+ now a type error rather than a silent slide into the `view` slot. The same
235
+ fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
236
+ callback types, which is not a break: an existing three-parameter implementation
237
+ still satisfies them, and an existing three-argument call still compiles.
238
+
239
+ **Breaking: `Scene` gained a required `removeMany`.** A hand-written object
240
+ typed as a `Scene` — a test double, most likely — no longer typechecks until it
241
+ implements it.
242
+
243
+ **Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
244
+ `index` became `detached: { id, parent, index }[]`, because a cascaded dependent
245
+ is not a descendant of the removed node and the tree has to be told about every
246
+ subtree that came out of it. A history persisted by an older build now throws
247
+ mid-undo rather than degrading. The break is deliberate; kit op payloads are not
248
+ versioned.
249
+
250
+ - c24e7de: Detached views follow pose overrides
251
+
252
+ `<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
253
+ which a pose override deliberately never bumps — so they kept painting document
254
+ poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
255
+ canvas driving a drag or a simulation silently disagreed with it.
256
+
257
+ `<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
258
+ subscribes to `scene.overrides`. A render (prop change or version bump) and an
259
+ override commit both just mark the surface dirty, and one animation frame
260
+ coalesces them — so a 60 Hz override loop repaints these views with no React
261
+ render, and a backgrounded tab stops painting them entirely. The mount paint
262
+ stays synchronous, so the first frame is still the scene rather than a blank
263
+ canvas. `<MinimapCanvas>` inherits all of this through it.
264
+
265
+ Repaints driven by a prop change are now asynchronous: they land on the next
266
+ animation frame rather than in the layout effect of the render that caused them.
267
+ Code that renders and then reads pixels in the same tick needs to wait a frame.
268
+
269
+ A minimap's _framing_ still derives from document poses, so a node overridden
270
+ outside the document bounds paints outside the fitted frame — recomputing the
271
+ fit per frame would rescale the whole minimap throughout a settle.
272
+
273
+ - ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
274
+
275
+ `ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
276
+ once instead of hiding two behind a select. Options carry an optional `short`
277
+ label — a capital or two — for the width a property row has; the full `label`
278
+ stays the accessible name, so the abbreviation never becomes the only thing
279
+ naming the option. A mixed selection selects no segment rather than picking a
280
+ winner.
281
+
282
+ `pair` now merges fields inside an object leaf, as it already did for section
283
+ rows — a hint shouldn't mean something different for being a field of a value
284
+ rather than a sibling of one. It merges _adjacent_ leaves in both places, so
285
+ the schema orders family, size, weight: size and weight pair, and family (which
286
+ sat between them) moves ahead of the pair rather than splitting it.
287
+
288
+ A stroke's cap, join and align share one row; property rows wrap rather than
289
+ overflow when the controls in them don't fit.
290
+
291
+ - be697dc: Add ephemeral pose overrides to the scene
292
+
293
+ `scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
294
+ hit-test paths read through and that history, `toJSON()` and `getVersion()`
295
+ never see. It is additive: a scene with no overrides behaves exactly as before.
296
+
297
+ This is where per-frame motion belongs. A 60 Hz loop previously had to write
298
+ through `setPose`, which records an undo entry (one per frame at best, batched)
299
+ and bumps the scene version, re-rendering every `useSyncExternalStore`
300
+ subscriber. It also had to allocate a fresh pose object per moving node per
301
+ frame, because the painter memo keys on pose reference. An override entry is
302
+ hoisted once and mutated in place; `overrides.commit()` publishes the frame and
303
+ invalidates the memo for the overridden nodes only.
304
+
305
+ `commit()` is required after an in-place mutation — without it the memo serves
306
+ the previous frame's draw. Overrides are cleared when a node is removed, since
307
+ ids are reusable. To make a frame permanent, write it once through `setPose`
308
+ and clear the override; that single step is the undo entry.
309
+
310
+ `ForceGraphDemo` now settles with zero history entries and bakes the result as
311
+ one, replacing a per-tick batch of 24 `setPose` calls.
312
+
313
+ - e909a3b: `fitTextPose` sizes a box the renderer will actually fill
314
+
315
+ It was the fourth site measuring text its own way: `ctx.measureText` per
316
+ character against system fonts, no kerning, `pose.text` only. Nothing masked
317
+ it the way the WebGL context masked the caret — a consumer calling it got a
318
+ box that disagreed with the paint, narrower by a kern on every pair and wrong
319
+ by the whole difference between the installed family and the registered face.
320
+ It goes through the shared layout now, so it sees kerning and per-run styling.
321
+
322
+ **Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
323
+
324
+ - 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
325
+
326
+ `requestRedraw()` marks the surface dirty and the next frame paints, so many
327
+ redraws in one tick cost one paint. The view gains an imperative path on the
328
+ canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
329
+ longer holds it in React state, so a camera moving at 60 Hz costs no renders.
330
+ Consumers passing a `view` prop stay controlled and are unaffected.
331
+
332
+ Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
333
+ that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
334
+ gives a host the scene without a render per mutation, `useSceneTextEdit`'s
335
+ `view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
336
+ `contentVersion` prop feeds the version that `getPaintedVersion()` reports.
337
+
338
+ Two public signatures changed. `usePinchZoomTool` takes a view getter,
339
+ `getView: () => View`, where it took a `View` — nothing re-renders to refresh a
340
+ captured value any more. `CanvasExtensionApi` gained five required members —
341
+ `getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
342
+ so external code hand-implementing that interface stops typechecking; code that
343
+ only calls through the ref is unaffected.
344
+
345
+ Pixels and DOM can now be a frame apart, in whichever direction the change came
346
+ from. A view change leads with pixels: `setView` paints without rendering, so
347
+ DOM built from the view is stale until something re-renders it — position
348
+ world-anchored DOM from `subscribeView`. A scene change leads with DOM:
349
+ `SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
350
+ pixels land next frame — compare `getPaintedVersion()` against the version you
351
+ are about to render when chrome must be in lockstep. Do not render scene-derived
352
+ DOM inside `startTransition`: React defers it and nothing forces it to catch up.
353
+
354
+ Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
355
+ mode is the one in-tree case — can likewise see a buffer one frame older;
356
+ `subscribeFrame` runs on the frame that painted and removes the lag. Nothing
357
+ paints while `document.hidden` is true, `syncPaint` included, so a readback from
358
+ a background tab returns the frame from before the tab was hidden.
359
+
360
+ - 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
361
+ recorded. A caller is entitled to reuse the array it uploads from, so storing
362
+ the reference recorded a value that later frames overwrote — a test reading
363
+ two frames back saw the same numbers twice and passed. Test-only surface.
364
+ - 3fb3a46: Release held keys when the window loses focus
365
+
366
+ A window that blurs mid-hold never delivers the keyup, so every in-flight
367
+ `key-held` handle stayed engaged until that key was pressed again — holding
368
+ Space and tabbing away left the hand tool on the hotkey stack indefinitely.
369
+
370
+ The gesture dispatcher now fires the `key-held` up phase for each held key on
371
+ window blur. Consumers that hand-rolled this reset can drop it; ongoing
372
+ invocations see a normal `onEnd`.
373
+
374
+ - ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
375
+
376
+ One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
377
+ round caps and joins, hairline weight reserved for structure, and filled
378
+ regions only where an action has a subject. Covers transport, history, view,
379
+ trial lifecycle, collection, state, instrument and status vocabulary. Import a
380
+ named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
381
+
382
+ `@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
383
+ so consumers have one import site for the whole set. `ImageIcon` was reachable
384
+ from core's icons folder but missing from its public barrel; it is exported
385
+ now.
386
+
387
+ Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
388
+ rather than hand-placed, because arrowheads and joins that miss their terminus
389
+ are invisible at chrome size.
390
+
391
+ - 3fb3a46: Compose `before` and `after` layer chains in both directions
392
+
393
+ `composeOrderedLayers` walked the two anchor maps separately: a chain hanging
394
+ off an `after` anchor only followed further `after` links, and likewise for
395
+ `before`. A custom layer anchored `before: 'scene'` carrying a second custom
396
+ anchored `after` it dropped that second layer to the tail with a spurious
397
+ dangling-reference warning.
398
+
399
+ Both walks now emit a layer's `before` chain, the layer, then its `after`
400
+ chain, so the two mix freely. Cycle detection and orphan fallback are
401
+ unchanged.
402
+
403
+ - d9f110e: Stop every frame loop while nothing can see it
404
+
405
+ New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
406
+ whether a frame may run: nothing runs while `document.hidden`, and a loop that
407
+ names an element also stops while that element is outside the viewport. A
408
+ request made while suspended is held rather than dropped and re-armed on
409
+ resume, so a loop never polls visibility or needs restarting by hand.
410
+
411
+ Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
412
+ `useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
413
+ counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
414
+ `useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
415
+ the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
416
+ it paints only dirty layers, but a hidden tab still commits React updates and
417
+ its view/size effect marks every layer dirty.
418
+
419
+ Loops measuring elapsed time rebase their clock through the new `onResume`
420
+ option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
421
+ meter reporting a rate nobody achieved, a tween jumping to its end value on
422
+ return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
423
+ export; nothing in the tree sets it.
424
+
425
+ `npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
426
+ in kit source, and runs in CI.
427
+
428
+ - 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
429
+
430
+ A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
431
+ `viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
432
+ ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
433
+ and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
434
+ second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
435
+ where the matcher picks a single winner.
436
+
437
+ Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
438
+ and jsdom reports an empty-string platform — not nullish, so the fallback never
439
+ fired and every mac binding in the kit was exercised only on the non-mac
440
+ branch. It reads `||` now.
441
+
442
+ Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
443
+ alongside the `viewport.pinchZoom` action that already handled the same
444
+ gesture, applying one pinch's factor twice — the opt-in broke the path that
445
+ worked without it. SceneCanvas drives pinch through the action alone, and the
446
+ flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
447
+ the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
448
+ for real; it previously left the action running. Bare `<Canvas>` keeps the hook
449
+ as its own pinch path.
450
+
451
+ - 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
452
+
453
+ Nothing in the kit exported one, so consumers hand-rolled it — their own
454
+ `ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
455
+ inverse built by projecting two points. That inverse silently drops
456
+ `pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
457
+ beside the paint instead of on it.
458
+
459
+ ```ts
460
+ useNodeOverlayFrame(scene, containerRef, nodeId, { view });
461
+ // → { box, toScreen, toLocal, width, height } | null
462
+ ```
463
+
464
+ `box` is the node's composed world box, unrotated — the frame `toScreen` maps
465
+ from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
466
+ lives in the pose→world leg, where it belongs: a node's stored geometry and its
467
+ bounds-frame paint are pre-rotation by definition, so neither of those two
468
+ changes.
469
+
470
+ `@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
471
+ `GradientHandles`: it reads the gradient out of a node's `fill` **or** its
472
+ `stroke` — `slot` is a prop — and commits each drag through `setFill` or
473
+ `setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
474
+
475
+ Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
476
+ `useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
477
+
478
+ - 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
479
+
480
+ `NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
481
+ color and `'none'` still skips the stroke; an object is a core `Stroke` whose
482
+ `width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
483
+ renderer, which has accepted them on `PathDrawCommand` all along. The object
484
+ wins outright over `data.strokeWidth` rather than merging with it, the same
485
+ rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
486
+ is baked onto the pose box the way a fill is, so a gradient stroke resolves
487
+ against the box it was authored against.
488
+
489
+ `kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
490
+
491
+ `NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
492
+ strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
493
+ `'outer'` none inside, which one number could not say, so picking grabbed the
494
+ wrong side. `ink` takes an optional context carrying the view scale, so a
495
+ `{ px }` stroke width resolves to world units. A painter that still returns
496
+ `{ filled, strokeWidth }` is read as a centered stroke and keeps working.
497
+
498
+ `setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
499
+ color pick replaces its paint and keeps width, cap, join and dash, and an
500
+ opacity drag sets the paint's `opacity`, which is the only form that works on
501
+ a gradient stroke.
502
+
503
+ Editing UI for the rich form is not here yet — a schema-driven color control
504
+ still writes a bare string over the object, so nodes carrying one are for
505
+ programmatic authorship until `SelectionPanel` learns the union. See
506
+ `docs/proposals/2026-08-26-node-stroke-union.md`.
507
+
508
+ - 62a3c46: Paint a gradient or pattern stroke instead of throwing.
509
+
510
+ `Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
511
+ servers there deliberately, but the renderer refused anything but a solid — so
512
+ importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
513
+ next frame. Both stroke paths now paint the ribbon through the same route a
514
+ fill takes, including under the inner/outer alignment stencil. A non-solid
515
+ even-odd fill no longer renders black.
516
+
517
+ - 5f6c28e: An object leaf's fields can be organised into groups
518
+
519
+ `ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
520
+ heads its fields under a label and contributes nothing to the path — the same
521
+ rule group keys follow at the top level of a schema, so a field inside one is
522
+ still addressed as a field of the object.
523
+
524
+ Without it, a value with many fields renders as one undifferentiated list. A
525
+ `TextStyle` is the case that needs it: its character and paragraph fields are
526
+ one value but read as two lists.
527
+
528
+ - 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
529
+
530
+ A compound value — a stroke, a shadow, a pattern spec — could be described as
531
+ sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
532
+ It shouldn't be: each control then writes one field of a value it can only
533
+ half see, and writing a field into something that isn't an object yet corrupts
534
+ it outright.
535
+
536
+ `ToolPrefObject` describes the value instead. Its `children` are ordinary
537
+ leaves whose paths are relative to the object, and every child edit commits
538
+ the parent object whole. A field that is itself a union declares the kind that
539
+ edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
540
+ value still held in a scalar form before a child edit lands on it, which is
541
+ how a stroke stored as a bare colour string gains a width.
542
+
543
+ `defaultNodeProperties` describes `data.stroke` this way, so the panel shows
544
+ Color, Width, Cap, Join and Align under one Stroke block, and the separate
545
+ `data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
546
+ `PrefsForm` already did. The one-off `stroke` pref kind added days ago is
547
+ replaced by this general one.
548
+
549
+ `dash` has no leaf: it is a `number[]` and no kind edits one. It survives
550
+ import, export and rendering untouched.
551
+
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
+
579
+ - f77bd95: `getChildren` means one thing on an adapter
580
+
581
+ `MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
582
+ drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
583
+ the z-ordering seam where `null` means the root. Both land on the same adapter
584
+ object, so `arrayAdapter` took the first shape from its config and exposed it
585
+ under the name the ops read with the second meaning. An op asking for root
586
+ order got `[]`, which reads as "the root has no siblings", and the slot it
587
+ captured was silently lost.
588
+
589
+ The two declarations are now one contract, and `arrayAdapter` answers the root
590
+ from its own item array rather than delegating — a consumer callback written
591
+ for node ids returns `[]` there, which cannot be told apart from a genuine
592
+ empty answer. A consumer's `getChildren` config is still only ever asked about
593
+ a node id.
594
+
595
+ `arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
596
+ than by anchor. That is unchanged, and it is why the ordinal fallback exists.
597
+
598
+ - 2ea772f: The canvas and the gradient editor now sample one gradient
599
+
600
+ `buildGradientRamp` carried its own interpolation beside
601
+ `sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
602
+ at either end and extrapolated past the first and last stop, the two picked
603
+ opposite sides of a coincident pair, and they parsed color differently — a stop
604
+ written as a CSS named color rendered on the canvas and threw in the editor.
605
+
606
+ `sampleGradientStops` keeps its semantics and is now the only implementation.
607
+ `resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
608
+ returns the color at `t`. The ramp cache builds its texels through those, so
609
+ there is no interpolation math left in the renderer.
610
+
611
+ Two behavior changes worth naming. `resolveColor` is the surviving parser, so
612
+ gradient stops accept named and functional colors everywhere — but no longer
613
+ hex without a leading `#`, which only the editor path had tolerated and the
614
+ canvas never accepted. And `sampleGradientStops` returns normalized hex at the
615
+ endpoints instead of echoing the raw stop string, so `'red'` comes back as
616
+ `'#ff0000'`.
617
+
618
+ **SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
619
+ the element already carried the reference, the built-in serializer returned
620
+ nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
621
+ shape disappeared in a browser with no warning at all. Serialization now falls
622
+ through to the same warning the pattern path already emits when nothing can
623
+ produce a paint server. A consumer that registers a `toSvg` for
624
+ `conic-gradient` still serializes and gets no warning.
625
+
626
+ - aba8d91: Answer "can this node be hit" in one place
627
+
628
+ Four tree walks answered it separately — the generic-adapter point pick, the
629
+ one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
630
+ marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
631
+ that had a test and disagreed on the rest, three times, silently. `pickWalk`
632
+ now owns every gate; a query supplies only its own shape test and the clip
633
+ predicate for its region.
634
+
635
+ Behavior that changes as a result:
636
+
637
+ - **A node painted at alpha 0 is no longer clickable.** The pick path reads the
638
+ same number the painter does — the view's `alphaFor` times any per-node
639
+ override alpha — so a node faded out of sight stops claiming clicks. The
640
+ floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
641
+ Alpha is per view: dimming a node in one view leaves it pickable in another.
642
+ - **A layer that is not painted no longer claims pointer events.** `drawLayers`
643
+ drops any layer missing from a supplied `layerOrder`, and the chrome hit path
644
+ only consulted `layerVisibility`. Both gates now run through one
645
+ `isLayerPainted`, which is exported.
646
+ - `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
647
+ did not; its default `poseBounds` answers a path pose instead of `NaN`, which
648
+ is what the shadow walk existed to work around.
649
+ - An ancestor clip now rejects an area query that reaches into the clip where
650
+ the node is not, or reaches the node where the clip is not — the two terms
651
+ together, where one alone let false positives through.
652
+
653
+ `useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
654
+ `passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
655
+ `adapterPickSource` and `ownClipOf` replace them.
656
+
657
+ - 2ea772f: A drag-to-insert reports the bounds it paints
658
+
659
+ The painter, the commit factory and `getGestureBounds()` each sized an
660
+ in-flight insert differently. The reporter read the drag rect alone, so a
661
+ centered Alt-drag reported a half-extent of `d` against a painted circumradius
662
+ of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
663
+ star, and a pencil scribble that looped back to its start reported nothing at
664
+ all. The painter and the commit agreed on polygon and star but not on line or
665
+ pencil: the commit posed the drag AABB for a line the painter drew endpoint to
666
+ endpoint, and fell back to the drag rect for a trail under four samples.
667
+
668
+ One function now answers it for all three. The zero-area skip in the painter
669
+ and the reporter tests the resolved extent rather than the raw drag rect, and
670
+ an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
671
+ `bounds` argument handed to a factory is unchanged.
672
+
673
+ - 3386d64: Path command opcodes derive from one table
674
+
675
+ `M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
676
+ once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
677
+ literals in the path transform, pose-rotation and pose-descriptor walkers. They
678
+ agreed, and nothing held them to each other: a sixth opcode desynchronizes two
679
+ packages' reading of the same `Uint8Array` with no exception and no type error,
680
+ and every walker misparses the coordinate stream from that command on.
681
+
682
+ `PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
683
+ `PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
684
+ core re-exports them by name, so the opcode constants keep their names, values
685
+ and literal types. The three walkers moved onto `forEachSegment` rather than
686
+ onto the accessor alone — they were duplicating the coordinate-cursor advance
687
+ as well as the length, and the cursor is the half that actually misreads.
688
+
689
+ Eight further files switch on these opcodes with inline literals. Five throw on
690
+ an unknown code; three — the path boolean adapter, the anchor-editing geometry,
691
+ and geom's own boolean adapter — have no `default` arm and would silently stop
692
+ advancing. Left as-is; they need per-command semantics, not one walker.
693
+
694
+ - 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
695
+
696
+ `@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
697
+ saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
698
+ leaf had neither `encoding` nor `options[].disabled`, so a dash-array
699
+ preference did not merely fail to select — choosing an option wrote the option
700
+ string over the stored dash array. labkit's two renderers were missing the
701
+ `paint` and `object` kinds outright.
702
+
703
+ ui's schema is now a rename re-export of core's declaration. The public `Pref*`
704
+ names are unchanged, and there is nothing left to keep in sync.
705
+
706
+ More importantly, all four renderer switches ended in `default:`, so adding a
707
+ built-in kind produced no error at any site and simply rendered nothing —
708
+ verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
709
+ `string` so app-defined prefs can ride the same tree, which means a `never`
710
+ guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
711
+ `Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
712
+ `isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
713
+ can discriminate and end in a `never`. App-defined kinds take the placeholder
714
+ path as before.
715
+
716
+ Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
717
+ arm threads sibling values, routes through `encoding.read` / `encoding.write`,
718
+ and honors `option.disabled`. `SelectionPanel` already did all of this — it was
719
+ only the forked copy that could not express it.
720
+
721
+ - 3386d64: Dragging out a text box shows a live preview
722
+
723
+ The set of insertable kinds and the `KitInsertShape` union sat on adjacent
724
+ lines with no linkage, and seven more sites restated one list or the other. The
725
+ drift was already live: the text tool binds `actionId: 'insert'` and commits
726
+ through the insert dep, but the runtime set never listed `text`, so a
727
+ drag-to-insert text box had no preview.
728
+
729
+ `SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
730
+ it has a built-in tool and whether it takes an insert preview. Both unions,
731
+ `KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
732
+ preview gate all derive from it.
733
+
734
+ Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
735
+ `readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
736
+ same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
737
+ positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
738
+ gains `'text'`, which is the fix itself: a consumer switching exhaustively over
739
+ it gains a case, handled by the existing box arm.
740
+
741
+ - c6c499d: Text layout is computed once, and the caret reads the layout that was painted
742
+
743
+ The paint, the pose silhouette and the click-to-edit caret each ran their own
744
+ walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
745
+ `layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
746
+ per call and the cache keyed on array identity; and the caret summed
747
+ `ctx.measureText` per character, which sees no kerning, reads system fonts
748
+ rather than the registered face, and ignores per-run styling entirely. The
749
+ caret could therefore answer with a different line, and a different glyph, than
750
+ the one under the pointer — masked in practice only because it asked a WebGL
751
+ canvas for a 2D context and got `null`, degrading silently to no caret at all.
752
+
753
+ `cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
754
+ and all three go through it. It keeps the array-identity `WeakMap` as the
755
+ renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
756
+ structure, which is what lets a caller that cannot hold a stable array hit it —
757
+ about 230× cheaper than laying out again, at roughly 4× the cost of the
758
+ identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
759
+ snapping is to the advance cells the glyphs were actually painted in.
760
+
761
+ **Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
762
+ `caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
763
+ an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
764
+ draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
765
+ double-click always seeds the caret instead of falling back to editing from
766
+ offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
767
+ `_resetLayoutCacheForTests`.
768
+
769
+ - 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
770
+
771
+ `registerFontOutlines` was a paint upgrade for a family that already had an
772
+ MSDF atlas; a family with only font bytes could not resolve, so it rendered
773
+ nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
774
+ `advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
775
+ outline-only family, and `layoutRuns` reads advances, kerning and the baseline
776
+ through one source the atlas and a parsed face both satisfy. `outlineMinSize`
777
+ does not gate such a family — there is no other tier to prefer.
778
+
779
+ This does not touch metric neutrality where it applies: a family that has an
780
+ atlas still resolves to the atlas, so registering outlines cannot move text
781
+ that was already rendering.
782
+
783
+ Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
784
+ and UMD under `main`; Node takes the UMD build, whose named exports it cannot
785
+ detect, so `parse` was undefined and every face failed to load — silently, via
786
+ the fallback to SDF. A browser bundler reading `module` never saw it.
787
+
788
+ Breaking for a consumer-supplied `OutlineParser`: a face must now report
789
+ metrics as well as geometry.
790
+
791
+ - 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
792
+
793
+ A kind bar over a per-kind body, driven by the paint-kind registry rather than
794
+ a fixed list, so a consumer's registered kind appears in the bar and renders
795
+ that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
796
+ the chip that showed a gradient as indeterminate and wrote a solid over it on
797
+ first touch — so the checkerboard now means a mixed selection and nothing else,
798
+ and a gradient stroke is editable rather than merely paintable.
799
+
800
+ Switching kinds keeps a per-kind memory for the control's lifetime, so
801
+ linear -> solid -> linear comes back with its stops instead of the ramp
802
+ `withGradientKind` cannot carry.
803
+
804
+ `PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
805
+ on `@weasel-js/svg` for its tile previews.
806
+
807
+ The bar offers **None**: "what kind of paint is this?" takes no-paint as an
808
+ answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
809
+ becomes `null`, and a stroke goes away entirely rather than keeping a width
810
+ that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
811
+ built-in kinds carry glyphs so six segments fit a property row.
812
+
813
+ `FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
814
+ and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
815
+ with its width — a whole paint editor cannot share a row with a slider.
816
+
817
+ - 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
818
+ frames and serializes.
819
+
820
+ `registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
821
+ re-seeds the five built-ins, matching the kit's other module-global
822
+ registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
823
+ `Editor`), a render slot, both frame-conversion directions, and an SVG
824
+ `<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
825
+ consumer's own paint as a `FillStyle` — the union itself stays closed, because
826
+ opening its discriminant would widen every built-in member.
827
+
828
+ Three defects fall out of the same change, each of which a sixth kind hit
829
+ immediately. The renderer's fill dispatch fell off the end of its switch into
830
+ an unguarded cast to the gradient union, so an unknown kind read `stops` off a
831
+ paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
832
+ an unknown kind untouched, leaving it painting in screen space on a node that
833
+ moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
834
+ still writing the `url(#id)` that referenced it.
835
+
836
+ Registering a kind now bumps the node memo generation, so a node painted
837
+ before the registration repaints rather than holding the frame it resolved
838
+ when the kind was unknown.
839
+
840
+ - 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
841
+
842
+ Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
843
+ `data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
844
+ where `undefined` takes the painter's fallback. Two new authoring helpers keep
845
+ hand-written node data short:
846
+
847
+ ```ts
848
+ data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
849
+ ```
850
+
851
+ **Breaking, with no compatibility path.** A document written against the old
852
+ shapes renders wrong rather than failing, which is accepted:
853
+
854
+ - `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
855
+ and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
856
+ A node holding `fill: '#f00'` now paints the default grey.
857
+ - `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
858
+ - `data.color` — the legacy alias `kit:path` and the rect fallback read — is
859
+ deleted. The fallback painter reads `data.fill` like everything else.
860
+ - `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
861
+ - `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
862
+ else. A painter returning `{ filled, strokeWidth }` no longer type-checks
863
+ and its reach is read as zero.
864
+ - `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
865
+ the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
866
+ - `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
867
+ of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
868
+ stroke into a color. SVG's `fill="none"` imports as `fill: null`.
869
+
870
+ **A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
871
+ the only slot a gradient or a pattern has, so it is the slot all of them use,
872
+ and the renderer multiplies a hex alpha by it — the two would fight if both
873
+ carried the value. `solid()` therefore moves an alpha channel out of the hex:
874
+ `solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
875
+
876
+ The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
877
+ `opacity` rather than splicing hex, so they now work on a gradient fill, which
878
+ they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
879
+ the node's existing paint through the new `paintWithColor`, keeping its opacity
880
+ unless the picked color states an alpha of its own — and `setStroke` keeps the
881
+ stroke's width, cap, join and dash instead of replacing the whole value.
882
+
883
+ New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
884
+ `paintWithColor`, `DEFAULT_SHAPE_FILL`.
885
+
886
+ `defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
887
+ — a color control pointed at a `FillStyle` reads `undefined` off a gradient and
888
+ writes a bare string over it — and the `data.stroke` object leaf drops its
889
+ `fromScalar`, which had nothing left to lift.
890
+
891
+ - a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
892
+
893
+ The text schema put `data.text` in a group named Text, so the section read
894
+ TEXT and the row inside it read Text — one word nested in itself — and the
895
+ style groups below it read as fields of the content string rather than as its
896
+ siblings. Content is its own section now, with the field full-width because
897
+ the section already names it.
898
+
899
+ A group with an empty `name` renders no heading. That already worked for
900
+ sections and is now documented on `ToolPrefGroup`, since it is how a schema
901
+ says "this group organises, it doesn't name": `Character` and `Paragraph`
902
+ carry the labels, and a `Typography` heading over them named nothing new.
903
+ It stays opt-in rather than a rule that rolls up any all-group parent —
904
+ a `Border` over `Top` / `Right` / `Bottom` needs its name.
905
+
906
+ Rows under a suppressed heading no longer indent. Depth drawn without a
907
+ visible parent put `Character` a level deeper than `Content` while being its
908
+ peer, which is the panel's own tree discipline broken by its own hand.
909
+
910
+ - 611b30e: Layers and deps answer for the view they are drawn for
911
+
912
+ Nine lookups closed over the _surface's_ state at construction, so they answered
913
+ for view zero in every view. `<CanvasView>` draws the surface's layer array
914
+ unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
915
+ a guarantee of answering for the wrong view rather than merely an unused
916
+ argument. A drag in view B ghosted in view A, the marquee painted in the wrong
917
+ view, chrome-caps resolved against the surface's selection, every Cmd+V centered
918
+ on the wrong camera, and Escape in view B cancelled view A.
919
+
920
+ **New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
921
+ and `getIsVisible()`. All three are **required members**: anyone hand-writing a
922
+ `CanvasViewHelpers` (a test double, a wrapper) has to add them.
923
+ `getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
924
+ have answered for one view.
925
+
926
+ **New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
927
+ alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
928
+ is the tool half.
929
+
930
+ **Layer options changed.** `createPathEditingOverlayLayer` and
931
+ `createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
932
+ `isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
933
+ now. `usePreviewGhostLayer` has lost `tools`. Both it and
934
+ `useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
935
+
936
+ **Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
937
+ result accept an optional trailing `PickCamera`. A world point does not carry
938
+ the scale it was produced under and picking has no draw envelope, so the caller
939
+ that produced the point supplies it; omitting it keeps the surface camera.
940
+
941
+ `useHoverTracking` took a `clientToWorld` thunk beside a world-space
942
+ `getNodeAtPoint` — the first resolved the view and the second did not, so hover
943
+ picked at the surface's scale inside a panel. It takes one
944
+ `nodeAtClientPoint(clientX, clientY)` now.
945
+
946
+ Anchor-editing target state stays surface-wide; only the preview resolution on
947
+ that path is per-view.
948
+
949
+ - 9ad8cb2: Picking answers for what was painted
950
+
951
+ Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
952
+ from something other than what the renderer drew.
953
+
954
+ **Pose overrides were painted through and picked around.** `PoseOverride.pose`
955
+ is documented as replacing the document pose _everywhere the render and
956
+ hit-test paths read one_, and `sceneAdapter.getPose` honored it. But
957
+ `<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
958
+ did the bounds resolver feeding selection chrome and the affordance
959
+ `ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
960
+ overrides painted them at one place and picked them at another. `effectivePose`
961
+ is now the single rule and every one of those reads through it.
962
+
963
+ **A clipped-away child was still clickable.** A container clips its subtree and
964
+ the renderer honors it, so a child outside the clip is not painted.
965
+ `useSelectTool`'s own walk has rejected those since clipping shipped; the walk
966
+ `<SceneCanvas>` installs instead had no clip term at all. The new
967
+ `passesAncestorClips` walks the parent chain per surviving candidate, so a flat
968
+ render-order scan can apply the same test.
969
+
970
+ **The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
971
+ 45° puts a corner 32 units above that box; a rubber-band over that corner was
972
+ rejected before the rotation-correct silhouette test ran, while a click on the
973
+ same pixel selected the shape.
974
+
975
+ - c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
976
+ tiles, coins, enemies and flagpole still declared `data.color`, the alias
977
+ removed when node paint became an object, so every one of them rendered in
978
+ the default gray — the demo whose whole point is being the visual twin of the
979
+ immediate-mode load test.
980
+ - d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
981
+
982
+ Three paths read a pose's stored, unrotated box where the rotated extent was
983
+ wanted.
984
+
985
+ `flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
986
+ came back turned the same way — invisible on a rectangle, whose AABB is
987
+ symmetric under a sign flip, and plainly wrong on an asymmetric one, which
988
+ translated instead of mirroring. It now negates the pose's rotation.
989
+
990
+ `deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
991
+ stored edges, while the dragged selection matched against them by its ink.
992
+ `RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
993
+ `deriveAlignmentGuides` reads its targets through the same projection — a new
994
+ `projection` option defaulting to the rect one, so existing callers get the fix
995
+ without a change.
996
+
997
+ `useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
998
+ rotated member ended up with a gap short by the difference; `centers` shared the
999
+ line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
1000
+ `distributeHorizontalAction` / `distributeVerticalAction` also take
1001
+ `params.mode`, so `gaps` is reachable from a binding rather than only from the
1002
+ hook.
1003
+
1004
+ Flip and distribute return different poses than before for rotated shapes.
1005
+ That is the fix, but it is a behavior change for anything depending on the
1006
+ old output.
1007
+
1008
+ - 3386d64: `@weasel-js/core/routing` exports the route-string projection
1009
+
1010
+ Anything rendering a `GestureSpec` as a route string had to re-implement the
1011
+ projection, and the copy in WeaselDraw's registry inspector had drifted three
1012
+ ways: it answered `drop` and `paste` with no gesture name, so every binding of
1013
+ either vanished from the route list; its argument lookup missed a spec field;
1014
+ and it gated targets on a hand-listed set of kinds, dropping them for
1015
+ `pointerDown`, `longPress` and `wheel`.
1016
+
1017
+ New from the routing subpath: `routesForSpec(spec)` — every route string one
1018
+ spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
1019
+ map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
1020
+ subpath index did not, so consumers reading `RegistryEntry.target` had no way
1021
+ to compare against the sentinel its own docs name.
1022
+
1023
+ - ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
1024
+
1025
+ `runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
1026
+ in their control flow — `***`/`**`/`*` and a two-character escape set — so
1027
+ reading or writing any other spelling meant forking both. They now take a
1028
+ `RunGrammar`: a table of markers pairing a repeated delimiter with the run
1029
+ flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
1030
+ today's behavior. Escaping follows the grammar's own delimiters.
1031
+
1032
+ Nothing changes for a caller that passes no grammar. `underline` and
1033
+ `strikethrough` still have no markdown spelling and are still dropped by
1034
+ `runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
1035
+ rather than editing the parser.
1036
+
1037
+ - 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
1038
+
1039
+ `createSelectionOverlayLayer` documents itself as equivalent to stacking the
1040
+ other two, and it was not. It reads `ChromeState` off the draw envelope,
1041
+ resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
1042
+ visibility and suppressed ids, and takes selection and poses from the envelope
1043
+ when they are omitted. The two primitives did none of that: they ignored the
1044
+ draw envelope entirely, required a construction-time `getPose` cascade, and
1045
+ knew nothing about the multi-selection union — so a consumer who stacked them,
1046
+ on the wrapper's own promise, got chrome in the wrong place with no way to
1047
+ tell.
1048
+
1049
+ All three now run one body and differ only in which passes they enable, so the
1050
+ promise holds by construction. `SelectionOutlineLayerOpts` and
1051
+ `SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
1052
+ that don't apply, which makes `getSelection` and `getPose` optional on both and
1053
+ adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
1054
+ `SelectionHandleStyle`.
1055
+
1056
+ - 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
1057
+
1058
+ The effect depended on the whole selection-context value, and the provider
1059
+ mints a new value object on every publish. So one publisher publishing refired
1060
+ the effect for every other publisher in scope, each of which republished its
1061
+ own ids — a newer selection got stomped back to an older one, and two
1062
+ publishers holding different ids under one provider never settled at all.
1063
+
1064
+ `publishSelection` is already a stable `useCallback`, so the effect now depends
1065
+ on it directly. No provider change and no API change.
1066
+
1067
+ - 84db1f6: Close four gaps that produced wrong answers with no error
1068
+
1069
+ Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
1070
+ `enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
1071
+ `default:` arm, so a command code they did not know fell out of the switch
1072
+ without advancing the coordinate cursor and every segment after it read the
1073
+ wrong floats. They now throw, matching the six sibling walkers. This is a
1074
+ behavior change for anyone feeding these a path built with an opcode outside
1075
+ `PATH_COMMANDS`: what used to come back subtly wrong now raises.
1076
+
1077
+ A `<CanvasView>` built its affordance hit-test without a device profile, so a
1078
+ nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
1079
+ zones against the 14px chrome the surface paints. It reads the profile
1080
+ `<SceneCanvas>` publishes.
1081
+
1082
+ `moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
1083
+ neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
1084
+ records had nowhere to land: undoing a delete through the move pipeline
1085
+ appended the node to the end of its parent instead of putting it back where it
1086
+ was. All three are there now.
1087
+
1088
+ The dev inspector's gesture panel formatted bindings with a private formatter
1089
+ that reported only modifiers set to `true`. The `ingest` action marks every
1090
+ modifier `'optional'`, so its drop and paste bindings rendered blank and the
1091
+ action was invisible on both gestures. Both of the panel's plain-text
1092
+ formatters now go through the kit's `routesForSpec`.
1093
+
1094
+ - 3386d64: Undoing a multi-node delete or group restores document order
1095
+
1096
+ Restoring by stored index cannot survive replay: history runs a batch's
1097
+ inverses in reverse, while indices captured before the mutation are only
1098
+ correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
1099
+ undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
1100
+
1101
+ Ops now record a `Slot` — an ordinal plus the id of the following sibling at
1102
+ capture. The anchor is the source of truth whenever it resolves, and it
1103
+ resolves whatever else the batch has already restored. The ordinal remains as
1104
+ the fallback for an adapter that can place by index but cannot enumerate
1105
+ children. `before: null` means "last" and needs no sibling list; an absent
1106
+ `before` means "unobserved", and the two survive `History.serialize` because
1107
+ `undefined` drops out of JSON and `null` does not.
1108
+
1109
+ The ops observe their own slot during `apply()` rather than taking one from the
1110
+ caller, so every existing emitter gets this without a call-site change.
1111
+ `createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
1112
+ docstring said it was sufficient on its own, which it never was.
1113
+
1114
+ Adapters without an ordering seam still append, as they did before:
1115
+ `arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
1116
+ neither that nor an `index` parameter on `insertNode`.
1117
+
1118
+ - 7a746df: A stroke's dash is edited as a style, not as an array
1119
+
1120
+ `Stroke.dash` already rendered, imported and exported; it had no control,
1121
+ because a `number[]` has no leaf kind. It doesn't need one — the thing a person
1122
+ chooses is a style, and the array is how it is stored. The stroke block gains a
1123
+ Solid / Dashed / Dotted / Custom bar under cap, join and align.
1124
+
1125
+ `ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
1126
+ the option string, the counterpart of the `unit` a number leaf already has for
1127
+ a value stored in a canonical unit. Both directions are handed the object the
1128
+ leaf is a field of, because a dash pattern is meaningless without the width it
1129
+ scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
1130
+ hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
1131
+ `strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
1132
+ 2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
1133
+ a new `disabled` option — one a control reports but refuses to author, since
1134
+ there is no array behind it. `solid` is stored as no dash at all, and an object
1135
+ leaf's field written as `undefined` is now removed rather than left holding it.
1136
+
1137
+ - 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
1138
+
1139
+ Nine option glyphs and four category glyphs join the icon set. The option
1140
+ glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
1141
+ shape rather than as a diagram of one. `align` is a circle zoomed until the
1142
+ ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
1143
+ the box's complement of it, and `center` is the annulus straddling the path,
1144
+ so the three are one band at three offsets. The categories are the bare path
1145
+ each row treats, drawn in the outlined register.
1146
+
1147
+ A schema carries a glyph _id_, not a component: `ToolPrefEnum`'s options gain
1148
+ `icon`, and every leaf gains one for rows whose own label is spent on a
1149
+ `pair`. Core ships no icon set and cannot depend on one, so the field is a
1150
+ plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
1151
+ `short` where it names no glyph.
1152
+
1153
+ `SelectionPanel` now honours `block` inside an object leaf, not only at the
1154
+ section level. A row whose fields are all `block` drops the 64px label column
1155
+ and spans the block. The default stroke schema uses both: paint and width
1156
+ share one label-less row, and cap/join/align share the next.
1157
+
1158
+ `align`'s options run inner, center, outer — the order the ink moves outward.
1159
+
1160
+ - 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
1161
+
1162
+ `markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
1163
+ through a new registry (`registerMarker`), shipping eight built-in shapes.
1164
+ Unlike SVG, the stroke stops short of a filled head rather than running under
1165
+ it to the tip; the distance is declared per marker, so an open V still reaches
1166
+ the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
1167
+ `<marker>` defs.
1168
+
1169
+ - 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
1170
+
1171
+ It accepted `{ color }` only, and merged through `paintWithColor`, which
1172
+ supersedes a non-solid paint with a solid one — a gradient stroke was
1173
+ unreachable even though `setStrokeOpacity` could already reach its alpha.
1174
+ `paint` now wins over `color`, a color arriving later in the gesture supersedes
1175
+ an earlier paint, and the stroke's width, cap, join, dash and align survive
1176
+ either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
1177
+ that has no color to pass.
1178
+
1179
+ Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
1180
+ from `DEFAULT_STROKE_COLOR`, painting the selection black where
1181
+ `setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
1182
+ `gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
1183
+ where the body builds a left-edge-to-right-edge one.
1184
+
1185
+ `@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
1186
+ core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
1187
+ it — and nothing imported it.
1188
+
1189
+ - 81213fc: Edit a node's stroke as the union it is
1190
+
1191
+ `data.stroke` holds `string | Stroke`, and the schema described it with a
1192
+ `color` leaf — which reads `undefined` off the object form, shows its own
1193
+ default, and writes a bare hex back over the stroke's width, cap, join and
1194
+ dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
1195
+ for `FillStyle`.
1196
+
1197
+ A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
1198
+ Its control shows whichever color the value has — the string itself, or a
1199
+ solid paint's color — gives a gradient stroke the indeterminate chip rather
1200
+ than claiming a color it doesn't have, and preserves the form on write.
1201
+
1202
+ `PrefsForm` gained the `stroke` case and the `paint` case it never had; a
1203
+ `paint` leaf used to render as the literal text `(paint: no renderer)`.
1204
+ `solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
1205
+ exported from `@weasel-js/ui` for consumers writing their own property
1206
+ renderers against either union.
1207
+
1208
+ Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
1209
+ remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
1210
+ for why that waits on the SVG mapping.
1211
+
1212
+ - 2f225d7: A thick stroke is clickable across its whole width
1213
+
1214
+ `shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
1215
+ stroke width for an `outer` align — but the AABB pre-filter that runs before it
1216
+ grew only by the pointer slop. So half a thick outer stroke's ink was
1217
+ unclickable: the point was rejected before the refinement that would have
1218
+ claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
1219
+ was at least as generous as the refinement, which it cannot be on its own,
1220
+ since it never sees the stroke. That budget is the caller's, and the comment
1221
+ says so now.
1222
+
1223
+ `ShapeCoversPointOptions.scale` was never passed either, so a stroke width
1224
+ declared in `px` resolved as world units and the reach was wrong at every zoom
1225
+ but 1 — while the caller computed `meanScale(view.scale)` one line above.
1226
+
1227
+ - 68069dc: Right-to-left text lays out in visual order
1228
+
1229
+ `LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
1230
+ analyses the paragraph, reorders each line after the wrap, and mirrors brackets
1231
+ in right-to-left runs. Given none, nothing changes: text lays out logically,
1232
+ exactly as before.
1233
+
1234
+ `@weasel-js/text` declares the `BidiResolver` interface and does not depend on
1235
+ `@weasel-js/bidi` — the dependency runs the other way from the usual, so a
1236
+ consumer who renders no right-to-left text never installs the Unicode tables,
1237
+ and a different implementation can be substituted. `@weasel-js/bidi` is a
1238
+ devDependency here only, for a test that drives real Hebrew through the real
1239
+ engine; types lining up is not evidence the semantics do.
1240
+
1241
+ `LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
1242
+ across `cells`**. Cells stay in logical order — slot `i` is still character `i`
1243
+ — while their x values follow the reordering. Sort on `x` for visual order, and
1244
+ read a cell's extent as `[x, x + advance)` rather than reaching for the next
1245
+ cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
1246
+ against each cell's own extent, taking a right-to-left cell's visually-leading
1247
+ half as the character's logical end.
1248
+
1249
+ Kerning is a gap between two adjacent characters, and the wrap measures it
1250
+ logically. Reordering can put a different pair side by side, so the gap taken
1251
+ is the one belonging to whichever of the two is logically second, and none at
1252
+ all across a direction boundary — where the pair never touched in the source.
1253
+
1254
+ Laying out right-to-left text with no engine now warns once, naming the import.
1255
+ The alternative is glyphs silently appearing reversed, which is the one real
1256
+ hazard of making this opt-in.
1257
+
1258
+ - 5d0ff9c: Every code point on a line gets a cell
1259
+
1260
+ `LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
1261
+ `cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
1262
+ `srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
1263
+ a consumer indexing per character no longer has to reconcile a sparse array
1264
+ against the source string.
1265
+
1266
+ The old arrays were documented as non-contiguous, and two causes were real:
1267
+
1268
+ - A code point no tier could serve was dropped outright, taking its caret stop
1269
+ with it. It now occupies a zero-advance cell. This is reachable whenever the
1270
+ dynamic canvas fallback is off — which is the normal configuration for a
1271
+ consumer registering its own outlines, where the outline tier has no rung
1272
+ below it.
1273
+ - A space opening a line — at the start of the text, or after a newline — was
1274
+ discarded. It now keeps its cell and still consumes no width, so a line is
1275
+ addressable per character without gaining an indent. A space that opens a
1276
+ _wrapped_ line was never affected: the wrap leaves it as a trailing cell on
1277
+ the line before.
1278
+
1279
+ Neither changes any geometry: both cells carry zero advance, zero tracking and
1280
+ no kerning, so bounds, line widths and glyph positions are unchanged.
1281
+
1282
+ A newline still has no cell, since it separates cells rather than being one.
1283
+ `srcEnd` is what a blank line carries in its place.
1284
+
1285
+ `drawsInk` is a property of the code point and the face, not of the call that
1286
+ produced it: it does not flip when a dynamic bake lands or the outline
1287
+ threshold is crossed, so the same text reports the same slots every time. A
1288
+ zero-advance combining mark is `true` — it inks without advancing.
1289
+
1290
+ - c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
1291
+ `data.fill` and `data.stroke` — the same two leaves every other node kind
1292
+ paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
1293
+ `TextStyle.stroke` are gone, with no compatibility read: a document that put
1294
+ its color in `style.fill` now renders in the default black rather than
1295
+ erroring, so check documents that predate this.
1296
+
1297
+ This fixes a real asymmetry rather than only moving fields. `data.stroke`
1298
+ already reached text through a fold in the painter, but `data.fill` did not:
1299
+ picking a fill color with a text node selected wrote a field nothing read, so
1300
+ the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
1301
+ the Appearance leaf now all mean the same thing on text as on a rect. The
1302
+ duplicate `data.style.fill` control is gone from the text schema with them.
1303
+
1304
+ `resolveTextStyle(style, paint)` takes the node's paint as a second argument
1305
+ and is what derives the caret and selection colors, so the edit overlay
1306
+ matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
1307
+ the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
1308
+ `data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
1309
+ `createTextLayer` is painted rather than black. `SvgTextNode` gained the same
1310
+ two, and SVG import and export carry text paint there instead of inside the
1311
+ style. `StyledRun.fill` and `.stroke` are unchanged and still override the
1312
+ node's per range — which is also where a caller with no node at all, a HUD
1313
+ widget or a debug overlay, now states its color.
1314
+
1315
+ `textCommandFromRuns` is exported from the package root.
1316
+
1317
+ - 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
1318
+ array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
1319
+ the last paths still doing what `drawImage` stopped doing; text now costs
1320
+ **3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
1321
+ ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
1322
+ image draw. No other command kind moved.
1323
+
1324
+ A text group is as many quads as it has glyphs, so unlike the image ring a
1325
+ slot's buffer grows to the largest run it has seen rather than being fixed at
1326
+ four vertices. The quad index pattern is a pure function of the quad count —
1327
+ the pattern for N quads is a prefix of the pattern for any larger N — so one
1328
+ index buffer serves every slot, grown the same way and written only when it
1329
+ grows.
1330
+
1331
+ - c2ffa49: Alignment can resolve against reading direction
1332
+
1333
+ `align` gains `start` and `end` alongside `left` / `center` / `right`, and
1334
+ `TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
1335
+ the relative pair resolves against the direction, the absolute pair ignores it.
1336
+ `resolveAlign(align, direction)` collapses one to the other and is exported for
1337
+ consumers that need an edge rather than an intent.
1338
+
1339
+ Direction is an input, not something this package discovers. `@weasel-js/text`
1340
+ has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
1341
+ what it found; nothing here sniffs an environment.
1342
+
1343
+ Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
1344
+ layout moves. Making `start` the default alignment is a separate call.
1345
+
1346
+ `@weasel-js/svg` carries the direction through: `direction` joins the
1347
+ inheritable presentation properties, and `text-anchor` is now written and read
1348
+ against it. Two things were wrong before and are worth naming, because both
1349
+ rendered plausible output:
1350
+
1351
+ - `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
1352
+ a mapping that assumed three values and read the fourth as its `else`.
1353
+ - SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
1354
+ right edge, while this model's default `align` is `left`. They agree under
1355
+ `ltr` and only there, so an RTL document with no explicit anchor imported as
1356
+ left-aligned.
1357
+
1358
+ This is alignment and round-tripping only. Layout still walks code points in
1359
+ logical order with the pen always increasing: there is no bidi reordering and
1360
+ no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
1361
+ renders in logical order, and Arabic still renders unjoined.
1362
+
1363
+ - 4c097ef: Sit every run on a line on one baseline
1364
+
1365
+ Mixed-size text hung each run off the _line top_ at its own ascent instead of
1366
+ off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
1367
+ with the big run's cap rather than standing on the line with it. Two faces with
1368
+ different ascents at the same size diverged the same way. Baseline alignment is
1369
+ what inline text does everywhere else, and the module header already claimed
1370
+ this behavior — the walk just never implemented it.
1371
+
1372
+ A line now sinks one baseline far enough to clear its tallest run's ascent and
1373
+ places every glyph against it. Glyph quads derive their top from that baseline
1374
+ rather than from the pen's line top, which is the whole of the change:
1375
+ `qy0 = baselineY + (yoffset - metrics.base) * scale`.
1376
+
1377
+ Uniform-size text — nearly all text — is unchanged, since the maximum over one
1378
+ value is that value. Only lines that actually mix sizes or faces move, and they
1379
+ move to where they always should have been.
1380
+
1381
+ The test named "mixed-size runs share a baseline on the same line" asserted only
1382
+ a quad count and passed throughout; it now asserts the baselines.
1383
+
1384
+ - 2b86e00: A text node's style is one value, not ten sibling paths
1385
+
1386
+ `data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
1387
+ `TextStyle` from ten independent leaves, each control writing a field of a
1388
+ value it could only half see. `data.style` is an object leaf now, with
1389
+ Character and Paragraph as groups inside it — groups head their fields and
1390
+ contribute nothing to the path, so a field is still a field of the style and
1391
+ one commit writes the whole thing.
1392
+
1393
+ An object leaf whose fields are entirely grouped no longer prints its own
1394
+ heading, which would stack straight onto the first group's, and a group's
1395
+ fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
1396
+ into an object leaf when listing what a kind exposes — the fields are the
1397
+ editable surface; the leaf is the container.
1398
+
1399
+ `SelectionPanel` has a story now, which is how the two layout defects above
1400
+ were found.
1401
+
1402
+ - d933a89: Superscript, subscript and overline for styled runs
1403
+
1404
+ `StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
1405
+ a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
1406
+ over two new primitives rather than a mechanism of its own:
1407
+
1408
+ - `baselineShift` — raise (positive) or lower (negative) a run off the line's
1409
+ shared baseline, in ems of the inherited font size.
1410
+ - `fontScale` — a multiplier on the inherited font size, the relative
1411
+ counterpart to `fontSize`. An absolute `fontSize` still wins over it.
1412
+
1413
+ Naming either directly overrides that half of `script` and leaves the other
1414
+ alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
1415
+ ±33.3% position — Adobe's defaults, so a character panel can show percentages
1416
+ its users already recognize) and are derived, not read from the font: `OS/2`
1417
+ carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
1418
+ them, and metrics that applied on one glyph tier and not the other would
1419
+ reflow text as it crossed the size threshold.
1420
+
1421
+ `resolveRuns` folds all of it into one world-unit `baselineShift` and a final
1422
+ `fontSize`, so layout never learns superscripts exist — it places a run against
1423
+ a baseline and an offset. The shift moves a run's glyphs, its outline geometry
1424
+ and its own decoration rules together, and deliberately does not feed back into
1425
+ the line's baseline or height: a superscript rides the line rather than
1426
+ reflowing it.
1427
+
1428
+ `overline` joins `underline` and `strikethrough` on both `TextStyle` and
1429
+ `StyledRun`, additive over the node style like the other two, and is now
1430
+ available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
1431
+ is unchanged — it stays silent on the decorations, as it always has been.
1432
+
1433
+ - 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
1434
+ its own final `onTick`. The last tick emitted the value and completed in one
1435
+ pass, so a write made from that tick — cancelling the tween — still got the
1436
+ completion callback, against the documented "not called on cancel" contract.
1437
+ - 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
1438
+
1439
+ Two ops inverted to something narrower than what they applied, so undo
1440
+ silently lost data.
1441
+
1442
+ `createDeleteOp.invert()` re-inserted a single node while `apply()` called
1443
+ `removeNode`, which cascades the whole subtree. Delete a container with two
1444
+ children, undo, and the container came back with `children: []` while both
1445
+ children were gone. The op now snapshots its descendants preorder through the
1446
+ adapter's optional `getNode` / `getChildren` — the snapshot is written back
1447
+ into `args`, so an op rebuilt from a serialized entry still inverts — and
1448
+ re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
1449
+ does not cascade, so the inverse skips any descendant the adapter still reports
1450
+ as live rather than duplicating it.
1451
+
1452
+ `createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
1453
+ instead of restoring the sibling slot and paint order changed. `ReparentArgs`
1454
+ now carries `fromIndex` / `toIndex` and places through the existing
1455
+ `getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
1456
+ `setParent`'s signature is unchanged. Adapters without that seam no-op as
1457
+ before. `groupAction` captures each member's index before mutating; `move` and
1458
+ `snapToContainer` pass none and are byte-identical.
1459
+
1460
+ `ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
1461
+ cascade, which is why nothing caught the first bug. It now runs against a
1462
+ tree-backed fake.
1463
+
1464
+ - 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
1465
+
1466
+ Every union a user looks at or clicks folded each member's _unrotated_ box.
1467
+ Select two shapes, rotate one, and the multi-selection frame and its handles
1468
+ sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
1469
+ out as the target bounds for paint _and_ hit-test, so the handles were both
1470
+ drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
1471
+ correct larger box.
1472
+
1473
+ `unionAABB` expands each rotated member via `axisAlignedBounds` before folding
1474
+ and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
1475
+ beside the rotation-free `unionBounds`, which stays correct for commit-time
1476
+ actions that write poses back in the unrotated frame; the module says which to
1477
+ reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
1478
+ another name. Both new functions are exported from the package root.
1479
+
1480
+ Moved onto it: `ChromeState.unionBounds`, the selection overlay's
1481
+ container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
1482
+ the wrong place whenever a member was rotated), and WeaselDraw's export
1483
+ viewBox, which clipped rotated shapes out of the copied SVG.
1484
+
1485
+ - 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
1486
+
1487
+ `useAction` returned early on a null registry, so an action registered above
1488
+ the provider — or with no provider mounted — silently never fired its
1489
+ bindings. It now warns in dev, naming the action id. Runtime behavior in
1490
+ production builds is unchanged.
1491
+
1492
+ - Updated dependencies [3386d64]
1493
+ - Updated dependencies [c6c499d]
1494
+ - Updated dependencies [20097e6]
1495
+ - Updated dependencies [84db1f6]
1496
+ - Updated dependencies [94f2446]
1497
+ - Updated dependencies [68069dc]
1498
+ - Updated dependencies [5d0ff9c]
1499
+ - Updated dependencies [0bb27a5]
1500
+ - Updated dependencies [c2ffa49]
1501
+ - Updated dependencies [4c097ef]
1502
+ - Updated dependencies [d933a89]
1503
+ - @weasel-js/geom@2.0.0-pre.0
1504
+ - @weasel-js/text@2.0.0-pre.0
1505
+ - @weasel-js/gestures@2.0.0-pre.0
1506
+ - @weasel-js/history@2.0.0-pre.0
1507
+ - @weasel-js/modes@2.0.0-pre.0
1508
+ - @weasel-js/paint@2.0.0-pre.0
1509
+ - @weasel-js/font@2.0.0-pre.0
1510
+
1511
+ ## 1.2.0
1512
+
1513
+ ### Patch Changes
1514
+
1515
+ - 53016f7: Add `actionShortcuts(action)` — an action's keyboard bindings, flattened into
1516
+ the shape `formatShortcut` / `formatShortcutParts` render.
1517
+
1518
+ Binding lists are written for a matcher, not a reader, so two things collapse:
1519
+ a spec's `key` may list spellings of one keycap (`['[', '{']` — the shifted
1520
+ bracket reports as `'{'`), and a modifier declared `'optional'` matches held or
1521
+ unheld, so it isn't part of what anyone presses. Non-keyboard bindings have no
1522
+ chip form and are skipped.
1523
+
1524
+ Every keyboard binding is returned, in declaration order — an action can answer
1525
+ to several (`reorder.forward` has three) and nothing marks one canonical.
1526
+
1527
+ WeaselDraw's command palette shows its shortcut chip again. It had been
1528
+ suppressed since `Action.defaultBinding: KeyBinding` was removed, pending a
1529
+ formatter for the replacement shape.
1530
+
1531
+ - e25e77b: `<Canvas>` accepts `layerVisibility` and `layerOrder`.
1532
+
1533
+ `drawLayers` has resolved layer visibility and draw order since it was written,
1534
+ but `<Canvas>` passed it `{}` and `undefined`, so the only way to control either
1535
+ was a layer's own `defaultVisible`. Both are now props: `layerVisibility` maps
1536
+ layer id to shown, falling back to `defaultVisible` for ids it omits and ignored
1537
+ entirely by `alwaysOn` layers; `layerOrder` lists ids bottom-first, and any
1538
+ layer it omits is not drawn.
1539
+
1540
+ Hiding a layer also stops it claiming pointer events through `hitTestExtras`,
1541
+ which previously walked every registered layer regardless. Draw and hit-test
1542
+ resolve visibility through one exported `isLayerVisible`, so a layer nobody can
1543
+ see cannot swallow a click.
1544
+
1545
+ - 8e00c13: Give `<CanvasView>` a selection of its own.
1546
+
1547
+ `selection` and `selectionOptions` on a view mirror the props of the same name
1548
+ on `<SceneCanvas>`: supply a `SelectionApi` to control one, or pass
1549
+ `selectionOptions` to have the view build its own. Either goes into the view's
1550
+ dep overlay, so an action dispatched inside that view reads and writes the
1551
+ view's selection and leaves the surface's alone. Pass neither and the view
1552
+ shares the surface's selection.
1553
+
1554
+ Nothing paints it yet — a view's chrome is still drawn from the surface's
1555
+ selection.
1556
+
1557
+ - c91e186: Add `<CanvasView>`: a second camera over a rect of an existing canvas, drawn
1558
+ through the same GL context, with input routed to it.
1559
+
1560
+ Declare one through `<SceneCanvas views={[...]}>`, or mount the component as a
1561
+ child — the same declaration either way, with children landing after every prop
1562
+ entry in paint and hit order. A view paints the surface's own layer stack
1563
+ through its camera, or a narrowed slice of it via `layers`, and owns its camera
1564
+ in the same hybrid controlled/uncontrolled mode `<Canvas>` uses. Wheel and drag
1565
+ inside its rect move that camera rather than the canvas's, and a gesture that
1566
+ wanders out of the rect stays with the camera it began under.
1567
+
1568
+ Two supporting changes: `createViewportLayer`'s `source` accepts a thunk, so a
1569
+ viewport can paint a stack assembled elsewhere rather than one closed over at
1570
+ construction; and `<SceneCanvas>` mounts a view registry, which is how a view
1571
+ and its surface find each other.
1572
+
1573
+ A canvas with no views declared is unchanged — no registry entries, so every
1574
+ point resolves to the canvas as before.
1575
+
1576
+ Not yet per-view: selection and chrome, affordance hit-testing, and pinch-zoom.
1577
+ A gesture inside a view reaches the ambient viewport actions and nothing else.
1578
+
1579
+ - cada4da: Answer chrome bounds and layer-helper bounds with one function.
1580
+
1581
+ `buildChromeState`'s `effectiveBoundsOf` and `CanvasHelpers.getEffectiveBounds`
1582
+ each spelled out the same cascade — the active tool's published preview, then
1583
+ the dispatcher's preview extras, then committed bounds — in two places that had
1584
+ to agree for a resize handle to sit on the shape it belongs to. They now share
1585
+ `boundsWithPreview`.
1586
+
1587
+ The helpers copy carried an extra committed-pose fallback for when no bounds
1588
+ resolver is wired. That branch was unreachable: a missing resolver means no
1589
+ `boundsOf` prop and no adapter, and without an adapter the pose lookup returns
1590
+ `null` too. Behavior is unchanged.
1591
+
1592
+ - 889b1d0: Give each of `useGestureDispatcher`'s view records its own camera.
1593
+
1594
+ A record may now carry a `ViewApi`. An event routed to it dispatches against
1595
+ the canvas dep registry with the `view` dep — and only that dep — replaced by
1596
+ the record's, so `viewport.dragPan` and the rest of the viewport actions move
1597
+ the view the gesture began in rather than the whole canvas. No second `setView`
1598
+ channel was needed: every viewport action already reads its camera from that
1599
+ dep.
1600
+
1601
+ This is what makes routing correct rather than merely wired. Records without a
1602
+ `ViewApi`, which is every record today, resolve `view` exactly as before.
1603
+
1604
+ - 9e6927a: Route each input event to the view it landed in, inside `useGestureDispatcher`.
1605
+
1606
+ The hook takes an optional `views` — a thunk returning the non-root dispatch
1607
+ records and a resolver that names one for a client point. `createViewResolver`
1608
+ satisfies the resolver shape, so a canvas holding several viewports can hand
1609
+ the two straight over. Pointerdown pins its pointer to the view it began in and
1610
+ pointerup releases it, so a drag that leaves a panel keeps reporting
1611
+ coordinates in the camera it started under. Keyboard and paste carry no
1612
+ coordinates and run on the view the last coordinate-bearing event resolved to.
1613
+ A resolved id with no live record falls back to the root, so a view that
1614
+ unmounts mid-gesture degrades rather than dropping the event.
1615
+
1616
+ No public change, and no change at all with `views` omitted: every event then
1617
+ runs on the record the flat options describe. Cancelling in-flight gestures —
1618
+ on unmount and on tool change — now reaches every view's dispatcher rather than
1619
+ only the root's.
1620
+
1621
+ - eafe4be: Resolve the dispatcher and its coordinate lookups per event inside
1622
+ `useGestureDispatcher`.
1623
+
1624
+ The hook took `dispatcher`, `affordanceAt`, `classifyTarget` and `clientToWorld`
1625
+ as four sibling options and read each through its own ref, and it bound the
1626
+ dispatcher once when the listener effect ran. Those four are one thing —
1627
+ everything about handling an event that depends on which view it landed in — so
1628
+ they are now one internal record, read fresh on each event.
1629
+
1630
+ No public change: the four options stay exactly as they are and become that
1631
+ record. They are the single-view façade, the same way `SceneCanvasProps` is.
1632
+
1633
+ This is groundwork for routing input to one of several views. Doing it this way
1634
+ means the hook keeps mounting once: a canvas with N views gets N dispatchers
1635
+ behind one listener set, rather than N copies of the hook all firing on every
1636
+ event.
1637
+
1638
+ - ae84ca1: Move one view's overlay-aware state into a `useViewHelpers` hook.
1639
+
1640
+ `<Canvas>` built its chrome state and its layer helpers inline: the bounds
1641
+ fallbacks, the committed-pose lookup, the tool preview cascade, `buildChromeState`
1642
+ and the `CanvasViewHelpers` object were about 120 lines of the component body
1643
+ closing over its props. They are now one hook taking explicit dependencies —
1644
+ adapter, geometry, bounds resolver, selection, tools, gesture source and the
1645
+ dispatcher's preview extras — and `<Canvas>` calls it for its own view.
1646
+
1647
+ Being a hook is the point. A canvas hosting several viewports cannot loop this
1648
+ work inside one component, but N components can each call it once, which is what
1649
+ per-view selection and chrome will be built from.
1650
+
1651
+ `CanvasHelpers`, `CanvasViewHelpers` and `CanvasSurfaceHelpers` moved to the new
1652
+ module and are still re-exported from `./canvas/Canvas` and the package root, so
1653
+ imports are unchanged. The hook takes only the `getPose` slice of the adapter
1654
+ rather than the full contract.
1655
+
1656
+ - 0514a37: `hitTestExtras` takes an optional `frame` naming the camera to test under.
1657
+
1658
+ The method read the canvas's own `view` and `dims` off refs, which is right for
1659
+ every existing caller and wrong for a point routed to a viewport node: there the
1660
+ world point is in the node's inner view and a layer resolving a screen-pixel
1661
+ tolerance needs that view and the node's rect size. `hitTestExtras(x, y, { view,
1662
+ dims })` supplies both; omitting `frame` keeps the previous behavior, so no
1663
+ existing call site changes.
1664
+
1665
+ - daa5ce6: Add a source rect and flip to `ImageDrawCommand`, so one bitmap can be drawn as
1666
+ many frames.
1667
+
1668
+ `source` is a sub-rectangle in bitmap pixels; `flipX` / `flipY` mirror the
1669
+ sampled region within the destination rect without moving the quad. Both are
1670
+ additive and optional — a command that sets neither draws exactly as before.
1671
+ Until now a sprite sheet needed a custom `ShaderDrawCommand` to do what is
1672
+ arithmetic on the quad's four UV pairs.
1673
+
1674
+ `source` is not range-checked: a rect past the bitmap edge samples outside
1675
+ `[0..1]`, which `CLAMP_TO_EDGE` smears. With `sampling: 'linear'` the filter
1676
+ also reaches half a texel beyond `source`, so an atlas whose frames touch will
1677
+ bleed at the seams — pad frames with a gutter or use `'nearest'`. The renderer
1678
+ deliberately does not inset for this, which would make an exact 1:1 blit soft.
1679
+
1680
+ New `frameRect(sheet, index)` and the `SpriteSheet` type turn a uniform grid
1681
+ (`frameWidth`, `frameHeight`, `columns`, optional `margin` and `spacing`,
1682
+ following the Tiled / Aseprite convention) into that source rect. It is
1683
+ row-major from 0 and does not wrap past the last cell — wrapping belongs to the
1684
+ animation, since a sheet does not know how many of its cells are filled.
1685
+
1686
+ `@weasel-js/hud`'s image widget takes `source`, `flipX` and `flipY` as options
1687
+ and gains `setSource` and `setFlip` to change them in place. `setFlip` merges,
1688
+ leaving an omitted axis alone. Without the setters a sprite animation would
1689
+ have to dispose and rebuild the widget every frame.
1690
+
1691
+ - 144e70a: Export `keySpecShortcut(spec)` — the chip form of one gesture spec, or
1692
+ `undefined` where a spec has none — and `actionBindings(action)`, the flat
1693
+ binding list `actionShortcuts` already reads.
1694
+
1695
+ `actionShortcuts` is now a dedupe over `keySpecShortcut`, so the spec-to-chip
1696
+ mapping has one implementation. It had two: ToolkitBuilder's binding table
1697
+ projected key specs inline, and rendered a `'optional'` modifier as a keycap
1698
+ the reader has to press. Surfaces that render drag, click and wheel specs
1699
+ alongside keyboard ones can now share the keyboard half without taking
1700
+ `actionShortcuts`' action-at-a-time shape.
1701
+
1702
+ - 2627cde: Fix a hook-order defect in the Badge effects and several stale-closure bugs,
1703
+ found by turning on a correctness lint baseline.
1704
+
1705
+ Six Badge effects (`Aqua`, `Bevel`, `Bevel2`, `Metal`, `Sheen`, `Woodgrain`)
1706
+ called `useId` after an early return keyed on `variant`. Changing a `<Badge>`'s
1707
+ variant to one those effects don't render, and back, remounted the component
1708
+ and issued fresh ids — so the `<clipPath>` and gradient ids their `url(#…)`
1709
+ references point at changed identity mid-life.
1710
+
1711
+ Also fixed: `Canvas.tsx`'s paint effect read a stale `helpersForLayers` through
1712
+ its closure rather than the ref the file maintains, and `useDeviceProfile`
1713
+ ignored a `targetScale` supplied by a provider.
1714
+
1715
+ `composeOrderedLayers` is now generic over the `LayersMap` it receives instead
1716
+ of taking `any`; inference at existing call sites is unchanged.
1717
+
1718
+ - 8b583b4: Default an unset miter limit to 4, not 10.
1719
+
1720
+ A stroke that sets no `miterLimit` used Canvas2D's 10. The SVG serializer omits
1721
+ the attribute for an unset field, so the same stroke exported and opened
1722
+ anywhere else renders at SVG's default of 4 — the kit disagreed with its own
1723
+ export format, and re-importing the file did not reconcile them.
1724
+
1725
+ 10 also lets an acute corner throw a miter spike four times the half-width. A
1726
+ stroked capital W put one in the middle of the letter, from the apex of a V
1727
+ most of the way down: measured at 7.99 units out on a half-width of 2, and
1728
+ inside the letterform where a bounding box never sees it. Glyph outlines are
1729
+ where this shows first because a type designer's sharpest vertices were never
1730
+ drawn to be stroked.
1731
+
1732
+ Strokes that set `miterLimit` explicitly are unaffected. Anything relying on
1733
+ the old default can set `miterLimit: 10`. No visual baseline moved: nothing in
1734
+ the demo set strokes a corner sharp enough to have been spiking.
1735
+
1736
+ - e61d3e3: Resolve selection chrome's bounds through one cascade, not two.
1737
+
1738
+ `createSelectionOverlayLayer`'s `getPose` is now optional alongside
1739
+ `getSelection`. Omitted, the layer takes bounds from the `ChromeState` on the
1740
+ draw envelope — the cascade its selection was already built with. `<Canvas>`
1741
+ and `<SceneCanvas>` each carried a `poseById` chain of their own for this;
1742
+ both are gone, and a consumer's `poseById` override still wins where it is set.
1743
+
1744
+ The two chains were supposed to agree and did not: they consulted the same
1745
+ preview sources in opposite priority, and only one of them carried rotation
1746
+ through. With one camera the disagreement was hard to see; per-view chrome
1747
+ would have made it visible.
1748
+
1749
+ - f0cc29c: Hit-test affordances against the chrome state that was painted.
1750
+
1751
+ The gesture dispatcher's `affordanceAt` built a `ChromeState` of its own —
1752
+ selection off a ref, bounds straight from the resolver, its own union AABB —
1753
+ next to the one the canvas helpers had already built. The two differed by the
1754
+ in-flight overlay: mid-drag, resize handles painted at the ghost while their
1755
+ hit regions stayed at the committed pose.
1756
+
1757
+ A surface now publishes its view chrome on the handle it attaches to the view
1758
+ registry, and the dispatcher reads it from there. `ChromeState` gains an
1759
+ `EMPTY_CHROME_STATE` for the before-attach case, and `anchorStateFrom` is the
1760
+ dep-registry read the mounter used to inline.
1761
+
1762
+ - 438970b: Draw selection chrome for the view that asked, not for the canvas.
1763
+
1764
+ `createSelectionOverlayLayer`'s `getSelection` is now optional. Omitted, the
1765
+ layer takes its ids from the `ChromeState` on the draw envelope — the same
1766
+ channel it already read the multi-selection union AABB from. `<Canvas>` and
1767
+ `<SceneCanvas>` stop passing one, so the single overlay layer a surface builds
1768
+ outlines whichever view is drawing it.
1769
+
1770
+ The multi-selection split is unchanged: the handle pass works against the
1771
+ synthetic union id, the outline pass against the real members.
1772
+
1773
+ - f2ba2ab: Let a view answer any dep for itself, not just `view`.
1774
+
1775
+ A dispatcher view record carried a `ViewApi`; it now carries a thunked
1776
+ `Partial<DepSchema>`, and an event routed to that view resolves every name in
1777
+ it from the view, everything else from the canvas registry. `view` becomes one
1778
+ entry rather than a special case, which is what per-view selection needs next.
1779
+
1780
+ This is also the answer to whether a view should get a `DepRegistryProvider` of
1781
+ its own: it should not. The registry is where a consumer registers _sources_,
1782
+ and one per view would fragment that — overriding `insert` would mean knowing
1783
+ how many views exist and overriding each. An overlay keeps one place to
1784
+ register and one authority per dep, with a view claiming only what is genuinely
1785
+ its own.
1786
+
1787
+ - 4ac9273: Route pinch and hover to the view under the pointer.
1788
+
1789
+ Both attached to the canvas and targeted the outer camera, so a pinch inside a
1790
+ panel zoomed the canvas beneath it and hover resolved the wrong node. The view
1791
+ registry now owns one `ViewResolver` for the surface, and the dispatcher, pinch
1792
+ and hover all ask it — one authority, so they cannot disagree about where a
1793
+ point landed.
1794
+
1795
+ `usePinchZoomTool` takes a `resolveTarget` option naming the camera an anchor
1796
+ belongs to, and measures the anchor from that camera's origin. Omitted, it is
1797
+ the canvas's own, as before.
1798
+
1799
+ - 8570a23: Export `resolveParams` from the package entry
1800
+
1801
+ `BindingOpts.params` may be a thunk, and its own doc comment tells callers to
1802
+ read it "via `resolveParams(opts?.params)`" — but that helper was defined and
1803
+ used internally, never re-exported. Every consumer writing a parametric
1804
+ `key-held` (or other) binding was stuck reimplementing the thunk check by
1805
+ hand. `resolveParams` is now importable from `@weasel-js/core`.
1806
+
1807
+ - 7c202d2: Put the selection on the scene, and restore it on undo.
1808
+
1809
+ `scene.getSelection()` / `scene.setSelection()` own the transient set of active
1810
+ ids. It is not document content — `toJSON` never carries it — but every history
1811
+ entry now records the selection its edit was made under, so undo and redo put
1812
+ back what was selected. Changing the selection is still never an undo step of
1813
+ its own.
1814
+
1815
+ Undoing a boolean op used to leave the selection pointing at the result node
1816
+ undo had just deleted; deleting a multi-selection and undoing left it empty.
1817
+
1818
+ `useSelection({ scene })` keeps the selection on the scene rather than in the
1819
+ hook. `<SceneCanvas>` does that by default, so every view over one scene shares
1820
+ a selection; a `<CanvasView>` opts out with `selection` / `selectionOptions`.
1821
+
1822
+ `@weasel-js/history` gains `CreateHistoryOptions.selection`, a get/set pair the
1823
+ engine reads and writes on the way past — supply it and entries carry
1824
+ `selectionBefore` / `selectionAfter`, omit it and the engine touches selection
1825
+ never. `recordEntry` takes the pre-batch selection as an option, because by the
1826
+ time it runs the live selection has already moved on.
1827
+
1828
+ `defaultCommitAdapter` carries `getSelection` / `setSelection` now, so
1829
+ selection-carrying ops replay without splicing `SelectionApi.adapterMethods`
1830
+ over it.
1831
+
1832
+ - c7b4705: Add a side-scroller demo that load-tests the animation timeline and the audio
1833
+ engine. The player is an eleven-joint rig posed by cross-faded
1834
+ `SampledTrack<Pose>` clips — the run cycle plays on a real `animator.timeline`
1835
+ whose time scale tracks ground speed, while jump and fall are seeked by vertical
1836
+ velocity rather than played. Footsteps fire from an `EventTrack` on that looping
1837
+ timeline, which is the timeline-to-audio bridge under the heaviest load it will
1838
+ see. Every sound is synthesized into an `AudioBuffer` at load, so the demo ships
1839
+ no assets.
1840
+
1841
+ Its HUD is the point: frame time, active voice count, footstep timing spread and
1842
+ a swarm control that pushes the voice pool past its limit, so the demo measures
1843
+ the two arcs rather than merely exercising them.
1844
+
1845
+ Findings are recorded in `docs/TODO.md` under Animation. The load-bearing one:
1846
+ `EventTrack` events are `{ t, fire: () => void }`, and `fire` receives no
1847
+ arguments, so an audio handler cannot learn the playhead's crossing time and is
1848
+ quantized to the animation frame instead of the audio clock.
1849
+
1850
+ - 6a5c047: Split `CanvasHelpers` into its per-view and per-surface halves.
1851
+
1852
+ `CanvasViewHelpers` is what one camera's own tools, gestures and selection
1853
+ answer — `getEffectivePose`, `getEffectiveBounds`, `getGestureBounds`,
1854
+ `subscribeGestures`, `getGestureVersion`, `getChromeState`.
1855
+ `CanvasSurfaceHelpers` is what a GL context has one of — `getDebug`,
1856
+ `getIsVisible`. `CanvasHelpers` extends both and is unchanged for layers, which
1857
+ still receive the whole object as their `data`.
1858
+
1859
+ The two are now built separately inside `<Canvas>`, so which side a lookup
1860
+ belongs on is a compile-time fact instead of a claim in a design doc. That is
1861
+ the boundary a canvas hosting several viewports has to build N of one side and
1862
+ one of the other across.
1863
+
1864
+ - 49e450c: Add `createViewResolver` — which view owns a client point, held steady for a
1865
+ gesture.
1866
+
1867
+ A canvas with viewport nodes on it has more than one camera, and a pointer event
1868
+ belongs to exactly one of them. The resolver hit-tests a list of
1869
+ `ResolvableView`s (a camera plus the rect it paints into) in reverse paint
1870
+ order, right and bottom edges exclusive, and falls back to the root view.
1871
+
1872
+ It pins a pointer on `begin` and releases it on `end`, so a drag that leaves its
1873
+ view's rect — over a neighbour, or off the canvas — keeps reporting coordinates
1874
+ in the space it started in. Without that, a marquee crossing a panel edge
1875
+ silently starts measuring against the wrong camera. A pointer that began on the
1876
+ root canvas is pinned to the root for the same reason. The pinned view is looked
1877
+ up fresh each call, so a rect that moves mid-gesture is honored.
1878
+
1879
+ `ViewTarget.origin` is the resolved view's client-space origin, ready to pass
1880
+ straight to `clientToWorld`. `ViewportLayer.resolvable(outer, dims)` supplies a
1881
+ viewport node as a candidate.
1882
+
1883
+ Nothing is wired into the dispatcher yet: tools still target the outer view.
1884
+
1885
+ - 6031085: Stroke text at any size, not only above the outline threshold.
1886
+
1887
+ A glyph escalates from its SDF tier to tessellated outlines once it covers
1888
+ `OUTLINE_MIN_SCREEN_PX` (48) on screen, and only the outline tier has geometry
1889
+ to stroke. Text below that silently dropped its stroke: the control was live,
1890
+ the paint never arrived, and the same text stroked correctly inside a magnifier
1891
+ that happened to lift it over the threshold.
1892
+
1893
+ A run carrying a stroke now escalates at any size. The threshold still governs
1894
+ unstroked text, where it is a choice between two correct renderings rather than
1895
+ between a stroke and nothing. A zero-width stroke does not escalate, and an
1896
+ explicit opt-out of the tier still wins — as does a run the tier cannot serve
1897
+ (no registered outlines, or synthetic bold, whose emboldening is an SDF
1898
+ threshold shift with no geometric equivalent).
1899
+
1900
+ - ccaaecd: `Stroke.width` accepts `{ px }` for a width in screen pixels, resolved against the accumulated transform scale at draw time. Callers previously divided by `meanScale(view.scale)` at each site; this moves that into the renderer and lets the stroke mesh cache key see the resolved width.
1901
+ - ec0eb08: Rename the viewport `computeFitView` to `computeFitViewport`. It was unreachable from the package entry: an identically-named export from the minimap module shadowed it. This is a breaking rename of a symbol nobody could import.
1902
+ - 726f85e: Make what a view paints hittable inside that view.
1903
+
1904
+ `<CanvasView>` registers an `affordanceAt` and a `classifyTarget` of its own,
1905
+ built the same way the surface builds its pair but against this view's chrome
1906
+ state and camera. A press inside a panel now lands on that panel's resize
1907
+ handles, rotation band and path anchors, and a body under the point classifies
1908
+ against the panel's selection — until now a gesture inside a panel reached only
1909
+ the ambient viewport actions.
1910
+
1911
+ Externally registered layers keep first refusal on the point, hit-tested
1912
+ against the view's frame and draw envelope rather than the canvas's.
1913
+
1914
+ The surface's context to its views widens to carry the hit-test half it is the
1915
+ authority on: the pickers, the node-kind resolver and the chrome-caps
1916
+ predicate.
1917
+
1918
+ - 601aa6b: Let a view build its own helpers and hand them to its layers.
1919
+
1920
+ `<CanvasView>` now calls `useViewHelpers` and passes the result through the
1921
+ viewport node's `data` thunk: its source layers draw against this view's chrome
1922
+ state, effective poses and gesture bounds, with the surface half of the
1923
+ envelope — debug sink, chrome-caps predicate — passing through untouched.
1924
+
1925
+ The inputs that hook needs are surface-wide (adapter, geometry, bounds
1926
+ resolver, tools, gesture source) and are read during a view's render, so
1927
+ `<SceneCanvas>` publishes them as context rather than on the `SurfaceHandle`,
1928
+ which is not attached until an effect runs.
1929
+
1930
+ Layers that read chrome off the draw envelope — affordance layers, the
1931
+ selection overlay's multi-union — follow the view. The selection overlay's
1932
+ per-id outline and handles still come from closures over the surface's
1933
+ selection.
1934
+
1935
+ - 9607185: Read a view's gesture previews from the view's own dispatcher.
1936
+
1937
+ `<CanvasView>` builds its helpers with a `GestureSource` and preview extras
1938
+ over the dispatcher it owns, rather than inheriting the surface's. A view has
1939
+ had its own dispatcher since routing landed, so a gesture inside a panel put
1940
+ its in-flight handles somewhere the panel's own chrome was not looking: no
1941
+ ghost, no tracking resize handles, no gesture bounds.
1942
+
1943
+ The dispatcher's contribution to those lookups is now one factory
1944
+ (`createDispatcherPreviewSources`) next to `createGestureSource`, instead of
1945
+ two closures inlined in `<SceneCanvas>`. The context a surface publishes to its
1946
+ views narrows to the scene-shaped half — adapter, geometry, bounds resolver,
1947
+ tools.
1948
+
1949
+ - 58f43e7: Apply the inner view transform to a viewport node's source layers.
1950
+
1951
+ A world-space `RenderLayer` emits world coords and relies on its caller to wrap
1952
+ them in `viewToMat3(view)`. `drawLayers` did that; `createViewportLayer` did
1953
+ not — it concatenated `layer.draw(...)` output under a bare translate to the
1954
+ rect origin. So a viewport's inner `view.x/y/scale` never reached the pixels,
1955
+ while its `reproject` inverse assumed they had. Content drew at raw world
1956
+ coords and hit-testing disagreed with what was on screen; at the identity inner
1957
+ view the two happened to coincide, which is why it looked right in the demo.
1958
+
1959
+ Both paths now go through one exported helper, `drawOneLayer`, which puts a
1960
+ layer's commands in the space its `space` field declares.
1961
+
1962
+ A screen-space source layer keeps drawing untransformed, but that means the
1963
+ viewport's own CSS-pixel space — coords relative to the rect's top-left,
1964
+ clipped to the rect. The previous doc comment claimed such layers rendered to
1965
+ the outer canvas instead; they never did.
1966
+
1967
+ - 2e22d99: A viewport node can host a live camera and its own per-view data.
1968
+
1969
+ `view` now accepts a thunk as well as a `View`. It is read fresh on every
1970
+ `draw`, `reproject` and `resolvable`, so those three cannot disagree about where
1971
+ the viewport is looking part-way through a gesture. The thunk receives the outer
1972
+ view and dims, so a derived camera — parallax, node-anchored scroll — is a
1973
+ function of the one hosting it.
1974
+
1975
+ A `data` thunk derives what the source layers receive from what the outer canvas
1976
+ passed down. Without it they get the outer canvas's `data`, as before. This is
1977
+ what lets a viewport showing the same scene through a second camera give its
1978
+ layers their own selection, chrome state and gesture previews instead of the
1979
+ hosting view's.
1980
+
1981
+ Both are additive: `CreateViewportLayerOpts` gained a second type parameter that
1982
+ defaults to the first, so existing call sites infer exactly as they did.
1983
+
1984
+ - Updated dependencies [7c202d2]
1985
+ - @weasel-js/history@1.2.0
1986
+ - @weasel-js/font@1.2.0
1987
+ - @weasel-js/geom@1.2.0
1988
+ - @weasel-js/gestures@1.2.0
1989
+ - @weasel-js/modes@1.2.0
1990
+
3
1991
  ## 1.1.0
4
1992
 
5
1993
  ### Minor Changes