@weasel-js/labkit 1.4.0 → 1.4.2

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 (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.js.map +0 -1
@@ -62,6 +62,10 @@ export interface TrialChromeProps {
62
62
 
63
63
  const NO_OP = (): void => {};
64
64
 
65
+ /** One frozen empty map, so a trial that has folded nothing keeps a stable
66
+ * context identity across renders. */
67
+ const NO_SECTIONS: Readonly<Record<string, boolean>> = Object.freeze({});
68
+
65
69
  /** The frame around a running instrument. Builds one chrome context, assembles
66
70
  * the contributions the instrument, the runtime and the lab declare, and hands
67
71
  * each region its slice. */
@@ -85,6 +89,8 @@ export function TrialChrome({
85
89
  if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
86
90
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
87
91
  const updateTrialSidebarWidth = useStore(storeCtx.store, (s) => s.updateTrialSidebarWidth);
92
+ const setTrialTitle = useStore(storeCtx.store, (s) => s.setTrialTitle);
93
+ const setTrialSectionCollapsed = useStore(storeCtx.store, (s) => s.setTrialSectionCollapsed);
88
94
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
89
95
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
90
96
  const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
@@ -104,6 +110,9 @@ export function TrialChrome({
104
110
 
105
111
  const configSchema = useConfigSchema(instrument);
106
112
 
113
+ const title = record.title ?? record.instrumentName;
114
+ const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
115
+
107
116
  const ctx = useMemo<TrialChromeContext>(() => {
108
117
  const setZoom = (z: number): void => {
109
118
  if (!view2d) return;
@@ -112,6 +121,8 @@ export function TrialChrome({
112
121
  return {
113
122
  trialId,
114
123
  instrumentName: record.instrumentName,
124
+ title,
125
+ setTitle: (next) => setTrialTitle(trialId, next),
115
126
  isLastTrial,
116
127
  zoom: view2d ? view2d.zoom : null,
117
128
  setZoom,
@@ -138,6 +149,8 @@ export function TrialChrome({
138
149
  updateTrialState(trialId, nextState as never);
139
150
  }
140
151
  },
152
+ collapsedSections,
153
+ setSectionCollapsed: (key, collapsed) => setTrialSectionCollapsed(trialId, key, collapsed),
141
154
  undockedPanels: undockedIds,
142
155
  undockPanel: (sectionId, as) => undockPanelAction(trialId, sectionId, as),
143
156
  dockPanel: (sectionId) => dockPanelAction(trialId, sectionId),
@@ -155,6 +168,7 @@ export function TrialChrome({
155
168
  record,
156
169
  instrument,
157
170
  lab,
171
+ title,
158
172
  isLastTrial,
159
173
  updateTrialView,
160
174
  updateTrialConfig,
@@ -168,6 +182,9 @@ export function TrialChrome({
168
182
  undockedIds,
169
183
  undockPanelAction,
170
184
  dockPanelAction,
185
+ setTrialTitle,
186
+ setTrialSectionCollapsed,
187
+ collapsedSections,
171
188
  ]);
172
189
 
173
190
  const contributions = useMemo(
@@ -203,11 +220,14 @@ export function TrialChrome({
203
220
  return (
204
221
  <section
205
222
  className="lk-trial"
206
- aria-label={`Trial ${record.instrumentName}`}
223
+ aria-label={`Trial ${title}`}
207
224
  tabIndex={-1}
208
225
  onKeyDown={handleKeyDown}
209
226
  >
210
- <TrialTitleBar title={record.instrumentName}>
227
+ <TrialTitleBar
228
+ title={title}
229
+ lead={<TitleBarRegion placement="lead" contributions={inRegion('titlebar')} ctx={ctx} />}
230
+ >
211
231
  <TitleBarRegion contributions={inRegion('titlebar')} ctx={ctx} />
212
232
  </TrialTitleBar>
213
233
  <div className="lk-trial__toolbar">
@@ -6,15 +6,18 @@ import { useTrialDrag } from './TrialDragContext';
6
6
  /** Props for `<TrialTitleBar>`. */
7
7
  export interface TrialTitleBarProps {
8
8
  title: string;
9
+ /** Rendered at the leading edge, before the title. */
10
+ lead?: ReactNode;
9
11
  children?: ReactNode;
10
12
  }
11
13
 
12
14
  // `useDragHandle` needs a node id and windease's DragProvider, so the draggable
13
15
  // form is a separate component rather than a conditional hook.
14
- function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: NodeId }) {
16
+ function Draggable({ nodeId, title, lead, children }: TrialTitleBarProps & { nodeId: NodeId }) {
15
17
  const handlers = useDragHandle(nodeId);
16
18
  return (
17
19
  <div className="lk-trial__titlebar lk-trial__titlebar--draggable" {...handlers}>
20
+ {lead}
18
21
  <span className="lk-trial__title">{title}</span>
19
22
  {children}
20
23
  </div>
@@ -24,16 +27,17 @@ function Draggable({ nodeId, title, children }: TrialTitleBarProps & { nodeId: N
24
27
  /** A trial's title bar. When the workspace allows reordering the whole bar is
25
28
  * the drag surface — there is no separate grip, because a window's title bar
26
29
  * is already the thing you expect to drag. */
27
- export function TrialTitleBar({ title, children }: TrialTitleBarProps) {
30
+ export function TrialTitleBar({ title, lead, children }: TrialTitleBarProps) {
28
31
  const drag = useTrialDrag();
29
32
  if (drag)
30
33
  return (
31
- <Draggable nodeId={drag.nodeId} title={title}>
34
+ <Draggable nodeId={drag.nodeId} title={title} lead={lead}>
32
35
  {children}
33
36
  </Draggable>
34
37
  );
35
38
  return (
36
39
  <div className="lk-trial__titlebar">
40
+ {lead}
37
41
  <span className="lk-trial__title">{title}</span>
38
42
  {children}
39
43
  </div>
@@ -4,6 +4,7 @@ export type { TrialBodyProps } from './TrialBody';
4
4
  export { TrialBody } from './TrialBody';
5
5
  export type { TrialChromeProps, UndoBindings } from './TrialChrome';
6
6
  export { TrialChrome } from './TrialChrome';
7
+ export type { AddTrialOptions } from './trialOps';
7
8
  export {
8
9
  addTrial,
9
10
  cloneTrial,
@@ -26,7 +26,23 @@ const counterWithCanvas: Instrument<CounterState, CounterConfig> = {
26
26
  },
27
27
  };
28
28
 
29
- const instruments: Instrument[] = [counter as Instrument, counterWithCanvas as Instrument];
29
+ interface SubjectConfig {
30
+ subject: string;
31
+ zoom: number;
32
+ }
33
+
34
+ const subjectInstrument: Instrument<{ label: string }, SubjectConfig> = {
35
+ name: 'Subject',
36
+ defaultConfig: () => ({ subject: 'none', zoom: 1 }),
37
+ initialState: (config) => ({ label: config.subject.toUpperCase() }),
38
+ render: () => null,
39
+ };
40
+
41
+ const instruments: Instrument[] = [
42
+ counter as Instrument,
43
+ counterWithCanvas as Instrument,
44
+ subjectInstrument as Instrument,
45
+ ];
30
46
 
31
47
  function head<T>(arr: T[]): T {
32
48
  const first = arr[0];
@@ -69,6 +85,28 @@ describe('addTrial', () => {
69
85
  addTrial(arr, instruments, 'Counter');
70
86
  expect(arr).toHaveLength(0);
71
87
  });
88
+
89
+ it('overlays a seed config on the instrument defaults', () => {
90
+ const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
91
+ expect(out[0]?.config).toEqual({ subject: 'brick', zoom: 1 });
92
+ });
93
+
94
+ it('seeds the config before initialState reads it', () => {
95
+ const out = addTrial([], instruments, 'Subject', { config: { subject: 'brick' } });
96
+ expect(out[0]?.state).toEqual({ label: 'BRICK' });
97
+ });
98
+
99
+ it('gives each trial the seed it was opened with', () => {
100
+ const a = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
101
+ const b = addTrial(a, instruments, 'Subject', { config: { subject: 'two' } });
102
+ expect(b.map((t) => (t.config as SubjectConfig).subject)).toEqual(['one', 'two']);
103
+ expect(b.map((t) => (t.state as { label: string }).label)).toEqual(['ONE', 'TWO']);
104
+ });
105
+
106
+ it('ignores a seed key whose value is undefined', () => {
107
+ const out = addTrial([], instruments, 'Subject', { config: { subject: undefined } });
108
+ expect(out[0]?.config).toEqual({ subject: 'none', zoom: 1 });
109
+ });
72
110
  });
73
111
 
74
112
  describe('cloneTrial', () => {
@@ -139,6 +177,16 @@ describe('resetTrial', () => {
139
177
  resetTrial(arr, head(arr).id, instruments);
140
178
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
179
  });
180
+
181
+ it('restores the seed the trial was opened with, not the bare defaults', () => {
182
+ let arr = addTrial([], instruments, 'Subject', { config: { subject: 'one' } });
183
+ arr = addTrial(arr, instruments, 'Subject', { config: { subject: 'two' } });
184
+ head(arr).config = { subject: 'edited', zoom: 4 } as SubjectConfig;
185
+ const reset = resetTrial(arr, head(arr).id, instruments);
186
+ expect(reset[0]?.config).toEqual({ subject: 'one', zoom: 1 });
187
+ expect(reset[0]?.state).toEqual({ label: 'ONE' });
188
+ expect(reset[1]?.config).toEqual({ subject: 'two', zoom: 1 });
189
+ });
142
190
  });
143
191
 
144
192
  describe('reorderTrials', () => {
@@ -19,15 +19,36 @@ function initialView(instrument: Instrument): TrialRecord['view'] {
19
19
  return declared ? structuredClone(declared) : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
20
20
  }
21
21
 
22
- /** Append a new trial running `instrumentName`, at that instrument's
23
- * default config and initial state. */
22
+ /** What a trial opens on beyond its instrument's own defaults. */
23
+ export interface AddTrialOptions<TC = Record<string, unknown>> {
24
+ /** Overlaid on `defaultConfig()` before `initialState` reads it, so a trial
25
+ * opened on a subject is running that subject from its first frame. Keys
26
+ * whose value is `undefined` are left to the default. */
27
+ config?: Partial<TC>;
28
+ }
29
+
30
+ /** `defaultConfig()` with `seed` written over it. Config is flat — a leaf's
31
+ * path is its config key — so this is the whole merge. */
32
+ function seedConfig<TC>(defaults: TC, seed: Partial<TC> | undefined): TC {
33
+ if (!seed) return defaults;
34
+ const out = { ...defaults };
35
+ for (const [key, value] of Object.entries(seed)) {
36
+ if (value !== undefined) (out as Record<string, unknown>)[key] = value;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Append a new trial running `instrumentName`, at that instrument's default
42
+ * config — with `options.config` written over it — and the initial state
43
+ * that config produces. */
24
44
  export function addTrial(
25
45
  trials: TrialRecord[],
26
46
  instruments: InstrumentList,
27
47
  instrumentName: string,
48
+ options: AddTrialOptions = {},
28
49
  ): TrialRecord[] {
29
50
  const instrument = findInstrument(instruments, instrumentName);
30
- const config = instrument.defaultConfig();
51
+ const config = seedConfig(instrument.defaultConfig(), options.config);
31
52
  const state = instrument.initialState(config);
32
53
  const record: TrialRecord = {
33
54
  id: crypto.randomUUID(),
@@ -37,6 +58,7 @@ export function addTrial(
37
58
  view: initialView(instrument),
38
59
  undoStack: { past: [], future: [] },
39
60
  };
61
+ if (options.config) record.configSeed = options.config;
40
62
  return [...trials, record];
41
63
  }
42
64
 
@@ -65,8 +87,8 @@ export function closeTrial(trials: TrialRecord[], id: string): TrialRecord[] {
65
87
  return next.length === trials.length ? trials : next;
66
88
  }
67
89
 
68
- /** Return a trial to its instrument's defaults, keeping its id and its
69
- * place in the list. */
90
+ /** Return a trial to what it opened on — its instrument's defaults under the
91
+ * seed `addTrial` was given — keeping its id and its place in the list. */
70
92
  export function resetTrial(
71
93
  trials: TrialRecord[],
72
94
  id: string,
@@ -76,7 +98,7 @@ export function resetTrial(
76
98
  const current = trials[idx];
77
99
  if (!current) return trials;
78
100
  const instrument = findInstrument(instruments, current.instrumentName);
79
- const config = instrument.defaultConfig();
101
+ const config = seedConfig(instrument.defaultConfig(), current.configSeed);
80
102
  const state = instrument.initialState(config);
81
103
  const reset: TrialRecord = {
82
104
  ...current,