@weasel-js/core 1.4.0-pre.0 → 1.4.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,438 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.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
+ - eb16573: Alt-drag-to-duplicate shows the duplicate cursor.
32
+
33
+ `clone` declared no cursor, so the one gesture in the kit that copies instead
34
+ of moving looked exactly like a move until you released. It now declares
35
+ `cursor: 'copy'` and `activeCursor: 'copy'`, which needs no modifier gate of
36
+ its own: the select tool binds clone behind `mods: { alt: true }`, so the hover
37
+ pump predicts the action — and shows the cursor — only while Alt is held over a
38
+ body, and drops it the moment Alt is released.
39
+
40
+ This replaces the `apps/draw` stub that arc 4 deleted. That stub forced
41
+ `cursor: copy` from a CSS rule fed by a hand-rolled Alt listener, scoped to
42
+ path-edit mode and applied over the whole canvas including where nothing would
43
+ happen. The affordance now shows wherever Alt-drag actually duplicates, in
44
+ every mode, through the cursor pipeline.
45
+
46
+ `AffordanceRegion.cursor` and a layer claim cannot express this — neither
47
+ `affordanceAt` nor `RenderLayer.hitTest` receives the event, so `Action.cursor`
48
+ is the kit's only modifier-gated cursor channel.
49
+ - 6650d67: Correct the actions-registry documentation, which described the API that was
50
+ replaced in May 2026.
51
+
52
+ `packages/core/README.md` said `<ActionsProvider>` wires a keydown listener,
53
+ showed an `Action` with a `run` callback, and told consumers to reach for
54
+ `useSelectAll` / `useEscape` / `useDuplicate` / `useNudge` / `useReorder`. None
55
+ of that is true: keystrokes and pointer gestures both route through the gesture
56
+ dispatcher matching `defaultBinding`, an action does its work through `invoker`,
57
+ and those five hooks were deleted. `docs/taxonomy.md` likewise still listed
58
+ `Action.run` and a fallback to a `useKeybinding` path that no longer exists.
59
+ - 04ea2e8: The painted cursor tier, and cursors that tell the five shape tools apart.
60
+
61
+ A cursor that cannot be a CSS cursor is now drawn into the canvas instead of
62
+ being dropped. `resolveCursorTier` escalates when a glyph is sized in world
63
+ units, or past the 128 CSS px above which the browser silently discards the
64
+ image; `<Canvas>` then sets `cursor: none` and a screen-space layer paints the
65
+ glyph at the pointer. Tools never choose the tier — they declare what they
66
+ want, and a brush stays a brush across the radius where it stops being
67
+ expressible as a CSS cursor.
68
+
69
+ `brush` ships with it, sized by `worldRadius` so its ring measures the brush at
70
+ every zoom. A glyph that measures something declares `CursorGlyph.radius` to
71
+ name the circle being sized, and world-sized glyphs hold their line weight in
72
+ CSS px while their geometry scales.
73
+
74
+ `paint.ts` is the shared half: the baker and the painter both consume its
75
+ ordered paint ops, so the two tiers draw one glyph rather than two drawings
76
+ that resemble each other. The layer itself lives in core, which is where
77
+ `RenderLayer` and `Path` live.
78
+
79
+ rect, ellipse, line, star and polygon were all bare `crosshair` and so
80
+ indistinguishable while in use; each now shows a crosshair badged with its
81
+ shape, hotspotted on the cross. `Tool.cursor` on a built `Tool` widens from
82
+ `string` to `CursorSpec`, which it should have done when `ToolDef.cursor` did.
83
+
84
+ The `apps/draw` `cursor: copy` stub and its hand-rolled Alt listener are gone.
85
+ They advertised an add-anchor sub-tool that does not exist, so the affordance
86
+ was removed rather than relocated; `docs/TODO.md` records where it returns.
87
+ - b656ebf: Rotatable cursors, and a `CursorSpec` for the four fields that declare one.
88
+
89
+ `bakeCursor` takes an `angle` in radians, quantized to 16 steps of 22.5°, and
90
+ turns both the glyph and its hotspot. `Tool.cursor`, `Action.cursor`,
91
+ `Action.activeCursor` and `AffordanceRegion.cursor` widen from `string` to
92
+ `CursorSpec` — either a CSS keyword, which passes through untouched, or
93
+ `{ glyph, size?, angle?, fallback? }`. Every cursor declaration written before
94
+ this keeps working.
95
+
96
+ Two glyphs ship with it. The selection's rotation ring now shows a real `rotate`
97
+ cursor instead of a bare `grab`, and the resize corners show a `resize` arrow
98
+ turned to the corner's actual axis — a rotated selection used to keep the
99
+ unrotated diagonal, because CSS has four diagonal keywords and a rotation needs
100
+ sixteen. The keyword remains as each spec's `fallback`.
101
+ - 5295c34: Draw on a lab's instrument: the `annotations` capability gets its overlay.
102
+
103
+ An instrument that declares `annotations` now gets a drawing surface on every
104
+ target it names — weasel tools, weasel selection, marks that pan and zoom with
105
+ what they mark — plus a palette (select, freehand, line, arrow, rectangle,
106
+ ellipse, text) and its own tool slot. `useAnnotations()` reaches the store from
107
+ the instrument's render or from a chrome contribution, and re-renders its
108
+ caller as marks change.
109
+
110
+ The lab's shared surface grew the buffer that makes this possible: one
111
+ `<canvas>` over `.lk-lab__body`, and `SurfaceHandle.registerPainter`, which is
112
+ how a resize of that buffer reaches every tile rather than the one that moved.
113
+ `getContainer()` names the element tile rects are measured against.
114
+
115
+ A mark is a weasel scene node in a scene of its own per target — a pane's
116
+ hit-test, marquee and paint walk the whole scene they are handed, so one shared
117
+ scene would put a neighbour's marks under the pointer. An annotation's id is
118
+ therefore `<target>/<node>`, and `createAnnotationStore` takes `targets` alone
119
+ plus an optional `restore`; `SerializedAnnotations` carries `scenes`, keyed by
120
+ target. Marks still do not survive a reload — the storage slot is the next arc.
121
+
122
+ Core adds `ArrowIcon` to the built-in tool glyphs.
123
+ - 2fbf611: Give a canvas its own provider scope with `<WeaselProvider isolate>`
124
+
125
+ An actions registry holds exactly one dispatcher, so a second `<SceneCanvas>`
126
+ joining a scope displaced the first and took its input away. Worse, the
127
+ detach was unconditional: whichever canvas unmounted — or merely re-rendered
128
+ with a new dispatcher identity — cleared the slot for the one still on screen.
129
+ The symptom was a canvas that stopped responding, naming neither canvas nor the
130
+ registry they shared.
131
+
132
+ `isolate` mounts every provider unconditionally instead of deferring to one
133
+ already in scope, so canvases that merely coexist get a scope each. This is the
134
+ shape consumers had already reached for by hand: `AnimationDemo` and
135
+ `BooleanOpsDemo` both mounted raw `ActionsProvider` / `SelectionContextProvider`
136
+ / `DepRegistryProvider` to shadow the ambient scope, and both now say `isolate`
137
+ instead.
138
+
139
+ `setDispatcher` and `setDepRegistry` return a release that clears the slot only
140
+ while the caller still holds it, so a departing canvas can no longer disable a
141
+ surviving one. A second dispatcher claiming an occupied registry warns once,
142
+ naming `isolate` as the fix.
143
+
144
+ Two canvases still cannot *share* one registry: a toolbar outside both has
145
+ nothing to say which one it drives. That needs a focused-canvas concept and is
146
+ not in this change.
147
+ - 36b6ee7: Add `@weasel-js/cursor` and give three tools real cursors.
148
+
149
+ A cursor glyph is SVG path `d` strings tagged with a paint role plus a hotspot
150
+ in glyph units — the one geometry form both a data-URI baker and a `Path2D`
151
+ painter consume without translating. `bakeCursor` renders one to a
152
+ `url(data:image/svg+xml,…)` string with the hotspot scaled to integer CSS px,
153
+ and `cursorFor` memoizes that per name and size. The pencil, pen and eyedropper
154
+ tools now show their own glyph instead of a shared `crosshair`.
155
+
156
+ Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
157
+ cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
158
+ throws above 128 CSS px rather than emitting a cursor the browser would drop
159
+ silently — that size is where a later painted tier will take over.
160
+
161
+ Glyph geometry is authored in `scripts/glyphs/` and generated to resolved
162
+ literals by `npm run gen:cursors`; `npm run proof:cursors` renders the baked
163
+ assets over three backgrounds for inspection.
164
+
165
+ New API: `bakeCursor`, `cursorFor`, `GLYPHS`, `haloFitsInBox`, `CursorGlyph`,
166
+ `CursorPath`, `CursorGlyphName`, `BakeOptions`, and the register constants
167
+ `CURSOR_INK`, `CURSOR_HALO`, `CURSOR_HALO_WIDTH`, `CURSOR_MAX_CSS_PX`.
168
+ - 7a0c568: Tell an event handler how late its crossing is
169
+
170
+ `EventTrack`'s `fire` took no arguments, so a handler could only ask its own
171
+ clock for "now" — when the frame was processed, not when the playhead crossed
172
+ the edge. That held footstep scheduling in the side-scroller at frame
173
+ resolution against an audio engine built for sample resolution: a measured peak
174
+ spread of 33–47 ms on the looping run cycle.
175
+
176
+ `fire(lateBy)` reports the gap between the crossing and the frame carrying it,
177
+ in timeline ms. It is never negative, including on the loop seam, where the
178
+ outgoing lap's tail fires after the playhead has already wrapped — the case
179
+ that makes a handler comparing against `handle.time()` read a negative
180
+ lateness. A nested timeline's events report the same figure as a top-level
181
+ one's; the track's offset cancels.
182
+
183
+ Nothing has to change to compile: a zero-argument function is assignable to the
184
+ new signature.
185
+
186
+ `lateBy` is a delta, not a clock reading, so events from two different
187
+ timelines still cannot be ordered against each other. That would need the
188
+ animator's virtual clock made public, which this does not do.
189
+
190
+ `SideScrollerDemo` now places each footfall a fixed budget after its true
191
+ crossing, so which frame happened to notice a contact turns into constant
192
+ latency rather than audible spread.
193
+ - a7fa697: Add an anchored-placement solver and keep HUD windows on their host.
194
+
195
+ `@weasel-js/geom` gains `placeRect` and `clampRectWithin`. `placeRect` resolves an
196
+ overlay against an anchor: it picks a side, flips to the opposite one when the
197
+ preferred side has no room, and slides along the alignment axis to stay inside a
198
+ boundary. `clampRectWithin` is the containment half on its own — move a rect the
199
+ shortest distance that puts it inside a boundary, keeping its size. Both are pure
200
+ and take an explicit boundary rect, so a boundary that does not start at the
201
+ origin resolves correctly.
202
+
203
+ A HUD window could previously be dragged fully off its host with no way to
204
+ recover it: `createWindow` clamped size but never position. Move drags and
205
+ `setBounds` now keep the window on the host. Resize drags are deliberately left
206
+ alone, so pulling an edge past the host does not fight the gesture.
207
+
208
+ `@weasel-js/core` gains `hostAnchorRect`, `hostAnchorCss` and `useHostAnchor`,
209
+ which hold a fixed-position panel against a host element's corner and keep it
210
+ inside the viewport. The corner is an alignment per axis rather than a fixed
211
+ one, and `useHostAnchor` takes a function that resolves the host, so a host held
212
+ in a ref and one found by selector work the same way.
213
+
214
+ `hostAnchorCss` pins whichever edges the alignment names. That is not cosmetic:
215
+ a panel whose width tracks its content holds the anchored edge still and grows
216
+ away from it, so pinning the wrong edge makes the anchored corner drift on every
217
+ content change.
218
+
219
+ Four places were carrying their own copy of that anchor math and now share this
220
+ one — `CursorCoordsHud`, `PickHud`, `ModalityHud`, and WeaselDraw's
221
+ `DispatchTracePanel`, which anchors the opposite corner. None of the four
222
+ clamped, so a panel could hang off the edge when the host was scrolled or the
223
+ panel was tall.
224
+ - 2272682: `createParallaxLayer` takes an optional `getOuterView`, so a plane can derive
225
+ from a ref-driven camera. It previously derived only from the canvas's `view`
226
+ prop; a consumer keeping a 60 Hz camera out of React state pins that prop to
227
+ identity and got identity back for every `pan` value — a backdrop that silently
228
+ never moved.
229
+
230
+ `useHandTool` no longer builds a velocity tracker and a decay loop it never
231
+ uses. `inertia` and `axis` were already inert; they are now documented as such
232
+ until the `viewport.dragPan` action implements them.
233
+ - 503b56d: Fix two path-walker bugs that produced wrong geometry with no error.
234
+
235
+ `tessellate` treated `Z` as a no-op, so a command following a close flattened
236
+ from the last point drawn rather than from the subpath start — SVG puts the pen
237
+ back at the start. `pathDistanceToPoint` dispatched through an `if`/`else if`
238
+ chain with no final `else`, so an unrecognized command code left the coordinate
239
+ cursor unadvanced and silently misaligned every later read; it now throws.
240
+ - ac2deea: Add `polylineFromPoints` — the open counterpart to `polygonFromPoints`.
241
+
242
+ Same geometry, without the closing edge. A freehand stroke or a measurement
243
+ line wants this; a region wants the closed one. The pencil tool's drag preview
244
+ was building its ghost with `polygonFromPoints`, so the edge from the newest
245
+ sample back to the first swept across the drawing as the stroke grew and read
246
+ as a marquee.
247
+ - 23ffb2f: `WeaselRenderer` can draw into a rect of a buffer it does not own.
248
+ `setTarget({ origin, clear })` applies a viewport and scissor inside `render()`,
249
+ so N renderers can share one WebGL context and one canvas without a frame clear
250
+ erasing a co-tenant. The rect's size is the renderer's own `width`/`height`, so
251
+ `resize()` remains the single source of it.
252
+
253
+ Adds API. Two behaviour changes for existing callers: `render()` now
254
+ re-establishes blend, depth, cull and clear colour every frame instead of once at
255
+ construction, so a co-tenant moving that state no longer corrupts weasel's
256
+ frames; and the constructor now throws when handed a WebGL2 context whose
257
+ attributes report no stencil buffer, which previously rendered clips and even-odd
258
+ fills wrong rather than failing. A context that cannot report its attributes is
259
+ unaffected.
260
+ - 016851c: Stop a stroke with no paint from blanking the whole document.
261
+
262
+ `SelectionPanel`'s object leaf started from `{}` when the node held no value
263
+ yet, so editing any non-paint field of `data.stroke` on an unstroked node
264
+ committed that field alone — a `Stroke` with no `paint`, which the type
265
+ forbids. The leaf's declared `default` was dead for writes; it now seeds from
266
+ it, so writing one field materializes a complete value.
267
+
268
+ Such a stroke threw out of `fillInPoseFrame`, and the throw escaped the painter
269
+ and took the frame with it: the document page and every other node vanished,
270
+ and the canvas stayed stale until something unrelated requested a redraw — so
271
+ WeaselDraw opened on an empty workspace and only drew once the pointer moved.
272
+ `resolveNodeStroke` now reads a paintless stroke as no stroke, and the text
273
+ painter routes through it like every other painter. The frame loop no longer
274
+ loses its dirty flag when a paint throws, so one bad frame is retried rather
275
+ than stranding the surface.
276
+ - c9dd37f: Render text decorations as a toggle row, and ship a builtin font-family control
277
+
278
+ `SelectionPanel` rendered every boolean leaf as a `Switch`, ignoring the leaf's
279
+ `control` entirely — so the three text decorations arrived as three switch rows
280
+ where every text editor puts one row of U / S / O. `ToolPrefBooleanControl` now
281
+ accepts `'toggle'`, `ToolPrefBoolean` carries a `short` label for it (the pair
282
+ takes the row's name, leaving the leaf only a glyph's worth of room), and the
283
+ panel honors both. Core's text schema asks for it: `underline`,
284
+ `strikethrough` and `overline` share a `Decoration` pair.
285
+
286
+ A run of adjacent leaves sharing a `pair` renders as one `ToggleBar`, not one
287
+ bar per leaf — the same segmented control the `Align` row beside it already
288
+ draws. Each segment still writes only its own leaf, so flipping one decoration
289
+ never invents values for the other two. An unset toggle is left unselected
290
+ rather than dimmed: unselected is what a toggle button's off state means, and
291
+ the dimming the `Switch` path uses for the same case reads as disabled on one.
292
+ A leaf a consumer claims with its own `renderers` entry drops out of the run.
293
+
294
+ `FontFamilySelect` moves from WeaselDraw into `@weasel-js/ui`, and
295
+ `SelectionPanel` reaches for it on a `font-family` leaf. Core's own default
296
+ text schema declares that kind, so a consumer passing no `renderers` — the
297
+ Storybook story, any app taking the defaults — got the literal
298
+ `(font-family: no renderer)` placeholder where the font picker belongs. The
299
+ control offers both tiers that can actually paint and probes substitution at
300
+ the node's own weight and style, so its label names the variant that will
301
+ render. `@weasel-js/ui` now depends on `@weasel-js/font`.
302
+ - 9a000ea: A stroked text node now gets hit reach from its stroke. `TEXT_PAINTER` declared
303
+ no `ink`, so picking fell back to a zero-outset default and a heavily outlined
304
+ glyph was unpickable across the width of its own outline.
305
+
306
+ `kit:derived` also now evaluates ahead of `kit:path` / `kit:shape` / `kit:image`.
307
+ A derived node whose `data` happens to carry a `path`, `shape` or `image` field
308
+ was silently painted by those painters instead of from its derived path.
309
+ - 016851c: Add an editor surface for superscript, subscript and overline.
310
+
311
+ `StyledRun.script`, `baselineShift`, `fontScale` and `overline` reached layout,
312
+ SVG and the DOM overlay but nothing could apply them. The character bar now
313
+ carries an x² / x₂ pair, an overline toggle beside B / I / U / S, and the two
314
+ primitives `script` presets — baseline shift and scale — as percentage fields
315
+ that show what the preset supplies and override just that half when typed over.
316
+ `overline` also joins the sidebar's node-level Character group. Superscript and
317
+ subscript take Cmd+Shift+= and Cmd+Shift+-; the unshifted pair is browser zoom,
318
+ which a page cannot cancel.
319
+
320
+ A styling written at a collapsed caret now arms `useTextEdit`'s new
321
+ `pendingStyle` and applies to the next character typed, instead of being
322
+ dropped or restyling the whole node. That is what `script` needs — it has no
323
+ node-level counterpart to write to by design — and it makes the bar agree with
324
+ Cmd+B, which already behaved this way. `rangeStyle` reports the styling *at* a
325
+ collapsed caret rather than `{}`, and `toggleStyle` is public.
326
+
327
+ Three fixes fall out of putting both paths through one implementation:
328
+ lowering a flag the node sets now works from the bar and from a collapsed
329
+ caret, not only from the keyboard over a range; a toggle reads the node's flags
330
+ as well as the runs, so Cmd+B inside a `fontWeight: 700` node clears bold
331
+ instead of adding it; and focus returns to the text after a styling control is
332
+ clicked, so typing continues in the document rather than reaching the app as
333
+ tool shortcuts.
334
+ - 8ddec11: Accept a named or cubic-bezier easing wherever a curve is taken, and let a
335
+ timeline's loop policy change after it is created.
336
+
337
+ `easing` was a bare function everywhere, which is fine to call and impossible to
338
+ name back, show in a picker, or serialize. It now also accepts the name of a
339
+ built-in (`'easeOutBack'`) or control points (`{ bezier: [0.4, 0, 0.2, 1] }`),
340
+ resolved by `resolveEasing` at the four places a curve is actually invoked. The
341
+ union is additive, so every existing function value stays assignable. Bezier x
342
+ control points are clamped to 0..1, which is what keeps the solve monotone, and
343
+ the control-point tuple is `readonly` so an `as const` preset is assignable.
344
+
345
+ `TimelineHandle.setLoop(loop)` sets policy and nothing else. A timeline already
346
+ parked at its duration does not restart — `rearm` declines to revive one — so
347
+ play it again by seeking to 0 and resuming. Restoring saved transport state
348
+ therefore cannot start playback as a side effect.
349
+
350
+ Both settings now read back. `AnimationHandle.timeScale()` returns an
351
+ animation's own scale, and `Animator.timeScale()` the global one, the way
352
+ `isPaused()` already pairs with `pause()`. `TimelineHandle.loop()` returns the
353
+ policy as it stands — `true`, `false`, or the laps a finite loop has left, which
354
+ falls as they are consumed. A transport UI can drive itself off the handle
355
+ instead of mirroring what it last wrote, which drifts as soon as anything else
356
+ holding the handle sets it.
357
+ - 28894b9: Fix the viewport primitives on an axis with negative scale.
358
+
359
+ `View.scale` is documented as pixels per world unit _per axis_, so `scale.y < 0`
360
+ is the ordinary way to spell a y-up camera. Two primitives did not read it that
361
+ way, and both failed silently rather than erroring.
362
+
363
+ `zoomAt` clamped the signed scale against positive bounds
364
+ (`min(max, max(min, scale * factor))`), so one wheel step on a y-up view
365
+ returned `scale.y = +0.1`: the axis flipped and the zoom collapsed to the
366
+ minimum. It now bounds the magnitude and restores the sign, so a clamp limits a
367
+ flipped axis instead of unflipping it.
368
+
369
+ `clampView` computed the visible world extent as `canvas.height / scale.y`,
370
+ which is negative on a flipped axis. That made the "is the view zoomed out past
371
+ the bounds" test never fire, and put the scroll interval on the wrong side of
372
+ the anchor — a y-up view could be panned outside its own bounds. It now takes
373
+ the extent as a magnitude and anchors the interval at the rect's far edge when
374
+ the axis is flipped.
375
+
376
+ Found while giving labkit's instrument canvas a declarable coordinate system:
377
+ routing its wheel through `zoomAt` looked like the obvious way to stop
378
+ reimplementing fixed-point zoom, and would have been a bug.
379
+ - c4ccd0a: Zoom now has one clamp. `DEFAULT_MIN_ZOOM` / `DEFAULT_MAX_ZOOM` are exported from
380
+ `@weasel-js/core` and every zoom path defaults from them — `zoomAt`, the
381
+ `viewport.zoom` and pinch actions, `usePinchZoomTool`, `fitViewToBounds`,
382
+ `computeWheelAction` and `useZoom`.
383
+
384
+ **Behavior change:** the three paths that carried the second, undocumented pair
385
+ now cap at 8x rather than 10x. `fitViewToBounds` could previously land at 10x and
386
+ the next pinch frame would clamp it straight back to 8x. Pass an explicit
387
+ `maxScale` / `max` to keep 10x.
388
+ - Updated dependencies [04ea2e8]
389
+ - Updated dependencies [b656ebf]
390
+ - Updated dependencies [36b6ee7]
391
+ - Updated dependencies [a7fa697]
392
+ - @weasel-js/cursor@1.4.0
393
+ - @weasel-js/geom@1.4.0
394
+ - @weasel-js/text@1.4.0
395
+ - @weasel-js/font@1.4.0
396
+ - @weasel-js/gestures@1.4.0
397
+ - @weasel-js/history@1.4.0
398
+ - @weasel-js/modes@1.4.0
399
+ - @weasel-js/paint@1.4.0
400
+
401
+ ## 1.4.0-pre.1
402
+
403
+ ### Patch Changes
404
+
405
+ - 36b6ee7: Add `@weasel-js/cursor` and give three tools real cursors.
406
+
407
+ A cursor glyph is SVG path `d` strings tagged with a paint role plus a hotspot
408
+ in glyph units — the one geometry form both a data-URI baker and a `Path2D`
409
+ painter consume without translating. `bakeCursor` renders one to a
410
+ `url(data:image/svg+xml,…)` string with the hotspot scaled to integer CSS px,
411
+ and `cursorFor` memoizes that per name and size. The pencil, pen and eyedropper
412
+ tools now show their own glyph instead of a shared `crosshair`.
413
+
414
+ Cursors ship as SVG with no bitmap fallback: Chrome rasterizes an SVG data-URI
415
+ cursor at device scale, so it is already crisp on a retina display. `bakeCursor`
416
+ throws above 128 CSS px rather than emitting a cursor the browser would drop
417
+ silently — that size is where a later painted tier will take over.
418
+
419
+ Glyph geometry is authored in `scripts/glyphs/` and generated to resolved
420
+ literals by `npm run gen:cursors`; `npm run proof:cursors` renders the baked
421
+ assets over three backgrounds for inspection.
422
+
423
+ New API: `bakeCursor`, `cursorFor`, `GLYPHS`, `haloFitsInBox`, `CursorGlyph`,
424
+ `CursorPath`, `CursorGlyphName`, `BakeOptions`, and the register constants
425
+ `CURSOR_INK`, `CURSOR_HALO`, `CURSOR_HALO_WIDTH`, `CURSOR_MAX_CSS_PX`.
426
+ - Updated dependencies [36b6ee7]
427
+ - @weasel-js/cursor@1.4.0-pre.1
428
+ - @weasel-js/font@1.4.0-pre.1
429
+ - @weasel-js/geom@1.4.0-pre.1
430
+ - @weasel-js/gestures@1.4.0-pre.1
431
+ - @weasel-js/history@1.4.0-pre.1
432
+ - @weasel-js/modes@1.4.0-pre.1
433
+ - @weasel-js/paint@1.4.0-pre.1
434
+ - @weasel-js/text@1.4.0-pre.1
435
+
3
436
  ## 1.4.0-pre.0
4
437
 
5
438
  ### Minor 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