@weasel-js/labkit 1.4.3 → 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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  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-QAGAHJKZ.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-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.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-C42T6DJT.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-ASWKLRKJ.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 +123 -348
  53. package/dist/index.js +794 -837
  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 +169 -117
  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 +163 -62
  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/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -0,0 +1,114 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { useLabContext } from '../lab/LabContext';
4
+ import { LabStoreContext } from '../state/context';
5
+ import { usePersistedState } from '../state/usePersistedState';
6
+ import { resolveLabTool } from '../tools/labTool';
7
+ import type { TrialTool } from '../tools/types';
8
+ import type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
9
+ import { mergeContributions } from './merge';
10
+ import { SidebarRegion } from './regions/SidebarRegion';
11
+ import { StatusRegion } from './regions/StatusRegion';
12
+ import { ToolbarRegion } from './regions/ToolbarRegion';
13
+
14
+ /** The lab's chrome context: the lab itself, plus the lab's tool slot. Throws
15
+ * outside a `<Lab>`. */
16
+ export function useLabChromeContext(): LabChromeContext {
17
+ const lab = useLabContext();
18
+ const storeCtx = useContext(LabStoreContext);
19
+ if (!storeCtx) throw new Error('[labkit] lab chrome requires <LabStoreProvider>');
20
+ const slot = useStore(storeCtx.store, (s) => s.activeToolId);
21
+ const storeInstruments = useStore(storeCtx.store, (s) => s.instruments);
22
+ const activeToolId = resolveLabTool(slot, storeInstruments ?? lab.instruments);
23
+ const setLabTool = useStore(storeCtx.store, (s) => s.setLabTool);
24
+ return useMemo(
25
+ () => ({ ...lab, activeToolId, setActiveTool: setLabTool }),
26
+ [lab, activeToolId, setLabTool],
27
+ );
28
+ }
29
+
30
+ /** The lab's contributions for one region, in declaration order. */
31
+ export function contributionsIn<TCtx = LabChromeContext>(
32
+ contributions: readonly LabContribution<TCtx>[],
33
+ region: LabRegion,
34
+ ): LabContribution<TCtx>[] {
35
+ return contributions.filter((c) => c.region === region);
36
+ }
37
+
38
+ /** The palette contributions a lab's `tools` stand for. Declaring a tool is
39
+ * shorthand for contributing a palette item that writes the lab's tool
40
+ * slot. */
41
+ export function toolContributions(tools: readonly TrialTool[]): LabContribution[] {
42
+ return tools.map((t) => ({
43
+ id: t.id,
44
+ region: 'palette' as const,
45
+ ...(t.group === undefined ? {} : { group: t.group }),
46
+ item: {
47
+ icon: t.icon,
48
+ label: t.label,
49
+ ...(t.shortcut === undefined ? {} : { shortcut: t.shortcut }),
50
+ },
51
+ }));
52
+ }
53
+
54
+ /** Everything the lab's chrome renders, from both of the ways a lab declares
55
+ * it. Tools and contributions share one id namespace, so a tool colliding
56
+ * with a contribution throws here rather than one of them silently losing. */
57
+ export function labContributions(
58
+ tools: readonly TrialTool[] | undefined,
59
+ chrome: readonly LabContribution[] | undefined,
60
+ ): LabContribution[] {
61
+ return mergeContributions<LabContribution>(toolContributions(tools ?? []), [...(chrome ?? [])]);
62
+ }
63
+
64
+ /** Props shared by the lab's region mounts. Each takes the lab's whole
65
+ * contribution list and lays out the part addressed to it. */
66
+ export interface LabRegionProps {
67
+ contributions: readonly LabContribution[];
68
+ }
69
+
70
+ /** The lab's action bar, in the shell header beside the title. */
71
+ export function LabHeaderRegion({ contributions }: LabRegionProps) {
72
+ const ctx = useLabChromeContext();
73
+ return (
74
+ <ToolbarRegion
75
+ region="header"
76
+ label="Lab actions"
77
+ contributions={contributionsIn(contributions, 'header')}
78
+ ctx={ctx}
79
+ />
80
+ );
81
+ }
82
+
83
+ /** The lab's sidebar sections. Fold state is a lab-scoped persisted value, so it
84
+ * survives a reload in a stored lab and lasts the session in an unstored one. */
85
+ export function LabSidebarRegion({ contributions }: LabRegionProps) {
86
+ const lab = useLabChromeContext();
87
+ const [collapsedSections, setFolds] = usePersistedState<Record<string, boolean>>(
88
+ 'lk-lab-sidebar-folds',
89
+ {},
90
+ { scope: 'lab' },
91
+ );
92
+ const ctx = useMemo(
93
+ () => ({
94
+ ...lab,
95
+ collapsedSections,
96
+ setSectionCollapsed: (key: string, collapsed: boolean) =>
97
+ setFolds((f) => ({ ...f, [key]: collapsed })),
98
+ }),
99
+ [lab, collapsedSections, setFolds],
100
+ );
101
+ return <SidebarRegion contributions={contributionsIn(contributions, 'sidebar')} ctx={ctx} />;
102
+ }
103
+
104
+ /** The lab's readouts, in the shell footer below the workspace. */
105
+ export function LabFooterRegion({ contributions }: LabRegionProps) {
106
+ const ctx = useLabChromeContext();
107
+ return (
108
+ <StatusRegion
109
+ region="footer"
110
+ contributions={contributionsIn(contributions, 'footer')}
111
+ ctx={ctx}
112
+ />
113
+ );
114
+ }
@@ -12,11 +12,10 @@ import {
12
12
  } from '@weasel-js/ui';
