@weasel-js/core 1.4.0-pre.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,489 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - dcef92c: Restore a displaced action when the registrant that displaced it unregisters.
8
+
9
+ The actions registry held one `Action` per id, so two `<SceneCanvas>` instances under one `<ActionsProvider>` collided: the second to mount displaced the first's `viewport.wheelPan` / `viewport.zoom` / `viewport.pinchZoom`, and its teardown then deleted the entry outright rather than uncovering what it had displaced. The canvas still on screen was left with no viewport actions at all — wheel pan and Cmd+wheel / Cmd+- / Cmd+0 dead, with no error. The `vertex-widths`, `curve-lab` and `rotated-resize-math` demos all mount several canvases this way.
10
+
11
+ `register` now stacks registrants per id, newest live, and the unregister it returns takes its own entry out wherever that entry has since ended up. So a displaced registrant becomes live again when the one above it leaves, and a registrant that was already displaced still disturbs nothing when it goes. Last-writer-wins is unchanged while both are mounted.
12
+
13
+ This closes the same hole for every other hook that registers a fixed id into a shared registry — `useStandardActions`, `useToolActions`, `useKeybindings`, `useContributions` — and lets `useActionsPropResolver` drop its restore hack, which re-registered a stale snapshot on cleanup and never took it off again.
14
+
15
+ `unregister(id)` is unchanged and still drops every registrant of that id: it is the "this action should not exist" door, not a release.
16
+ - 73039aa: Collapse four duplicated helpers and drop three dead modules.
17
+
18
+ `Badge`'s shape-control table lived twice — once in `shapeControls.ts`, which nothing imported, and once re-declared inside the stories, which is the copy that rendered. The stories now import the module, so the badge shape defaults have one definition again. `Badge`, `Shield` and `Perforated` shared eleven identical lines of ResizeObserver measurement for the same viewBox-unit conversion; that is now `useSvgBox`.
19
+
20
+ `composeSelectionPose` and `makeContainerAwareBoundsResolver` each carried their own copy of the leaf walk, including the rule that keeps an empty container from contributing bounds — one function now, so the rule can be fixed in one place.
21
+ - b91a8dd: Coalesce consecutive image draws into one batch, and add `kind: 'sprites'` for a run handed over packed.
22
+
23
+ `drawImage` was one `drawElements` per command, so an atlas-backed wall of thumbnails paid a draw call per thumbnail: 20,000 of them cost 51ms a frame on an M2 Max, three frames' budget for one frame's work. Consecutive image quads now stage into an `ImageBatch` and flush as a single draw — 10.6ms for that frame, and 0.094ms where it was 3.05 at 512 quads.
24
+
25
+ Nothing changes for a consumer emitting `kind: 'image'`. A run merges across a group transform, a group alpha and a per-command opacity, because all three ride the vertices — opacity through a new `a_opacity` attribute, which is exact rather than conditional since `u_opacity` multiplies the alpha after the color matrix. A run breaks on a different bitmap, a different `sampling`, a clip boundary, or a color matrix.
26
+
27
+ `SpritesDrawCommand` is for the case where even a command object per quad is too much. It carries one bitmap and a `Float32Array` of `SPRITE_STRIDE` floats a sprite — `dx, dy, dw, dh, sx, sy, sw, sh, opacity`, source in bitmap pixels, a negative `sw`/`sh` mirroring that axis — and stages through the same run, so a packed run and the image commands around it merge into one draw. It takes 20,000 sprites to 0.79ms. Below a few thousand a plain run of image commands merges into the same draw and reads better.
28
+
29
+ Ring slots come in tiers sized to the flush. The driver's write hazard is per buffer object, so a one-quad flush into a slot sized for 256 waits on the whole thing — a frame of 20,000 quads that nothing merges cost 92ms against one slot size and 53ms against tiered ones, matching the unbatched path it replaces.
30
+ - caad52f: Wire the hand tool's `inertia` and `axis` options, which accepted a full
31
+ config and did nothing.
32
+
33
+ Both are now binding params on `viewport.dragPan`, so any consumer binding
34
+ that action gets them — not only `useHandTool`. `axis` drops one component of
35
+ every pan delta. `inertia` coasts the view after release through a new
36
+ optional `view.decay` dep, which `<SceneCanvas>` wires from `useDecayLoop`;
37
+ where no such dep is published the pan simply lands, as before.
38
+
39
+ `useVelocityTracker`'s logic is now also available hook-free as
40
+ `createVelocityTracker`, because an Action descriptor is a static object and
41
+ cannot call hooks. `InertiaConfig` moves next to `DecayLoopConfig` and
42
+ `PanBounds` in `useDecayLoop`; `useHandTool` re-exports it, so the
43
+ `HandToolInertiaConfig` alias on the barrel is unchanged.
44
+
45
+ `viewport.wheelPan` gains the same `axis` option through a
46
+ `makeViewportWheelPanAction` factory, matching its `makeViewportZoomAction` /
47
+ `makePinchZoomAction` siblings, and `<SceneCanvas viewport={{ pan: { axis } }}>`
48
+ reaches it. Two things fall out: `viewport={{ inertia: true }}` was documented
49
+ as "on with defaults" but produced no inertia — only the object form did — and
50
+ `ParallaxDemo` can drop the `setViewXOnly` commit clamp it used because the
51
+ axis options did not work.
52
+ - 0b0f13f: Put every drag in the kit on one pointer lifecycle, and recover the releases the DOM does not deliver.
53
+
54
+ Fourteen pointerdown-to-pointerup lifecycles each answered capture, pointer identity, teardown and lost-pointer recovery for themselves. They now run on `openPointerSession`: `Slider`, `BandEditor`, `Timeline`'s `Lane` and `Ruler`, `LayeredCurveEditor`, `ResizeHandle`, `useReorderDragList`, `MinimapCanvas`, labkit's `LayerList`, `usePanZoom`, `useOrbit` and `FloatingPanel`. A drag released over another window, or whose element unmounts mid-gesture, now ends instead of hanging in flight.
55
+
56
+ A third recovery rule joins the two that shipped with the primitive: a fresh press on a pointer still believed held reports `'superseded'`, because the release landed somewhere that never told us and the pointer never came back for the missed-release rule to see. Without it a stale session steers the next press. `useGestureDispatcher` applies the same rule to its own multi-pointer lifecycle.
57
+
58
+ Breaking: hooks that drove their drag through returned React props no longer return them, because the session owns the gesture from the press.
59
+
60
+ - `useReorderDragList`'s `containerProps` is `{ ref }` only; `onPointerMove` / `onPointerUp` / `onPointerCancel` are gone. It gains `onPress(id, mods)` — a press released without engaging a drag, fired for locked rows too, with the modifiers held at press. That is the click-vs-drag decision consumers previously had to reconstruct by sampling drag state before forwarding the pointerup, which no longer works now that the session ends first.
61
+ - labkit's `PanZoomHandlers` and `OrbitHandlers` lose `onPointerMove` / `onPointerUp`. `usePanZoom` gains `onTap` for the same reason.
62
+
63
+ The five `@weasel-js/ui` drag surfaces pass `capture: false` deliberately and now assert it: capture retargets `pointerup` to the capture element and kills the click on consumer-rendered content inside a slider thumb, a band body, or curve-editor chrome.
64
+ - 00af9ac: Add `openPointerSession`, and put the kit's drag lifecycles on it.
65
+
66
+ `useHandleDrag`, `startThresholdDrag` and `useDragHandle` each owned a pointerdown-to-pointerup lifecycle and each answered the same four questions differently. Capture: two took it untry'd, one never took it. Listeners: one on the element, two on `document`. Pointer identity: none of the three filtered by `pointerId`, so a second finger drove and could end a drag in progress. Teardown on unmount: one had none, one had it for half its lifecycle.
67
+
68
+ None of them — nor the dispatcher — handled `lostpointercapture`, and none read a `pointermove` with no button held as the release it missed. So a drag whose pointer left the element, or whose capturing element was removed mid-gesture, hung in flight with no end and no cancel.
69
+
70
+ `openPointerSession(origin, downEvent, callbacks)` now decides all of it once: capture on the origin, listeners on the document so a removed element cannot strand the gesture, every event filtered to its own pointer, `lostpointercapture` and the missed release both closing the session, and one `cancel()` for unmount, Escape or blur. The missed-release rule disarms itself when the press reports no button state, so synthesized events do not read as instant releases.
71
+
72
+ `useGestureDispatcher` keeps its own multi-pointer lifecycle — one canvas listener set keyed by `pointerId` is the right shape for multitouch — but takes both recovery rules from the same module, so there is one implementation of each rather than two that drift.
73
+
74
+ Breaking, in `useHandleDrag`: `onEnd` now fires only on a real release and receives `{ point, moved, event }` instead of a bare event; a cancelled gesture reports through the new `onCancel(reason)`. The old signature made every commit-on-end consumer sniff `e.type === 'pointercancel'` to tell an edit from an abandoned drag, and hold its own ref to recover the end position — `GradientEditor` does neither now.
75
+ - 9b9224c: Remove `space` from `ToolModifiers`.
76
+
77
+ Breaking for anyone constructing a `ToolModifiers` literal: the field is gone and an object still carrying it is now an excess property. Reading `ctx.modifiers.space` was already meaningless — `Canvas` hardcoded `false` at both construction sites, so the field never once reported a held space bar.
78
+
79
+ Nothing needed it. Space-for-hand is armed by the tool's own `hotkey: 'space'` declaration, which routes through `tool.offhand` and the dispatcher's key-held lifecycle and never consults `ToolCtx`. The field existed for a mid-gesture read that no tool ever wrote or performed.
80
+ - @weasel-js/cursor@1.4.1
81
+ - @weasel-js/font@1.4.1
82
+ - @weasel-js/geom@1.4.1
83
+ - @weasel-js/gestures@1.4.1
84
+ - @weasel-js/history@1.4.1
85
+ - @weasel-js/modes@1.4.1
86
+ - @weasel-js/paint@1.4.1
87
+ - @weasel-js/text@1.4.1
88
+
89
+ ## 1.4.0
90
+
91
+ ### Minor Changes
92
+
93
+ - 1214ff5: Split a canvas's paint target from its input target.
94
+
95
+ `<SceneCanvas paintInto={{ canvas, x, y }} inputElement={el}>` paints into a
96
+ rect of a canvas you own and takes pointer input from an element you own, so N
97
+ canvases share one GL context and one buffer. Each needs its own
98
+ `<WeaselProvider isolate>`.
99
+
100
+ The ref handle names both elements: `element` is where input, focus and the
101
+ cursor live, and is now typed `HTMLElement` because detached it is not a canvas;
102
+ `surface` is where pixels land. Attached, they are the same `<canvas>` and
103
+ `element` keeps working as before. The HUDs render when detached too, anchored
104
+ to the input box rather than to the shared surface every pane sits in.
105
+
106
+ Breaking, narrowly: `createLoupe`'s `element` option is now `canvas`, with an
107
+ optional `input` for the element aim is measured against.
108
+ `CanvasExtensionApi.element` no longer satisfies an `HTMLCanvasElement` — read
109
+ `surface` for pixels. And `clientToWorld`'s first parameter widens to
110
+ `HTMLElement`, which stops compiling for a consumer who annotated that parameter
111
+ as `HTMLCanvasElement`; one who let it infer is unaffected.
112
+
113
+ <!-- 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" -->
114
+
115
+ ### Patch Changes
116
+
117
+ - eb16573: Alt-drag-to-duplicate shows the duplicate cursor.
118
+
119
+ `clone` declared no cursor, so the one gesture in the kit that copies instead
120
+ of moving looked exactly like a move until you released. It now declares
121
+ `cursor: 'copy'` and `activeCursor: 'copy'`, which needs no modifier gate of
122
+ its own: the select tool binds clone behind `mods: { alt: true }`, so the hover
123
+ pump predicts the action — and shows the cursor — only while Alt is held over a
124
+ body, and drops it the moment Alt is released.
125
+
126
+ This replaces the `apps/draw` stub that arc 4 deleted. That stub forced
127
+ `cursor: copy` from a CSS rule fed by a hand-rolled Alt listener, scoped to
128
+ path-edit mode and applied over the whole canvas including where nothing would
129
+ happen. The affordance now shows wherever Alt-drag actually duplicates, in
130
+ every mode, through the cursor pipeline.
131
+
132
+ `AffordanceRegion.cursor` and a layer claim cannot express this — neither
133
+ `affordanceAt` nor `RenderLayer.hitTest` receives the event, so `Action.cursor`
134
+ is the kit's only modifier-gated cursor channel.
135
+ - 6650d67: Correct the actions-registry documentation, which described the API that was
136
+ replaced in May 2026.
137
+
138
+ `packages/core/README.md` said `<ActionsProvider>` wires a keydown listener,
139
+ showed an `Action` with a `run` callback, and told consumers to reach for
140
+ `useSelectAll` / `useEscape` / `useDuplicate` / `useNudge` / `useReorder`. None
141
+ of that is true: keystrokes and pointer gestures both route through the gesture
142
+ dispatcher matching `defaultBinding`, an action does its work through `invoker`,
143
+ and those five hooks were deleted. `docs/taxonomy.md` likewise still listed
144
+ `Action.run` and a fallback to a `useKeybinding` path that no longer exists.
145
+ - 04ea2e8: The painted cursor tier, and cursors that tell the five shape tools apart.
146
+
147
+ A cursor that cannot be a CSS cursor is now drawn into the canvas instead of
148
+ being dropped. `resolveCursorTier` escalates when a glyph is sized in world
149
+ units, or past the 128 CSS px above which the browser silently discards the
150
+ image; `<Canvas>` then sets `cursor: none` and a screen-space layer paints the
151
+ glyph at the pointer. Tools never choose the tier — they declare what they
152
+ want, and a brush stays a brush across the radius where it stops being
153
+ expressible as a CSS cursor.
154
+
155
+ `brush` ships with it, sized by `worldRadius` so its ring measures the brush at
156
+ every zoom. A glyph that measures something declares `CursorGlyph.radius` to
157
+ name the circle being sized, and world-sized glyphs hold their line weight in
158
+ CSS px while their geometry scales.
159
+
160
+ `paint.ts` is the shared half: the baker and the painter both consume its
161
+ ordered paint ops, so the two tiers draw one glyph rather than two drawings
162
+ that resemble each other. The layer itself lives in core, which is where
163
+ `RenderLayer` and `Path` live.
164
+
165
+ rect, ellipse, line, star and polygon were all bare `crosshair` and so
166
+ indistinguishable while in use; each now shows a crosshair badged with its
167
+ shape, hotspotted on the cross. `Tool.cursor` on a built `Tool` widens from
168
+ `string` to `CursorSpec`, which it should have done when `ToolDef.cursor` did.
169
+
170
+ The `apps/draw` `cursor: copy` stub and its hand-rolled Alt listener are gone.
171
+ They advertised an add-anchor sub-tool that does not exist, so the affordance
172
+ was removed rather than relocated; `docs/TODO.md` records where it returns.
173
+ - b656ebf: Rotatable cursors, and a `CursorSpec` for the four fields that declare one.
174
+
175
+ `bakeCursor` takes an `angle` in radians, quantized to 16 steps of 22.5°, and
176
+ turns both the glyph and its hotspot. `Tool.cursor`, `Action.cursor`,
177
+ `Action.activeCursor` and `AffordanceRegion.cursor` widen from `string` to
178
+ `CursorSpec` — either a CSS keyword, which passes through untouched, or
179
+ `{ glyph, size?, angle?, fallback? }`. Every cursor declaration written before
180
+ this keeps working.
181
+
182
+ Two glyphs ship with it. The selection's rotation ring now shows a real `rotate`
183
+ cursor instead of a bare `grab`, and the resize corners show a `resize` arrow
184
+ turned to the corner's actual axis — a rotated selection used to keep the
185
+ unrotated diagonal, because CSS has four diagonal keywords and a rotation needs
186
+ sixteen. The keyword remains as each spec's `fallback`.
187
+ - 5295c34: Draw on a lab's instrument: the `annotations` capability gets its overlay.
188
+
189
+ An instrument that declares `annotations` now gets a drawing surface on every
190
+ target it names — weasel tools, weasel selection, marks that pan and zoom with
191
+ what they mark — plus a palette (select, freehand, line, arrow, rectangle,
192
+ ellipse, text) and its own tool slot. `useAnnotations()` reaches the store from
193
+ the instrument's render or from a chrome contribution, and re-renders its
194
+ caller as marks change.
195
+
196
+ The lab's shared surface grew the buffer that makes this possible: one
197
+ `<canvas>` over `.lk-lab__body`, and `SurfaceHandle.registerPainter`, which is
198
+ how a resize of that buffer reaches every tile rather than the one that moved.
199
+ `getContainer()` names the element tile rects are measured against.
200
+
201
+ A mark is a weasel scene node in a scene of its own per target — a pane's
202
+ hit-test, marquee and paint walk the whole scene they are handed, so one shared
203
+ scene would put a neighbour's marks under the pointer. An annotation's id is
204
+ therefore `<target>/<node>`, and `createAnnotationStore` takes `targets` alone
205
+ plus an optional `restore`; `SerializedAnnotations` carries `scenes`, keyed by
206
+ target. Marks still do not survive a reload — the storage slot is the next arc.
207
+
208
+ Core adds `ArrowIcon` to the built-in tool glyphs.
209
+ - 2fbf611: Give a canvas its own provider scope with `<WeaselProvider isolate>`
210
+
211
+ An actions registry holds exactly one dispatcher, so a second `<SceneCanvas>`
212
+ joining a scope displaced the first and took its input away. Worse, the
213
+ detach was unconditional: whichever canvas unmounted — or merely re-rendered
214
+ with a new dispatcher identity — cleared the slot for the one still on screen.
215
+ The symptom was a canvas that stopped responding, naming neither canvas nor the
216
+ registry they shared.
217
+
218
+ `isolate` mounts every provider unconditionally instead of deferring to one
219
+ already in scope, so canvases that merely coexist get a scope each. This is the
220
+ shape consumers had already reached for by hand: `AnimationDemo` and
221
+ `BooleanOpsDemo` both mounted raw `ActionsProvider` / `SelectionContextProvider`
222
+ / `DepRegistryProvider` to shadow the ambient scope, and both now say `isolate`
223
+ instead.
224
+
225
+ `setDispatcher` and `setDepRegistry` return a release that clears the slot only
226
+ while the caller still holds it, so a departing canvas can no longer disable a
227
+ surviving one. A second dispatcher claiming an occupied registry warns once,
228
+ naming `isolate` as the fix.
229
+
230
+ Two canvases still cannot *share* one registry: a toolbar outside both has
231
+ nothing to say which one it drives. That needs a focused-canvas concept and is
232
+ not in this change.
233
+ - 36b6ee7: Add `@weasel-js/cursor` and give three tools real cursors.
234
+
235
+ A cursor glyph is SVG path `d` strings tagged with a paint role plus a hotspot
236
+ in glyph units — the one geometry form both a data-URI baker and a `Path2D`
237
+ painter consume without translating. `bakeCursor` renders one to a
238
+ `url(data:image/svg+xml,…)` string with the hotspot scaled to integer CSS px,
239
+ and `cursorFor` memoizes that per name and size. The pencil, pen and eyedropper
240
+ tools now show their own glyph instead of a shared `crosshair`.
241
+
242
+ Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
243
+ cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
244
+ throws above 128 CSS px rather than emitting a cursor the browser would drop
245
+ silently — that size is where a later painted tier will take over.
246
+
247
+ Glyph geometry is authored in `scripts/glyphs/` and generated to resolved
248
+ literals by `npm run gen:cursors`; `npm run proof:cursors` renders the baked
249
+ assets over three backgrounds for inspection.
250
+
251
+ New API: `bakeCursor`, `cursorFor`, `GLYPHS`, `haloFitsInBox`, `CursorGlyph`,
252
+ `CursorPath`, `CursorGlyphName`, `BakeOptions`, and the register constants
253
+ `CURSOR_INK`, `CURSOR_HALO`, `CURSOR_HALO_WIDTH`, `CURSOR_MAX_CSS_PX`.
254
+ - 7a0c568: Tell an event handler how late its crossing is
255
+
256
+ `EventTrack`'s `fire` took no arguments, so a handler could only ask its own
257
+ clock for "now" — when the frame was processed, not when the playhead crossed
258
+ the edge. That held footstep scheduling in the side-scroller at frame
259
+ resolution against an audio engine built for sample resolution: a measured peak
260
+ spread of 33–47 ms on the looping run cycle.
261
+
262
+ `fire(lateBy)` reports the gap between the crossing and the frame carrying it,
263
+ in timeline ms. It is never negative, including on the loop seam, where the
264
+ outgoing lap's tail fires after the playhead has already wrapped — the case
265
+ that makes a handler comparing against `handle.time()` read a negative
266
+ lateness. A nested timeline's events report the same figure as a top-level
267
+ one's; the track's offset cancels.
268
+
269
+ Nothing has to change to compile: a zero-argument function is assignable to the
270
+ new signature.
271
+
272
+ `lateBy` is a delta, not a clock reading, so events from two different
273
+ timelines still cannot be ordered against each other. That would need the
274
+ animator's virtual clock made public, which this does not do.
275
+
276
+ `SideScrollerDemo` now places each footfall a fixed budget after its true
277
+ crossing, so which frame happened to notice a contact turns into constant
278
+ latency rather than audible spread.
279
+ - a7fa697: Add an anchored-placement solver and keep HUD windows on their host.
280
+
281
+ `@weasel-js/geom` gains `placeRect` and `clampRectWithin`. `placeRect` resolves an
282
+ overlay against an anchor: it picks a side, flips to the opposite one when the
283
+ preferred side has no room, and slides along the alignment axis to stay inside a
284
+ boundary. `clampRectWithin` is the containment half on its own — move a rect the
285
+ shortest distance that puts it inside a boundary, keeping its size. Both are pure
286
+ and take an explicit boundary rect, so a boundary that does not start at the
287
+ origin resolves correctly.
288
+
289
+ A HUD window could previously be dragged fully off its host with no way to
290
+ recover it: `createWindow` clamped size but never position. Move drags and
291
+ `setBounds` now keep the window on the host. Resize drags are deliberately left
292
+ alone, so pulling an edge past the host does not fight the gesture.
293
+
294
+ `@weasel-js/core` gains `hostAnchorRect`, `hostAnchorCss` and `useHostAnchor`,
295
+ which hold a fixed-position panel against a host element's corner and keep it
296
+ inside the viewport. The corner is an alignment per axis rather than a fixed
297
+ one, and `useHostAnchor` takes a function that resolves the host, so a host held
298
+ in a ref and one found by selector work the same way.
299
+
300
+ `hostAnchorCss` pins whichever edges the alignment names. That is not cosmetic:
301
+ a panel whose width tracks its content holds the anchored edge still and grows
302
+ away from it, so pinning the wrong edge makes the anchored corner drift on every
303
+ content change.
304
+
305
+ Four places were carrying their own copy of that anchor math and now share this
306
+ one — `CursorCoordsHud`, `PickHud`, `ModalityHud`, and WeaselDraw's
307
+ `DispatchTracePanel`, which anchors the opposite corner. None of the four
308
+ clamped, so a panel could hang off the edge when the host was scrolled or the
309
+ panel was tall.
310
+ - 2272682: `createParallaxLayer` takes an optional `getOuterView`, so a plane can derive
311
+ from a ref-driven camera. It previously derived only from the canvas's `view`
312
+ prop; a consumer keeping a 60 Hz camera out of React state pins that prop to
313
+ identity and got identity back for every `pan` value — a backdrop that silently
314
+ never moved.
315
+
316
+ `useHandTool` no longer builds a velocity tracker and a decay loop it never
317
+ uses. `inertia` and `axis` were already inert; they are now documented as such
318
+ until the `viewport.dragPan` action implements them.
319
+ - 503b56d: Fix two path-walker bugs that produced wrong geometry with no error.
320
+
321
+ `tessellate` treated `Z` as a no-op, so a command following a close flattened
322
+ from the last point drawn rather than from the subpath start — SVG puts the pen
323
+ back at the start. `pathDistanceToPoint` dispatched through an `if`/`else if`
324
+ chain with no final `else`, so an unrecognized command code left the coordinate
325
+ cursor unadvanced and silently misaligned every later read; it now throws.
326
+ - ac2deea: Add `polylineFromPoints` — the open counterpart to `polygonFromPoints`.
327
+
328
+ Same geometry, without the closing edge. A freehand stroke or a measurement
329
+ line wants this; a region wants the closed one. The pencil tool's drag preview
330
+ was building its ghost with `polygonFromPoints`, so the edge from the newest
331
+ sample back to the first swept across the drawing as the stroke grew and read
332
+ as a marquee.
333
+ - 23ffb2f: `WeaselRenderer` can draw into a rect of a buffer it does not own.
334
+ `setTarget({ origin, clear })` applies a viewport and scissor inside `render()`,
335
+ so N renderers can share one WebGL context and one canvas without a frame clear
336
+ erasing a co-tenant. The rect's size is the renderer's own `width`/`height`, so
337
+ `resize()` remains the single source of it.
338
+
339
+ Adds API. Two behaviour changes for existing callers: `render()` now
340
+ re-establishes blend, depth, cull and clear colour every frame instead of once at
341
+ construction, so a co-tenant moving that state no longer corrupts weasel's
342
+ frames; and the constructor now throws when handed a WebGL2 context whose
343
+ attributes report no stencil buffer, which previously rendered clips and even-odd
344
+ fills wrong rather than failing. A context that cannot report its attributes is
345
+ unaffected.
346
+ - 016851c: Stop a stroke with no paint from blanking the whole document.
347
+
348
+ `SelectionPanel`'s object leaf started from `{}` when the node held no value
349
+ yet, so editing any non-paint field of `data.stroke` on an unstroked node
350
+ committed that field alone — a `Stroke` with no `paint`, which the type
351
+ forbids. The leaf's declared `default` was dead for writes; it now seeds from
352
+ it, so writing one field materializes a complete value.
353
+
354
+ Such a stroke threw out of `fillInPoseFrame`, and the throw escaped the painter
355
+ and took the frame with it: the document page and every other node vanished,
356
+ and the canvas stayed stale until something unrelated requested a redraw — so
357
+ WeaselDraw opened on an empty workspace and only drew once the pointer moved.
358
+ `resolveNodeStroke` now reads a paintless stroke as no stroke, and the text
359
+ painter routes through it like every other painter. The frame loop no longer
360
+ loses its dirty flag when a paint throws, so one bad frame is retried rather
361
+ than stranding the surface.
362
+ - c9dd37f: Render text decorations as a toggle row, and ship a builtin font-family control
363
+
364
+ `SelectionPanel` rendered every boolean leaf as a `Switch`, ignoring the leaf's
365
+ `control` entirely — so the three text decorations arrived as three switch rows
366
+ where every text editor puts one row of U / S / O. `ToolPrefBooleanControl` now
367
+ accepts `'toggle'`, `ToolPrefBoolean` carries a `short` label for it (the pair
368
+ takes the row's name, leaving the leaf only a glyph's worth of room), and the
369
+ panel honors both. Core's text schema asks for it: `underline`,
370
+ `strikethrough` and `overline` share a `Decoration` pair.
371
+
372
+ A run of adjacent leaves sharing a `pair` renders as one `ToggleBar`, not one
373
+ bar per leaf — the same segmented control the `Align` row beside it already
374
+ draws. Each segment still writes only its own leaf, so flipping one decoration
375
+ never invents values for the other two. An unset toggle is left unselected
376
+ rather than dimmed: unselected is what a toggle button's off state means, and
377
+ the dimming the `Switch` path uses for the same case reads as disabled on one.
378
+ A leaf a consumer claims with its own `renderers` entry drops out of the run.
379
+
380
+ `FontFamilySelect` moves from WeaselDraw into `@weasel-js/ui`, and
381
+ `SelectionPanel` reaches for it on a `font-family` leaf. Core's own default
382
+ text schema declares that kind, so a consumer passing no `renderers` — the
383
+ Storybook story, any app taking the defaults — got the literal
384
+ `(font-family: no renderer)` placeholder where the font picker belongs. The
385
+ control offers both tiers that can actually paint and probes substitution at
386
+ the node's own weight and style, so its label names the variant that will
387
+ render. `@weasel-js/ui` now depends on `@weasel-js/font`.
388
+ - 9a000ea: A stroked text node now gets hit reach from its stroke. `TEXT_PAINTER` declared
389
+ no `ink`, so picking fell back to a zero-outset default and a heavily outlined
390
+ glyph was unpickable across the width of its own outline.
391
+
392
+ `kit:derived` also now evaluates ahead of `kit:path` / `kit:shape` / `kit:image`.
393
+ A derived node whose `data` happens to carry a `path`, `shape` or `image` field
394
+ was silently painted by those painters instead of from its derived path.
395
+ - 016851c: Add an editor surface for superscript, subscript and overline.
396
+
397
+ `StyledRun.script`, `baselineShift`, `fontScale` and `overline` reached layout,
398
+ SVG and the DOM overlay but nothing could apply them. The character bar now
399
+ carries an x² / x₂ pair, an overline toggle beside B / I / U / S, and the two
400
+ primitives `script` presets — baseline shift and scale — as percentage fields
401
+ that show what the preset supplies and override just that half when typed over.
402
+ `overline` also joins the sidebar's node-level Character group. Superscript and
403
+ subscript take Cmd+Shift+= and Cmd+Shift+-; the unshifted pair is browser zoom,
404
+ which a page cannot cancel.
405
+
406
+ A styling written at a collapsed caret now arms `useTextEdit`'s new
407
+ `pendingStyle` and applies to the next character typed, instead of being
408
+ dropped or restyling the whole node. That is what `script` needs — it has no
409
+ node-level counterpart to write to by design — and it makes the bar agree with
410
+ Cmd+B, which already behaved this way. `rangeStyle` reports the styling *at* a
411
+ collapsed caret rather than `{}`, and `toggleStyle` is public.
412
+
413
+ Three fixes fall out of putting both paths through one implementation:
414
+ lowering a flag the node sets now works from the bar and from a collapsed
415
+ caret, not only from the keyboard over a range; a toggle reads the node's flags
416
+ as well as the runs, so Cmd+B inside a `fontWeight: 700` node clears bold
417
+ instead of adding it; and focus returns to the text after a styling control is
418
+ clicked, so typing continues in the document rather than reaching the app as
419
+ tool shortcuts.
420
+ - 8ddec11: Accept a named or cubic-bezier easing wherever a curve is taken, and let a
421
+ timeline's loop policy change after it is created.
422
+
423
+ `easing` was a bare function everywhere, which is fine to call and impossible to
424
+ name back, show in a picker, or serialize. It now also accepts the name of a
425
+ built-in (`'easeOutBack'`) or control points (`{ bezier: [0.4, 0, 0.2, 1] }`),
426
+ resolved by `resolveEasing` at the four places a curve is actually invoked. The
427
+ union is additive, so every existing function value stays assignable. Bezier x
428
+ control points are clamped to 0..1, which is what keeps the solve monotone, and
429
+ the control-point tuple is `readonly` so an `as const` preset is assignable.
430
+
431
+ `TimelineHandle.setLoop(loop)` sets policy and nothing else. A timeline already
432
+ parked at its duration does not restart — `rearm` declines to revive one — so
433
+ play it again by seeking to 0 and resuming. Restoring saved transport state
434
+ therefore cannot start playback as a side effect.
435
+
436
+ Both settings now read back. `AnimationHandle.timeScale()` returns an
437
+ animation's own scale, and `Animator.timeScale()` the global one, the way
438
+ `isPaused()` already pairs with `pause()`. `TimelineHandle.loop()` returns the
439
+ policy as it stands — `true`, `false`, or the laps a finite loop has left, which
440
+ falls as they are consumed. A transport UI can drive itself off the handle
441
+ instead of mirroring what it last wrote, which drifts as soon as anything else
442
+ holding the handle sets it.
443
+ - 28894b9: Fix the viewport primitives on an axis with negative scale.
444
+
445
+ `View.scale` is documented as pixels per world unit _per axis_, so `scale.y < 0`
446
+ is the ordinary way to spell a y-up camera. Two primitives did not read it that
447
+ way, and both failed silently rather than erroring.
448
+
449
+ `zoomAt` clamped the signed scale against positive bounds
450
+ (`min(max, max(min, scale * factor))`), so one wheel step on a y-up view
451
+ returned `scale.y = +0.1`: the axis flipped and the zoom collapsed to the
452
+ minimum. It now bounds the magnitude and restores the sign, so a clamp limits a
453
+ flipped axis instead of unflipping it.
454
+
455
+ `clampView` computed the visible world extent as `canvas.height / scale.y`,
456
+ which is negative on a flipped axis. That made the "is the view zoomed out past
457
+ the bounds" test never fire, and put the scroll interval on the wrong side of
458
+ the anchor — a y-up view could be panned outside its own bounds. It now takes
459
+ the extent as a magnitude and anchors the interval at the rect's far edge when
460
+ the axis is flipped.
461
+
462
+ Found while giving labkit's instrument canvas a declarable coordinate system:
463
+ routing its wheel through `zoomAt` looked like the obvious way to stop
464
+ reimplementing fixed-point zoom, and would have been a bug.
465
+ - c4ccd0a: Zoom now has one clamp. `DEFAULT_MIN_ZOOM` / `DEFAULT_MAX_ZOOM` are exported from
466
+ `@weasel-js/core` and every zoom path defaults from them — `zoomAt`, the
467
+ `viewport.zoom` and pinch actions, `usePinchZoomTool`, `fitViewToBounds`,
468
+ `computeWheelAction` and `useZoom`.
469
+
470
+ **Behavior change:** the three paths that carried the second, undocumented pair
471
+ now cap at 8x rather than 10x. `fitViewToBounds` could previously land at 10x and
472
+ the next pinch frame would clamp it straight back to 8x. Pass an explicit
473
+ `maxScale` / `max` to keep 10x.
474
+ - Updated dependencies [04ea2e8]
475
+ - Updated dependencies [b656ebf]
476
+ - Updated dependencies [36b6ee7]
477
+ - Updated dependencies [a7fa697]
478
+ - @weasel-js/cursor@1.4.0
479
+ - @weasel-js/geom@1.4.0
480
+ - @weasel-js/text@1.4.0
481
+ - @weasel-js/font@1.4.0
482
+ - @weasel-js/gestures@1.4.0
483
+ - @weasel-js/history@1.4.0
484
+ - @weasel-js/modes@1.4.0
485
+ - @weasel-js/paint@1.4.0
486
+
3
487
  ## 1.4.0-pre.1
