@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
@@ -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,15 @@ 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>(
43
+ c: RegionContribution<TCtx>,
44
+ ctx: TCtx,
45
+ region: string,
46
+ ): ReactNode {
39
47
  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;
48
+ if (c.region !== region || !c.item) return null;
49
+ const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed, onActivate } =
50
+ c.item as ToolbarItem<TCtx>;
42
51
  return (
43
52
  <Toolbar.Button
44
53
  key={c.id}
@@ -56,15 +65,20 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
56
65
  );
57
66
  }
58
67
 
59
- /** Lays a trial's `toolbar` contributions out, grouped by their `group`. */
60
- export function ToolbarRegion({ contributions, ctx }: ToolbarRegionProps) {
68
+ /** Lays a bar's contributions out, grouped by their `group`. */
69
+ export function ToolbarRegion<TCtx = TrialChromeContext>({
70
+ contributions,
71
+ ctx,
72
+ region = 'toolbar',
73
+ label = 'Trial actions',
74
+ }: ToolbarRegionProps<TCtx>) {
61
75
  if (contributions.length === 0) return null;
62
76
  const groups = groupsOf(contributions);
63
77
  return (
64
- <Toolbar aria-label="Trial actions">
78
+ <Toolbar aria-label={label}>
65
79
  {groups.map((g) => (
66
80
  <Toolbar.Group key={g.key} end={g.end} aria-label={g.key}>
67
- {g.entries.map((c) => renderEntry(c, ctx))}
81
+ {g.entries.map((c) => renderEntry(c, ctx, region))}
68
82
  </Toolbar.Group>
69
83
  ))}
70
84
  </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;
@@ -157,9 +189,4 @@ export interface TrialChromeContext {
157
189
  clone: () => void;
158
190
  reset: () => void;
159
191
  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
192
  }
@@ -35,6 +35,10 @@ describe('builder', () => {
35
35
  expect(n.annotations.suffix).toBe('px');
36
36
  });
37
37
 
38
+ it('annotates a number with a display format', () => {
39
+ expect(f.number(0).format('compact').annotations.format).toBe('compact');
40
+ });
41
+
38
42
  it('keeps a custom node kind across a chain', () => {
39
43
  const n = f.custom('vector2', { x: 0 }).label('Offset');
40
44
  expect(n.kind).toBe('vector2');
@@ -1,4 +1,4 @@
1
- import type { PrefLeaf } from '@weasel-js/ui';
1
+ import type { PrefLeaf, PrefNumberFormat } from '@weasel-js/ui';
2
2
  import type {
3
3
  Annotations,
4
4
  BranchAnnotations,
@@ -21,7 +21,7 @@ export function isConfigBranch(entry: ConfigEntry): entry is ConfigBranch {
21
21
 
22
22
  /** Shared chaining surface. Every method clones, so a node can be reused as a
23
23
  * base for several leaves without one bleeding into the next. */
24
- abstract class BaseNode<T> implements ConfigNode<T> {
24
+ export abstract class BaseNode<T> implements ConfigNode<T> {
25
25
  abstract readonly kind: string | null;
26
26
 
27
27
  // `default` is a reserved word in a parameter position, so the field is
@@ -89,7 +89,7 @@ abstract class BaseNode<T> implements ConfigNode<T> {
89
89
  }
90
90
  }
91
91
 
92
- class NumberNode extends BaseNode<number> {
92
+ export class NumberNode extends BaseNode<number> {
93
93
  readonly kind = 'number';
94
94
 
95
95
  /** Bound the value. A number with both bounds renders as a slider. */
@@ -107,6 +107,12 @@ class NumberNode extends BaseNode<number> {
107
107
  return this.ann({ suffix });
108
108
  }
109
109
 
110
+ /** Show the value in a named format — `'compact'` reads `2.0M`. Presentation
111
+ * only: the stored value stays a plain number, and a typed `2.5m` reads back. */
112
+ format(format: PrefNumberFormat): this {
113
+ return this.ann({ format });
114
+ }
115
+
110
116
  /** Force a slider even without both bounds. */
111
117
  slider(): this {
112
118
  return this.ann({ control: 'slider' });
@@ -118,7 +124,7 @@ class NumberNode extends BaseNode<number> {
118
124
  }
119
125
  }
120
126
 
121
- class BooleanNode extends BaseNode<boolean> {
127
+ export class BooleanNode extends BaseNode<boolean> {
122
128
  readonly kind = 'boolean';
123
129
 
124
130
  /** Ask for a switch. `ControlPanel` still draws a checkbox; weasel-ui's
@@ -128,7 +134,7 @@ class BooleanNode extends BaseNode<boolean> {
128
134
  }
129
135
  }
130
136
 
131
- class StringNode extends BaseNode<string> {
137
+ export class StringNode extends BaseNode<string> {
132
138
  readonly kind = 'string';
133
139
 
134
140
  placeholder(placeholder: string): this {
@@ -145,11 +151,11 @@ class StringNode extends BaseNode<string> {
145
151
  }
146
152
  }
147
153
 
148
- class ColorNode extends BaseNode<string> {
154
+ export class ColorNode extends BaseNode<string> {
149
155
  readonly kind = 'color';
150
156
  }
151
157
 
152
- class EnumNode<T extends string> extends BaseNode<T> {
158
+ export class EnumNode<T extends string> extends BaseNode<T> {
153
159
  readonly kind = 'enum';
154
160
 
155
161
  /** Render as a segmented control rather than a select. */
@@ -165,12 +171,12 @@ class EnumNode<T extends string> extends BaseNode<T> {
165
171
  }
166
172
 
167
173
  /** A leaf whose kind the rule chain decides. */
168
- class ValueNode<T> extends BaseNode<T> {
174
+ export class ValueNode<T> extends BaseNode<T> {
169
175
  readonly kind = null;
170
176
  }
171
177
 
172
178
  /** A leaf of a kind labkit does not ship a row for. */
173
- class CustomNode<T> extends BaseNode<T> {
179
+ export class CustomNode<T> extends BaseNode<T> {
174
180
  constructor(
175
181
  readonly kind: string,
176
182
  defaultValue: T,
@@ -190,7 +196,7 @@ class CustomNode<T> extends BaseNode<T> {
190
196
  * one is read and written at a dotted path. Distinct from `.section()`, which
191
197
  * puts a heading over sibling leaves and leaves their paths alone.
192
198
  */
193
- class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
199
+ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
194
200
  constructor(
195
201
  readonly children: S,
196
202
  readonly annotations: Readonly<BranchAnnotations> = {},
@@ -0,0 +1,11 @@
1
+ import { f } from './builder';
2
+
3
+ export const flat = f.schema({ n: f.number(1).range(0, 4), s: f.string('x').label('S') });
4
+
5
+ export const grouped = f.schema({
6
+ g: f.group({ on: f.boolean(true).toggle(), e: f.enum('a', ['a', 'b']).radio() }).label('G'),
7
+ });
8
+
9
+ export const node = f.number(1).slider();
10
+
11
+ export const group = f.group({ c: f.color('#fff'), v: f.value(1), k: f.custom('k', 'x') });
@@ -0,0 +1,51 @@
1
+ import { resolve } from 'node:path';
2
+ import ts from 'typescript';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const root = resolve(__dirname, '../../../..');
6
+ const fixture = resolve(__dirname, 'declarationEmit.fixture.ts');
7
+
8
+ /** Emits declarations for one file under the root tsconfig and returns every diagnostic, formatted. */
9
+ function declarationDiagnostics(file: string): string[] {
10
+ const parsed = ts.getParsedCommandLineOfConfigFile(
11
+ resolve(root, 'tsconfig.json'),
12
+ {},
13
+ {
14
+ ...ts.sys,
15
+ onUnRecoverableConfigFileDiagnostic: (d) => {
16
+ throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
17
+ },
18
+ },
19
+ );
20
+ if (!parsed) throw new Error('could not read the root tsconfig');
21
+ const options: ts.CompilerOptions = {
22
+ ...parsed.options,
23
+ noEmit: false,
24
+ declaration: true,
25
+ emitDeclarationOnly: true,
26
+ declarationMap: false,
27
+ sourceMap: false,
28
+ incremental: false,
29
+ tsBuildInfoFile: undefined,
30
+ types: [],
31
+ };
32
+ const program = ts.createProgram({ rootNames: [file], options });
33
+ const source = program.getSourceFile(file);
34
+ if (!source) throw new Error(`${file} is not in the program`);
35
+ const emitted = program.emit(source, () => {}, undefined, true);
36
+ const diagnostics = [
37
+ ...program.getSyntacticDiagnostics(source),
38
+ ...program.getSemanticDiagnostics(source),
39
+ ...emitted.diagnostics,
40
+ ];
41
+ return diagnostics.map((d) => {
42
+ const text = ts.flattenDiagnosticMessageText(d.messageText, '\n');
43
+ return d.file ? `${d.file.fileName}: TS${d.code} ${text}` : `TS${d.code} ${text}`;
44
+ });
45
+ }
46
+
47
+ describe('config schema declarations', () => {
48
+ it('emit for exported schemas, groups and nodes', () => {
49
+ expect(declarationDiagnostics(fixture)).toEqual([]);
50
+ }, 60_000);
51
+ });
@@ -0,0 +1,46 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const IMPORT =
6
+ /^\s*(?:import|export)\s+(?!type\b)[^'"]*from\s+['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gm;
7
+
8
+ const isFile = (path: string) => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
9
+
10
+ function walk(file: string, seen = new Set<string>()): Set<string> {
11
+ if (seen.has(file)) return seen;
12
+ seen.add(file);
13
+ const source = readFileSync(file, 'utf8');
14
+ for (const match of source.matchAll(IMPORT)) {
15
+ const spec = match[1] ?? match[2] ?? match[3];
16
+ if (!spec?.startsWith('.')) continue;
17
+ const base = resolve(dirname(file), spec.replace(/\.js$/, ''));
18
+ const candidates = [
19
+ resolve(dirname(file), spec),
20
+ `${base}.ts`,
21
+ `${base}.tsx`,
22
+ resolve(base, 'index.ts'),
23
+ resolve(base, 'index.tsx'),
24
+ ];
25
+ const hit = candidates.find(isFile);
26
+ // An import the walk cannot follow would let a stylesheet behind it pass unseen.
27
+ if (!hit) throw new Error(`${file}: cannot resolve ${spec}`);
28
+ walk(hit, seen);
29
+ }
30
+ return seen;
31
+ }
32
+
33
+ describe('@weasel-js/labkit/config', () => {
34
+ it('reaches no stylesheet', () => {
35
+ const files = [...walk(resolve(__dirname, 'index.ts'))];
36
+ expect(files.filter((f) => /\.(less|css)$/.test(f))).toEqual([]);
37
+ });
38
+
39
+ it('is published as an entry', async () => {
40
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
41
+ expect(pkg.exports['./config']).toEqual({
42
+ types: './dist/config/index.d.ts',
43
+ import: './dist/config/index.js',
44
+ });
45
+ });
46
+ });
@@ -1,4 +1,16 @@
1
- export { f, isConfigBranch } from './builder';
1
+ export {
2
+ BaseNode,
3
+ BooleanNode,
4
+ ColorNode,
5
+ CustomNode,
6
+ EnumNode,
7
+ f,
8
+ GroupNode,
9
+ isConfigBranch,
10
+ NumberNode,
11
+ StringNode,
12
+ ValueNode,
13
+ } from './builder';
2
14
  export { fromConfigFields } from './fromConfigField';
3
15
  export {
4
16
  fillConfigDefaults,
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { f, GroupNode, NumberNode } from './index';
3
+
4
+ describe('config node classes', () => {
5
+ it('are exported as values', () => {
6
+ expect(typeof NumberNode).toBe('function');
7
+ expect(typeof GroupNode).toBe('function');
8
+ expect(f.number(1)).toBeInstanceOf(NumberNode);
9
+ expect(f.group({ n: f.number(1) })).toBeInstanceOf(GroupNode);
10
+ });
11
+
12
+ it('can be extended', () => {
13
+ class Mine extends NumberNode {}
14
+ const node = new Mine(2);
15
+ expect(node).toBeInstanceOf(NumberNode);
16
+ expect(node.default).toBe(2);
17
+ });
18
+ });
@@ -1,4 +1,4 @@
1
- import type { PrefGroup, PrefLeaf, PrefRenderer } from '@weasel-js/ui';
1
+ import type { PrefGroup, PrefLeaf, PrefNumberFormat, PrefRenderer } from '@weasel-js/ui';
2
2
 
3
3
  /**
4
4
  * Renders the control cell for one config leaf. Identical to weasel-ui's
@@ -25,6 +25,8 @@ export interface Annotations {
25
25
  max?: number;
26
26
  step?: number;
27
27
  suffix?: string;
28
+ /** How a number's value is shown. `compact` abbreviates from a thousand up. */
29
+ format?: PrefNumberFormat;
28
30
  control?: string;
29
31
  options?: readonly ConfigOption[];
30
32
  placeholder?: string;
@@ -444,6 +444,22 @@ describe('<ControlPanel> suffix', () => {
444
444
  });
445
445
  });
446
446
 
447
+ describe('<ControlPanel> format', () => {
448
+ it('shows a slider readout in the format the leaf declares', () => {
449
+ render(
450
+ <ControlPanel
451
+ schema={resolveConfigSchema(
452
+ f.schema({ glyphs: f.number(0).range(0, 2_000_000).format('compact') }),
453
+ [],
454
+ )}
455
+ config={{ glyphs: 2_000_000 }}
456
+ setConfig={vi.fn()}
457
+ />,
458
+ );
459
+ expect(screen.getByDisplayValue('2.0M')).toBeInTheDocument();
460
+ });
461
+ });
462
+
447
463
  describe('<ControlPanel> collapsible sections', () => {
448
464
  const schema = resolveConfigSchema(
449
465
  f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
@@ -6,6 +6,7 @@ import {
6
6
  NumberRow,
7
7
  type PrefGroup,
8
8
  type PrefLeaf,
9
+ type PrefNumberFormat,
9
10
  type PropertyAlign,
10
11
  type PropertyDensity,
11
12
  PropertyGroup,
@@ -26,12 +27,12 @@ import { isLeafVisible } from '../config/visible';
26
27
  import type { ConfigField } from './types';
27
28
 
28
29
  /** How a panel packs its rows into the two-column property grid.
29
- * - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colours) sit
30
+ * - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colors) sit
30
31
  * two per row; the ones that need the width — text, sliders, segmented
31
32
  * toggles — span it.
32
33
  * - `'pairs'`: every row pairs. A control that needs the full width says so
33
34
  * itself, with `<PropertyRow span>`.
34
- * - `'one-up'`: one control per row, colours excepted.
35
+ * - `'one-up'`: one control per row, colors excepted.
35
36
  */
36
37
  export type ControlPack = 'auto' | 'pairs' | 'one-up';
37
38
 
@@ -247,6 +248,7 @@ function ControlRow<TC extends Record<string, unknown>>({
247
248
  const max = extra<number>(leaf, 'max');
248
249
  const step = extra<number>(leaf, 'step');
249
250
  const suffix = extra<string>(leaf, 'suffix');
251
+ const notation = extra<PrefNumberFormat>(leaf, 'format');
250
252
  if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
251
253
  return (
252
254
  <SliderRow
@@ -255,6 +257,7 @@ function ControlRow<TC extends Record<string, unknown>>({
255
257
  min={min}
256
258
  max={max}
257
259
  step={step}
260
+ notation={notation}
258
261
  unit={suffix}
259
262
  onChange={write}
260
263
  layout={layout}
@@ -0,0 +1,5 @@
1
+ // fake-indexeddb's `./auto` export declares no `types` condition, so under
2
+ // `moduleResolution: Bundler` tsc finds no declaration for it — the ambient one
3
+ // the package ships at its root is never part of the compilation. The subpath
4
+ // is side-effect-only, so a bare declaration is the whole of it.
5
+ declare module 'fake-indexeddb/auto';
package/src/index.test.ts CHANGED
@@ -51,6 +51,13 @@ describe('public export surface', () => {
51
51
  });
52
52
  });
53
53
 
54
+ describe('ui re-exports', () => {
55
+ it('carries ToggleBar and Button from the package root', () => {
56
+ expect(labkit).toHaveProperty('ToggleBar');
57
+ expect(labkit).toHaveProperty('Button');
58
+ });
59
+ });
60
+
54
61
  describe('surface, job and orbit entry points', () => {
55
62
  it('are reachable from the package root', async () => {
56
63
  const kit = await import('./index');