13
13
  import { ExportMenu } from '../annotations/ExportMenu';
14
14
  import { MarkList } from '../annotations/MarkList';
15
- import { ANNOTATION_TOOLS } from '../annotations/toolMap';
16
15
  import type { ControlRenderer } from '../config/types';
17
16
  import { ControlPanel } from '../controls/ControlPanel';
18
17
  import type { Instrument } from '../instrument/types';
19
- import { Select } from '../passthrough/weasel-ui';
18
+ import { MenuButton } from '../passthrough/weasel-ui';
20
19
  import { FpsMeter } from '../primitives/FpsMeter';
21
20
  import { ScaleIndicator } from '../primitives/ScaleIndicator';
22
21
  import { ZoomControl } from '../primitives/ZoomControl';
@@ -82,16 +81,10 @@ export function builtinContributions(
82
81
  });
83
82
  }
84
83
 
85
- // Declaring `annotations` provides the drawing palette, the way declaring
86
- // `tools` provides the instrument's own; both write the same tool slot.
87
84
  // A tool id shares the contribution namespace, so a tool called `close`
88
- // collides with the built-in close button and throws — and so do two tools
89
- // of the same name from these two sources.
90
- const tools = [
91
- ...(instrument.annotations ? ANNOTATION_TOOLS : []),
92
- ...(instrument.tools?.tools ?? []),
93
- ];
94
- for (const t of tools) {
85
+ // collides with the built-in close button and throws. Annotation tools are
86
+ // not here: the lab's rail carries them.
87
+ for (const t of instrument.tools?.tools ?? []) {
95
88
  out.push({
96
89
  id: t.id,
97
90
  region: 'palette',
@@ -116,7 +109,7 @@ export function builtinContributions(
116
109
 
117
110
  // Zoom acts on the view of the trial, so it is a viewport control. A trial
118
111
  // holding a non-2D view reports zoom as null and gets none of this.
119
- if (instrument.canvas != null && zoom !== null) {
112
+ if ((instrument.canvas != null || instrument.stage != null) && zoom !== null) {
120
113
  out.push({
121
114
  id: 'zoom-out',
122
115
  region: 'viewport',
@@ -153,15 +146,17 @@ export function builtinContributions(
153
146
  id: 'scale',
154
147
  region: 'status',
155
148
  group: 'view',
156
- render: () => <ScaleIndicator />,
157
- });
158
- out.push({
159
- id: 'fps',
160
- region: 'status',
161
- group: 'view',
162
- end: true,
163
- render: () => <FpsMeter />,
149
+ render: (c) => <ScaleIndicator zoom={c.zoom ?? 1} />,
164
150
  });
151
+ if (instrument.canvas != null) {
152
+ out.push({
153
+ id: 'fps',
154
+ region: 'status',
155
+ group: 'view',
156
+ end: true,
157
+ render: () => <FpsMeter />,
158
+ });
159
+ }
165
160
  }
166
161
 
167
162
  if (instrument.annotations != null) {
@@ -205,24 +200,18 @@ export function builtinContributions(
205
200
  }
206
201
 
207
202
  if (ctx.savedSnapshots.length > 0) {
208
- // A picker is not an icon button, so it takes the render escape. Held at
209
- // `selectedKey={null}` so it stays a "load one" action rather than
210
- // drifting into a display of what was loaded last.
203
+ // A menu is not an icon button, so it takes the render escape.
211
204
  out.push({
212
205
  id: 'snapshot-load',
213
206
  region: 'toolbar',
214
207
  group: 'trial',
215
208
  end: true,
216
209
  render: (c) => (
217
- <Select
218
- className="lk-toolbar__load-select"
210
+ <MenuButton
219
211
  aria-label="Load snapshot"
220
- placeholder="Load…"
221
- selectedKey={null}
222
- options={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
223
- onSelectionChange={(id) => {
224
- if (id != null) c.loadSnapshot(String(id));
225
- }}
212
+ label="Load…"
213
+ items={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
214
+ onAction={(id) => c.loadSnapshot(id)}
226
215
  />
227
216
  ),
228
217
  });
@@ -1,4 +1,15 @@
1
1
  export { builtinContributions } from './builtins';
2
+ export type { LabRegionProps } from './LabChrome';
3
+ export {
4
+ contributionsIn,
5
+ LabFooterRegion,
6
+ LabHeaderRegion,
7
+ LabSidebarRegion,
8
+ labContributions,
9
+ toolContributions,
10
+ useLabChromeContext,
11
+ } from './LabChrome';
12
+ export type { LabChromeContext, LabContribution, LabRegion } from './labTypes';
2
13
  export { mergeContributions, suppressContributions } from './merge';
3
14
  export type { PaletteRegionProps } from './regions/PaletteRegion';
4
15
  export { PaletteRegion } from './regions/PaletteRegion';
@@ -13,11 +24,15 @@ export { ToolbarRegion } from './regions/ToolbarRegion';
13
24
  export type { ViewportRegionProps } from './regions/ViewportRegion';
14
25
  export { ViewportRegion } from './regions/ViewportRegion';
15
26
  export type {
27
+ ContributionBase,
16
28
  IconComponent,
29
+ RegionContribution,
17
30
  SidebarSection,
31
+ SidebarSlotContext,
18
32
  StatusReadout,
19
33
  ToolbarItem,
20
34
  ToolItem,
35
+ ToolSlotContext,
21
36
  TrialChromeContext,
22
37
  TrialContribution,
23
38
  TrialRegion,
@@ -0,0 +1,208 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { Instrument } from '../instrument/types';
4
+ import { Lab } from '../lab/Lab';
5
+ import { LabShell } from '../lab/LabShell';
6
+ import type { LabChromeContext, LabContribution } from './labTypes';
7
+ import { ToolbarRegion } from './regions/ToolbarRegion';
8
+
9
+ const Glyph = () => <svg />;
10
+ const bare: Instrument = {
11
+ name: 'Bare',
12
+ defaultConfig: () => ({}),
13
+ initialState: () => ({}),
14
+ render: () => null,
15
+ };
16
+
17
+ function silenceRenderError(): void {
18
+ vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ }
20
+
21
+ describe('lab-level chrome', () => {
22
+ it('renders a header contribution beside the lab header', () => {
23
+ const contribution: LabContribution = {
24
+ id: 'publish',
25
+ region: 'header',
26
+ item: { icon: Glyph, label: 'Publish', onActivate: () => {} },
27
+ };
28
+ render(
29
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
30
+ );
31
+ expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
32
+ });
33
+
34
+ it('hands a header contribution the lab, not a trial', () => {
35
+ const contribution: LabContribution = {
36
+ id: 'another',
37
+ region: 'header',
38
+ item: {
39
+ icon: Glyph,
40
+ label: 'Another',
41
+ onActivate: (ctx) => ctx.addTrial('Bare'),
42
+ },
43
+ };
44
+ render(
45
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
46
+ );
47
+ expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(1);
48
+ fireEvent.click(screen.getByRole('button', { name: 'Another' }));
49
+ expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(2);
50
+ });
51
+
52
+ it('renders a palette contribution in the lab rail and selects it', () => {
53
+ const contribution: LabContribution = {
54
+ id: 'measure',
55
+ region: 'palette',
56
+ item: { icon: Glyph, label: 'Measure' },
57
+ };
58
+ render(
59
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
60
+ );
61
+ const button = screen.getByRole('button', { name: 'Measure' });
62
+ fireEvent.click(button);
63
+ expect(screen.getByRole('button', { name: 'Measure' })).toHaveAttribute('aria-current', 'true');
64
+ });
65
+
66
+ it('gives a palette contribution the whole lab context, not a tool slot alone', () => {
67
+ // The shape the `as unknown as TrialChromeContext` cast used to fake: a
68
+ // contribution reading anything past the tool slot got `undefined`.
69
+ const seen: LabChromeContext[] = [];
70
+ const contribution: LabContribution = {
71
+ id: 'readout',
72
+ region: 'palette',
73
+ render: (ctx) => {
74
+ seen.push(ctx);
75
+ return <span>{`${ctx.trials.length} trial(s), mode ${ctx.mode}`}</span>;
76
+ },
77
+ };
78
+ render(
79
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
80
+ );
81
+ expect(screen.getByText('1 trial(s), mode auto')).toBeInTheDocument();
82
+ expect(seen[0]?.activeToolId).toBeNull();
83
+ expect(typeof seen[0]?.saveSnapshot).toBe('function');
84
+ });
85
+
86
+ it('renders a footer contribution', () => {
87
+ const contribution: LabContribution = {
88
+ id: 'count',
89
+ region: 'footer',
90
+ item: { text: 'idle' },
91
+ };
92
+ render(
93
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
94
+ );
95
+ expect(screen.getByText('idle')).toBeInTheDocument();
96
+ });
97
+
98
+ it('throws when two lab contributions share an id', () => {
99
+ silenceRenderError();
100
+ const clash: LabContribution[] = [
101
+ { id: 'same', region: 'header', item: { icon: Glyph, label: 'One', onActivate: () => {} } },
102
+ { id: 'same', region: 'footer', item: { text: 'two' } },
103
+ ];
104
+ expect(() =>
105
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={clash} />),
106
+ ).toThrow(/duplicate contribution id "same"/);
107
+ });
108
+
109
+ it('keeps the lab tool slot and lab palette contributions in one namespace', () => {
110
+ silenceRenderError();
111
+ const clash: LabContribution = {
112
+ id: 'pick',
113
+ region: 'palette',
114
+ item: { icon: Glyph, label: 'Mine' },
115
+ };
116
+ expect(() =>
117
+ render(
118
+ <Lab
119
+ title="T"
120
+ instruments={[bare]}
121
+ defaultInstrument="Bare"
122
+ tools={[{ id: 'pick', label: 'Pick', icon: Glyph }]}
123
+ labChrome={[clash]}
124
+ />,
125
+ ),
126
+ ).toThrow(/duplicate contribution id "pick"/);
127
+ });
128
+ });
129
+
130
+ describe('lab sidebar region', () => {
131
+ const tree: LabContribution = {
132
+ id: 'tree',
133
+ region: 'sidebar',
134
+ item: { title: 'Stories', body: <p>story list</p> },
135
+ };
136
+
137
+ it('renders a section beside the workspace', () => {
138
+ const { container } = render(
139
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
140
+ );
141
+ expect(screen.getByText('story list')).toBeInTheDocument();
142
+ expect(container.querySelector('.lk-lab__sidebar')).not.toBeNull();
143
+ expect(screen.getByRole('separator', { name: /lab sidebar/i })).toBeInTheDocument();
144
+ });
145
+
146
+ it('folds a section', () => {
147
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
148
+ fireEvent.click(screen.getByRole('button', { name: 'Stories' }));
149
+ expect(screen.queryByText('story list')).toBeNull();
150
+ });
151
+
152
+ it('offers no tear-out at lab level', () => {
153
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
154
+ expect(screen.queryByRole('button', { name: 'Undock Stories' })).toBeNull();
155
+ });
156
+
157
+ it('hands a render contribution the lab context', () => {
158
+ const seen: LabChromeContext[] = [];
159
+ const probe: LabContribution = {
160
+ id: 'probe',
161
+ region: 'sidebar',
162
+ render: (ctx) => {
163
+ seen.push(ctx);
164
+ return <p>probe</p>;
165
+ },
166
+ };
167
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />);
168
+ expect(screen.getByText('probe')).toBeInTheDocument();
169
+ expect(seen[0]?.trials).toHaveLength(1);
170
+ });
171
+
172
+ it('adds no strip to a lab without sidebar chrome', () => {
173
+ const { container } = render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
174
+ expect(container.querySelector('.lk-lab__panes')).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe('lab chrome without <Lab>', () => {
179
+ // The rail `apps/theme-editor` mounts: `<LabShell>` with no trial runtime
180
+ // above it, so the context handed to `onActivate` is the consumer's own.
181
+ interface RailCtx {
182
+ undo: () => void;
183
+ }
184
+
185
+ it('renders a contribution typed against a consumer context and hands it back', () => {
186
+ const undo = vi.fn();
187
+ const ctx: RailCtx = { undo };
188
+ const rail: readonly LabContribution<RailCtx>[] = [
189
+ {
190
+ id: 'undo',
191
+ region: 'header',
192
+ item: { icon: Glyph, label: 'Undo', showLabel: true, onActivate: (c) => c.undo() },
193
+ },
194
+ ];
195
+ render(
196
+ <LabShell
197
+ title="Palette lab"
198
+ header={
199
+ <ToolbarRegion region="header" label="Lab actions" contributions={rail} ctx={ctx} />
200
+ }
201
+ >
202
+ <div />
203
+ </LabShell>,
204
+ );
205
+ fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
206
+ expect(undo).toHaveBeenCalledTimes(1);
207
+ });
208
+ });
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { LabContextValue } from '../lab/LabContext';
3
+ import type {
4
+ ContributionBase,
5
+ SidebarSection,
6
+ StatusReadout,
7
+ ToolbarItem,
8
+ ToolItem,
9
+ ToolSlotContext,
10
+ } from './types';
11
+
12
+ /**
13
+ * A named position in the lab's own chrome — a box the shell owns, outside any
14
+ * one trial. `header` and `footer` are the shell's two bars, named as
15
+ * `<LabShell>` names them; `palette` is the tool rail down the side of the
16
+ * workspace; `sidebar` is a column of sections left of the tool rail.
17
+ */
18
+ export type LabRegion = 'header' | 'palette' | 'sidebar' | 'footer';
19
+
20
+ /**
21
+ * Everything a lab-level contribution can read and command: the lab's own
22
+ * state and operations, plus the lab's tool slot — which every trial whose
23
+ * instrument declares no tools of its own reflects and writes.
24
+ */
25
+ export interface LabChromeContext extends LabContextValue, ToolSlotContext {}
26
+
27
+ /**
28
+ * A contribution to the lab's chrome. Shaped as `TrialContribution` is, and
29
+ * merged and suppressed by the same rules, but keyed to the lab's regions and
30
+ * rendered with the lab's context. Supplying `render` instead of `item` opts
31
+ * out of the region's layout.
32
+ *
33
+ * `TCtx` is what the chrome hands a handler. It defaults to `LabChromeContext`,
34
+ * which is what `<Lab>` renders its regions with; a consumer mounting the
35
+ * regions under a bare `<LabShell>` names whatever it passes as `ctx` instead.
36
+ */
37
+ export type LabContribution<TCtx = LabChromeContext> =
38
+ | (ContributionBase & {
39
+ region: 'header';
40
+ item: ToolbarItem<TCtx>;
41
+ render?: never;
42
+ })
43
+ | (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
44
+ | (ContributionBase & { region: 'sidebar'; item: SidebarSection; render?: never })
45
+ | (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
46
+ | (ContributionBase & {
47
+ region: LabRegion;
48
+ item?: never;
49
+ render: (ctx: TCtx) => ReactNode;
50
+ });
@@ -1,5 +1,3 @@
1
- import type { TrialContribution } from './types';
2
-
3
1
  /**
4
2
  * Concatenate contribution bundles into one list, preserving order — order
5
3
  * decides how a region lays its contributions out, so it is part of the
@@ -8,10 +6,10 @@ import type { TrialContribution } from './types';
8
6
  * Throws on a duplicate id rather than dropping one: a contribution silently
9
7
  * losing to a later bundle is the failure a registry exists to prevent.
10
8
  */
11
- export function mergeContributions(
12
- ...bundles: readonly TrialContribution[][]
13
- ): TrialContribution[] {
14
- const out: TrialContribution[] = [];
9
+ export function mergeContributions<T extends { id: string }>(
10
+ ...bundles: readonly (readonly T[])[]
11
+ ): T[] {
12
+ const out: T[] = [];
15
13
  const seen = new Set<string>();
16
14
  for (const bundle of bundles) {
17
15
  for (const entry of bundle) {
@@ -34,10 +32,10 @@ export function mergeContributions(
34
32
  * silently suppresses nothing is the same class of bug as a duplicate id
35
33
  * silently winning.
36
34
  */
37
- export function suppressContributions(
38
- bundle: readonly TrialContribution[],
35
+ export function suppressContributions<T extends { id: string }>(
36
+ bundle: readonly T[],
39
37
  ids: readonly string[],
40
- ): TrialContribution[] {
38
+ ): T[] {
41
39
  const present = new Set(bundle.map((c) => c.id));
42
40
  for (const id of ids) {
43
41
  if (!present.has(id)) {
@@ -1,15 +1,17 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
- import type { TrialChromeContext, TrialContribution } from '../types';
3
+ import type { RegionContribution, ToolSlotContext } from '../types';
4
4
  import { PaletteRegion } from './PaletteRegion';
5
5
 
6
6
  const Glyph = () => <svg />;
7
7
 
8
- function ctxWith(activeToolId: string | null, setActiveTool = vi.fn()) {
9
- return { trialId: 't1', activeToolId, setActiveTool } as unknown as TrialChromeContext;
8
+ // The region reads a tool slot and nothing else, so the fixture is a real
9
+ // `ToolSlotContext` rather than a faked trial.
10
+ function ctxWith(activeToolId: string | null, setActiveTool = vi.fn()): ToolSlotContext {
11
+ return { activeToolId, setActiveTool };
10
12
  }
11
13
 
12
- function tool(id: string): TrialContribution {
14
+ function tool(id: string): RegionContribution<ToolSlotContext> {
13
15
  return { id, region: 'palette', item: { icon: Glyph, label: id } };
14
16
  }
15
17
 
@@ -1,16 +1,28 @@
1
1
  import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
2
  import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
3
- import type { TrialChromeContext, TrialContribution } from '../types';
3
+ import type {
4
+ RegionContribution,
5
+ ToolItem,
6
+ ToolSlotContext,
7
+ TrialChromeContext,
8
+ } from '../types';
4
9
 
5
10
  /** Props for `<PaletteRegion>`. */
6
- export interface PaletteRegionProps {
7
- contributions: readonly TrialContribution[];
8
- ctx: TrialChromeContext;
11
+ export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeContext> {
12
+ contributions: readonly RegionContribution<NoInfer<TCtx>>[];
13
+ ctx: TCtx;
14
+ /** Which region's items to lay out. The lab's rail names the same box
15
+ * `palette` too, so this only moves for a chrome that does not. */
16
+ region?: string;
9
17
  }
10
18
 
11
- /** A trial's tool strip. Selection lives in the trial's or the lab's tool
12
- * slot; this region only reflects it. */
13
- export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
19
+ /** A tool strip: a trial's, or the lab's. Selection lives in whichever tool
20
+ * slot the context carries; this region only reflects it. */
21
+ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>({
22
+ contributions,
23
+ ctx,
24
+ region = 'palette',
25
+ }: PaletteRegionProps<TCtx>) {
14
26
  const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>('vertical');
15
27
  if (contributions.length === 0) return null;
16
28
  return (
@@ -25,8 +37,8 @@ export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
25
37
  <ToolGroup orientation="vertical">
26
38
  {contributions.map((c) => {
27
39
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
28
- if (c.region !== 'palette' || !c.item) return null;
29
- const { icon: Icon, label, shortcut, disabled } = c.item;
40
+ if (c.region !== region || !c.item) return null;
41
+ const { icon: Icon, label, shortcut, disabled } = c.item as ToolItem;
30
42
  return (
31
43
  <ToolButton
32
44
  key={c.id}
@@ -1,10 +1,17 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { SidebarSection, TrialChromeContext, TrialContribution } from '../types';
2
+ import type {
3
+ RegionContribution,
4
+ SidebarSection,
5
+ SidebarSlotContext,
6
+ TrialChromeContext,
7
+ } from '../types';
3
8
 
4
9
  /** Props for `<SidebarRegion>`. */
5
- export interface SidebarRegionProps {
6
- contributions: readonly TrialContribution[];
7
- ctx: TrialChromeContext;
10
+ export interface SidebarRegionProps<TCtx extends SidebarSlotContext = TrialChromeContext> {
11
+ contributions: readonly RegionContribution<NoInfer<TCtx>>[];
12
+ ctx: TCtx;
13
+ /** Which region's items to lay out. */
14
+ region?: string;
8
15
  }
9
16
 
10
17
  function Section({
@@ -48,17 +55,22 @@ function Section({
48
55
  );
49
56
  }
50
57
 
51
- /** Lays a trial's `sidebar` contributions out as titled, collapsible sections.
52
- * A section the trial has torn out is not rendered here — `Trial` portals its
53
- * body into the workspace instead. */
54
- export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
58
+ /** Lays `sidebar` contributions out as titled, collapsible sections — a
59
+ * trial's, or the lab's. A section the trial has torn out is not rendered
60
+ * here — `Trial` portals its body into the workspace instead. A context with
61
+ * no `undockPanel` offers no tear-out. */
62
+ export function SidebarRegion<TCtx extends SidebarSlotContext = TrialChromeContext>({
63
+ contributions,
64
+ ctx,
65
+ region = 'sidebar',
66
+ }: SidebarRegionProps<TCtx>) {
55
67
  if (contributions.length === 0) return null;
56
68
  return (
57
69
  <>
58
70
  {contributions.map((c) => {
59
71
  if (c.render) return <div key={c.id}>{c.render(ctx)}</div>;
60
- if (c.region !== 'sidebar' || !c.item) return null;
61
- if (ctx.undockedPanels.includes(c.id)) return null;
72
+ if (c.region !== region || !c.item) return null;
73
+ if (ctx.undockedPanels?.includes(c.id)) return null;
62
74
  const item = c.item as SidebarSection;
63
75
  return (
64
76
  <Section
@@ -67,9 +79,9 @@ export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
67
79
  collapsed={ctx.collapsedSections[c.id] ?? item.defaultCollapsed ?? false}
68
80
  onCollapsedChange={(next) => ctx.setSectionCollapsed(c.id, next)}
69
81
  onUndock={
70
- item.undockable === false
71
- ? undefined
72
- : () => ctx.undockPanel(c.id, item.undockAs ?? 'tile')
82
+ ctx.undockPanel && item.undockable !== false
83
+ ? () => ctx.undockPanel?.(c.id, item.undockAs ?? 'tile')
84
+ : undefined
73
85
  }
74
86
  >
75
87
  {item.body}