4
488
 
5
489
  ### Patch Changes
package/README.md CHANGED
@@ -70,28 +70,32 @@ Core doesn't ship a prebuilt atlas — bake one with `npm run gen:font -- <font.
70
70
 
71
71
  ## Actions registry
72
72
 
73
- `<ActionsProvider>` wires a single `keydown` listener and dispatches to a registry of `Action` descriptors. `<SceneCanvas>` auto-mounts a provider (if no parent provider exists) and registers default actions for select-all, escape, duplicate, nudge, and reorder, all derived from the scene/selection/adapter it already owns.
73
+ An `Action` is a named operation — `delete`, `duplicate`, `group`, `insert`, `viewport.dragPan` — paired with the input that triggers it. `<ActionsProvider>` holds the registered descriptors, and the gesture dispatcher matches live input against each one's `defaultBinding`. Keystrokes and pointer gestures take the same path, so a keyboard shortcut and a drag are two bindings on one action rather than two mechanisms.
74
+
75
+ `<SceneCanvas>` auto-mounts a provider when none is above it and registers the kit-standard descriptors, derived from the scene, selection, view and history it already owns.
74
76
 
75
77
  ```tsx
76
- import { ActionsProvider, SceneCanvas } from '@weasel-js/core';
77
-
78
- <ActionsProvider>
79
- <SceneCanvas
80
- scene={scene}
81
- selection={selection}
82
- actions={{
83
- selectAll: null, // disable the default Cmd+A
84
- copy: { // add an app-specific action
85
- label: 'Copy',
86
- defaultBinding: { key: 'c', mod: true },
87
- run: () => clipboard.copy(selection.current),
88
- },
89
- }}
90
- />
91
- </ActionsProvider>
78
+ import { SceneCanvas } from '@weasel-js/core';
79
+
80
+ <SceneCanvas
81
+ scene={scene}
82
+ selection={selection}
83
+ actions={{
84
+ duplicate: null, // drop the default
85
+ 'app.publish': { // add your own
86
+ id: 'app.publish',
87
+ label: 'Publish',
88
+ defaultBinding: { kind: 'key', key: 'p', mods: { mod: true } },
89
+ requires: ['selection'],
90
+ invoker: { timing: 'immediate', run: ({ selection }) => publish(selection.get()) },
91
+ },
92
+ }}
93
+ />
92
94
  ```
