@weasel-js/core 1.4.4 → 1.5.1

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