@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { CrosshairIcon, HandIcon, PencilIcon } from '@weasel-js/ui';
3
+ import type { ConfigField } from '../controls/types';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { Lab } from '../lab/Lab';
6
+
7
+ interface DemoConfig extends Record<string, unknown> {
8
+ radius: number;
9
+ filled: boolean;
10
+ }
11
+
12
+ interface DemoState extends Record<string, unknown> {
13
+ spokes: number;
14
+ }
15
+
16
+ const fields: ConfigField[] = [
17
+ { key: 'radius', label: 'Radius', type: 'slider', default: 60, min: 10, max: 120, step: 1 },
18
+ { key: 'filled', label: 'Filled', type: 'checkbox', default: true },
19
+ ];
20
+
21
+ /** Declares every capability that contributes chrome, so all five trial
22
+ * regions render at once. */
23
+ const FullInstrument: Instrument<DemoState, DemoConfig> = {
24
+ name: 'Every Region',
25
+ defaultConfig: () => ({ radius: 60, filled: true }),
26
+ initialState: () => ({ spokes: 7 }),
27
+ configSchema: () => fields,
28
+ undo: {},
29
+ layers: { ids: ['wheel'] },
30
+ tools: {
31
+ tools: [
32
+ { id: 'draw', label: 'Draw', icon: PencilIcon, shortcut: 'D' },
33
+ { id: 'pan', label: 'Pan', icon: HandIcon, shortcut: 'H' },
34
+ { id: 'measure', label: 'Measure', icon: CrosshairIcon, shortcut: 'M' },
35
+ ],
36
+ },
37
+ canvas: {
38
+ layers: [
39
+ {
40
+ id: 'wheel',
41
+ draw: (ctx, { state, config }) => {
42
+ ctx.strokeStyle = '#8ab';
43
+ ctx.fillStyle = 'rgba(136, 170, 187, 0.25)';
44
+ ctx.lineWidth = 1.5;
45
+ ctx.beginPath();
46
+ ctx.arc(160, 120, config.radius, 0, Math.PI * 2);
47
+ if (config.filled) ctx.fill();
48
+ ctx.stroke();
49
+ for (let i = 0; i < state.spokes; i++) {
50
+ const a = (i / state.spokes) * Math.PI * 2;
51
+ ctx.beginPath();
52
+ ctx.moveTo(160, 120);
53
+ ctx.lineTo(160 + Math.cos(a) * config.radius, 120 + Math.sin(a) * config.radius);
54
+ ctx.stroke();
55
+ }
56
+ },
57
+ },
58
+ ],
59
+ },
60
+ render: ({ trial }) => (
61
+ <div className="lk-region-demo-readout">tool: {trial.activeToolId ?? 'none'}</div>
62
+ ),
63
+ };
64
+
65
+ const meta: Meta<typeof Lab> = {
66
+ title: 'labkit/Chrome/Regions',
67
+ component: Lab,
68
+ parameters: { layout: 'fullscreen' },
69
+ };
70
+ export default meta;
71
+
72
+ type Story = StoryObj<typeof Lab>;
73
+
74
+ export const EveryRegion: Story = {
75
+ args: {
76
+ instruments: [FullInstrument],
77
+ defaultInstrument: 'Every Region',
78
+ title: 'Chrome Regions',
79
+ storage: null,
80
+ },
81
+ };
82
+
83
+ /** A consumer dropping a built-in and adding its own in its place. */
84
+ export const SuppressedAndReplaced: Story = {
85
+ args: {
86
+ instruments: [FullInstrument],
87
+ defaultInstrument: 'Every Region',
88
+ title: 'Suppress + Replace',
89
+ storage: null,
90
+ suppress: ['snapshot'],
91
+ chrome: [
92
+ {
93
+ id: 'export',
94
+ region: 'toolbar',
95
+ group: 'trial',
96
+ end: true,
97
+ item: {
98
+ icon: CrosshairIcon,
99
+ label: 'Export',
100
+ showLabel: true,
101
+ onActivate: () => {},
102
+ },
103
+ },
104
+ ],
105
+ },
106
+ };
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { f } from '../config/builder';
3
+ import { fromConfigFields } from '../config/fromConfigField';
4
+ import { resolveConfigSchema } from '../config/resolve';
5
+ import type { Instrument } from '../instrument/types';
6
+ import { builtinContributions } from './builtins';
7
+ import type { TrialChromeContext } from './types';
8
+
9
+ const ctx: TrialChromeContext = {
10
+ trialId: 't1',
11
+ instrumentName: 'Stub',
12
+ isLastTrial: false,
13
+ zoom: 1,
14
+ setZoom: () => {},
15
+ canUndo: true,
16
+ canRedo: false,
17
+ undo: () => {},
18
+ redo: () => {},
19
+ configFields: [],
20
+ configSchema: fromConfigFields([]),
21
+ config: {},
22
+ setConfig: () => {},
23
+ savedSnapshots: [],
24
+ saveSnapshot: () => {},
25
+ loadSnapshot: () => {},
26
+ clone: () => {},
27
+ reset: () => {},
28
+ close: () => {},
29
+ activeToolId: null,
30
+ setActiveTool: () => {},
31
+ };
32
+
33
+ const bare: Instrument = {
34
+ name: 'Stub',
35
+ defaultConfig: () => ({}),
36
+ initialState: () => ({}),
37
+ render: () => null,
38
+ };
39
+
40
+ const withCanvas: Instrument = { ...bare, canvas: { layers: [] } };
41
+
42
+ const ctxWithZoom = (zoom: number | null): TrialChromeContext => ({ ...ctx, zoom });
43
+
44
+ function ids(instrument: Instrument, c: TrialChromeContext = ctx): string[] {
45
+ return builtinContributions(instrument, c).map((x) => x.id);
46
+ }
47
+
48
+ describe('builtinContributions', () => {
49
+ it('always contributes the trial actions', () => {
50
+ expect(ids(bare)).toEqual(['snapshot', 'clone', 'reset', 'close']);
51
+ });
52
+
53
+ it('contributes undo and redo only when the instrument declares undo', () => {
54
+ expect(ids(bare)).not.toContain('undo');
55
+ expect(ids({ ...bare, undo: {} })).toContain('undo');
56
+ expect(ids({ ...bare, undo: {} })).toContain('redo');
57
+ });
58
+
59
+ it('puts zoom controls in the viewport region, not the toolbar', () => {
60
+ const zoomIn = builtinContributions(withCanvas, ctx).find((c) => c.id === 'zoom-in');
61
+ expect(zoomIn?.region).toBe('viewport');
62
+ });
63
+
64
+ it('contributes no viewport controls when the view is not 2D', () => {
65
+ expect(ids(withCanvas, ctxWithZoom(null))).not.toContain('zoom-in');
66
+ });
67
+
68
+ it('contributes a settings section only when the schema has fields', () => {
69
+ expect(ids(bare)).not.toContain('settings');
70
+ const withFields = {
71
+ ...ctx,
72
+ configSchema: fromConfigFields([
73
+ { key: 'n', label: 'N', type: 'number' as const, default: 1 },
74
+ ]),
75
+ };
76
+ expect(ids(bare, withFields)).toContain('settings');
77
+ });
78
+
79
+ it('contributes a settings section for a builder schema too', () => {
80
+ const withSchema = {
81
+ ...ctx,
82
+ configSchema: resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []),
83
+ };
84
+ expect(ids(bare, withSchema)).toContain('settings');
85
+ });
86
+
87
+ it('reflects undo availability in the item, not by omitting it', () => {
88
+ const withUndo: Instrument = { ...bare, undo: {} };
89
+ const list = builtinContributions(withUndo, { ...ctx, canUndo: false });
90
+ const undo = list.find((c) => c.id === 'undo');
91
+ expect(undo?.item).toMatchObject({ disabled: true });
92
+ });
93
+
94
+ it('marks close as danger and disables it on the last trial', () => {
95
+ const list = builtinContributions(bare, { ...ctx, isLastTrial: true });
96
+ expect(list.find((c) => c.id === 'close')?.item).toMatchObject({
97
+ danger: true,
98
+ disabled: true,
99
+ });
100
+ });
101
+
102
+ it('offers the snapshot loader only once a snapshot has been taken', () => {
103
+ expect(ids(bare)).not.toContain('snapshot-load');
104
+ const withSaves = {
105
+ ...ctx,
106
+ savedSnapshots: [
107
+ {
108
+ id: 's1',
109
+ name: 'First',
110
+ trialId: 't1',
111
+ instrumentName: 'Stub',
112
+ config: {},
113
+ state: {},
114
+ savedAt: 1,
115
+ },
116
+ ],
117
+ };
118
+ expect(ids(bare, withSaves)).toContain('snapshot-load');
119
+ });
120
+
121
+ it('produces no duplicate ids for a fully-declared instrument', () => {
122
+ const full: Instrument = {
123
+ ...bare,
124
+ undo: {},
125
+ canvas: { layers: [] },
126
+ layers: { ids: ['a'] },
127
+ };
128
+ const list = builtinContributions(full, ctx);
129
+ expect(new Set(list.map((c) => c.id)).size).toBe(list.length);
130
+ });
131
+ });
132
+
133
+ describe('view readouts', () => {
134
+ it('contributes fps and scale to the status bar when the trial has a canvas', () => {
135
+ const out = builtinContributions(withCanvas, ctxWithZoom(1));
136
+ const status = out.filter((c) => c.region === 'status').map((c) => c.id);
137
+ expect(status).toContain('fps');
138
+ expect(status).toContain('scale');
139
+ });
140
+
141
+ it('contributes the zoom control to the viewport, replacing the readout', () => {
142
+ const out = builtinContributions(withCanvas, ctxWithZoom(1));
143
+ const viewport = out.filter((c) => c.region === 'viewport').map((c) => c.id);
144
+ expect(viewport).toContain('zoom-control');
145
+ expect(out.map((c) => c.id)).not.toContain('zoom-readout');
146
+ });
147
+
148
+ it('contributes none of them to a trial with no canvas', () => {
149
+ const out = builtinContributions(bare, ctxWithZoom(null));
150
+ expect(out.map((c) => c.id)).not.toContain('fps');
151
+ expect(out.map((c) => c.id)).not.toContain('zoom-control');
152
+ });
153
+ });
@@ -0,0 +1,205 @@
1
+ import {
2
+ CloneIcon,
3
+ CloseIcon,
4
+ FitIcon,
5
+ RedoIcon,
6
+ ResetIcon,
7
+ SnapshotIcon,
8
+ UndoIcon,
9
+ ZoomInIcon,
10
+ ZoomOutIcon,
11
+ } from '@weasel-js/ui';
12
+ import type { ControlRenderer } from '../config/types';
13
+ import { ControlPanel } from '../controls/ControlPanel';
14
+ import type { Instrument } from '../instrument/types';
15
+ import { Select } from '../passthrough/weasel-ui';
16
+ import { FpsMeter } from '../primitives/FpsMeter';
17
+ import { ScaleIndicator } from '../primitives/ScaleIndicator';
18
+ import { ZoomControl } from '../primitives/ZoomControl';
19
+ import type { TrialChromeContext, TrialContribution } from './types';
20
+
21
+ const ZOOM_STEP = 1.25;
22
+
23
+ /**
24
+ * The contributions a trial gets from what its instrument declared. This is
25
+ * the whole "declaring a capability provides the chrome" rule — it replaces
26
+ * the presence checks that used to be spread across the runtime.
27
+ */
28
+ export function builtinContributions(
29
+ instrument: Instrument,
30
+ ctx: TrialChromeContext,
31
+ controls?: Record<string, ControlRenderer>,
32
+ ): TrialContribution[] {
33
+ const out: TrialContribution[] = [];
34
+ const zoom = ctx.zoom;
35
+
36
+ if (instrument.undo != null) {
37
+ out.push({
38
+ id: 'undo',
39
+ region: 'toolbar',
40
+ group: 'history',
41
+ item: {
42
+ icon: UndoIcon,
43
+ label: 'Undo',
44
+ shortcut: 'Mod+Z',
45
+ disabled: !ctx.canUndo,
46
+ onActivate: ctx.undo,
47
+ },
48
+ });
49
+ out.push({
50
+ id: 'redo',
51
+ region: 'toolbar',
52
+ group: 'history',
53
+ item: {
54
+ icon: RedoIcon,
55
+ label: 'Redo',
56
+ shortcut: 'Mod+Shift+Z',
57
+ disabled: !ctx.canRedo,
58
+ onActivate: ctx.redo,
59
+ },
60
+ });
61
+ }
62
+
63
+ // A tool id shares the contribution namespace, so a tool called `close`
64
+ // collides with the built-in close button and throws. Both are contributions
65
+ // to one trial's chrome.
66
+ for (const t of instrument.tools?.tools ?? []) {
67
+ out.push({
68
+ id: t.id,
69
+ region: 'palette',
70
+ group: t.group,
71
+ item: { icon: t.icon, label: t.label, shortcut: t.shortcut },
72
+ });
73
+ }
74
+
75
+ // Zoom acts on the view of the trial, so it is a viewport control. A trial
76
+ // holding a non-2D view reports zoom as null and gets none of this.
77
+ if (instrument.canvas != null && zoom !== null) {
78
+ out.push({
79
+ id: 'zoom-out',
80
+ region: 'viewport',
81
+ group: 'zoom',
82
+ item: {
83
+ icon: ZoomOutIcon,
84
+ label: 'Zoom out',
85
+ onActivate: () => ctx.setZoom(zoom / ZOOM_STEP),
86
+ },
87
+ });
88
+ out.push({
89
+ id: 'zoom-in',
90
+ region: 'viewport',
91
+ group: 'zoom',
92
+ item: {
93
+ icon: ZoomInIcon,
94
+ label: 'Zoom in',
95
+ onActivate: () => ctx.setZoom(zoom * ZOOM_STEP),
96
+ },
97
+ });
98
+ out.push({
99
+ id: 'actual-size',
100
+ region: 'viewport',
101
+ group: 'zoom',
102
+ item: { icon: FitIcon, label: 'Actual size', onActivate: () => ctx.setZoom(1) },
103
+ });
104
+ out.push({
105
+ id: 'zoom-control',
106
+ region: 'viewport',
107
+ group: 'zoom',
108
+ render: (c) => <ZoomControl zoom={c.zoom ?? 1} onZoomChange={c.setZoom} />,
109
+ });
110
+ out.push({
111
+ id: 'scale',
112
+ region: 'status',
113
+ group: 'view',
114
+ render: () => <ScaleIndicator />,
115
+ });
116
+ out.push({
117
+ id: 'fps',
118
+ region: 'status',
119
+ group: 'view',
120
+ end: true,
121
+ render: () => <FpsMeter />,
122
+ });
123
+ }
124
+
125
+ if (Object.keys(ctx.configSchema.group.children).length > 0) {
126
+ out.push({
127
+ id: 'settings',
128
+ region: 'sidebar',
129
+ item: {
130
+ title: 'Settings',
131
+ body: (
132
+ <ControlPanel
133
+ schema={ctx.configSchema}
134
+ config={ctx.config as Record<string, unknown>}
135
+ setConfig={(key, value) => ctx.setConfig(String(key), value)}
136
+ renderers={controls}
137
+ />
138
+ ),
139
+ },
140
+ });
141
+ }
142
+
143
+ out.push({
144
+ id: 'snapshot',
145
+ region: 'toolbar',
146
+ group: 'history',
147
+ item: {
148
+ icon: SnapshotIcon,
149
+ label: 'Save snapshot',
150
+ shortcut: 'Mod+S',
151
+ onActivate: () => ctx.saveSnapshot(),
152
+ },
153
+ });
154
+ if (ctx.savedSnapshots.length > 0) {
155
+ // A picker is not an icon button, so it takes the render escape. Held at
156
+ // `selectedKey={null}` so it stays a "load one" action rather than
157
+ // drifting into a display of what was loaded last.
158
+ out.push({
159
+ id: 'snapshot-load',
160
+ region: 'toolbar',
161
+ group: 'trial',
162
+ end: true,
163
+ render: (c) => (
164
+ <Select
165
+ className="lk-toolbar__load-select"
166
+ aria-label="Load snapshot"
167
+ placeholder="Load…"
168
+ selectedKey={null}
169
+ options={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
170
+ onSelectionChange={(id) => {
171
+ if (id != null) c.loadSnapshot(String(id));
172
+ }}
173
+ />
174
+ ),
175
+ });
176
+ }
177
+ out.push({
178
+ id: 'clone',
179
+ region: 'toolbar',
180
+ group: 'trial',
181
+ end: true,
182
+ item: { icon: CloneIcon, label: 'Clone trial', onActivate: ctx.clone },
183
+ });
184
+ out.push({
185
+ id: 'reset',
186
+ region: 'toolbar',
187
+ group: 'trial',
188
+ end: true,
189
+ item: { icon: ResetIcon, label: 'Reset trial', onActivate: ctx.reset },
190
+ });
191
+ out.push({
192
+ id: 'close',
193
+ region: 'titlebar',
194
+ end: true,
195
+ item: {
196
+ icon: CloseIcon,
197
+ label: ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial',
198
+ danger: true,
199
+ disabled: ctx.isLastTrial,
200
+ onActivate: ctx.close,
201
+ },
202
+ });
203
+
204
+ return out;
205
+ }
@@ -0,0 +1,25 @@
1
+ export { builtinContributions } from './builtins';
2
+ export { mergeContributions, suppressContributions } from './merge';
3
+ export type { PaletteRegionProps } from './regions/PaletteRegion';
4
+ export { PaletteRegion } from './regions/PaletteRegion';
5
+ export type { SidebarRegionProps } from './regions/SidebarRegion';
6
+ export { SidebarRegion } from './regions/SidebarRegion';
7
+ export type { StatusRegionProps } from './regions/StatusRegion';
8
+ export { StatusRegion } from './regions/StatusRegion';
9
+ export type { TitleBarRegionProps } from './regions/TitleBarRegion';
10
+ export { TitleBarRegion } from './regions/TitleBarRegion';
11
+ export type { ToolbarRegionProps } from './regions/ToolbarRegion';
12
+ export { ToolbarRegion } from './regions/ToolbarRegion';
13
+ export type { ViewportRegionProps } from './regions/ViewportRegion';
14
+ export { ViewportRegion } from './regions/ViewportRegion';
15
+ export type {
16
+ IconComponent,
17
+ SidebarSection,
18
+ StatusReadout,
19
+ ToolbarItem,
20
+ ToolItem,
21
+ TrialChromeContext,
22
+ TrialContribution,
23
+ TrialRegion,
24
+ ViewportControl,
25
+ } from './types';
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mergeContributions, suppressContributions } from './merge';
3
+ import type { TrialContribution } from './types';
4
+
5
+ const Glyph = () => null;
6
+
7
+ function toolbarItem(id: string): TrialContribution {
8
+ return {
9
+ id,
10
+ region: 'toolbar',
11
+ item: { icon: Glyph, label: id, onActivate: () => {} },
12
+ };
13
+ }
14
+
15
+ describe('mergeContributions', () => {
16
+ it('concatenates bundles in order', () => {
17
+ const out = mergeContributions([toolbarItem('a')], [toolbarItem('b'), toolbarItem('c')]);
18
+ expect(out.map((c) => c.id)).toEqual(['a', 'b', 'c']);
19
+ });
20
+
21
+ it('throws on a duplicate id, naming it', () => {
22
+ expect(() => mergeContributions([toolbarItem('undo')], [toolbarItem('undo')])).toThrow(
23
+ /duplicate contribution id "undo"/,
24
+ );
25
+ });
26
+
27
+ it('accepts no bundles', () => {
28
+ expect(mergeContributions()).toEqual([]);
29
+ });
30
+ });
31
+
32
+ describe('suppressContributions', () => {
33
+ it('removes the named ids', () => {
34
+ const out = suppressContributions(
35
+ [toolbarItem('a'), toolbarItem('b'), toolbarItem('c')],
36
+ ['b'],
37
+ );
38
+ expect(out.map((c) => c.id)).toEqual(['a', 'c']);
39
+ });
40
+
41
+ it('throws on an id that is not there', () => {
42
+ expect(() => suppressContributions([toolbarItem('a')], ['snapshto'])).toThrow(
43
+ /cannot suppress "snapshto"/,
44
+ );
45
+ });
46
+
47
+ it('is a no-op for an empty suppress list', () => {
48
+ const bundle = [toolbarItem('a')];
49
+ expect(suppressContributions(bundle, [])).toEqual(bundle);
50
+ });
51
+ });
@@ -0,0 +1,52 @@
1
+ import type { TrialContribution } from './types';
2
+
3
+ /**
4
+ * Concatenate contribution bundles into one list, preserving order — order
5
+ * decides how a region lays its contributions out, so it is part of the
6
+ * result, not an accident of it.
7
+ *
8
+ * Throws on a duplicate id rather than dropping one: a contribution silently
9
+ * losing to a later bundle is the failure a registry exists to prevent.
10
+ */
11
+ export function mergeContributions(
12
+ ...bundles: readonly TrialContribution[][]
13
+ ): TrialContribution[] {
14
+ const out: TrialContribution[] = [];
15
+ const seen = new Set<string>();
16
+ for (const bundle of bundles) {
17
+ for (const entry of bundle) {
18
+ if (seen.has(entry.id)) {
19
+ throw new Error(
20
+ `[labkit] mergeContributions: duplicate contribution id "${entry.id}". ` +
21
+ 'Two bundles registered the same id; rename one, or suppress the ' +
22
+ 'built-in before adding yours.',
23
+ );
24
+ }
25
+ seen.add(entry.id);
26
+ out.push(entry);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * Drop contributions by id. Throws when an id is not present: a typo that
34
+ * silently suppresses nothing is the same class of bug as a duplicate id
35
+ * silently winning.
36
+ */
37
+ export function suppressContributions(
38
+ bundle: readonly TrialContribution[],
39
+ ids: readonly string[],
40
+ ): TrialContribution[] {
41
+ const present = new Set(bundle.map((c) => c.id));
42
+ for (const id of ids) {
43
+ if (!present.has(id)) {
44
+ throw new Error(
45
+ `[labkit] cannot suppress "${id}": no contribution with that id. ` +
46
+ `Present ids: ${[...present].join(', ')}`,
47
+ );
48
+ }
49
+ }
50
+ const drop = new Set(ids);
51
+ return bundle.filter((c) => !drop.has(c.id));
52
+ }
@@ -0,0 +1,14 @@
1
+ .lk-palette-region {
2
+ display: flex;
3
+ flex-direction: column;
4
+ padding: var(--wzl-space-xs);
5
+ border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
6
+ background: var(--wzl-surface-sunken);
7
+
8
+ // labkit's `:where(button)` default sets an explicit height, and because
9
+ // :where() carries no specificity it also wins over ToolButton, which sizes
10
+ // itself from padding.
11
+ button {
12
+ height: auto;
13
+ }
14
+ }
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { TrialChromeContext, TrialContribution } from '../types';
4
+ import { PaletteRegion } from './PaletteRegion';
5
+
6
+ const Glyph = () => <svg />;
7
+
8
+ function ctxWith(activeToolId: string | null, setActiveTool = vi.fn()) {
9
+ return { trialId: 't1', activeToolId, setActiveTool } as unknown as TrialChromeContext;
10
+ }
11
+
12
+ function tool(id: string): TrialContribution {
13
+ return { id, region: 'palette', item: { icon: Glyph, label: id } };
14
+ }
15
+
16
+ describe('PaletteRegion', () => {
17
+ it('renders nothing when empty', () => {
18
+ const { container } = render(<PaletteRegion contributions={[]} ctx={ctxWith(null)} />);
19
+ expect(container).toBeEmptyDOMElement();
20
+ });
21
+
22
+ it('marks the resolved tool as current', () => {
23
+ render(
24
+ <PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith('brush')} />,
25
+ );
26
+ expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('aria-current', 'true');
27
+ expect(screen.getByRole('button', { name: 'eraser' })).not.toHaveAttribute('aria-current');
28
+ });
29
+
30
+ it('sets the tool on click', () => {
31
+ const setActiveTool = vi.fn();
32
+ render(<PaletteRegion contributions={[tool('brush')]} ctx={ctxWith(null, setActiveTool)} />);
33
+ screen.getByRole('button', { name: 'brush' }).click();
34
+ expect(setActiveTool).toHaveBeenCalledWith('brush');
35
+ });
36
+ });
@@ -0,0 +1,41 @@
1
+ import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
+ import type { TrialChromeContext, TrialContribution } from '../types';
3
+
4
+ /** Props for `<PaletteRegion>`. */
5
+ export interface PaletteRegionProps {
6
+ contributions: readonly TrialContribution[];
7
+ ctx: TrialChromeContext;
8
+ }
9
+
10
+ /** A trial's tool strip. Selection lives in the trial's or the lab's tool
11
+ * slot; this region only reflects it. */
12
+ export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
13
+ if (contributions.length === 0) return null;
14
+ return (
15
+ <div
16
+ className="lk-palette-region"
17
+ role="toolbar"
18
+ aria-label="Tools"
19
+ aria-orientation="vertical"
20
+ >
21
+ <ToolGroup orientation="vertical">
22
+ {contributions.map((c) => {
23
+ if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
24
+ if (c.region !== 'palette' || !c.item) return null;
25
+ const { icon: Icon, label, shortcut, disabled } = c.item;
26
+ return (
27
+ <ToolButton
28
+ key={c.id}
29
+ icon={<Icon size={16} />}
30
+ label={label}
31
+ shortcut={shortcut}
32
+ active={ctx.activeToolId === c.id}
33
+ disabled={disabled}
34
+ onClick={() => ctx.setActiveTool(c.id)}
35
+ />
36
+ );
37
+ })}
38
+ </ToolGroup>
39
+ </div>
40
+ );
41
+ }