93
95
 
94
- The `actions` prop accepts `null` (disable all defaults), a partial override of any default by id, or a full `Action` descriptor for new ids. Consumers that need finer control can call individual hooks (`useSelectAll`, `useEscape`, `useDuplicate`, `useNudge`, `useReorder`) which auto-register into a parent provider when present and fall back to direct keybindings when not.
96
+ The `actions` prop takes `null` to unregister every default, or a record keyed by action id. Each value is `null` to drop that one id, a partial `Action` to merge onto the default of the same id, or a complete `Action` to register a new one.
97
+
98
+ An action does its work through `invoker`, not a bare callback. `{ timing: 'immediate' }` runs once; `{ timing: 'ongoing' }` returns a handle so a drag can preview while it moves and commit at the end. The deps an invoker reads (`selection`, `scene`, `applyOps`, …) are declared in `requires` and resolved at invocation time, which is what lets a consumer swap one — see `useDepSource`.
95
99
 
96
100
  ## Custom shaders (`@experimental`)
97
101
 
@@ -129,7 +129,7 @@ interface SolidPaint {
129
129
  opacity?: number;
130
130
  }
131
131
  /** DrawCommand variants implemented through step 6. */
132
- type DrawCommand = PathDrawCommand | GroupDrawCommand | TextDrawCommand | ImageDrawCommand | ShaderDrawCommand;
132
+ type DrawCommand = PathDrawCommand | GroupDrawCommand | TextDrawCommand | ImageDrawCommand | SpritesDrawCommand | ShaderDrawCommand;
133
133
  /** Draw a path, filled and/or stroked. The workhorse command: every shape the
134
134
  * kit draws that is not text, an image, or a custom shader is one of these. */
