@weasel-js/labkit 1.4.4 → 1.5.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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ Annotations are on the `pre` tag until the next stable release:
36
36
  ## A lab
37
37
 
38
38
  ```tsx
39
- import { type ConfigOf, defineInstrument, f, Lab, localStorageAdapter } from '@weasel-js/labkit';
39
+ import { type ConfigOf, defineInstrument, f, Lab } from '@weasel-js/labkit';
40
40
  import '@weasel-js/labkit/styles.css';
41
41
 
42
42
  const config = f.schema({
@@ -64,13 +64,24 @@ export function App() {
64
64
  instruments={[Histogram]}
65
65
  defaultInstrument="Histogram"
66
66
  title="Histogram"
67
- storage={localStorageAdapter}
68
67
  storageKey="histogram-lab"
69
68
  />
70
69
  );
71
70
  }
72
71
  ```
73
72
 
73
+ `storageKey` makes the lab persist: its trials, snapshots, layout and mode
74
+ survive a reload and stay in step across tabs. It persists to IndexedDB — or
75
+ localStorage, with a warning, where IndexedDB will not open — and `storage`
76
+ names another substrate: `localStorageAdapter`, `sessionStorageAdapter`,
77
+ `urlHashAdapter`, or your own `StorageAdapter` over a server. The lab renders
78
+ `fallback` while it loads. Leave `storageKey` off and nothing persists.
79
+
80
+ A widget's own state persists the same way through `usePersistedState(name,
81
+ initial)`, which is `useState` whose value survives a reload — kept per trial
82
+ inside one, and plain `useState` wherever no `<Lab>` or `<Persistence>` is
83
+ above it.
84
+
74
85
  `f.schema` states the config once — values, types and controls — and the trial
75
86
  renders the settings panel from it. `render` returns the instrument's own DOM;
76
87
  returning `null` alongside `canvas` means the canvas layers are the whole
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { RefObject } from 'react';
3
- import { V as ViewTransform, W as WorldFrame } from './frac-z7ker2Vx.js';
3
+ import { k as ViewTransform, W as WorldFrame } from './frac-8R6UmlvN.js';
4
4
 
5
5
  /** One layer of a canvas stack: its id, whether it is currently shown, and how
6
6
  * it paints itself. */
@@ -0,0 +1,116 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ButtonHTMLAttributes, ReactElement } from 'react';
3
+
4
+ /** Visual weight of a button. Defaults to `secondary`. */
5
+ type ButtonVariant = 'primary' | 'secondary' | 'ghost';
6
+ /** Button height and type scale. */
7
+ type ButtonSize = 'sm' | 'md';
8
+ type ButtonBase = {
9
+ variant?: ButtonVariant;
10
+ size?: ButtonSize;
11
+ /** A button that stays down: renders `aria-pressed` and holds the active
12
+ * treatment while it is on. For a control that reports a state rather than
13
+ * firing an action -- a panel toggle, a mode switch. Left off, the button
14
+ * announces no pressed state at all, which is what an action button wants. */
15
+ pressed?: boolean;
16
+ disabled?: boolean;
17
+ loading?: boolean;
18
+ leadingIcon?: ReactNode;
19
+ trailingIcon?: ReactNode;
20
+ fullWidth?: boolean;
21
+ type?: 'button' | 'submit' | 'reset';
22
+ className?: string;
23
+ style?: ButtonHTMLAttributes<HTMLButtonElement>['style'];
24
+ children?: ReactNode;
25
+ onClick?: ButtonHTMLAttributes<HTMLButtonElement>['onClick'];
26
+ };
27
+ type ButtonRegular = ButtonBase & {
28
+ iconOnly?: false;
29
+ ariaLabel?: string;
30
+ };
31
+ type ButtonIconOnly = ButtonBase & {
32
+ iconOnly: true;
33
+ ariaLabel: string;
34
+ };
35
+ /**
36
+ * Props for {@link Button}. Setting `iconOnly` makes `ariaLabel` required,
37
+ * since an icon-only button has no text for a screen reader to announce.
38
+ */
39
+ type ButtonProps = ButtonRegular | ButtonIconOnly;
40
+ /**
41
+ * Standard button. `loading` swaps the leading icon for a spinner, marks the
42
+ * button `aria-busy`, and leaves the label in place; it does not disable the
43
+ * button, so pass `disabled` too if the click should be blocked.
44
+ *
45
+ * `ref` forwards to the underlying `<button>`.
46
+ */
47
+ declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLButtonElement>>;
48
+
49
+ /** One segment of a {@link ToggleBar}, identified by its `value`. */
50
+ type ToggleBarItem<V extends string | number = string> = {
51
+ value: V;
52
+ label?: ReactNode;
53
+ ariaLabel?: string;
54
+ disabled?: boolean;
55
+ };
56
+ /** Segment height and type scale for a {@link ToggleBar}. */
57
+ type ToggleBarSize = 'sm' | 'md';
58
+ /** Visual treatment of a {@link ToggleBar}. */
59
+ type ToggleBarVariant = 'default' | 'minimal' | 'flat';
60
+ type CommonProps = {
61
+ ariaLabel?: string;
62
+ className?: string;
63
+ height?: number;
64
+ /** Size variant. `sm` is ~60% of the default height with reduced padding
65
+ * and font size — sized for dense surfaces like lab control panels. */
66
+ size?: ToggleBarSize;
67
+ /** Visual variant. `minimal` strips the pill track and glass treatment;
68
+ * selection becomes a flat accent. For dense diagnostic surfaces. */
69
+ variant?: ToggleBarVariant;
70
+ };
71
+ /**
72
+ * Props for {@link ToggleBar}, discriminated on `mode`. Single mode is
73
+ * controlled by one value or `null`; multiple mode by an array, and only it
74
+ * accepts `mixedValues`.
75
+ */
76
+ type ToggleBarProps<V extends string | number = string> = (CommonProps & {
77
+ mode?: 'single';
78
+ items: readonly ToggleBarItem<V>[];
79
+ value: V | null;
80
+ onChange: (next: V | null) => void;
81
+ allowDeselect?: boolean;
82
+ }) | (CommonProps & {
83
+ mode: 'multiple';
84
+ items: readonly ToggleBarItem<V>[];
85
+ value: readonly V[];
86
+ /**
87
+ * Values that are neither on nor off — the sources this bar
88
+ * aggregates disagree (a text range that is bold in part of it, a
89
+ * multi-selection whose nodes differ). Rendered `aria-pressed="mixed"`,
90
+ * the ARIA tri-state a toggle button actually has, rather than
91
+ * `SelectionPanel`'s reduced-opacity-plus-`title` workaround for
92
+ * `Switch`, which has no indeterminate state to render.
93
+ *
94
+ * `value` wins where the two lists overlap, so a caller that can't
95
+ * cheaply keep them disjoint doesn't get an ambiguous segment.
96
+ *
97
+ * Clicking a mixed segment turns it fully **on**, matching the
98
+ * everywhere-else convention (and `toggleFlagInRange`'s rule for a
99
+ * partially-styled text range) that a mixed toggle resolves toward
100
+ * the affirmative rather than clearing.
101
+ */
102
+ mixedValues?: readonly V[];
103
+ onChange: (next: V[]) => void;
104
+ });
105
+ /**
106
+ * Segmented control for choosing among a fixed set of values — one of them
107
+ * (`mode: 'single'`, the default) or any number (`mode: 'multiple'`).
108
+ *
109
+ * Single mode ignores a click on the already-selected segment unless
110
+ * `allowDeselect` is set. Arrow keys move focus without changing the value;
111
+ * Space and Enter commit.
112
+ */
113
+ declare function ToggleBar<V extends string | number = string>(props: ToggleBarProps<V>): ReactElement;
114
+
115
+ export { Button as B, ToggleBar as T };
116
+ export type { ButtonProps as a, ButtonSize as b, ButtonVariant as c, ToggleBarItem as d, ToggleBarProps as e, ToggleBarSize as f, ToggleBarVariant as g };
@@ -1,7 +1,5 @@
1
1
  import { ComponentType, ReactNode, RefObject } from 'react';
2
- import { R as ResolvedConfig, a as ConfigSchema, C as ConfigPath } from './types-B9_zrHmb.js';
3
- import { C as ConfigField } from './types-x92Kfeme.js';
4
- import { c as SavedSnapshot, i as TrialInfo } from './types-BP2OCcpg.js';
2
+ import { C as ConfigPath, V as ValueAtPath, R as ResolvedConfig, a as ConfigField, b as ConfigSchema } from './types-Ca2LCnPq.js';
5
3
  import { J as JobHandle, a as JobCapability } from './types-DJ79Tg5J.js';
6
4
  import { Scene } from '@weasel-js/core';
7
5
 
@@ -33,15 +31,188 @@ declare function resolveFrame(spec: WorldSpec | undefined, size: ViewportSize):
33
31
  * `draw` works in them. Must stay the exact inverse of `screenToWorld`. */
34
32
  declare function applyCamera(ctx: CanvasRenderingContext2D, view: ViewTransform, frame?: WorldFrame): void;
35
33
 
34
+ /** A sidebar section torn out of its trial, and where it went. */
35
+ interface UndockedPanel {
36
+ trialId: string;
37
+ sectionId: string;
38
+ as: 'tile' | 'floating';
39
+ }
40
+ /** Undocked panels, keyed by {@link panelKey}. */
41
+ type UndockedPanels = Record<string, UndockedPanel>;
42
+ declare function panelKey(trialId: string, sectionId: string): string;
43
+ declare function undockPanel(panels: UndockedPanels, trialId: string, sectionId: string, as?: UndockedPanel['as']): UndockedPanels;
44
+ /** Dock one section back, or — with no `sectionId` — every panel the trial
45
+ * owns, which is what closing a trial wants. */
46
+ declare function dockPanel(panels: UndockedPanels, trialId: string, sectionId?: string): UndockedPanels;
47
+
48
+ /** A trial's undo history, as snapshots of its state either side of the
49
+ * present. */
50
+ interface UndoStack {
51
+ past: unknown[];
52
+ future: unknown[];
53
+ }
54
+ /** The trial a per-trial call is coming from. `id` is the id `useTileId`
55
+ * scopes a surface tile under, so a consumer keying its own per-trial
56
+ * registry and labkit's tiles agree on the key. */
57
+ interface TrialInfo<TV = unknown> {
58
+ id: string;
59
+ /** The trial's view, in whatever shape the instrument keeps it. */
60
+ view: TV;
61
+ }
62
+ /** One trial as the store holds it: which instrument it runs, that
63
+ * instrument's config and state, the camera, and the undo history. */
64
+ interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
65
+ id: string;
66
+ instrumentName: string;
67
+ config: TC;
68
+ state: TS;
69
+ /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
70
+ * but never read into. A 3D lab puts an orbit here and keeps all three. */
71
+ view: TV;
72
+ /** The config `addTrial` opened this trial on, overlaid on the instrument's
73
+ * defaults. Kept so Reset restores the trial's own subject rather than the
74
+ * bare defaults. */
75
+ configSeed?: Partial<TC>;
76
+ /** This trial's own tool slot. Undefined means it reads the lab's. */
77
+ activeToolId?: string | null;
78
+ /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
79
+ * until someone moves the seam. */
80
+ sidebarWidth?: number;
81
+ /** What the title bar reads. Undefined — the state a trial opens in, and the
82
+ * one `setTitle(null)` returns it to — means the instrument's name. */
83
+ title?: string | null;
84
+ /** Which of the trial's collapsible sections are folded, keyed as
85
+ * `TrialChromeContext.collapsedSections` describes. A key that is absent
86
+ * takes the section's own default. */
87
+ collapsedSections?: Record<string, boolean>;
88
+ /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
89
+ * wrote them. Opaque here, and absent for a trial whose instrument declares
90
+ * no `annotations` — or declares its own `storage`. Not in `state`, which
91
+ * belongs to the instrument and is typed as such. */
92
+ annotations?: unknown;
93
+ undoStack: UndoStack;
94
+ }
95
+ /** A named, saved copy of a trial's config and state, restorable into any
96
+ * trial running the same instrument. */
97
+ interface SavedSnapshot {
98
+ id: string;
99
+ name: string;
100
+ trialId: string;
101
+ instrumentName: string;
102
+ config: unknown;
103
+ state: unknown;
104
+ savedAt: number;
105
+ }
106
+ /** `auto` follows the OS; the other two are an explicit choice. */
107
+ type LabMode = 'auto' | 'light' | 'dark';
108
+ /** Everything a lab persists: its trials, its saved snapshots, and the
109
+ * chosen color mode. */
110
+ interface LabStoreState {
111
+ trials: TrialRecord[];
112
+ savedSnapshots: SavedSnapshot[];
113
+ mode: LabMode;
114
+ /** The lab's tool slot — what a trial with no slot of its own resolves to. */
115
+ activeToolId: string | null;
116
+ /** Per-trial tile extents, keyed by trial id. Opaque here — the
117
+ * shape belongs to whatever lays the trials out. */
118
+ layout: Record<string, unknown>;
119
+ /** Sidebar sections torn out of their trial. A panel here is not rendered in
120
+ * its trial's sidebar; the workspace renders it instead. */
121
+ undockedPanels: UndockedPanels;
122
+ /** The instruments this store serializes, migrates and fills configs for.
123
+ * Null for a store built without them, whose trials look their instrument
124
+ * up on the lab instead. Not persisted. */
125
+ instruments: InstrumentList | null;
126
+ }
127
+ /** A change someone else made to one record: its new value, or `undefined`
128
+ * when it was deleted. */
129
+ type StorageChange = [key: string, value: unknown];
130
+ /** Where a lab persists itself: asynchronous keyed storage of
131
+ * structured-clone values, so IndexedDB, the URL, memory or a server can all
132
+ * back one. */
133
+ interface StorageAdapter {
134
+ /** `undefined` when the key is absent. */
135
+ get(key: string): Promise<unknown>;
136
+ list(prefix: string): Promise<[string, unknown][]>;
137
+ /** Rejects when the value did not land. */
138
+ set(key: string, value: unknown): Promise<void>;
139
+ delete(key: string): Promise<void>;
140
+ /** Reports writes under `prefix` made by anyone but this adapter — another
141
+ * tab, another instance, a server. Omit when the substrate cannot tell. */
142
+ subscribe?(prefix: string, on: (changes: StorageChange[]) => void): () => void;
143
+ }
144
+ /** What `useTrialState` hands an instrument: its state and config, with
145
+ * a setter for each. */
146
+ interface TrialStateHandle<TS, TC> {
147
+ state: TS;
148
+ setState: (next: TS | ((prev: TS) => TS)) => void;
149
+ config: TC;
150
+ /** Write one config value, by dotted path — `'grid.size'` for a leaf under
151
+ * an `f.group`, `'cellSize'` for one at the root. */
152
+ setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
153
+ }
154
+ /** Options for `createLabStore`, which knows nothing about storage. */
155
+ interface CreateLabStoreOptions {
156
+ /** A document already read and migrated — what `openLabStore` hands over. */
157
+ initial?: LabDocument;
158
+ initialMode?: LabMode;
159
+ /** Each instrument's default config, keyed by instrument name, used to fill
160
+ * the gaps in a stored one. A config saved before its schema grew a branch
161
+ * arrives holding that branch's defaults rather than `undefined`, and keeps
162
+ * whatever keys the schema has since stopped naming. */
163
+ configDefaults?: Record<string, () => unknown>;
164
+ /** Each instrument's `migrateConfig`, keyed by instrument name, run on a
165
+ * stored config before its defaults fill it. */
166
+ configMigrations?: Record<string, (stored: unknown) => unknown>;
167
+ /** How each instrument's state survives a reload. Hydration is the first
168
+ * thing `createLabStore` does, so these have to arrive with the store. */
169
+ serializers?: InstrumentSerializers;
170
+ /** The instruments to serialize, migrate and fill configs for. Given, it
171
+ * supplies all three and the three fields above are ignored. */
172
+ instruments?: InstrumentList;
173
+ }
174
+ /** What a store does per instrument on the way in and out. */
175
+ interface InstrumentHooks {
176
+ serializers: InstrumentSerializers;
177
+ configDefaults: Record<string, () => unknown>;
178
+ configMigrations: Record<string, (stored: unknown) => unknown>;
179
+ }
180
+ /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
181
+ * instrument whose state is already JSON-safe needs no entry. `deserialize`
182
+ * is handed the config the state was saved against — a trial's own for a
183
+ * reload, the snapshot's for a load — since a state rebuilt without it can
184
+ * disagree with the settings sitting next to it. */
185
+ type InstrumentSerializers = Record<string, {
186
+ serialize?: (state: unknown) => unknown;
187
+ deserialize?: (data: unknown, config: unknown) => unknown;
188
+ } | undefined>;
189
+ /** A trial as it is persisted: everything but the undo history, which is
190
+ * session-only. */
191
+ type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
192
+ /** Everything a lab persists, under one key, at a known version. */
193
+ interface LabDocument {
194
+ version: number;
195
+ trials: SerializedTrial[];
196
+ saves: SavedSnapshot[];
197
+ layout: Record<string, unknown>;
198
+ /** Sidebar sections torn out of their trial, keyed by trial and section. */
199
+ undockedPanels: UndockedPanels;
200
+ mode: LabMode;
201
+ }
202
+ /** Migrates a document one version forward. Index `i` in the chain takes a
203
+ * version-`i` document to version `i + 1`. */
204
+ type Migration = (doc: Record<string, unknown>) => Record<string, unknown>;
205
+
36
206
  /** A named position in a trial's chrome. Content is not a region — that is
37
- * the instrument. */
207
+ * the instrument. The lab's own boxes are named in `LabRegion`. */
38
208
  type TrialRegion = 'titlebar' | 'toolbar' | 'palette' | 'sidebar' | 'viewport' | 'status';
39
209
  /** An icon component taking a pixel size, as `@weasel-js/ui` glyphs do. */
40
210
  type IconComponent = ComponentType<{
41
211
  size?: number;
42
212
  }>;
43
- /** A button in the trial toolbar. */
44
- interface ToolbarItem {
213
+ /** A button in a toolbar. `TCtx` is the chrome context its `onActivate` is
214
+ * handed — a trial's, or the lab's for a lab-level bar. */
215
+ interface ToolbarItem<TCtx = TrialChromeContext> {
45
216
  icon: IconComponent;
46
217
  label: string;
47
218
  /** Shown in the tooltip. Not bound here — the trial owns its keymap. */
@@ -54,10 +225,10 @@ interface ToolbarItem {
54
225
  pressed?: boolean;
55
226
  /** Render the label beside the glyph rather than only in the tooltip. */
56
227
  showLabel?: boolean;
57
- /** Handed the trial's chrome context, so a contribution declared from the
58
- * lab can reach `ctx.saveSnapshot()` and the rest without the `render`
59
- * escape. A zero-argument handler stays valid. */
60
- onActivate: (ctx: TrialChromeContext) => void;
228
+ /** Handed the chrome context it was declared against, so a contribution can
229
+ * reach `ctx.saveSnapshot()` and the rest without the `render` escape. A
230
+ * zero-argument handler stays valid. */
231
+ onActivate: (ctx: TCtx) => void;
61
232
  }
62
233
  /** A selectable tool in the palette region. */
63
234
  interface ToolItem {
@@ -79,12 +250,12 @@ interface SidebarSection {
79
250
  body: ReactNode;
80
251
  }
81
252
  /** A control acting on the view of the trial, not on the trial. */
82
- interface ViewportControl {
253
+ interface ViewportControl<TCtx = TrialChromeContext> {
83
254
  icon: IconComponent;
84
255
  label: string;
85
256
  disabled?: boolean;
86
257
  /** Handed the trial's chrome context, as `ToolbarItem.onActivate` is. */
87
- onActivate: (ctx: TrialChromeContext) => void;
258
+ onActivate: (ctx: TCtx) => void;
88
259
  }
89
260
  /** A readout in the status bar. */
90
261
  interface StatusReadout {
@@ -93,7 +264,7 @@ interface StatusReadout {
93
264
  /** Tooltip. */
94
265
  title?: string;
95
266
  }
96
- /** What every contribution shares. */
267
+ /** What every contribution shares, whichever chrome it is declared against. */
97
268
  interface ContributionBase {
98
269
  id: string;
99
270
  /** Groups sort by first appearance; items sort within a group by
@@ -136,18 +307,46 @@ type TrialContribution = (ContributionBase & {
136
307
  item?: never;
137
308
  render: (ctx: TrialChromeContext) => ReactNode;
138
309
  });
310
+ /**
311
+ * A tool slot a region can reflect and write. Both chrome contexts carry one:
312
+ * a trial's resolves to the lab's when its instrument declares no tools.
313
+ */
314
+ interface ToolSlotContext {
315
+ activeToolId: string | null;
316
+ setActiveTool: (id: string) => void;
317
+ }
318
+ /** What a sidebar region reflects and writes: which sections are folded, and —
319
+ * where the chrome can tear a section out — where it goes. A trial supplies
320
+ * all four; a lab supplies the fold state only. */
321
+ interface SidebarSlotContext {
322
+ collapsedSections: Readonly<Record<string, boolean>>;
323
+ setSectionCollapsed: (key: string, collapsed: boolean) => void;
324
+ undockedPanels?: readonly string[];
325
+ undockPanel?: (sectionId: string, as?: 'tile' | 'floating') => void;
326
+ }
327
+ /**
328
+ * A contribution as a region renderer sees it. A renderer checks the region
329
+ * name against its own and narrows `item` itself, which is what lets one
330
+ * renderer serve both chromes — the trial's and the lab's — without knowing
331
+ * which context it was handed.
332
+ */
333
+ interface RegionContribution<TCtx> extends ContributionBase {
334
+ region: string;
335
+ item?: unknown;
336
+ render?: (ctx: TCtx) => ReactNode;
337
+ }
139
338
  /**
140
339
  * Everything a contribution can read about the trial it is being rendered
141
340
  * into. Replaces the three separate slot contexts, which each carried a
142
341
  * hand-picked subset.
143
342
  */
144
- interface TrialChromeContext {
343
+ interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext {
145
344
  trialId: string;
146
345
  instrumentName: string;
147
- /** What the title bar reads, which is the instrument's name until something
148
- * calls `setTitle`. */
346
+ /** What the title bar reads, which is the instrument's title (or, lacking
347
+ * one, its name) until something calls `setTitle`. */
149
348
  title: string;
150
- /** Retitle this trial; `null` restores the instrument name. Persisted with
349
+ /** Retitle this trial; `null` restores the instrument's title. Persisted with
151
350
  * the trial, so a title survives a reload. */
152
351
  setTitle: (title: string | null) => void;
153
352
  isLastTrial: boolean;
@@ -189,10 +388,6 @@ interface TrialChromeContext {
189
388
  clone: () => void;
190
389
  reset: () => void;
191
390
  close: () => void;
192
- /** Resolved active tool: the trial's slot, or the lab's when the trial has
193
- * none. Null when neither holds one. */
194
- activeToolId: string | null;
195
- setActiveTool: (id: string) => void;
196
391
  }
197
392
 
198
393
  /** A point on the magnified surface, in its own CSS pixels. */
@@ -245,7 +440,7 @@ interface LoupeCapability<TS = unknown, TC = unknown> {
245
440
  /** Held for a momentary peek while the loupe is off. Default `'Alt'`; `null`
246
441
  * turns hold-to-peek off. Matched against `KeyboardEvent.key`. */
247
442
  peekKey?: string | null;
248
- /** Called with the colour under the aim, wherever the surface can say. The
443
+ /** Called with the color under the aim, wherever the surface can say. The
249
444
  * canvas painter reads it back; a DOM loupe has no pixels to sample. */
250
445
  onColorChange?: (hex: string) => void;
251
446
  }
@@ -353,6 +548,18 @@ interface CanvasCapability<TS = unknown, TC = unknown> {
353
548
  minZoom?: number;
354
549
  maxZoom?: number;
355
550
  }
551
+ /** Declares that an instrument's DOM is content of a fixed size, which the
552
+ * trial pans and zooms the way it does a canvas's layers. Not combined with
553
+ * `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
554
+ interface StageCapability {
555
+ /** The content's own size in CSS pixels, at zoom 1. */
556
+ size: ViewportSize;
557
+ /** Where the view starts. Omitted, the content opens centered and shrunk to
558
+ * fit (`fitStage`). A function is called once the stage knows its size. */
559
+ initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
560
+ minZoom?: number;
561
+ maxZoom?: number;
562
+ }
356
563
  /** Declares which of an instrument's layers the trial should offer
357
564
  * show/hide controls for. */
