@weasel-js/core 1.3.0-pre.0 → 1.4.0-pre.0

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