135
135
  interface PathDrawCommand {
@@ -230,6 +230,38 @@ interface ImageDrawCommand {
230
230
  flipX?: boolean;
231
231
  flipY?: boolean;
232
232
  }
233
+ /** Floats per sprite in `SpritesDrawCommand.sprites`. */
234
+ declare const SPRITE_STRIDE = 9;
235
+ /**
236
+ * Draw many quads sampling one bitmap — an atlas, a sprite sheet, a wall of
237
+ * thumbnails. The same picture as a run of `ImageDrawCommand`s the renderer
238
+ * would coalesce anyway, handed over already packed so it never walks a
239
+ * command object per quad.
240
+ *
241
+ * Reach for it past a few thousand sprites. Below that a plain run of image
242
+ * commands merges into the same single draw and reads better; the packed form
243
+ * exists because at 20,000 the object walk is about half the frame.
244
+ *
245
+ * The sprites are one run: they share a texture, a filter, and whatever group
246
+ * transform, alpha, color matrix and clip are live, exactly as a merged run of
247
+ * image commands would. Anything varying per sprite is in the array.
248
+ */
249
+ interface SpritesDrawCommand {
250
+ kind: 'sprites';
251
+ image: ImageBitmap;
252
+ /** Magnification filter for the whole run, as `ImageDrawCommand.sampling`. */
253
+ sampling?: 'linear' | 'nearest';
254
+ /**
255
+ * `SPRITE_STRIDE` floats per sprite:
256
+ * `dx, dy, dw, dh, sx, sy, sw, sh, opacity`.
257
+ *
258
+ * Destination is in the group's coordinates; source is in bitmap pixels,
259
+ * like `ImageDrawCommand.source`. A negative `sw` or `sh` mirrors that axis
260
+ * within the source rect, which is what `flipX` / `flipY` do. A trailing
261
+ * partial sprite is ignored.
262
+ */
263
+ sprites: Float32Array;
264
+ }
233
265
  /**
234
266
  * Custom shader draw command. The renderer generates a quad over `bounds`
235
267
  * and dispatches the consumer's fragment shader with the kit's vertex prelude.
@@ -253,4 +285,4 @@ interface ShaderDrawCommand {
253
285
  };
254
286
  }
255
287
 
256
- export { type DrawCommand as D, type GroupDrawCommand as G, type ImageDrawCommand as I, type Mat3 as M, type PathDrawCommand as P, type ShaderDrawCommand as S, type TextDrawCommand as T, type ShaderProgramHandle as a, type ShaderUniform as b, type SolidPaint as c, mat3 as m, registerProgram as r };
288
+ export { type DrawCommand as D, type GroupDrawCommand as G, type ImageDrawCommand as I, type Mat3 as M, type PathDrawCommand as P, SPRITE_STRIDE as S, type TextDrawCommand as T, type ShaderDrawCommand as a, type ShaderProgramHandle as b, type ShaderUniform as c, type SolidPaint as d, type SpritesDrawCommand as e, mat3 as m, registerProgram as r };