358
565
  interface LayerCapability {
@@ -391,7 +598,8 @@ type HitResult = {
391
598
  layerId?: string;
392
599
  pointId?: string;
393
600
  };
394
- /** A trial's camera. */
601
+ /** A trial's camera. `zoom` is always positive and finite once labkit holds
602
+ * it — see `normalize2DView`. */
395
603
  type ViewTransform = {
396
604
  zoom: number;
397
605
  pan: Point;
@@ -424,7 +632,10 @@ type DragFeedback = {
424
632
  * is what makes the trial provide the corresponding chrome.
425
633
  */
426
634
  interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
635
+ /** The id a trial record names its instrument by. */
427
636
  name: string;
637
+ /** What a trial of this instrument and the add-trial menu read. Default: `name`. */
638
+ title?: string;
428
639
  defaultConfig: () => TC;
429
640
  initialState: (config: TC) => TS;
430
641
  /** The instrument's config, declared once: values, types and controls.
@@ -438,9 +649,16 @@ interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
438
649
  * layers rather than instead of them; return `null` for canvas only. */
439
650
  render: (ctx: RenderContext<TS, TC>) => ReactNode;
440
651
  onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
652
+ /** Moves a stored config's values to where the current schema keeps them —
653
+ * `gridSize` to `grid.size` — before the defaults fill its gaps, so it
654
+ * returns only what it moved and leaves the rest to them. It runs on every
655
+ * read of a stored config, including one already moved, so it must hand
656
+ * back a current config unchanged. */
657
+ migrateConfig?: (stored: unknown) => unknown;
441
658
  serialize?: (state: TS) => unknown;
442
659
  deserialize?: (data: unknown, config: TC) => TS;
443
660
  canvas?: CanvasCapability<TS, TC>;
661
+ stage?: StageCapability;
444
662
  layers?: LayerCapability;
445
663
  dragDrop?: DragDropCapability<TS, TC>;
446
664
  undo?: UndoCapability;
@@ -479,9 +697,10 @@ interface AnnotationStoreOptions {
479
697
  targets: () => readonly AnnotationTargetInfo[];
480
698
  /** Serialized scenes from a previous `toJSON`, keyed by target. */
481
699
  restore?: Readonly<Record<string, unknown>>;
482
- /** The instrument's vocabulary, so an export draws a mark in the colour its
483
- * status gives it. */
484
- meaning?: AnnotationMeaning;
700
+ /** The instrument's vocabulary, so an export draws a mark in the color its
701
+ * status gives it. A thunk is read at each capture, for a vocabulary that
702
+ * changes under a store built once. */
703
+ meaning?: AnnotationMeaning | (() => AnnotationMeaning | undefined);
485
704
  /** The trial's live config. A getter for the same reason `targets` is: the
486
705
  * store is built once and the config changes under it. */
487
706
  config?: () => unknown;
@@ -535,7 +754,7 @@ interface AnnotationStatus {
535
754
  id: string;
536
755
  label: string;
537
756
  /** What a mark in this status is drawn in. Omitted, it takes the default
538
- * mark colour — a status is allowed to be a label and nothing more. */
757
+ * mark color — a status is allowed to be a label and nothing more. */
539
758
  color?: string;
540
759
  }
541
760
  /** The optional meaning tier: what a mark means, as opposed to where it is.
@@ -651,9 +870,10 @@ interface AnnotationTarget extends AnnotationTargetInfo {
651
870
  /** Where an instrument keeps its own marks. Declaring this means labkit never
652
871
  * writes its trial slot — for an instrument whose marks belong in a format it
653
872
  * already owns. Both halves are called outside React; `save` is already
654
- * debounced by the time it arrives. */
873
+ * debounced by the time it arrives. A trial shows an empty body until `load`
874
+ * settles. */
655
875
  interface AnnotationStorage {
656
- load: () => SerializedAnnotations | null | undefined;
876
+ load: () => Promise<SerializedAnnotations | null | undefined>;
657
877
  save: (doc: SerializedAnnotations) => void;
658
878
  }
659
879
  /** Declares that an instrument accepts marks: which regions take them,
@@ -663,6 +883,8 @@ interface AnnotationsCapability<TS = unknown, TC = unknown> {
663
883
  * is called once per trial, and its targets are that trial's own. */
664
884
  targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
665
885
  meaning?: AnnotationMeaning;
886
+ /** An instrument replacing this one under a live trial must pass the same
887
+ * object: the trial keeps the marks it loaded from the first. */
666
888
  storage?: AnnotationStorage;
667
889
  /** Fires after every finished export, labkit's own chrome included. A
668
890
  * notification, not an interception: a host wanting its own flow calls
@@ -758,5 +980,5 @@ declare function fracContains(box: FracRect, pt: FracPoint, tol?: number): boole
758
980
  * answers false for two rects that merely overlap. */
759
981
  declare function fracEncloses(outer: FracRect, inner: FracRect): boolean;
760
982
 
761
- export { LOUPE_DEFAULTS as L, annotationsFromJSON as a3, applyCamera as a4, createAnnotationScene as a5, createAnnotationStore as a6, fracContains as a7, fracEncloses as a8, fracToWorld as a9, resolveFrame as aa, resolveLoupe as ab, roundFrac as ac, worldToFrac as ad, DEFAULT_FRAME as x };
762
- export type { TrialRegion as $, AnnotationData as A, LayerCapability as B, CaptureSource as C, DragFeedback as D, LayerDescriptor as E, FracPoint as F, LoupeCapability as G, HitResult as H, Instrument as I, LoupeDeclaration as J, LoupeRenderArgs as K, MarkScene as M, ResolvedLoupe as N, SidebarSection as O, PaletteItem as P, StatusReadout as Q, RenderContext as R, SerializedAnnotations as S, TrialTool as T, SystemEvent as U, ViewTransform as V, WorldFrame as W, ToolCapability as X, ToolItem as Y, ToolbarItem as Z, TrialChromeContext as _, Point as a, UndoCapability as a0, ViewportControl as a1, ViewportSize as a2, LoupePoint as ae, LoupeMode as af, DragDropCapability as b, WorldSpec as c, WorldRect as d, AnnotationMeaning as e, CaptureOptions as f, CaptureResult as g, AnnotationTarget as h, AnnotationsApi as i, AnnotationsCapability as j, AnnotationKind as k, InstrumentList as l, TrialContribution as m, Annotation as n, AnnotationInit as o, AnnotationPatch as p, AnnotationQuery as q, AnnotationStatus as r, AnnotationStoreOptions as s, AnnotationTargetInfo as t, CanvasCapability as u, CanvasLayer as v, CaptureDeps as w, FracRect as y, IconComponent as z };
983
+ export { DEFAULT_FRAME as D, LOUPE_DEFAULTS as a7, resolveLoupe as aA, roundFrac as aB, worldToFrac as aC, annotationsFromJSON as au, createAnnotationScene as av, createAnnotationStore as aw, fracContains as ax, fracEncloses as ay, fracToWorld as az, applyCamera as b, dockPanel as j, panelKey as p, resolveFrame as r, undockPanel as u };
984
+ export type { CanvasCapability as $, AnnotationData as A, AnnotationTarget as B, CreateLabStoreOptions as C, AnnotationsApi as E, AnnotationsCapability as F, TrialInfo as G, TrialTool as H, InstrumentSerializers as I, AnnotationKind as J, Instrument as K, LabDocument as L, Migration as M, TrialContribution as N, Annotation as O, PaletteItem as P, AnnotationInit as Q, AnnotationPatch as R, SerializedTrial as S, TrialRecord as T, UndoStack as U, ViewportSize as V, WorldFrame as W, AnnotationQuery as X, AnnotationStatus as Y, AnnotationStoreOptions as Z, AnnotationTargetInfo as _, WorldSpec as a, CanvasLayer as a0, CaptureDeps as a1, ContributionBase as a2, FracPoint as a3, FracRect as a4, HitResult as a5, IconComponent as a6, LayerCapability as a8, LayerDescriptor as a9, LoupePoint as aD, LoupeMode as aE, LoupeCapability as aa, LoupeDeclaration as ab, LoupeRenderArgs as ac, RegionContribution as ad, RenderContext as ae, ResolvedLoupe as af, SerializedAnnotations as ag, SidebarSection as ah, SidebarSlotContext as ai, StageCapability as aj, StatusReadout as ak, SystemEvent as al, ToolCapability as am, ToolItem as an, ToolSlotContext as ao, ToolbarItem as ap, TrialChromeContext as aq, TrialRegion as ar, UndoCapability as as, ViewportControl as at, StorageAdapter as c, LabStoreState as d, SavedSnapshot as e, StorageChange as f, TrialStateHandle as g, UndockedPanel as h, UndockedPanels as i, ViewTransform as k, Point as l, DragFeedback as m, DragDropCapability as n, LabMode as o, InstrumentList as q, InstrumentHooks as s, WorldRect as t, AnnotationMeaning as v, MarkScene as w, CaptureSource as x, CaptureOptions as y, CaptureResult as z };