@weasel-js/labkit 1.2.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 (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useState } from 'react';
3
+ import type { TrialChromeContext, TrialContribution } from '../types';
4
+
5
+ /** Props for `<SidebarRegion>`. */
6
+ export interface SidebarRegionProps {
7
+ contributions: readonly TrialContribution[];
8
+ ctx: TrialChromeContext;
9
+ }
10
+
11
+ function Section({
12
+ title,
13
+ defaultCollapsed,
14
+ children,
15
+ }: {
16
+ title: string;
17
+ defaultCollapsed: boolean;
18
+ children: ReactNode;
19
+ }) {
20
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
21
+ return (
22
+ <section className="lk-sidebar-section">
23
+ <button
24
+ type="button"
25
+ className="lk-sidebar-section__head"
26
+ aria-expanded={!collapsed}
27
+ onClick={() => setCollapsed((c) => !c)}
28
+ >
29
+ {title}
30
+ </button>
31
+ {collapsed ? null : <div className="lk-sidebar-section__body">{children}</div>}
32
+ </section>
33
+ );
34
+ }
35
+
36
+ /** Lays a trial's `sidebar` contributions out as titled, collapsible sections. */
37
+ export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
38
+ if (contributions.length === 0) return null;
39
+ return (
40
+ <>
41
+ {contributions.map((c) => {
42
+ if (c.render) return <div key={c.id}>{c.render(ctx)}</div>;
43
+ if (c.region !== 'sidebar' || !c.item) return null;
44
+ return (
45
+ <Section
46
+ key={c.id}
47
+ title={c.item.title}
48
+ defaultCollapsed={c.item.defaultCollapsed ?? false}
49
+ >
50
+ {c.item.body}
51
+ </Section>
52
+ );
53
+ })}
54
+ </>
55
+ );
56
+ }
@@ -0,0 +1,31 @@
1
+ import { StatusBar } from '../../primitives/StatusBar';
2
+ import type { TrialChromeContext, TrialContribution } from '../types';
3
+
4
+ /** Props for `<StatusRegion>`. */
5
+ export interface StatusRegionProps {
6
+ contributions: readonly TrialContribution[];
7
+ ctx: TrialChromeContext;
8
+ }
9
+
10
+ /** Lays a trial's `status` contributions out as readouts. */
11
+ export function StatusRegion({ contributions, ctx }: StatusRegionProps) {
12
+ if (contributions.length === 0) return null;
13
+ return (
14
+ <StatusBar>
15
+ {contributions.map((c) => {
16
+ if (c.render)
17
+ return (
18
+ <StatusBar.Section key={c.id} end={c.end}>
19
+ {c.render(ctx)}
20
+ </StatusBar.Section>
21
+ );
22
+ if (c.region !== 'status' || !c.item) return null;
23
+ return (
24
+ <StatusBar.Section key={c.id} end={c.end}>
25
+ <span title={c.item.title}>{c.item.text}</span>
26
+ </StatusBar.Section>
27
+ );
28
+ })}
29
+ </StatusBar>
30
+ );
31
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { TrialChromeContext, TrialContribution } from '../types';
3
+
4
+ /** Props for `<TitleBarRegion>`. */
5
+ export interface TitleBarRegionProps {
6
+ contributions: readonly TrialContribution[];
7
+ ctx: TrialChromeContext;
8
+ }
9
+
10
+ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
11
+ if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
12
+ if (c.region !== 'titlebar' || !c.item) return null;
13
+ const { icon: Icon, label, shortcut, disabled, danger } = c.item;
14
+ return (
15
+ <button
16
+ key={c.id}
17
+ type="button"
18
+ className={`lk-titlebar-button${danger ? ' lk-titlebar-button--danger' : ''}`}
19
+ disabled={disabled}
20
+ aria-label={label}
21
+ title={shortcut ? `${label} (${shortcut})` : label}
22
+ onClick={c.item.onActivate}
23
+ // The bar behind these buttons is the window drag surface, so a press
24
+ // that lands here must not also start a drag.
25
+ onPointerDown={(e) => e.stopPropagation()}
26
+ onMouseDown={(e) => e.stopPropagation()}
27
+ >
28
+ <Icon size={14} />
29
+ </button>
30
+ );
31
+ }
32
+
33
+ /** Lays a trial's `titlebar` contributions out, at the far end of the bar. */
34
+ export function TitleBarRegion({ contributions, ctx }: TitleBarRegionProps) {
35
+ if (contributions.length === 0) return null;
36
+ return (
37
+ <span className="lk-trial__titlebar-actions">
38
+ {contributions.map((c) => renderEntry(c, ctx))}
39
+ </span>
40
+ );
41
+ }
@@ -0,0 +1,82 @@
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 { ToolbarRegion } from './ToolbarRegion';
5
+
6
+ const Glyph = () => <svg data-testid="glyph" />;
7
+
8
+ const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
9
+
10
+ function item(id: string, over: Partial<TrialContribution> = {}): TrialContribution {
11
+ return {
12
+ id,
13
+ region: 'toolbar',
14
+ item: { icon: Glyph, label: id, onActivate: () => {} },
15
+ ...over,
16
+ } as TrialContribution;
17
+ }
18
+
19
+ describe('ToolbarRegion', () => {
20
+ it('renders nothing when it has no contributions', () => {
21
+ const { container } = render(<ToolbarRegion contributions={[]} ctx={ctx} />);
22
+ expect(container).toBeEmptyDOMElement();
23
+ });
24
+
25
+ it('renders one button per contribution, labelled', () => {
26
+ render(<ToolbarRegion contributions={[item('undo'), item('redo')]} ctx={ctx} />);
27
+ expect(screen.getByRole('button', { name: 'undo' })).toBeInTheDocument();
28
+ expect(screen.getByRole('button', { name: 'redo' })).toBeInTheDocument();
29
+ });
30
+
31
+ it('fires onActivate on click', () => {
32
+ const onActivate = vi.fn();
33
+ render(
34
+ <ToolbarRegion
35
+ contributions={[
36
+ { id: 'go', region: 'toolbar', item: { icon: Glyph, label: 'Go', onActivate } },
37
+ ]}
38
+ ctx={ctx}
39
+ />,
40
+ );
41
+ screen.getByRole('button', { name: 'Go' }).click();
42
+ expect(onActivate).toHaveBeenCalledOnce();
43
+ });
44
+
45
+ it('groups contributions sharing a group into one group element', () => {
46
+ render(
47
+ <ToolbarRegion
48
+ contributions={[
49
+ item('undo', { group: 'history' }),
50
+ item('redo', { group: 'history' }),
51
+ item('close', { group: 'trial' }),
52
+ ]}
53
+ ctx={ctx}
54
+ />,
55
+ );
56
+ expect(screen.getAllByRole('group')).toHaveLength(2);
57
+ });
58
+
59
+ it('renders a render-escape contribution verbatim', () => {
60
+ render(
61
+ <ToolbarRegion
62
+ contributions={[{ id: 'custom', region: 'toolbar', render: () => <b>custom</b> }]}
63
+ ctx={ctx}
64
+ />,
65
+ );
66
+ expect(screen.getByText('custom')).toBeInTheDocument();
67
+ });
68
+
69
+ it('disables a button whose item says so', () => {
70
+ render(
71
+ <ToolbarRegion
72
+ contributions={[
73
+ item('undo', {
74
+ item: { icon: Glyph, label: 'Undo', disabled: true, onActivate: () => {} },
75
+ }),
76
+ ]}
77
+ ctx={ctx}
78
+ />,
79
+ );
80
+ expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
81
+ });
82
+ });