@weasel-js/labkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,870 @@
1
+ /**
2
+ * An invertible mutation. Applied via an adapter; produces an inverse op
3
+ * that, when applied to the same adapter, undoes the original.
4
+ *
5
+ * Adapters are intentionally typed loosely here so different op types can
6
+ * require different adapter capabilities. Each op is responsible for
7
+ * narrowing the adapter via the methods it calls.
8
+ *
9
+ * Lives here rather than in `@weasel-js/core` because an invertible,
10
+ * replayable mutation is a history concept: this package is what pushes ops
11
+ * onto a stack, inverts them, coalesces them, and rebuilds them from a
12
+ * serialized snapshot. Nothing about the shape is core-specific — it names no
13
+ * scene, node, or pose type. Core re-exports it from `core/ops/types` so its
14
+ * own call sites read unchanged.
15
+ */
16
+ interface Op {
17
+ /** Apply the mutation. Return `false` (or `'noop'`) to signal that
18
+ * nothing changed — the history layer then skips pushing an undo
19
+ * entry for the batch when *every* op reports no-op. Returning
20
+ * `undefined`/`void` means "mutated" (the common case; existing ops
21
+ * don't need to change). */
22
+ apply(adapter: unknown): void | boolean | 'noop';
23
+ invert(): Op;
24
+ label?: string;
25
+ coalesceKey?: string;
26
+ /** Stable factory name for op-registry lookup. Kit-emitted ops always
27
+ * set this; consumer ops without a name can't round-trip through
28
+ * `History.serialize()` and are dropped from persisted snapshots. */
29
+ name?: string;
30
+ /** Serializable args (JSON / structured-clone-safe) that, paired with
31
+ * `name`, reconstruct the op via the registry's `rebuildOp`. */
32
+ args?: unknown;
33
+ }
34
+
35
+ interface Journal {
36
+ readonly targetId: string | undefined;
37
+ readonly forkedAtEntryId: number;
38
+ applyBatch(ops: Op[], label: string): void;
39
+ undo(): void;
40
+ redo(): void;
41
+ canUndo(): boolean;
42
+ canRedo(): boolean;
43
+ entries(): {
44
+ undo: HistoryEntry[];
45
+ redo: HistoryEntry[];
46
+ };
47
+ commit(label: string): void;
48
+ cancel(): void;
49
+ suspend(): void;
50
+ isActive(): boolean;
51
+ }
52
+
53
+ /** Wire form of a single op inside a serialized history. The pair
54
+ * `(name, args)` reconstructs a live `Op` via the op-factory registry. */
55
+ interface SerializedOp {
56
+ name: string;
57
+ args: unknown;
58
+ }
59
+ /** Wire form of one history entry. `forwardOps` / `baseOps` mirror the
60
+ * in-memory entry's fields (see `Entry` above) but only carry the
61
+ * serializable `(name, args)` projection of each op. */
62
+ interface SerializedHistoryEntry {
63
+ id: number;
64
+ label: string;
65
+ forwardOps: SerializedOp[];
66
+ baseOps: SerializedOp[];
67
+ }
68
+ /** Snapshot of an entire `History` instance. Designed to live alongside the
69
+ * scene snapshot in IDB so a reload restores the undo / redo stacks to
70
+ * exactly where they were. */
71
+ interface SerializedHistory {
72
+ version: 1;
73
+ undoStack: SerializedHistoryEntry[];
74
+ /** Stored newest-first, mirroring the in-memory stack so a deserialized
75
+ * history matches the original's `entries().redo` ordering. */
76
+ redoStack: SerializedHistoryEntry[];
77
+ nextEntryId: number;
78
+ /** Entries dropped because at least one of their ops lacked a `name`
79
+ * and therefore couldn't round-trip through the op-factory registry.
80
+ * Always present (zero when nothing was dropped) so callers can detect
81
+ * loss without parsing the debug log. */
82
+ droppedEntries: number;
83
+ }
84
+ /** Read-only view of a history entry exposed via `History.entries()`. */
85
+ interface HistoryEntry {
86
+ /** Stable monotonic id (preserved across coalesce merges). */
87
+ id: number;
88
+ /** Human-readable label (the `label` arg passed to `applyOps`). */
89
+ label: string;
90
+ /** Push/last-coalesce timestamp (ms). */
91
+ timestamp: number;
92
+ /** Set of node ids touched by any op in this entry. Populated from ops
93
+ * whose `args` carry an `id` field (transform, setPath, reparent) or a
94
+ * `node.id` field (insert, delete). Ops without a recognisable id field
95
+ * contribute nothing. May be `undefined` for deserialized entries
96
+ * restored from an older snapshot that predates this field. */
97
+ touchedIds?: ReadonlySet<string>;
98
+ }
99
+
100
+ /**
101
+ * Pose composition for hierarchical scene graphs.
102
+ *
103
+ * As of the nesting change, `getPose(id)` on adapters returns the
104
+ * **local** pose — relative to the object's direct parent. Anything in the
105
+ * kit that needs to draw, hit-test, snap, or otherwise reason about world
106
+ * coordinates routes through `composeWorldPose`, which walks the parent
107
+ * chain and folds local poses together via a consumer-supplied `compose`.
108
+ *
109
+ * Pose shape is generic, so the compose strategy is too. For the common
110
+ * `{x, y, width, height}` axis-aligned rect, use `composeRectPose` —
111
+ * translation only, child dimensions preserved. Custom pose shapes (paths,
112
+ * matrix transforms) supply their own.
113
+ *
114
+ * The inverse — `rebaseLocalPose` — converts a world-space pose into a
115
+ * local pose under a target parent. Used when reparenting so the visual
116
+ * world position of a child is preserved across the parent change.
117
+ */
118
+ /**
119
+ * Axis-aligned rectangle pose with optional rotation. The canonical pose
120
+ * shape used by `composeRectPose` and the `unionBounds` helper. Rotation is
121
+ * in radians, pivoted on the unrotated AABB center; absent === 0. Kit-side
122
+ * code that consumes rotation already reads `pose.rotation ?? 0`
123
+ * (`SceneCanvas.defaultDrawOne`, `rotate/handle.ts`, `pathInWorld.ts`), so
124
+ * the slot exists on every default scene whether or not the consumer
125
+ * populates it. Defined here in core/transforms so the compose helpers
126
+ * don't depend on features.
127
+ */
128
+ interface RectPose {
129
+ x: number;
130
+ y: number;
131
+ width: number;
132
+ height: number;
133
+ /** Rotation in radians around the unrotated AABB center. Absent === 0. */
134
+ rotation?: number;
135
+ }
136
+
137
+ /** Fill rule used by polygon path hit-testing and `ctx.fill()`. */
138
+ type PathFillRule = 'nonzero' | 'evenodd';
139
+ /**
140
+ * Polygon path with arbitrary contours and optional bezier segments.
141
+ * Multi-contour: each `M` opens a new subpath; `Z` closes the current one.
142
+ * Open subpaths (no `Z`) render as polylines and don't contribute to fills.
143
+ */
144
+ interface PolygonPath {
145
+ kind: 'polygon';
146
+ commands: Uint8Array;
147
+ coords: Float32Array;
148
+ fillRule: PathFillRule;
149
+ }
150
+ /**
151
+ * Axis-aligned rectangle. Fast path for the (very common) case where the
152
+ * shape is just a rect — preserves O(1) bounds and hit-test, avoids the
153
+ * polygon kernel entirely. Promote to `PolygonPath` only when the shape
154
+ * grows beyond what a rect can express.
155
+ */
156
+ interface RectPath {
157
+ kind: 'rect';
158
+ x: number;
159
+ y: number;
160
+ width: number;
161
+ height: number;
162
+ }
163
+ /** Canonical path shape — either an axis-aligned rect (fast path) or a polygon command stream. */
164
+ type Path = PolygonPath | RectPath;
165
+
166
+ /**
167
+ * # SceneNode — the thing in the scene
168
+ *
169
+ * A `SceneNode` is the single canonical unit of a weasel scene. Everything
170
+ * the user sees on the canvas — a shape, a group, an annotation, a tile —
171
+ * is one of these. Containers and leaves are both nodes; the kit has no
172
+ * other concept of "scene element."
173
+ *
174
+ * ## Three orthogonal slots
175
+ *
176
+ * Every node carries three independent slots, plus its tree position:
177
+ *
178
+ * - **`data: TData`** — app-defined payload. The kit never inspects it.
179
+ * Color, label, kind, glyph, sample-rate, whatever the app's domain
180
+ * calls for. Mutated via `Scene.update(id, { data })`.
181
+ *
182
+ * - **`pose: TPose`** — local transform, relative to the node's direct
183
+ * parent (or world, for root nodes). Default `RectPose` is
184
+ * `{ x, y, width, height }`, but `TPose` is fully generic so apps can
185
+ * use rotated rects, paths, ellipses, etc. The kit composes world
186
+ * poses via `composeWorldPose` when rendering / hit-testing / snapping.
187
+ *
188
+ * - **`layer: TLayer`** — a string tag associating the node with a
189
+ * visual `RenderLayer` at draw time. Separate from `LayerRecord` (the
190
+ * per-layer visible/locked metadata held by the `Scene`).
191
+ *
192
+ * Tree position lives on the node itself: every node has a `parent` (or
193
+ * `null` for roots), and `ContainerNode` adds an ordered `children: NodeId[]`.
194
+ *
195
+ * ## Identity is by `NodeId`, not by reference
196
+ *
197
+ * Nodes are addressed by `NodeId` everywhere outside the scene tree:
198
+ * selection is `NodeId[]`, ops reference `NodeId`s, adapter methods accept
199
+ * `string` ids and look up the node on demand. The `Node` object itself is
200
+ * a snapshot of current state — don't hold references to it across scene
201
+ * updates; look up by id when you need the latest.
202
+ *
203
+ * Picking helpers (`pickBest`, `pickEvery`) likewise return ids, not nodes —
204
+ * they're hit-testing primitives that stay ignorant of node payload shape.
205
+ *
206
+ * ## Vocabulary
207
+ *
208
+ * The kit-internal name is `Node`; the public re-export is `SceneNode`
209
+ * (avoids collision with DOM `Node` at call sites). Adapter methods speak
210
+ * the same vocabulary: `getNode`, `getNodes`, `insertNode`, `removeNode`,
211
+ * `cloneNode`, `addNode`. Older code, demos, and comments may still say
212
+ * "object" or "item" — those are historical aliases for the same concept.
213
+ */
214
+ /** Opaque branded id. Treat as opaque outside the kit. */
215
+ type NodeId = string & {
216
+ readonly __brand: 'NodeId';
217
+ };
218
+ interface NodeBase<TData, TLayer extends string, TPose> {
219
+ id: NodeId;
220
+ layer: TLayer;
221
+ pose: TPose;
222
+ data: TData;
223
+ parent: NodeId | null;
224
+ }
225
+ interface LeafNode<TData, TLayer extends string, TPose = RectPose> extends NodeBase<TData, TLayer, TPose> {
226
+ kind: 'leaf';
227
+ }
228
+ interface ContainerNode<TData, TLayer extends string, TPose = RectPose> extends NodeBase<TData, TLayer, TPose> {
229
+ kind: 'container';
230
+ children: NodeId[];
231
+ /** Optional clip-path source. Re-evaluated each render. Returning `null`
232
+ * means "no clip for this container right now"; an empty / zero-area path
233
+ * means "clip everything out" (children render nowhere). When set, the
234
+ * renderer rasterizes the returned path into the stencil buffer and
235
+ * paints descendants only where it covers. */
236
+ clipFromPose?: (pose: TPose) => Path | null;
237
+ }
238
+ type Node<TData, TLayer extends string, TPose = RectPose> = LeafNode<TData, TLayer, TPose> | ContainerNode<TData, TLayer, TPose>;
239
+ interface LayerRecordBase<TLayer extends string> {
240
+ id: TLayer;
241
+ visible: boolean;
242
+ locked: boolean;
243
+ }
244
+ interface SystemLayerRecord<TLayer extends string> extends LayerRecordBase<TLayer> {
245
+ kind: 'system';
246
+ }
247
+ interface UserLayerRecord<TLayer extends string> extends LayerRecordBase<TLayer> {
248
+ kind: 'user';
249
+ name: string;
250
+ }
251
+ type LayerRecord<TLayer extends string> = SystemLayerRecord<TLayer> | UserLayerRecord<TLayer>;
252
+ interface AddNodeSpec<TData, TLayer extends string, TPose = RectPose> {
253
+ kind: 'leaf' | 'container';
254
+ layer: TLayer;
255
+ pose: TPose;
256
+ data: TData;
257
+ parent?: NodeId | null;
258
+ index?: number;
259
+ /** Explicit id wins over the Scene's `generateId` and the kit default. */
260
+ id?: NodeId;
261
+ /** Only meaningful when `kind === 'container'`. Attach a clip-path function
262
+ * to the node; ignored for leaves. Mirrors `ContainerNode.clipFromPose`. */
263
+ clipFromPose?: (pose: TPose) => Path | null;
264
+ }
265
+ interface RegisteredOp<P> {
266
+ apply: (payload: P) => void;
267
+ revert: (payload: P) => void;
268
+ }
269
+ interface SystemLayerSpec<TLayer extends string> {
270
+ id: TLayer;
271
+ visible?: boolean;
272
+ locked?: boolean;
273
+ }
274
+ /** Argument to `Scene.addLayer`. Always produces a `UserLayerRecord`
275
+ * (`kind: 'user'`). */
276
+ interface AddLayerSpec<TLayer extends string> {
277
+ id: TLayer;
278
+ name: string;
279
+ /** Default `true`. */
280
+ visible?: boolean;
281
+ /** Default `false`. */
282
+ locked?: boolean;
283
+ /** Render-stack position. Default: top of stack (highest render index). */
284
+ index?: number;
285
+ }
286
+ /** JSON-serializable shape of a Scene's current state. Produced by
287
+ * `scene.toJSON()`; consumed by `sceneFromJSON()`. Function fields
288
+ * (e.g., `clipFromPose`) appear as string keys (`clipFromPoseKey`) and
289
+ * are resolved through `SceneRegistry` at load time. */
290
+ interface SerializedScene<TData, TLayer extends string, TPose> {
291
+ version: 1;
292
+ systemLayers: readonly SystemLayerSpec<TLayer>[];
293
+ nodes: readonly SerializedNode<TData, TLayer, TPose>[];
294
+ }
295
+ /** JSON-serializable shape of a single node. Mirrors `AddNodeSpec` but
296
+ * with function fields replaced by registry keys. */
297
+ interface SerializedNode<TData, TLayer extends string, TPose> {
298
+ id: string;
299
+ kind: 'leaf' | 'container';
300
+ layer: TLayer;
301
+ pose: TPose;
302
+ data: TData;
303
+ /** Parent id; omitted for roots. */
304
+ parent?: string;
305
+ /** Registry key for the container's clip-path factory.
306
+ * Containers only; omitted when the container has no clip. */
307
+ clipFromPoseKey?: string;
308
+ }
309
+ interface Scene<TData, TLayer extends string, TPose = RectPose> {
310
+ readonly nodes: ReadonlyMap<NodeId, Node<TData, TLayer, TPose>>;
311
+ readonly roots: readonly NodeId[];
312
+ readonly layers: readonly LayerRecord<TLayer>[];
313
+ get(id: NodeId): Node<TData, TLayer, TPose> | undefined;
314
+ childrenOf(id: NodeId): readonly NodeId[];
315
+ ancestorsOf(id: NodeId): readonly NodeId[];
316
+ renderOrder(): Iterable<NodeId>;
317
+ add(spec: AddNodeSpec<TData, TLayer, TPose>): NodeId;
318
+ /** Delete `id` **and its entire subtree** — every descendant is removed in
319
+ * the same operation. Recorded as one undoable step; `undo()` restores the
320
+ * whole subtree (root + descendants, child order intact). */
321
+ remove(id: NodeId): void;
322
+ update(id: NodeId, patch: {
323
+ data: TData;
324
+ }): void;
325
+ setPose(id: NodeId, pose: TPose): void;
326
+ /** Retag `id` to `layer`. On a **container this cascades**: every descendant
327
+ * is moved to the same layer, recorded as a **single** undo step.
328
+ *
329
+ * Invariants:
330
+ * - **Layer floor** — a child may not render below its parent, so retagging
331
+ * to a layer *below* the node's parent throws. Retagging to the parent's
332
+ * layer or any higher one is allowed; a node with no parent is
333
+ * unconstrained.
334
+ * - **No-op elision** — setting the layer a node already has does nothing
335
+ * and pushes **no** history entry. */
336
+ setLayer(id: NodeId, layer: TLayer): void;
337
+ /** Reparent `id` under `parent` (or to a root when `parent` is `null`) at
338
+ * `index` within the new sibling list, appending when `index` is omitted.
339
+ * Siblings are reindexed. Recorded as one undoable step.
340
+ *
341
+ * Rejected (throws) when:
342
+ * - `parent` exists but is a **leaf**, not a container;
343
+ * - the move would form a **cycle** — `parent` is `id` itself or one of
344
+ * `id`'s own descendants;
345
+ * - it would drop `id` **below its new parent's layer** (child may not
346
+ * render below its parent).
347
+ *
348
+ * `move(id, null)` — detaching to a root — is always allowed regardless of
349
+ * layer, since a root has no parent to render beneath. */
350
+ move(id: NodeId, parent: NodeId | null, index?: number): void;
351
+ /** Shift `id` to `index` within its **current** parent's child list. Unlike
352
+ * {@link move}, the parent never changes — only sibling order. */
353
+ reorder(id: NodeId, index: number): void;
354
+ setLayerVisible(layer: TLayer, visible: boolean): void;
355
+ setLayerLocked(layer: TLayer, locked: boolean): void;
356
+ addLayer(spec: AddLayerSpec<TLayer>): void;
357
+ removeLayer(layer: TLayer): void;
358
+ renameLayer(layer: TLayer, name: string): void;
359
+ moveLayer(layer: TLayer, index: number): void;
360
+ registerOp<P>(kind: string, handler: RegisteredOp<P>): void;
361
+ recordOp<P>(op: {
362
+ kind: string;
363
+ payload: P;
364
+ }): void;
365
+ /** Install (or clear) the active-journal accessor after scene construction.
366
+ * Useful when the journal source (typically a mode machine) is built
367
+ * with `scene.history` as a dependency — a chicken-and-egg situation
368
+ * where the accessor can't be passed in via `UseSceneOptions`.
369
+ *
370
+ * Pass `null` to detach. Overrides any `getActiveJournal` set in
371
+ * `UseSceneOptions`. */
372
+ setActiveJournalAccessor(fn: (() => Journal | null) | null): void;
373
+ /** Apply a batch of ops with journal-aware routing.
374
+ *
375
+ * - **Without active journal** (or no `getActiveJournal` in options):
376
+ * the ops themselves are recorded as one undo entry on the scene's own
377
+ * history, rebound to `adapter` — undo replays each op's `invert()`
378
+ * against that same adapter. Consecutive `applyBatch` entries can
379
+ * coalesce via matching op `coalesceKey`s when the scene opts into
380
+ * `coalesceWindowMs`.
381
+ * - **With active journal**: routes ops to `journal.applyBatch(ops, label)`.
382
+ * The scene's history recording is suppressed for the duration so the
383
+ * journal's inner history — not the scene's undo stack — tracks the batch.
384
+ * Mutations still happen on `adapter` / scene state.
385
+ *
386
+ * `adapter` must be the same adapter the ops expect (typically a
387
+ * `SceneCanvasAdapter`). Pass `this` from `sceneToAdapter` or a compatible
388
+ * adapter. */
389
+ applyBatch(ops: Op[], label: string, adapter: unknown): void;
390
+ undo(): boolean;
391
+ redo(): boolean;
392
+ canUndo(): boolean;
393
+ canRedo(): boolean;
394
+ batch<T>(label: string, fn: () => T): T;
395
+ /** Read-only snapshot of every history entry currently reachable from
396
+ * the present state. Oldest applied first, then redoable entries in
397
+ * the order they'd be re-applied. Each entry id is stable. */
398
+ historyEntries(): readonly {
399
+ id: string;
400
+ label: string;
401
+ }[];
402
+ /** Index of the "current state". Equals the count of applied entries;
403
+ * `0` means "nothing applied" (initial). */
404
+ historyIndex(): number;
405
+ /** Jump to the given history index by calling undo/redo repeatedly.
406
+ * Clamps to [0, total]. Returns true if any movement occurred. */
407
+ jumpToHistoryIndex(index: number): boolean;
408
+ /** Snapshot the undo/redo history in a JSON-serializable form (the
409
+ * engine's `SerializedHistory`). Entries containing any nameless op are
410
+ * dropped (hand-rolled anonymous ops passed to `applyBatch`); kit and
411
+ * consumer-registered ops always carry names. Payload JSON-safety
412
+ * (e.g. typed arrays inside poses) is the caller's concern. Do not call
413
+ * mid-`batch` — the open batch's ops are not yet recorded. */
414
+ serializeHistory(): SerializedHistory;
415
+ /** Replace the undo/redo history from a `serializeHistory()` snapshot.
416
+ * Call on a scene whose node/layer state already matches the snapshot's
417
+ * head state (i.e. right after `loadState` from the paired scene
418
+ * snapshot); node state is NOT mutated. Ops re-registered via
419
+ * `registerOp` before this call round-trip; unknown kinds become no-op
420
+ * placeholders; external ops rebuild via the global op-factory registry
421
+ * and replay against the `setHistoryAdapter` accessor. Restored entries
422
+ * never coalesce with new ones. Notifies once. Do not call mid-`batch`:
423
+ * the stacks are replaced underneath the open batch, whose eventual
424
+ * flush would graft onto (and evict against) the restored stacks. */
425
+ restoreHistory(snapshot: SerializedHistory): void;
426
+ /** Install (or clear with `null`) the accessor for the adapter that
427
+ * RESTORED external ops (recorded via `applyBatch`, rebuilt from a
428
+ * `restoreHistory` snapshot) apply against on undo/redo. Resolved lazily
429
+ * at each apply, so wiring order relative to `restoreHistory` doesn't
430
+ * matter. Live `applyBatch` entries are unaffected (they bind their
431
+ * call-site adapter). If unset when a restored op applies, the op is a
432
+ * debug-warned no-op. */
433
+ setHistoryAdapter(fn: (() => unknown) | null): void;
434
+ /** Snapshot the current scene state to a JSON-serializable shape.
435
+ * History (undo/redo stacks) is NOT captured. Function fields like
436
+ * `ContainerNode.clipFromPose` are translated to string keys via the
437
+ * scene's registry; throws if any function field has no matching key. */
438
+ toJSON(): SerializedScene<TData, TLayer, TPose>;
439
+ /** Replace this scene's entire node + layer state in place from a snapshot
440
+ * produced by `toJSON()`. Unlike `sceneFromJSON`, the existing Scene
441
+ * instance is preserved — holders such as `<SceneCanvas>` keep their
442
+ * reference. History (undo/redo) is cleared, matching `sceneFromJSON`.
443
+ * Bumps `getVersion()` and notifies subscribers exactly once.
444
+ *
445
+ * Throws on an unsupported version or unknown registry/layer ids; on a
446
+ * malformed snapshot the scene is left empty or partially populated (callers should treat a
447
+ * `loadState` throw as fatal and reload). Snapshots from `toJSON()` are
448
+ * always well-formed. */
449
+ loadState(json: SerializedScene<TData, TLayer, TPose>): void;
450
+ subscribe(listener: () => void): () => void;
451
+ /** Monotonically increasing version. Snapshot for `useSyncExternalStore`. */
452
+ getVersion(): number;
453
+ }
454
+
455
+ /**
456
+ * Viewport state. `(view.x, view.y)` is the **world point currently
457
+ * rendered at the canvas top-left**; `view.scale.x` / `view.scale.y` is
458
+ * pixels per world unit on each axis (default `{ x: 1, y: 1 }`). So:
459
+ *
460
+ * screenX = (worldX - view.x) * view.scale.x
461
+ * screenY = (worldY - view.y) * view.scale.y
462
+ * worldX = screenX / view.scale.x + view.x
463
+ * worldY = screenY / view.scale.y + view.y
464
+ *
465
+ * `scale` is always a 2-vector. Input convenience types
466
+ * {@link ZoomFactor} and {@link ZoomBound} let callers pass a scalar
467
+ * when they want both axes treated the same.
468
+ */
469
+ interface View {
470
+ x: number;
471
+ y: number;
472
+ scale: {
473
+ x: number;
474
+ y: number;
475
+ };
476
+ }
477
+
478
+ /**
479
+ * 2D affine matrix utilities. Column-major 9-element Float32Array, matching
480
+ * `WebGL2RenderingContext.uniformMatrix3fv` byte order so we can pass the
481
+ * array directly without a transpose flag.
482
+ *
483
+ * Layout (column-major):
484
+ * [m00, m10, 0,
485
+ * m01, m11, 0,
486
+ * tx, ty, 1]
487
+ *
488
+ * `apply(m, x, y)` returns `[m * (x, y, 1)] = [m00*x + m01*y + tx,
489
+ * m10*x + m11*y + ty]`.
490
+ */
491
+ type Mat3 = Float32Array;
492
+
493
+ /**
494
+ * registerTexture — accepts an image source, assigns an opaque id, stores in
495
+ * a module-level registry. Actual GL upload happens lazily at draw time in
496
+ * drawShader() via GLTextureCache.upload (which is idempotent).
497
+ *
498
+ * Lifecycle: textures live for the renderer's lifetime. No unregister in v1.
499
+ *
500
+ * Convention §9: this registry stores image data only — no per-renderer state.
501
+ * Each WeaselRenderer's GLTextureCache does its own dedup via has(id).
502
+ */
503
+ interface TextureHandle {
504
+ readonly id: string;
505
+ }
506
+
507
+ /**
508
+ * FillStyle and Stroke types — the unified shape for "what color or texture
509
+ * paints these pixels," modeled on SVG's paint-server concept.
510
+ *
511
+ * - `FillStyle` is a tagged union: solid color, pattern, or gradient. Used
512
+ * wherever a kit option previously took `fillStyle: string`.
513
+ * - `Stroke` pairs a `FillStyle` with structural stroke parameters (width, dash,
514
+ * line cap/join, alignment).
515
+ * - These types are consumed by the GL renderer's DrawCommand path fills
516
+ * and strokes.
517
+ *
518
+ * The 2D `applyPaint` / `applyStroke` / `renderFilledRegion` helpers that
519
+ * formerly lived alongside these types were deleted with the 2D backend in
520
+ * Step 10. `alignedStrokeRect` survives as a pure geometry helper used by
521
+ * path tessellation and the selection overlay.
522
+ */
523
+
524
+ /**
525
+ * Color/texture strategy for fills (and, via `Stroke.paint`, strokes).
526
+ *
527
+ * `fill` is optional and defaults to `'solid'` — `{ color: '#abc' }` is
528
+ * equivalent to `{ fill: 'solid', color: '#abc' }`. Pattern paints must set
529
+ * `fill: 'pattern'` explicitly.
530
+ *
531
+ * The `'pattern'` variant's payload is a `TextureHandle` (registered via
532
+ * `registerTexture()`). The kit-level factory `createTilePattern` (and the
533
+ * `patterns-builtin` catalog: `hatch`, `crosshatch`, `dots`, `chunks`)
534
+ * produces these handles by rendering a tile to an `OffscreenCanvas` and
535
+ * registering the resulting `ImageBitmap` as a GL texture.
536
+ */
537
+ type FillStyle = {
538
+ fill?: 'solid';
539
+ color: string;
540
+ opacity?: number;
541
+ } | {
542
+ fill: 'pattern';
543
+ pattern: TextureHandle;
544
+ opacity?: number;
545
+ } | {
546
+ fill: 'linear-gradient';
547
+ from: {
548
+ x: number;
549
+ y: number;
550
+ };
551
+ to: {
552
+ x: number;
553
+ y: number;
554
+ };
555
+ stops: GradStop[];
556
+ opacity?: number;
557
+ } | {
558
+ fill: 'radial-gradient';
559
+ center: {
560
+ x: number;
561
+ y: number;
562
+ };
563
+ radius: number;
564
+ stops: GradStop[];
565
+ opacity?: number;
566
+ } | {
567
+ fill: 'conic-gradient';
568
+ center: {
569
+ x: number;
570
+ y: number;
571
+ };
572
+ angle: number;
573
+ stops: GradStop[];
574
+ opacity?: number;
575
+ };
576
+ /** A single color stop within a gradient. `offset` is in 0..1. */
577
+ interface GradStop {
578
+ offset: number;
579
+ color: string;
580
+ }
581
+ /**
582
+ * Where a stroke sits relative to the geometric edge it strokes.
583
+ *
584
+ * - `'center'` (default): canvas-native — half the stroke width sits inside
585
+ * the geometry, half outside.
586
+ * - `'inner'`: the entire stroke lies inside the geometry. The outer edge of
587
+ * the stroke coincides with the geometric edge.
588
+ * - `'outer'`: the entire stroke lies outside the geometry. The inner edge
589
+ * of the stroke coincides with the geometric edge.
590
+ *
591
+ * Mirrors the (proposed) SVG `stroke-alignment` property. Honoring `inner`
592
+ * or `outer` is the renderer's responsibility — for axis-aligned rects, the
593
+ * kit shifts coordinates by `width / 2`. For arbitrary paths, renderers
594
+ * typically use a stencil mask of the stroked path against the geometry.
595
+ */
596
+ type StrokeAlign = 'center' | 'inner' | 'outer';
597
+ /** Stroke style: a FillStyle plus structural line parameters. */
598
+ interface Stroke {
599
+ paint: FillStyle;
600
+ width?: number;
601
+ /** Per `CanvasRenderingContext2D.setLineDash` — empty/omitted = solid. */
602
+ dash?: number[];
603
+ cap?: 'butt' | 'round' | 'square';
604
+ join?: 'miter' | 'round' | 'bevel';
605
+ /**
606
+ * Miter join fallback threshold. When the miter length exceeds
607
+ * `miterLimit * width / 2`, the join falls back to a bevel. Default 10
608
+ * (matching Canvas2D). SVG's default is 4; consumers that want SVG
609
+ * fidelity should set this explicitly when constructing strokes from
610
+ * SVG sources where the attribute was omitted.
611
+ */
612
+ miterLimit?: number;
613
+ /** Where the stroke sits relative to the geometric edge. Default `'center'`. */
614
+ align?: StrokeAlign;
615
+ /**
616
+ * Per-anchor RGBA, flat (length = 4 × countPathAnchors(path)). Each
617
+ * value in 0..1. Arc-length interpolated across the tessellated ribbon
618
+ * between consecutive anchors. When set, `paint` is still required —
619
+ * its `opacity` (and color, as a placeholder) flow through the shader.
620
+ */
621
+ vertexColors?: number[];
622
+ /**
623
+ * Per-anchor stroke width (length = `countPathAnchors(path)`). When set,
624
+ * the tessellator interpolates half-widths along each segment to produce
625
+ * a tapered ribbon. `width` is used as the fallback for any anchor whose
626
+ * entry is missing or non-finite. Pressure-driven pencil strokes use
627
+ * this; pair with `pressureToWidth` to derive widths from stylus input.
628
+ *
629
+ * Joins between adjacent segments whose widths differ by more than
630
+ * `varyingWidthJoinThreshold` (default 1.5×) are forced to bevel
631
+ * regardless of the `join` setting — miter math is unstable when widths
632
+ * vary across the corner; smooth round joins with mismatched widths
633
+ * are a future enhancement.
634
+ */
635
+ vertexWidths?: number[];
636
+ /**
637
+ * Max width ratio (greater / lesser) at which a non-bevel join is
638
+ * preserved when `vertexWidths` causes adjacent segments to differ.
639
+ * Beyond this ratio the join falls back to bevel. Default 1.5. Ignored
640
+ * when `vertexWidths` is absent.
641
+ */
642
+ varyingWidthJoinThreshold?: number;
643
+ }
644
+
645
+ /**
646
+ * Typography for `TextPose` and friends. Every field is optional; consumers
647
+ * pass `{}` or override the few they care about. Defaults live in
648
+ * `DEFAULT_TEXT_STYLE` and are applied at render/measure time, never written
649
+ * back to the pose.
650
+ *
651
+ * `fill` follows the kit-wide `FillStyle` model — solid color or pattern. The
652
+ * contenteditable edit overlay flattens non-solid fills to `'#000'` for CSS
653
+ * since the browser can't paint with a texture handle.
654
+ */
655
+
656
+ /** User-facing text style. All fields optional; defaults applied at render time via `resolveTextStyle`. */
657
+ interface TextStyle {
658
+ /** Font size in world units. Default 16. */
659
+ fontSize?: number;
660
+ /** Default `'sans-serif'`. */
661
+ fontFamily?: string;
662
+ /** Default 400. */
663
+ fontWeight?: number | string;
664
+ /** Default `'normal'`. */
665
+ fontStyle?: 'normal' | 'italic';
666
+ /** Default `'left'`. */
667
+ align?: 'left' | 'center' | 'right';
668
+ /** Multiplier applied to `fontSize`. Default 1.2. */
669
+ lineHeight?: number;
670
+ /** Default `{ fill: 'solid', color: '#000' }`. */
671
+ fill?: FillStyle;
672
+ /**
673
+ * Caret color used by the edit overlay. Defaults to the text color when
674
+ * `fill` is solid; falls back to `#000` for non-solid paints.
675
+ */
676
+ caretColor?: string;
677
+ /**
678
+ * Selection background color used by the edit overlay's `::selection`
679
+ * pseudo-element. Defaults to a 25%-opacity tint of `caretColor` via CSS
680
+ * `color-mix`. Pass `'none'` to fall back to the browser-native highlight.
681
+ */
682
+ selectionBackground?: string;
683
+ /** Selection text color paired with `selectionBackground`. Default: inherits text color. */
684
+ selectionColor?: string;
685
+ }
686
+
687
+ /**
688
+ * Apply node-level `ResolvedTextStyle` defaults to each `StyledRun`,
689
+ * producing a fully-resolved run with every styling field set. Downstream
690
+ * layout and draw never re-resolve defaults — `ResolvedRun` is the
691
+ * canonical shape the renderer consumes.
692
+ *
693
+ * `bold`/`italic` toggles on a run are folded into `fontWeight`/`fontStyle`:
694
+ * `bold: true` → fontWeight 700, `italic: true` → fontStyle 'italic'.
695
+ * Explicit `fontFamily` / `fontSize` / `fill` on the run override the
696
+ * node-level value.
697
+ */
698
+
699
+ interface ResolvedRun {
700
+ text: string;
701
+ fontFamily: string;
702
+ fontSize: number;
703
+ fontWeight: number;
704
+ fontStyle: 'normal' | 'italic';
705
+ fill: FillStyle;
706
+ }
707
+
708
+ /**
709
+ * Box vertical alignment for text draw commands. Given the command's box
710
+ * `height` and the laid-out text block's height, returns the Y offset to
711
+ * apply to every quad. `'top'` (or an undefined `align`, or a missing box
712
+ * `height`) is the legacy behavior: offset 0.
713
+ */
714
+ type TextVerticalAlign = 'top' | 'center' | 'bottom';
715
+
716
+ /**
717
+ * registerProgram — public API for registering custom shader programs.
718
+ *
719
+ * Stores raw GLSL source strings in a module-level registry. GL compilation
720
+ * happens on each WeaselRenderer via WeaselRenderer.registerProgram(), which
721
+ * calls getProgramSource() and compiles the result. This keeps registerProgram
722
+ * GL-context-agnostic — identical pattern to registerFont storing ImageBitmap.
723
+ *
724
+ * Convention §9: module-level state = source strings only; compiled GL
725
+ * programs live on each renderer's programRegistry (Map<id, ShaderProgram>).
726
+ *
727
+ * Lifecycle: program sources live for the module lifetime. No unregister in v1.
728
+ */
729
+
730
+ /** Opaque handle to a compiled custom shader program. */
731
+ interface ShaderProgramHandle {
732
+ readonly id: string;
733
+ }
734
+ /**
735
+ * Scalar and vector uniform types accepted by the custom shader uniform binder.
736
+ *
737
+ * | TS type | GL call |
738
+ * |-------------------------|--------------------------------------|
739
+ * | number | uniform1f |
740
+ * | [n, n] | uniform2fv |
741
+ * | [n, n, n] | uniform3fv |
742
+ * | [n, n, n, n] | uniform4fv |
743
+ * | Float32Array length 9 | uniformMatrix3fv (column-major) |
744
+ * | Float32Array length 16 | uniformMatrix4fv (column-major) |
745
+ * | TextureHandle | bind to next tex unit + uniform1i |
746
+ */
747
+ type ShaderUniform = number | [number, number] | [number, number, number] | [number, number, number, number] | Float32Array | TextureHandle;
748
+
749
+ /** DrawCommand variants implemented through step 6. */
750
+ type DrawCommand = PathDrawCommand | GroupDrawCommand | TextDrawCommand | ImageDrawCommand | ShaderDrawCommand;
751
+ interface PathDrawCommand {
752
+ kind: 'path';
753
+ path: Path;
754
+ /** Any `FillStyle` variant: solid, pattern, or gradient (linear/radial/conic). */
755
+ fill?: FillStyle;
756
+ /** Stroke spec. Only solid `paint` supported through step 4. */
757
+ stroke?: Stroke;
758
+ /**
759
+ * Optional flat RGBA-per-path-anchor color array (length =
760
+ * `4 × countPathAnchors(path)`, floats in 0..1). The renderer
761
+ * arc-length-interpolates these per-anchor colors across the
762
+ * flattened/triangulated mesh between consecutive anchors using the
763
+ * mesh's `anchorA` / `anchorB` / `anchorT` parameterization.
764
+ *
765
+ * **`fill` must also be set when using `vertexColors`.** The renderer
766
+ * only enters the per-vertex shader path when the command has a fill
767
+ * (the fill provides the opacity uniform; the vertex colors override
768
+ * the fill's color). Pass any solid `fill` (e.g. `{ color: '#fff' }`)
769
+ * as the placeholder; the per-vertex colors win in the shader.
770
+ */
771
+ vertexColors?: number[];
772
+ }
773
+ interface GroupDrawCommand {
774
+ kind: 'group';
775
+ transform?: Mat3;
776
+ alpha?: number;
777
+ /**
778
+ * Optional 4×5 color matrix (row-major, 20 numbers) — `out = M₄ₓ₄ * in + bias`.
779
+ * Accumulated multiplicatively down the group stack. Defaults to identity.
780
+ */
781
+ colorMatrix?: number[];
782
+ /** Optional clip path. When set, the renderer rasterizes this path into
783
+ * the stencil buffer before drawing `children`; the children paint only
784
+ * where the clip covers. Nested groups with clips intersect — a child
785
+ * cannot escape an ancestor's clip. Max 7 nesting levels; the renderer
786
+ * throws if exceeded. */
787
+ clip?: Path;
788
+ children: DrawCommand[];
789
+ }
790
+ /**
791
+ * Text draw command. Renders one or more runs at (`x`, `y`) in screen
792
+ * space, optionally word-wrapping at `maxWidth`. The renderer resolves
793
+ * each run's `(fontFamily, fontWeight, fontStyle)` to an MSDF atlas via
794
+ * `resolveFontVariant` and bucket-draws by atlas + color group.
795
+ *
796
+ * `style` carries node-level defaults (`lineHeight`, anti-alias width)
797
+ * that don't belong on individual runs.
798
+ */
799
+ interface TextDrawCommand {
800
+ kind: 'text';
801
+ x: number;
802
+ y: number;
803
+ runs: ResolvedRun[];
804
+ maxWidth?: number;
805
+ align?: 'left' | 'center' | 'right';
806
+ style: TextStyle;
807
+ /** Box height for vertical alignment. When set with `verticalAlign`,
808
+ * the laid-out block shifts within `[y, y+height]`. */
809
+ height?: number;
810
+ /** Default 'top' — the legacy top-anchored behavior. */
811
+ verticalAlign?: TextVerticalAlign;
812
+ }
813
+ /**
814
+ * Image draw command — renders `image` at screen-space rect (x, y, w, h).
815
+ * The image is stretched to fit; no tiling. Use a pattern FillStyle on a path
816
+ * for tiling.
817
+ */
818
+ interface ImageDrawCommand {
819
+ kind: 'image';
820
+ image: ImageBitmap;
821
+ x: number;
822
+ y: number;
823
+ w: number;
824
+ h: number;
825
+ opacity?: number;
826
+ }
827
+ /**
828
+ * Custom shader draw command. The renderer generates a quad over `bounds`
829
+ * and dispatches the consumer's fragment shader with the kit's vertex prelude.
830
+ *
831
+ * `uniforms` keys must match names declared in the consumer's fragment shader.
832
+ * The kit automatically sets `u_bounds`, `u_view`, and `u_proj` — do not
833
+ * declare those in `uniforms`.
834
+ *
835
+ * @experimental API may change before v2.
836
+ */
837
+ interface ShaderDrawCommand {
838
+ kind: 'shader';
839
+ program: ShaderProgramHandle;
840
+ uniforms: Record<string, ShaderUniform>;
841
+ /** Screen-space bounding rect in CSS pixels. */
842
+ bounds: {
843
+ x: number;
844
+ y: number;
845
+ w: number;
846
+ h: number;
847
+ };
848
+ }
849
+
850
+ /**
851
+ * Axis-aligned rectangle in world space. Kit-wide `Bounds` shape used by
852
+ * selection, group, and viewport helpers. The optional `rotation` field
853
+ * (radians, around the AABB center) lets selection chrome attach a rotated
854
+ * orientation to an otherwise axis-aligned rect without needing a parallel
855
+ * type.
856
+ */
857
+ interface Bounds {
858
+ x: number;
859
+ y: number;
860
+ width: number;
861
+ height: number;
862
+ rotation?: number;
863
+ }
864
+ /** Pixel dimensions of the canvas viewport. */
865
+ interface ViewportDims {
866
+ width: number;
867
+ height: number;
868
+ }
869
+
870
+ export type { Bounds as B, DrawCommand as D, Node as N, Op as O, PathDrawCommand as P, Scene as S, View as V, ViewportDims as a, NodeId as b };