@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,24 @@
1
+ import type { IconComponent } from '../chrome/types';
2
+
3
+ /**
4
+ * A tool a trial can be in. labkit's own — core's `ToolsApi` carries hotkey
5
+ * slots, ambient tools, eligibility tiers and canvas overlay layers, all bound
6
+ * to the gesture dispatcher, and a labkit instrument is an arbitrary canvas or
7
+ * DOM tree rather than a weasel scene.
8
+ */
9
+ export interface TrialTool {
10
+ id: string;
11
+ label: string;
12
+ icon: IconComponent;
13
+ /** Shown in the tooltip. Not bound here — the instrument owns its keymap. */
14
+ shortcut?: string;
15
+ /** Presentation grouping in the palette. Ungrouped tools sort after grouped. */
16
+ group?: string;
17
+ }
18
+
19
+ /** What an instrument declares to get a palette region. */
20
+ export interface ToolCapability {
21
+ tools: TrialTool[];
22
+ /** Which tool a fresh trial starts in. Defaults to the first. */
23
+ initial?: string;
24
+ }
@@ -0,0 +1,113 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { f } from '../config/builder';
4
+ import type { ConfigRule, ControlRenderer } from '../config/types';
5
+ import { defineInstrument } from '../instrument/defineInstrument';
6
+ import { Lab } from '../lab/Lab';
7
+
8
+ const schemaInstrument = defineInstrument({
9
+ name: 'Schema',
10
+ config: f.schema({
11
+ showGrid: f.boolean(true),
12
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
13
+ }),
14
+ initialState: () => ({}),
15
+ render: () => null,
16
+ });
17
+
18
+ const legacyInstrument = defineInstrument({
19
+ name: 'Legacy',
20
+ defaultConfig: () => ({ showGrid: true }),
21
+ configSchema: () => [
22
+ { type: 'checkbox' as const, key: 'showGrid', label: 'Show grid', default: true },
23
+ ],
24
+ initialState: () => ({}),
25
+ render: () => null,
26
+ });
27
+
28
+ describe('a trial renders its instrument config', () => {
29
+ it('renders a builder schema into the settings sidebar', () => {
30
+ render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
31
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
32
+ expect(screen.getByText('Grid spacing')).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders a legacy ConfigField list through the same path', () => {
36
+ render(<Lab instruments={[legacyInstrument]} defaultInstrument="Legacy" />);
37
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
38
+ });
39
+
40
+ it('writes a config change back to the trial', () => {
41
+ render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
42
+ const checkbox = screen.getByLabelText('Show grid') as HTMLInputElement;
43
+ expect(checkbox.checked).toBe(true);
44
+ fireEvent.click(checkbox);
45
+ expect((screen.getByLabelText('Show grid') as HTMLInputElement).checked).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe('lab-wide config seams', () => {
50
+ it('applies a lab-wide rule before labkit inference', () => {
51
+ const inst = defineInstrument({
52
+ name: 'Ruled',
53
+ config: f.schema({ tintColor: f.value('#ffffff') }),
54
+ initialState: () => ({}),
55
+ render: () => null,
56
+ });
57
+ const colorByName: ConfigRule = (ctx) =>
58
+ ctx.key.endsWith('Color') ? { kind: 'color' } : null;
59
+ render(
60
+ <Lab instruments={[inst]} defaultInstrument="Ruled" configRules={[colorByName]} />,
61
+ );
62
+ expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'color');
63
+ });
64
+
65
+ it('without the rule the same leaf falls back to a text input', () => {
66
+ const inst = defineInstrument({
67
+ name: 'Unruled',
68
+ config: f.schema({ tintColor: f.value('#ffffff') }),
69
+ initialState: () => ({}),
70
+ render: () => null,
71
+ });
72
+ render(<Lab instruments={[inst]} defaultInstrument="Unruled" />);
73
+ expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'text');
74
+ });
75
+
76
+ it('supplies a lab-wide control for a kind labkit does not ship', () => {
77
+ const inst = defineInstrument({
78
+ name: 'Custom',
79
+ config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
80
+ initialState: () => ({}),
81
+ render: () => null,
82
+ });
83
+ const vector2: ControlRenderer = (ctx) => (
84
+ <span>vec:{String((ctx.value as { x: number }).x)}</span>
85
+ );
86
+ render(
87
+ <Lab instruments={[inst]} defaultInstrument="Custom" controls={{ vector2 }} />,
88
+ );
89
+ expect(screen.getByText('vec:3')).toBeInTheDocument();
90
+ });
91
+
92
+ it('shows the placeholder when no control is supplied for that kind', () => {
93
+ const inst = defineInstrument({
94
+ name: 'Unwired',
95
+ config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
96
+ initialState: () => ({}),
97
+ render: () => null,
98
+ });
99
+ render(<Lab instruments={[inst]} defaultInstrument="Unwired" />);
100
+ expect(screen.getByText(/vector2/)).toBeInTheDocument();
101
+ });
102
+
103
+ it('omits the settings section for an instrument with no config', () => {
104
+ const inst = defineInstrument({
105
+ name: 'Bare',
106
+ defaultConfig: () => ({}),
107
+ initialState: () => ({}),
108
+ render: () => null,
109
+ });
110
+ render(<Lab instruments={[inst]} defaultInstrument="Bare" />);
111
+ expect(screen.queryByText('Settings')).not.toBeInTheDocument();
112
+ });
113
+ });
@@ -0,0 +1,73 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { defineInstrument } from '../instrument/defineInstrument';
5
+ import type { JobEvent } from '../job/types';
6
+ import { Lab } from '../lab/Lab';
7
+
8
+ interface S {
9
+ items: number[];
10
+ }
11
+ interface C {
12
+ n: number;
13
+ }
14
+
15
+ function makeInstrument(name: string, n: number, delay: number) {
16
+ return defineInstrument<S, C, number>({
17
+ name,
18
+ defaultConfig: () => ({ n }),
19
+ initialState: () => ({ items: [] }),
20
+ render: () => null,
21
+ job: {
22
+ auto: true,
23
+ run: async function* ({ config, signal }): AsyncGenerator<JobEvent<number>> {
24
+ yield { kind: 'total', total: config.n };
25
+ for (let i = 0; i < config.n; i++) {
26
+ await new Promise((r) => setTimeout(r, delay));
27
+ if (signal.aborted) return;
28
+ yield { kind: 'item', item: i };
29
+ }
30
+ },
31
+ onItem: (item, state) => ({ items: [...state.items, item] }),
32
+ },
33
+ });
34
+ }
35
+
36
+ describe('a trial whose instrument declares a job', () => {
37
+ it('shows progress in the trial chrome', async () => {
38
+ const fast = makeInstrument('fast', 3, 1);
39
+ render(<Lab instruments={[fast]} defaultInstrument="fast" storage={null} />);
40
+ const bar = await waitFor(() => screen.getByRole('progressbar', { name: /job progress/i }));
41
+ await waitFor(() => {
42
+ // A job that reported a total drives a determinate bar, so the count and
43
+ // the accessible value have to agree.
44
+ expect(bar).toHaveAttribute('aria-valuemax', '3');
45
+ expect(bar).toHaveAttribute('aria-valuenow', '3');
46
+ });
47
+ expect(document.querySelector('.lk-job__count')?.textContent).toMatch(/3\s*\/\s*3/);
48
+ });
49
+
50
+ it('offers a cancel control while running, and drops it once cancelled', async () => {
51
+ const slow = makeInstrument('slow', 40, 20);
52
+ render(<Lab instruments={[slow]} defaultInstrument="slow" storage={null} />);
53
+ const cancel = await waitFor(() => screen.getByRole('button', { name: /cancel/i }));
54
+ await userEvent.click(cancel);
55
+ await waitFor(() => {
56
+ expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
57
+ });
58
+ });
59
+
60
+ it('renders no job chrome for an instrument that declares none', async () => {
61
+ const plain = defineInstrument<S, C>({
62
+ name: 'plain',
63
+ defaultConfig: () => ({ n: 0 }),
64
+ initialState: () => ({ items: [] }),
65
+ render: () => null,
66
+ });
67
+ render(<Lab instruments={[plain]} defaultInstrument="plain" storage={null} />);
68
+ await waitFor(() => {
69
+ expect(document.querySelector('.lk-trial')).toBeInTheDocument();
70
+ });
71
+ expect(document.querySelector('.lk-trial__job')).not.toBeInTheDocument();
72
+ });
73
+ });
@@ -1,10 +1,17 @@
1
1
  .lk-trial {
2
2
  display: flex;
3
+ // Click-focusable so the trial's own shortcuts reach it; the trial is not a
4
+ // control, so it draws no focus ring of its own.
5
+ outline: none;
3
6
  flex-direction: column;
4
7
  height: 100%;
5
- background: var(--wzl-surface);
6
- border: 1px solid var(--wzl-border);
8
+ // The lab paints --wzl-surface, so a trial on the same token was separated
9
+ // from its own workspace by nothing but a hairline. Raised reads as a panel,
10
+ // and in dark it is translucent, so the backdrop still shows through.
11
+ background: var(--wzl-surface-raised);
12
+ border: var(--wzl-border-w) solid var(--wzl-border-raised);
7
13
  border-radius: var(--wzl-radius-md);
14
+ box-shadow: 0 1px 3px var(--wzl-shadow);
8
15
  overflow: hidden;
9
16
 
10
17
  &__body {
@@ -15,21 +22,52 @@
15
22
 
16
23
  &__sidebar {
17
24
  flex-shrink: 0;
25
+ display: flex;
26
+ flex-direction: column;
27
+ min-height: 0;
28
+ // Sections floor at their content height, so without this the last ones
29
+ // are cut rather than reachable.
30
+ overflow-y: auto;
18
31
  }
19
32
 
33
+ // An instrument's rendered output is dropped in here directly, so the
34
+ // content well is what makes `height: 100%` mean anything to it. Without
35
+ // the column context and the stretched child, anything a lab returns
36
+ // collapses to its intrinsic height and the trial looks empty below it.
20
37
  &__content {
38
+ // The viewport region anchors against this well.
39
+ position: relative;
21
40
  flex: 1;
41
+ min-width: 0;
42
+ min-height: 0;
43
+ display: flex;
44
+ flex-direction: column;
22
45
  overflow: auto;
46
+
47
+ > * {
48
+ flex: 1 1 auto;
49
+ min-height: 0;
50
+ }
51
+
52
+ // An instrument owns its own interior; the frame contributes chrome, not
53
+ // inset. `--flush` is now only about scrolling.
54
+ &--flush {
55
+ overflow: hidden;
56
+ }
57
+ }
58
+
59
+ &__canvas-host {
60
+ position: relative;
61
+ width: 100%;
62
+ height: 100%;
23
63
  }
24
64
 
25
65
  &__toolbar {
26
66
  flex-shrink: 0;
27
- border-bottom: 1px solid var(--wzl-line-subtle);
28
67
  }
29
68
 
30
69
  &__status {
31
70
  flex-shrink: 0;
32
- border-top: 1px solid var(--wzl-line-subtle);
33
71
  }
34
72
 
35
73
  &--unknown {
@@ -51,8 +89,87 @@
51
89
  font-size: var(--wzl-font-size-sm);
52
90
  }
53
91
 
92
+ // Same `width: 100%` default as the header's Select — pinned so it cannot
93
+ // absorb the toolbar's slack. Placeholder-only, so it need only fit "Load…".
94
+ .lk-root .lk-toolbar__load-select {
95
+ width: 88px;
96
+ }
97
+
54
98
  .lk-sidebar__placeholder {
55
99
  padding: var(--wzl-space-sm);
56
100
  color: var(--wzl-fg-muted);
57
101
  font-size: var(--wzl-font-size-sm);
58
102
  }
103
+
104
+
105
+ .lk-trial__titlebar {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: var(--wzl-space-sm);
109
+ flex-shrink: 0;
110
+ padding: 0 var(--wzl-space-sm);
111
+ min-height: calc(var(--wzl-font-size-sm) * var(--wzl-leading) + var(--wzl-space-xs));
112
+ background: var(--wzl-surface-sunken);
113
+ border-bottom: var(--wzl-border-w) solid var(--wzl-border);
114
+ border-radius: var(--wzl-radius-md) var(--wzl-radius-md) 0 0;
115
+
116
+ // The bar is the drag surface, so it carries the cursor rather than an
117
+ // affordance of its own.
118
+ &--draggable {
119
+ cursor: grab;
120
+ touch-action: none;
121
+
122
+ &:active { cursor: grabbing; }
123
+ }
124
+ }
125
+
126
+ .lk-trial__titlebar-actions {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ gap: 2px;
130
+ margin-left: auto;
131
+ }
132
+
133
+ // Prefixed with `.lk-root` to outrank base.less's `:where(button)` default,
134
+ // which would give a 12px glyph a 24px chrome box on the title bar.
135
+ .lk-root .lk-titlebar-button {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ width: 18px;
140
+ height: 18px;
141
+ padding: 0;
142
+ border: 0;
143
+ border-radius: var(--wzl-radius-sm);
144
+ background: transparent;
145
+ backdrop-filter: none;
146
+ -webkit-backdrop-filter: none;
147
+ // Matches .lk-trial__title — the X reads as part of the bar's own type,
148
+ // not as chrome sitting on top of it.
149
+ color: var(--wzl-fg-muted);
150
+ cursor: pointer;
151
+
152
+ &:hover {
153
+ background: var(--wzl-surface-hover);
154
+ color: var(--wzl-fg);
155
+ }
156
+
157
+ &:disabled {
158
+ opacity: 0.4;
159
+ cursor: default;
160
+ }
161
+
162
+ &--danger:hover:not(:disabled) {
163
+ background: color-mix(in srgb, var(--wzl-danger) 15%, transparent);
164
+ color: var(--wzl-danger);
165
+ }
166
+ }
167
+
168
+ .lk-trial__title {
169
+ font-size: var(--wzl-font-size-sm);
170
+ font-weight: var(--wzl-font-weight-bold);
171
+ color: var(--wzl-fg-muted);
172
+ white-space: nowrap;
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
175
+ }
@@ -5,6 +5,7 @@ import { noneAdapter } from '../state/adapters';
5
5
  import { LabStoreContext } from '../state/context';
6
6
  import { createLabStore } from '../state/store';
7
7
  import type { TrialRecord } from '../state/types';
8
+ import { DEFAULT_VIEW } from '../state/view';
8
9
  import { TrialChrome } from './TrialChrome';
9
10
 
10
11
  const noop = () => {};
@@ -52,7 +53,14 @@ function Harness() {
52
53
  config: {},
53
54
  setState: () => {},
54
55
  setConfig: () => {},
55
- trial: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
56
+ trial: {
57
+ id: 'ws-demo',
58
+ view: DEFAULT_VIEW,
59
+ setView: () => {},
60
+ zoom: 1,
61
+ setZoom: () => {},
62
+ activeToolId: null,
63
+ },
56
64
  emit: () => {},
57
65
  })}
58
66
  </TrialChrome>
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { fireEvent, render, screen, within } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
@@ -6,92 +6,9 @@ import { Lab } from '../lab/Lab';
6
6
  import { LabContext, type LabContextValue } from '../lab/LabContext';
7
7
  import { LabStoreContext } from '../state/context';
8
8
  import { createLabStore } from '../state/store';
9
- import type { TrialRecord } from '../state/types';
10
- import { DefaultToolbar } from './DefaultToolbar';
11
- import type { TrialToolbarContext } from './slotTypes';
9
+ import type { SavedSnapshot, TrialRecord } from '../state/types';
12
10
  import { TrialChrome } from './TrialChrome';
13
11
 
14
- function makeCtx(overrides: Partial<TrialToolbarContext> = {}): TrialToolbarContext {
15
- return {
16
- trialId: 'ws-1',
17
- instrumentName: 'Stub',
18
- hasUndo: false,
19
- canUndo: false,
20
- canRedo: false,
21
- undo: vi.fn(),
22
- redo: vi.fn(),
23
- zoom: 1,
24
- setZoom: vi.fn(),
25
- zoomIn: vi.fn(),
26
- zoomOut: vi.fn(),
27
- resetZoom: vi.fn(),
28
- hasCanvas: false,
29
- savedSnapshots: [],
30
- saveSnapshot: vi.fn(),
31
- loadSnapshot: vi.fn(),
32
- clone: vi.fn(),
33
- reset: vi.fn(),
34
- close: vi.fn(),
35
- isLastTrial: false,
36
- ...overrides,
37
- };
38
- }
39
-
40
- describe('<DefaultToolbar>', () => {
41
- it('renders close button', () => {
42
- render(<DefaultToolbar ctx={makeCtx()} />);
43
- expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
44
- });
45
-
46
- it('disables close when isLastTrial is true', () => {
47
- render(<DefaultToolbar ctx={makeCtx({ isLastTrial: true })} />);
48
- expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
49
- });
50
-
51
- it('omits undo/redo buttons when hasUndo is false', () => {
52
- render(<DefaultToolbar ctx={makeCtx({ hasUndo: false })} />);
53
- expect(screen.queryByRole('button', { name: /undo/i })).toBeNull();
54
- expect(screen.queryByRole('button', { name: /redo/i })).toBeNull();
55
- });
56
-
57
- it('shows undo/redo buttons when hasUndo is true', () => {
58
- render(<DefaultToolbar ctx={makeCtx({ hasUndo: true, canUndo: true, canRedo: false })} />);
59
- expect(screen.getByRole('button', { name: /undo/i })).not.toBeDisabled();
60
- expect(screen.getByRole('button', { name: /redo/i })).toBeDisabled();
61
- });
62
-
63
- it('omits zoom buttons when hasCanvas is false', () => {
64
- render(<DefaultToolbar ctx={makeCtx({ hasCanvas: false })} />);
65
- expect(screen.queryByTitle('Zoom in')).toBeNull();
66
- expect(screen.queryByTitle('Zoom out')).toBeNull();
67
- });
68
-
69
- it('shows zoom controls when hasCanvas is true', () => {
70
- render(<DefaultToolbar ctx={makeCtx({ hasCanvas: true, zoom: 1.5 })} />);
71
- expect(screen.getByTitle('Zoom in')).toBeInTheDocument();
72
- expect(screen.getByTitle('Zoom out')).toBeInTheDocument();
73
- expect(screen.getByText('150%')).toBeInTheDocument();
74
- });
75
-
76
- it('shows load select when snapshots exist', () => {
77
- const ctx = makeCtx({
78
- savedSnapshots: [
79
- {
80
- id: 's1',
81
- name: 'First',
82
- trialId: 'ws-1',
83
- instrumentName: 'Stub',
84
- config: {},
85
- state: {},
86
- savedAt: 1,
87
- },
88
- ],
89
- });
90
- render(<DefaultToolbar ctx={ctx} />);
91
- expect(screen.getByRole('combobox', { name: /load snapshot/i })).toBeInTheDocument();
92
- });
93
- });
94
-
95
12
  const stubInstrument: Instrument = {
96
13
  name: 'Stub',
97
14
  defaultConfig: () => ({}),
@@ -113,6 +30,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
113
30
  function ChromeHarness({
114
31
  children,
115
32
  labOverrides,
33
+ instrument = stubInstrument,
116
34
  ...props
117
35
  }: { children?: ReactNode; labOverrides?: Partial<LabContextValue> } & Partial<ChromeProps>) {
118
36
  const store = createLabStore({
@@ -120,7 +38,7 @@ function ChromeHarness({
120
38
  storage: { read: () => null, write: () => {} },
121
39
  });
122
40
  const labCtx: LabContextValue = {
123
- instruments: [stubInstrument],
41
+ instruments: [instrument],
124
42
  trials: [stubRecord],
125
43
  addTrial: vi.fn(),
126
44
  cloneTrial: vi.fn(),
@@ -141,7 +59,7 @@ function ChromeHarness({
141
59
  <TrialChrome
142
60
  trialId="ws-1"
143
61
  record={stubRecord}
144
- instrument={stubInstrument}
62
+ instrument={instrument}
145
63
  isLastTrial={false}
146
64
  {...props}
147
65
  >
@@ -158,15 +76,47 @@ describe('<TrialChrome>', () => {
158
76
  expect(screen.getByTestId('content')).toBeInTheDocument();
159
77
  });
160
78
 
161
- it('renders default toolbar when no toolbar prop is given', () => {
79
+ it('renders the built-in trial actions', () => {
80
+ render(<ChromeHarness />);
81
+ expect(screen.getByRole('button', { name: 'Close trial' })).toBeInTheDocument();
82
+ expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
83
+ });
84
+
85
+ it('disables close on the last trial', () => {
86
+ render(<ChromeHarness isLastTrial />);
87
+ expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
88
+ });
89
+
90
+ it('omits undo and redo unless the instrument declares undo', () => {
162
91
  render(<ChromeHarness />);
163
- expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
92
+ expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
93
+ render(
94
+ <ChromeHarness
95
+ instrument={{ ...stubInstrument, undo: {} }}
96
+ undoBindings={{ canUndo: true, canRedo: false, undo: vi.fn(), redo: vi.fn() }}
97
+ />,
98
+ );
99
+ expect(screen.getByRole('button', { name: 'Undo' })).not.toBeDisabled();
100
+ expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled();
101
+ });
102
+
103
+ it('offers the snapshot loader once a snapshot exists', () => {
104
+ const snapshot: SavedSnapshot = {
105
+ id: 's1',
106
+ name: 'First',
107
+ trialId: 'ws-1',
108
+ instrumentName: 'Stub',
109
+ config: {},
110
+ state: {},
111
+ savedAt: 1,
112
+ };
113
+ render(<ChromeHarness labOverrides={{ savedSnapshots: [snapshot] }} />);
114
+ expect(screen.getByRole('button', { name: /load snapshot/i })).toBeInTheDocument();
164
115
  });
165
116
 
166
- it('renders custom toolbar when toolbar prop is provided', () => {
167
- render(<ChromeHarness toolbar={() => <div data-testid="custom-toolbar">custom</div>} />);
168
- expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument();
169
- expect(screen.queryByRole('button', { name: /close/i })).toBeNull();
117
+ it('renders a consumer contribution alongside the built-ins', () => {
118
+ render(<ChromeHarness chrome={[{ id: 'mine', region: 'status', item: { text: 'ready' } }]} />);
119
+ expect(screen.getByText('ready')).toBeInTheDocument();
170
120
  });
171
121
 
172
122
  it('Cmd+S triggers saveSnapshot', () => {
@@ -178,10 +128,31 @@ describe('<TrialChrome>', () => {
178
128
  });
179
129
  });
180
130
 
181
- describe('<Lab> + TrialChrome integration', () => {
131
+ describe('chrome regions in a mounted lab', () => {
132
+ function renderLabWith(instrument: Instrument) {
133
+ return render(<Lab title="T" instruments={[instrument]} defaultInstrument={instrument.name} />);
134
+ }
135
+
136
+ it('puts zoom in the viewport region and not in the toolbar', () => {
137
+ renderLabWith({ ...stubInstrument, canvas: { layers: [] }, undo: {} });
138
+ const toolbar = document.querySelector('.lk-trial__toolbar') as HTMLElement;
139
+ const viewport = document.querySelector('.lk-viewport-controls') as HTMLElement;
140
+ expect(within(viewport).getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
141
+ expect(within(toolbar).queryByRole('button', { name: 'Zoom in' })).toBeNull();
142
+ });
143
+
144
+ it('renders no undo group for an instrument that does not declare undo', () => {
145
+ renderLabWith({ ...stubInstrument, canvas: { layers: [] } });
146
+ expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
147
+ });
148
+
149
+ it('renders no viewport region for an instrument with no canvas', () => {
150
+ renderLabWith(stubInstrument);
151
+ expect(document.querySelector('.lk-viewport-controls')).toBeNull();
152
+ });
153
+
182
154
  it('Lab provides context for nested TrialChrome', () => {
183
- render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
184
- // Lab seeds a trial; no chrome rendered without children — sanity check Lab mounts.
155
+ renderLabWith(stubInstrument);
185
156
  expect(document.querySelector('.lk-lab')).toBeTruthy();
186
157
  });
187
158
  });