@weasel-js/labkit 1.4.4 → 1.5.1

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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -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,23 @@
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 { RegionContribution, ToolItem, ToolSlotContext, TrialChromeContext } from '../types';
4
4
 
5
5
  /** Props for `<PaletteRegion>`. */
6
- export interface PaletteRegionProps {
7
- contributions: readonly TrialContribution[];
8
- ctx: TrialChromeContext;
6
+ export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeContext> {
7
+ contributions: readonly RegionContribution<NoInfer<TCtx>>[];
8
+ ctx: TCtx;
9
+ /** Which region's items to lay out. The lab's rail names the same box
10
+ * `palette` too, so this only moves for a chrome that does not. */
11
+ region?: string;
9
12
  }
10
13
 
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) {
14
+ /** A tool strip: a trial's, or the lab's. Selection lives in whichever tool
15
+ * slot the context carries; this region only reflects it. */
16
+ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>({
17
+ contributions,
18
+ ctx,
19
+ region = 'palette',
20
+ }: PaletteRegionProps<TCtx>) {
14
21
  const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>('vertical');
15
22
  if (contributions.length === 0) return null;
16
23
  return (
@@ -25,8 +32,8 @@ export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
25
32
  <ToolGroup orientation="vertical">
26
33
  {contributions.map((c) => {
27
34
  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;
35
+ if (c.region !== region || !c.item) return null;
36
+ const { icon: Icon, label, shortcut, disabled } = c.item as ToolItem;
30
37
  return (
31
38
  <ToolButton
32
39
  key={c.id}
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect } from '@weasel-js/forge/play';
3
+ import { LabRoot } from '../../lab/LabRoot';
4
+ import type { SidebarSlotContext } from '../types';
5
+ import { SidebarRegion } from './SidebarRegion';
6
+
7
+ const meta: Meta<typeof SidebarRegion> = {
8
+ title: 'labkit/Chrome/SidebarRegion',
9
+ component: SidebarRegion,
10
+ parameters: { layout: 'fullscreen' },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof SidebarRegion>;
15
+
16
+ const ctx: SidebarSlotContext = { collapsedSections: {}, setSectionCollapsed: () => {} };
17
+
18
+ // jsdom has no layout, so this runs in the browser story projects.
19
+ export const LastSectionFillsItsPane: Story = {
20
+ render: () => (
21
+ <LabRoot>
22
+ <div className="lk-lab__aside" data-testid="pane" style={{ height: 400 }}>
23
+ <SidebarRegion
24
+ ctx={ctx}
25
+ contributions={[
26
+ { id: 'first', region: 'sidebar', item: { title: 'First', body: <p>one</p> } },
27
+ { id: 'last', region: 'sidebar', item: { title: 'Last', body: <p>two</p> } },
28
+ ]}
29
+ />
30
+ </div>
31
+ </LabRoot>
32
+ ),
33
+ play: async ({ canvasElement }) => {
34
+ const pane = canvasElement.querySelector('[data-testid="pane"]') as HTMLElement;
35
+ const sections = [...pane.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
36
+ const first = sections[0] as HTMLElement;
37
+ const last = sections[sections.length - 1] as HTMLElement;
38
+ await expect(Math.round(last.getBoundingClientRect().bottom)).toBe(
39
+ Math.round(pane.getBoundingClientRect().bottom),
40
+ );
41
+ await expect(getComputedStyle(last).borderBottomWidth).toBe('0px');
42
+ await expect(getComputedStyle(first).borderBottomWidth).not.toBe('0px');
43
+ },
44
+ };
@@ -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}
@@ -1,14 +1,20 @@
1
1
  import { StatusBar } from '../../primitives/StatusBar';
2
- import type { TrialChromeContext, TrialContribution } from '../types';
2
+ import type { RegionContribution, StatusReadout, TrialChromeContext } from '../types';
3
3
 
4
4
  /** Props for `<StatusRegion>`. */
5
- export interface StatusRegionProps {
6
- contributions: readonly TrialContribution[];
7
- ctx: TrialChromeContext;
5
+ export interface StatusRegionProps<TCtx = TrialChromeContext> {
6
+ contributions: readonly RegionContribution<NoInfer<TCtx>>[];
7
+ ctx: TCtx;
8
+ /** Which region's readouts to lay out; the lab's footer is `footer`. */
9
+ region?: string;
8
10
  }
9
11
 
10
- /** Lays a trial's `status` contributions out as readouts. */
11
- export function StatusRegion({ contributions, ctx }: StatusRegionProps) {
12
+ /** Lays a bar's readouts out. */
13
+ export function StatusRegion<TCtx = TrialChromeContext>({
14
+ contributions,
15
+ ctx,
16
+ region = 'status',
17
+ }: StatusRegionProps<TCtx>) {
12
18
  if (contributions.length === 0) return null;
13
19
  return (
14
20
  <StatusBar>
@@ -19,10 +25,11 @@ export function StatusRegion({ contributions, ctx }: StatusRegionProps) {
19
25
  {c.render(ctx)}
20
26
  </StatusBar.Section>
21
27
  );
22
- if (c.region !== 'status' || !c.item) return null;
28
+ if (c.region !== region || !c.item) return null;
29
+ const item = c.item as StatusReadout;
23
30
  return (
24
31
  <StatusBar.Section key={c.id} end={c.end}>
25
- <span title={c.item.title}>{c.item.text}</span>
32
+ <span title={item.title}>{item.text}</span>
26
33
  </StatusBar.Section>
27
34
  );
28
35
  })}
@@ -1,24 +1,28 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { Toolbar } from '../../primitives/Toolbar';
3
- import type { TrialChromeContext, TrialContribution } from '../types';
3
+ import type { RegionContribution, ToolbarItem, TrialChromeContext } from '../types';
4
4
 
5
5
  /** Props for `<ToolbarRegion>`. */
6
- export interface ToolbarRegionProps {
7
- contributions: readonly TrialContribution[];
8
- ctx: TrialChromeContext;
6
+ export interface ToolbarRegionProps<TCtx = TrialChromeContext> {
7
+ contributions: readonly RegionContribution<NoInfer<TCtx>>[];
8
+ ctx: TCtx;
9
+ /** Which region's items to lay out; the lab's action bar is `header`. */
10
+ region?: string;
11
+ /** The bar's accessible name. */
12
+ label?: string;
9
13
  }
10
14
 
11
- interface Group {
15
+ interface Group<TCtx> {
12
16
  key: string;
13
17
  end: boolean;
14
- entries: TrialContribution[];
18
+ entries: RegionContribution<TCtx>[];
15
19
  }
16
20
 
17
21
  /** Bucket by group, preserving first-appearance order. Ungrouped
18
22
  * contributions each become their own bucket so they stay in place. */
19
- function groupsOf(contributions: readonly TrialContribution[]): Group[] {
20
- const groups: Group[] = [];
21
- const byKey = new Map<string, Group>();
23
+ function groupsOf<TCtx>(contributions: readonly RegionContribution<TCtx>[]): Group<TCtx>[] {
24
+ const groups: Group<TCtx>[] = [];
25
+ const byKey = new Map<string, Group<TCtx>>();
22
26
  for (const c of contributions) {
23
27
  if (c.group == null) {
24
28
  groups.push({ key: c.id, end: c.end ?? false, entries: [c] });
@@ -35,10 +39,19 @@ function groupsOf(contributions: readonly TrialContribution[]): Group[] {
35
39
  return groups;
36
40
  }
37
41
 
38
- function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
42
+ function renderEntry<TCtx>(c: RegionContribution<TCtx>, ctx: TCtx, region: string): ReactNode {
39
43
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
40
- if (c.region !== 'toolbar' || !c.item) return null;
41
- const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed, onActivate } = c.item;
44
+ if (c.region !== region || !c.item) return null;
45
+ const {
46
+ icon: Icon,
47
+ label,
48
+ shortcut,
49
+ disabled,
50
+ danger,
51
+ showLabel,
52
+ pressed,
53
+ onActivate,
54
+ } = c.item as ToolbarItem<TCtx>;
42
55
  return (
43
56
  <Toolbar.Button
44
57
  key={c.id}
@@ -56,15 +69,20 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
56
69
  );
57
70
  }
58
71
 
59
- /** Lays a trial's `toolbar` contributions out, grouped by their `group`. */
60
- export function ToolbarRegion({ contributions, ctx }: ToolbarRegionProps) {
72
+ /** Lays a bar's contributions out, grouped by their `group`. */
73
+ export function ToolbarRegion<TCtx = TrialChromeContext>({
74
+ contributions,
75
+ ctx,
76
+ region = 'toolbar',
77
+ label = 'Trial actions',
78
+ }: ToolbarRegionProps<TCtx>) {
61
79
  if (contributions.length === 0) return null;
62
80
  const groups = groupsOf(contributions);
63
81
  return (
64
- <Toolbar aria-label="Trial actions">
82
+ <Toolbar aria-label={label}>
65
83
  {groups.map((g) => (
66
84
  <Toolbar.Group key={g.key} end={g.end} aria-label={g.key}>
67
- {g.entries.map((c) => renderEntry(c, ctx))}
85
+ {g.entries.map((c) => renderEntry(c, ctx, region))}
68
86
  </Toolbar.Group>
69
87
  ))}
70
88
  </Toolbar>
@@ -4,14 +4,15 @@ import type { ConfigField } from '../controls/types';
4
4
  import type { SavedSnapshot } from '../state/types';
5
5
 
6
6
  /** A named position in a trial's chrome. Content is not a region — that is
7
- * the instrument. */
7
+ * the instrument. The lab's own boxes are named in `LabRegion`. */
8
8
  export type TrialRegion = 'titlebar' | 'toolbar' | 'palette' | 'sidebar' | 'viewport' | 'status';
9
9
 
10
10
  /** An icon component taking a pixel size, as `@weasel-js/ui` glyphs do. */
11
11
  export type IconComponent = ComponentType<{ size?: number }>;
12
12
 
13
- /** A button in the trial toolbar. */
14
- export interface ToolbarItem {
13
+ /** A button in a toolbar. `TCtx` is the chrome context its `onActivate` is
14
+ * handed — a trial's, or the lab's for a lab-level bar. */
15
+ export interface ToolbarItem<TCtx = TrialChromeContext> {
15
16
  icon: IconComponent;
16
17
  label: string;
17
18
  /** Shown in the tooltip. Not bound here — the trial owns its keymap. */
@@ -24,10 +25,10 @@ export interface ToolbarItem {
24
25
  pressed?: boolean;
25
26
  /** Render the label beside the glyph rather than only in the tooltip. */
26
27
  showLabel?: boolean;
27
- /** Handed the trial's chrome context, so a contribution declared from the
28
- * lab can reach `ctx.saveSnapshot()` and the rest without the `render`
29
- * escape. A zero-argument handler stays valid. */
30
- onActivate: (ctx: TrialChromeContext) => void;
28
+ /** Handed the chrome context it was declared against, so a contribution can
29
+ * reach `ctx.saveSnapshot()` and the rest without the `render` escape. A
30
+ * zero-argument handler stays valid. */
31
+ onActivate: (ctx: TCtx) => void;
31
32
  }
32
33
 
33
34
  /** A selectable tool in the palette region. */
@@ -52,12 +53,12 @@ export interface SidebarSection {
52
53
  }
53
54
 
54
55
  /** A control acting on the view of the trial, not on the trial. */
55
- export interface ViewportControl {
56
+ export interface ViewportControl<TCtx = TrialChromeContext> {
56
57
  icon: IconComponent;
57
58
  label: string;
58
59
  disabled?: boolean;
59
60
  /** Handed the trial's chrome context, as `ToolbarItem.onActivate` is. */
60
- onActivate: (ctx: TrialChromeContext) => void;
61
+ onActivate: (ctx: TCtx) => void;
61
62
  }
62
63
 
63
64
  /** A readout in the status bar. */
@@ -68,8 +69,8 @@ export interface StatusReadout {
68
69
  title?: string;
69
70
  }
70
71
 
71
- /** What every contribution shares. */
72
- interface ContributionBase {
72
+ /** What every contribution shares, whichever chrome it is declared against. */
73
+ export interface ContributionBase {
73
74
  id: string;
74
75
  /** Groups sort by first appearance; items sort within a group by
75
76
  * declaration order. Contributions with no group sort after grouped ones. */
@@ -96,18 +97,49 @@ export type TrialContribution =
96
97
  render: (ctx: TrialChromeContext) => ReactNode;
97
98
  });
98
99
 
100
+ /**
101
+ * A tool slot a region can reflect and write. Both chrome contexts carry one:
102
+ * a trial's resolves to the lab's when its instrument declares no tools.
103
+ */
104
+ export interface ToolSlotContext {
105
+ activeToolId: string | null;
106
+ setActiveTool: (id: string) => void;
107
+ }
108
+
109
+ /** What a sidebar region reflects and writes: which sections are folded, and —
110
+ * where the chrome can tear a section out — where it goes. A trial supplies
111
+ * all four; a lab supplies the fold state only. */
112
+ export interface SidebarSlotContext {
113
+ collapsedSections: Readonly<Record<string, boolean>>;
114
+ setSectionCollapsed: (key: string, collapsed: boolean) => void;
115
+ undockedPanels?: readonly string[];
116
+ undockPanel?: (sectionId: string, as?: 'tile' | 'floating') => void;
117
+ }
118
+
119
+ /**
120
+ * A contribution as a region renderer sees it. A renderer checks the region
121
+ * name against its own and narrows `item` itself, which is what lets one
122
+ * renderer serve both chromes — the trial's and the lab's — without knowing
123
+ * which context it was handed.
124
+ */
125
+ export interface RegionContribution<TCtx> extends ContributionBase {
126
+ region: string;
127
+ item?: unknown;
128
+ render?: (ctx: TCtx) => ReactNode;
129
+ }
130
+
99
131
  /**
100
132
  * Everything a contribution can read about the trial it is being rendered
101
133
  * into. Replaces the three separate slot contexts, which each carried a
102
134
  * hand-picked subset.
103
135
  */
104
- export interface TrialChromeContext {
136
+ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext {
105
137
  trialId: string;
106
138
  instrumentName: string;
107
- /** What the title bar reads, which is the instrument's name until something
108
- * calls `setTitle`. */
139
+ /** What the title bar reads, which is the instrument's title (or, lacking
140
+ * one, its name) until something calls `setTitle`. */
109
141
  title: string;
110
- /** Retitle this trial; `null` restores the instrument name. Persisted with
142
+ /** Retitle this trial; `null` restores the instrument's title. Persisted with
111
143
  * the trial, so a title survives a reload. */
112
144
  setTitle: (title: string | null) => void;
113
145
  isLastTrial: boolean;
@@ -133,6 +165,9 @@ export interface TrialChromeContext {
133
165
  * `config`, since a builder schema has no `ConfigField[]` form. */
134
166
  configFields: ConfigField[];
135
167
  config: unknown;
168
+ /** The trial's unpinned paths. `config` stays raw, so a control drawn from
169
+ * it sits at the value un-pinning writes back. */
170
+ auto: ReadonlySet<string>;
136
171
  setConfig: (key: string, value: unknown) => void;
137
172
 
138
173
  /** Which of this trial's collapsible sections are folded. A sidebar
@@ -157,9 +192,4 @@ export interface TrialChromeContext {
157
192
  clone: () => void;
158
193
  reset: () => void;
159
194
  close: () => void;
160
-
161
- /** Resolved active tool: the trial's slot, or the lab's when the trial has
162
- * none. Null when neither holds one. */
163
- activeToolId: string | null;
164
- setActiveTool: (id: string) => void;
165
195
  }
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auto, isAuto } from './auto';
3
+
4
+ describe('auto', () => {
5
+ it('is a symbol distinct from every ordinary config value', () => {
6
+ expect(typeof auto).toBe('symbol');
7
+ expect(isAuto(auto)).toBe(true);
8
+ for (const v of [undefined, null, 0, '', 'auto', false, Symbol('auto'), {}]) {
9
+ expect(isAuto(v)).toBe(false);
10
+ }
11
+ });
12
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Written at a config path to mean "do not pin this — let the instrument
3
+ * decide". Accepted by `setConfig`, by a trial's `configSeed`, and by
4
+ * `.initial()` in a schema. It is normalized into the trial's set of unpinned
5
+ * paths on the way into the store and is never itself stored, so a serialized
6
+ * trial contains no sentinel and the last pinned value survives.
7
+ */
8
+ export const auto: unique symbol = Symbol('weasel.auto');
9
+
10
+ /** The type of the `auto` sentinel, for widening a value parameter. */
11
+ export type Auto = typeof auto;
12
+
13
+ export function isAuto(value: unknown): value is Auto {
14
+ return value === auto;
15
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auto } from './auto';
3
+ import { autoPathsOf, resolveAutoConfig } from './autoConfig';
4
+ import { f } from './builder';
5
+ import { resolveConfigSchema } from './resolve';
6
+
7
+ const schema = f.schema({
8
+ width: f.number(432),
9
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
10
+ cols: f.number(3).initial(auto),
11
+ grid: f.group({ size: f.number(8).auto(() => 99) }),
12
+ });
13
+ const resolved = resolveConfigSchema(schema);
14
+ const raw = { width: 432, gap: 12, cols: 3, grid: { size: 8 } };
15
+
16
+ describe('autoPathsOf', () => {
17
+ it('lists the dotted paths a schema starts auto', () => {
18
+ expect(autoPathsOf(resolved)).toEqual(['cols']);
19
+ });
20
+
21
+ it('reaches leaves nested under a group', () => {
22
+ const nested = resolveConfigSchema(
23
+ f.schema({ grid: f.group({ size: f.number(8).initial(auto) }) }),
24
+ );
25
+ expect(autoPathsOf(nested)).toEqual(['grid.size']);
26
+ });
27
+ });
28
+
29
+ describe('resolveAutoConfig', () => {
30
+ it('leaves a config with nothing auto exactly as it was', () => {
31
+ expect(resolveAutoConfig(resolved, raw, new Set())).toEqual(raw);
32
+ });
33
+
34
+ it('replaces an auto path that has a resolver with the computed value', () => {
35
+ const out = resolveAutoConfig(resolved, raw, new Set(['gap']));
36
+ expect(out.gap).toBe(18);
37
+ expect(out.width).toBe(432);
38
+ });
39
+
40
+ it('deletes an auto path that has no resolver', () => {
41
+ const out = resolveAutoConfig(resolved, raw, new Set(['cols'])) as Record<string, unknown>;
42
+ expect('cols' in out).toBe(false);
43
+ });
44
+
45
+ it('resolves a leaf nested under a group', () => {
46
+ const out = resolveAutoConfig(resolved, raw, new Set(['grid.size'])) as {
47
+ grid: { size: number };
48
+ };
49
+ expect(out.grid.size).toBe(99);
50
+ });
51
+
52
+ it('lets one resolver read another auto path, already resolved', () => {
53
+ const chained = resolveConfigSchema(
54
+ f.schema({
55
+ a: f.number(1).auto(() => 10),
56
+ b: f.number(2).auto((c) => (c.a as number) * 3),
57
+ }),
58
+ );
59
+ const out = resolveAutoConfig(chained, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
60
+ a: number;
61
+ b: number;
62
+ };
63
+ expect(out).toEqual({ a: 10, b: 30 });
64
+ });
65
+
66
+ it('resolves a dependency declared after its dependent', () => {
67
+ const backward = resolveConfigSchema(
68
+ f.schema({
69
+ b: f.number(2).auto((c) => (c.a as number) * 3),
70
+ a: f.number(1).auto(() => 10),
71
+ }),
72
+ );
73
+ const out = resolveAutoConfig(backward, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
74
+ a: number;
75
+ b: number;
76
+ };
77
+ expect(out).toEqual({ a: 10, b: 30 });
78
+ });
79
+
80
+ it('throws naming the path when resolvers cycle', () => {
81
+ const cyclic = resolveConfigSchema(
82
+ f.schema({
83
+ a: f.number(1).auto((c) => (c.b as number) + 1),
84
+ b: f.number(2).auto((c) => (c.a as number) + 1),
85
+ }),
86
+ );
87
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/cycle/i);
88
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/"a"/);
89
+ });
90
+
91
+ it('throws on a resolver that reads its own path', () => {
92
+ const selfish = resolveConfigSchema(
93
+ f.schema({ a: f.number(1).auto((c) => (c.a as number) + 1) }),
94
+ );
95
+ expect(() => resolveAutoConfig(selfish, { a: 1 }, new Set(['a']))).toThrow(/cycle/i);
96
+ });
97
+
98
+ it('ignores an auto path the schema does not have', () => {
99
+ expect(resolveAutoConfig(resolved, raw, new Set(['gone']))).toEqual(raw);
100
+ });
101
+
102
+ it('does not mutate the config it was given', () => {
103
+ const before = structuredClone(raw);
104
+ resolveAutoConfig(resolved, raw, new Set(['gap', 'grid.size']));
105
+ expect(raw).toEqual(before);
106
+ });
107
+ });