@weasel-js/labkit 1.7.1 → 1.7.2

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 (67) hide show
  1. package/dist/chrome/index.js +4 -4
  2. package/dist/{chunk-GAHELLJT.js → chunk-RODIY7HI.js} +7 -7
  3. package/dist/{chunk-GAHELLJT.js.map → chunk-RODIY7HI.js.map} +1 -1
  4. package/dist/{chunk-4KSC7NXL.js → chunk-U6UQVGFP.js} +1984 -1892
  5. package/dist/chunk-U6UQVGFP.js.map +1 -0
  6. package/dist/chunk-WFALF2X2.js +302 -0
  7. package/dist/chunk-WFALF2X2.js.map +1 -0
  8. package/dist/{chunk-JFOJESJF.js → chunk-WWJDPNZZ.js} +22 -22
  9. package/dist/chunk-WWJDPNZZ.js.map +1 -0
  10. package/dist/{chunk-CEGFXTBD.js → chunk-XRX5S5NT.js} +4 -4
  11. package/dist/{chunk-CEGFXTBD.js.map → chunk-XRX5S5NT.js.map} +1 -1
  12. package/dist/{chunk-FB4MIZOO.js → chunk-XZDCEMEL.js} +6 -6
  13. package/dist/chunk-XZDCEMEL.js.map +1 -0
  14. package/dist/controls/index.d.ts +55 -3
  15. package/dist/controls/index.js +3 -2
  16. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +135 -134
  19. package/dist/index.js.map +1 -1
  20. package/dist/layers/index.js +1 -1
  21. package/dist/passthrough/weasel-ui.js +1 -1
  22. package/dist/primitives/index.js +3 -3
  23. package/dist/styles.css +228 -11
  24. package/package.json +11 -11
  25. package/src/annotations/capture.browser.test.ts +9 -3
  26. package/src/canvas/Stage.test.tsx +34 -25
  27. package/src/chrome/labRegions.test.tsx +37 -26
  28. package/src/chrome/regions/PaletteRegion.test.tsx +2 -2
  29. package/src/controls/ControlMatrix.less +245 -0
  30. package/src/controls/ControlMatrix.stories.less +13 -0
  31. package/src/controls/ControlMatrix.stories.tsx +135 -0
  32. package/src/controls/ControlMatrix.test.tsx +206 -0
  33. package/src/controls/ControlMatrix.tsx +446 -0
  34. package/src/controls/ControlPanel.tsx +4 -2
  35. package/src/controls/index.ts +6 -0
  36. package/src/dragdrop/dragDrop.test.tsx +34 -33
  37. package/src/index.ts +6 -0
  38. package/src/lab/Lab.chrome.test.tsx +15 -10
  39. package/src/lab/Lab.nebula.test.tsx +5 -5
  40. package/src/lab/Lab.surface.test.tsx +30 -21
  41. package/src/lab/Lab.test.tsx +75 -74
  42. package/src/lab/Lab.undock.test.tsx +15 -10
  43. package/src/lab/LabHeader.test.tsx +15 -14
  44. package/src/lab/LabPalette.test.tsx +12 -7
  45. package/src/lab/LabShell.less +1 -2
  46. package/src/lab/LabZoom.test.tsx +55 -44
  47. package/src/lab/Workspace.surface.test.tsx +16 -13
  48. package/src/lab/Workspace.test.tsx +32 -27
  49. package/src/overview/OverviewMarks.test.tsx +9 -8
  50. package/src/overview/TrialOverview.test.tsx +18 -17
  51. package/src/primitives/FpsMeter.less +1 -2
  52. package/src/primitives/JobProgress.less +1 -2
  53. package/src/primitives/ScaleIndicator.less +1 -1
  54. package/src/primitives/StatusBar.less +1 -1
  55. package/src/specimen/Specimen.less +1 -0
  56. package/src/styles.less +3 -0
  57. package/src/trial/Trial.annotations.persist.test.tsx +20 -9
  58. package/src/trial/Trial.annotations.test.tsx +46 -41
  59. package/src/trial/Trial.canvas.test.tsx +21 -17
  60. package/src/trial/Trial.config.test.tsx +34 -27
  61. package/src/trial/Trial.loupe.test.tsx +27 -18
  62. package/src/trial/Trial.targets.test.tsx +9 -8
  63. package/src/trial/Trial.test.tsx +51 -46
  64. package/src/trial/Trial.trialId.test.tsx +7 -6
  65. package/dist/chunk-4KSC7NXL.js.map +0 -1
  66. package/dist/chunk-FB4MIZOO.js.map +0 -1
  67. package/dist/chunk-JFOJESJF.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -115,18 +115,18 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.7.1",
119
- "@weasel-js/theme": "1.7.1",
118
+ "@weasel-js/core": "1.7.2",
119
+ "@weasel-js/theme": "1.7.2",
120
120
  "react": "^19.0.0",
121
121
  "react-dom": "^19.0.0"
122
122
  },
123
123
  "dependencies": {
124
- "@weasel-js/geom": "1.7.1",
125
- "@weasel-js/kernel3d": "1.7.1",
126
- "@weasel-js/loupe": "1.7.1",
127
- "@weasel-js/quantity": "1.7.1",
128
- "@weasel-js/svg": "1.7.1",
129
- "@weasel-js/ui": "1.7.1",
124
+ "@weasel-js/geom": "1.7.2",
125
+ "@weasel-js/kernel3d": "1.7.2",
126
+ "@weasel-js/loupe": "1.7.2",
127
+ "@weasel-js/quantity": "1.7.2",
128
+ "@weasel-js/svg": "1.7.2",
129
+ "@weasel-js/ui": "1.7.2",
130
130
  "earcut": "3.2.3",
131
131
  "polygon-clipping": "^0.15.7",
132
132
  "react-aria-components": "^1.5.0",
@@ -136,8 +136,8 @@
136
136
  "devDependencies": {
137
137
  "@biomejs/biome": "^2.4.12",
138
138
  "@testing-library/jest-dom": "^7.0.1",
139
- "@weasel-js/guides": "1.7.1",
140
- "@weasel-js/modes": "1.7.1",
139
+ "@weasel-js/guides": "1.7.2",
140
+ "@weasel-js/modes": "1.7.2",
141
141
  "less": "^4.2.0",
142
142
  "rollup-plugin-dts": "^6.4.1",
143
143
  "tsx": "^4.19.0",
@@ -3,12 +3,13 @@
3
3
  * default family registered lazily the way an app registers it: a text mark
4
4
  * captured before anything has drawn text still carries its glyphs.
5
5
  */
6
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+
7
7
  import { registerFont } from '@weasel-js/font';
8
8
  import { _resetFontRegistryForTests } from '@weasel-js/font/test-seams';
9
- import { createAnnotationStore } from './store';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
10
  import metricsUrl from '../../../../assets/fonts/inter/inter.json?url';
11
11
  import atlasUrl from '../../../../assets/fonts/inter/inter.png?url';
12
+ import { createAnnotationStore } from './store';
12
13
 
13
14
  /** Pixels where the mark's red ink landed on the transparent export. */
14
15
  async function inkPixels(blob: Blob): Promise<number> {
@@ -32,7 +33,12 @@ describe('a raster capture of a text mark', () => {
32
33
  const store = createAnnotationStore({
33
34
  targets: () => [{ id: 'bare', content: { w: 120, h: 40 } }],
34
35
  });
35
- store.add({ target: 'bare', kind: 'text', title: 'Hello', frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.5 } });
36
+ store.add({
37
+ target: 'bare',
38
+ kind: 'text',
39
+ title: 'Hello',
40
+ frac: { x: 0.1, y: 0.2, w: 0.5, h: 0.5 },
41
+ });
36
42
 
37
43
  const result = await store.capture('bare', { format: 'png', scale: 2 });
38
44
 
@@ -1,4 +1,5 @@
1
- import { act, fireEvent, render, screen, within } from '@testing-library/react';
1
+ import { act, fireEvent, screen, within } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { useState } from 'react';
3
4
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
5
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -47,8 +48,8 @@ describe('fitStage', () => {
47
48
  });
48
49
 
49
50
  describe('<Stage>', () => {
50
- it('draws its content through the camera', () => {
51
- const { container } = render(
51
+ it('draws its content through the camera', async () => {
52
+ const { container } = await renderSettled(
52
53
  <Stage
53
54
  size={{ width: 300, height: 100 }}
54
55
  view={{ zoom: 2, pan: { x: 10, y: 20 } }}
@@ -66,7 +67,7 @@ describe('<Stage>', () => {
66
67
  expect(el.style.getPropertyValue('--lk-stage-h')).toBe('100px');
67
68
  });
68
69
 
69
- it('zooms on the wheel', () => {
70
+ it('zooms on the wheel', async () => {
70
71
  function Host() {
71
72
  const [view, setView] = useState<ViewTransform>({ zoom: 1, pan: { x: 0, y: 0 } });
72
73
  return (
@@ -75,14 +76,14 @@ describe('<Stage>', () => {
75
76
  </Stage>
76
77
  );
77
78
  }
78
- const { container } = render(<Host />);
79
+ const { container } = await renderSettled(<Host />);
79
80
  const host = container.querySelector('.lk-stage');
80
81
  if (!host) throw new Error('no stage');
81
82
  fireEvent.wheel(host, { deltaY: -200, clientX: 0, clientY: 0 });
82
83
  expect(zoomOf(content(container))).toBeGreaterThan(1);
83
84
  });
84
85
 
85
- it('zooms on a wheel handed to its camera from outside the stage', () => {
86
+ it('zooms on a wheel handed to its camera from outside the stage', async () => {
86
87
  // What an annotation target's input box does: it is portalled out of the
87
88
  // stage, so its wheel never bubbles here.
88
89
  const slot: CameraWheelSlot = { current: null };
@@ -96,19 +97,19 @@ describe('<Stage>', () => {
96
97
  </CameraWheelContext.Provider>
97
98
  );
98
99
  }
99
- const { container } = render(<Host />);
100
- act(() => {
100
+ const { container } = await renderSettled(<Host />);
101
+ await act(async () => {
101
102
  slot.current?.(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
102
103
  });
103
104
  expect(zoomOf(content(container))).toBeGreaterThan(1);
104
105
  });
105
106
 
106
- it('listens for the wheel actively, so it can keep the page from scrolling', () => {
107
+ it('listens for the wheel actively, so it can keep the page from scrolling', async () => {
107
108
  // A proxy: jsdom does not enforce passive listeners, so a preventDefault in
108
109
  // a passive one succeeds here and fails in a browser. React registers its
109
110
  // wheel listeners passive; asserting the registration is what can fail.
110
111
  const add = vi.spyOn(HTMLElement.prototype, 'addEventListener');
111
- const { container } = render(
112
+ const { container } = await renderSettled(
112
113
  <Stage
113
114
  size={{ width: 100, height: 100 }}
114
115
  view={{ zoom: 1, pan: { x: 0, y: 0 } }}
@@ -127,7 +128,7 @@ describe('<Stage>', () => {
127
128
  add.mockRestore();
128
129
  });
129
130
 
130
- it('tells the surface its tiles moved when the camera does', () => {
131
+ it('tells the surface its tiles moved when the camera does', async () => {
131
132
  // A transform moves a tile without resizing anything, which is the one
132
133
  // kind of move a ResizeObserver never reports.
133
134
  const invalidateRects = vi.fn();
@@ -139,7 +140,7 @@ describe('<Stage>', () => {
139
140
  </Stage>
140
141
  </SurfaceContext.Provider>
141
142
  );
142
- const { rerender } = render(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
+ const { rerender } = await renderSettled(at({ zoom: 1, pan: { x: 0, y: 0 } }));
143
144
  invalidateRects.mockClear();
144
145
  rerender(at({ zoom: 1.5, pan: { x: 0, y: 0 } }));
145
146
  expect(invalidateRects).toHaveBeenCalled();
@@ -162,25 +163,29 @@ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
162
163
  });
163
164
 
164
165
  describe('an instrument that declares a stage', () => {
165
- it('renders its DOM on the stage', () => {
166
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
166
+ it('renders its DOM on the stage', async () => {
167
+ const { container } = await renderSettled(
168
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
169
+ );
167
170
  expect(content(container)).toContainElement(screen.getByTestId('art'));
168
171
  });
169
172
 
170
- it('gets the zoom controls, and they move the stage', () => {
171
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
173
+ it('gets the zoom controls, and they move the stage', async () => {
174
+ const { container } = await renderSettled(
175
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
176
+ );
172
177
  const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
173
- act(() => {
178
+ await act(async () => {
174
179
  fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
175
180
  });
176
181
  expect(zoomOf(content(container))).toBeCloseTo(1.25);
177
- act(() => {
182
+ await act(async () => {
178
183
  fireEvent.click(trialView.getByRole('button', { name: 'Actual size' }));
179
184
  });
180
185
  expect(zoomOf(content(container))).toBe(1);
181
186
  });
182
187
 
183
- it('zooms about the middle of the view, not the stage origin', () => {
188
+ it('zooms about the middle of the view, not the stage origin', async () => {
184
189
  const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
185
190
  x: 0,
186
191
  y: 0,
@@ -193,9 +198,11 @@ describe('an instrument that declares a stage', () => {
193
198
  toJSON: () => ({}),
194
199
  });
195
200
  try {
196
- const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
201
+ const { container } = await renderSettled(
202
+ <Lab instruments={[staged]} defaultInstrument="Staged" />,
203
+ );
197
204
  const trialView = within(screen.getByRole('toolbar', { name: 'View' }));
198
- act(() => {
205
+ await act(async () => {
199
206
  fireEvent.click(trialView.getByRole('button', { name: 'Zoom in' }));
200
207
  });
201
208
  const el = content(container);
@@ -208,12 +215,12 @@ describe('an instrument that declares a stage', () => {
208
215
  }
209
216
  });
210
217
 
211
- it('is what earns them: DOM content without one gets none', () => {
212
- render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
218
+ it('is what earns them: DOM content without one gets none', async () => {
219
+ await renderSettled(<Lab instruments={[plain]} defaultInstrument="Plain" />);
213
220
  expect(screen.queryByRole('toolbar', { name: 'View' })).toBeNull();
214
221
  });
215
222
 
216
- it('draws its overlay on the stage, outside the camera', () => {
223
+ it('draws its overlay on the stage, outside the camera', async () => {
217
224
  const overlaid = defineInstrument<{ n: number }, Record<string, never>>({
218
225
  name: 'Overlaid',
219
226
  defaultConfig: () => ({}),
@@ -224,7 +231,9 @@ describe('an instrument that declares a stage', () => {
224
231
  overlay: ({ state }) => <div data-testid="legend">{state.n}</div>,
225
232
  },
226
233
  });
227
- const { container } = render(<Lab instruments={[overlaid]} defaultInstrument="Overlaid" />);
234
+ const { container } = await renderSettled(
235
+ <Lab instruments={[overlaid]} defaultInstrument="Overlaid" />,
236
+ );
228
237
  const legend = screen.getByTestId('legend');
229
238
  expect(legend).toHaveTextContent('7');
230
239
  expect(container.querySelector('.lk-stage')).toContainElement(legend);
@@ -1,4 +1,5 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
  import type { Instrument } from '../instrument/types';
4
5
  import { Lab } from '../lab/Lab';
@@ -19,19 +20,19 @@ function silenceRenderError(): void {
19
20
  }
20
21
 
21
22
  describe('lab-level chrome', () => {
22
- it('renders a header contribution beside the lab header', () => {
23
+ it('renders a header contribution beside the lab header', async () => {
23
24
  const contribution: LabContribution = {
24
25
  id: 'publish',
25
26
  region: 'header',
26
27
  item: { icon: Glyph, label: 'Publish', onActivate: () => {} },
27
28
  };
28
- render(
29
+ await renderSettled(
29
30
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
30
31
  );
31
32
  expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
32
33
  });
33
34
 
34
- it('hands a header contribution the lab, not a trial', () => {
35
+ it('hands a header contribution the lab, not a trial', async () => {
35
36
  const contribution: LabContribution = {
36
37
  id: 'another',
37
38
  region: 'header',
@@ -41,21 +42,23 @@ describe('lab-level chrome', () => {
41
42
  onActivate: (ctx) => ctx.addTrial('Bare'),
42
43
  },
43
44
  };
44
- render(
45
+ await renderSettled(
45
46
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
46
47
  );
47
48
  expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(1);
48
- fireEvent.click(screen.getByRole('button', { name: 'Another' }));
49
+ await act(async () => {
50
+ fireEvent.click(screen.getByRole('button', { name: 'Another' }));
51
+ });
49
52
  expect(screen.getAllByLabelText(/^Trial /)).toHaveLength(2);
50
53
  });
51
54
 
52
- it('renders a palette contribution in the lab rail and selects it', () => {
55
+ it('renders a palette contribution in the lab rail and selects it', async () => {
53
56
  const contribution: LabContribution = {
54
57
  id: 'measure',
55
58
  region: 'palette',
56
59
  item: { icon: Glyph, label: 'Measure' },
57
60
  };
58
- render(
61
+ await renderSettled(
59
62
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
60
63
  );
61
64
  const button = screen.getByRole('button', { name: 'Measure' });
@@ -63,7 +66,7 @@ describe('lab-level chrome', () => {
63
66
  expect(screen.getByRole('button', { name: 'Measure' })).toHaveAttribute('aria-current', 'true');
64
67
  });
65
68
 
66
- it('gives a palette contribution the whole lab context, not a tool slot alone', () => {
69
+ it('gives a palette contribution the whole lab context, not a tool slot alone', async () => {
67
70
  // The shape the `as unknown as TrialChromeContext` cast used to fake: a
68
71
  // contribution reading anything past the tool slot got `undefined`.
69
72
  const seen: LabChromeContext[] = [];
@@ -75,7 +78,7 @@ describe('lab-level chrome', () => {
75
78
  return <span>{`${ctx.trials.length} trial(s), mode ${ctx.mode}`}</span>;
76
79
  },
77
80
  };
78
- render(
81
+ await renderSettled(
79
82
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
80
83
  );
81
84
  expect(screen.getByText('1 trial(s), mode auto')).toBeInTheDocument();
@@ -83,19 +86,19 @@ describe('lab-level chrome', () => {
83
86
  expect(typeof seen[0]?.saveSnapshot).toBe('function');
84
87
  });
85
88
 
86
- it('renders a footer contribution', () => {
89
+ it('renders a footer contribution', async () => {
87
90
  const contribution: LabContribution = {
88
91
  id: 'count',
89
92
  region: 'footer',
90
93
  item: { text: 'idle' },
91
94
  };
92
- render(
95
+ await renderSettled(
93
96
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[contribution]} />,
94
97
  );
95
98
  expect(screen.getByText('idle')).toBeInTheDocument();
96
99
  });
97
100
 
98
- it('throws when two lab contributions share an id', () => {
101
+ it('throws when two lab contributions share an id', async () => {
99
102
  silenceRenderError();
100
103
  const clash: LabContribution[] = [
101
104
  { id: 'same', region: 'header', item: { icon: Glyph, label: 'One', onActivate: () => {} } },
@@ -106,7 +109,7 @@ describe('lab-level chrome', () => {
106
109
  ).toThrow(/duplicate contribution id "same"/);
107
110
  });
108
111
 
109
- it('keeps the lab tool slot and lab palette contributions in one namespace', () => {
112
+ it('keeps the lab tool slot and lab palette contributions in one namespace', async () => {
110
113
  silenceRenderError();
111
114
  const clash: LabContribution = {
112
115
  id: 'pick',
@@ -134,8 +137,8 @@ describe('lab sidebar region', () => {
134
137
  item: { title: 'Stories', body: <p>story list</p> },
135
138
  };
136
139
 
137
- it('renders a section beside the workspace', () => {
138
- const { container } = render(
140
+ it('renders a section beside the workspace', async () => {
141
+ const { container } = await renderSettled(
139
142
  <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
140
143
  );
141
144
  expect(screen.getByText('story list')).toBeInTheDocument();
@@ -143,18 +146,22 @@ describe('lab sidebar region', () => {
143
146
  expect(screen.getByRole('separator', { name: /lab sidebar/i })).toBeInTheDocument();
144
147
  });
145
148
 
146
- it('folds a section', () => {
147
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
149
+ it('folds a section', async () => {
150
+ await renderSettled(
151
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
152
+ );
148
153
  fireEvent.click(screen.getByRole('button', { name: 'Stories' }));
149
154
  expect(screen.queryByText('story list')).toBeNull();
150
155
  });
151
156
 
152
- it('offers no tear-out at lab level', () => {
153
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />);
157
+ it('offers no tear-out at lab level', async () => {
158
+ await renderSettled(
159
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[tree]} />,
160
+ );
154
161
  expect(screen.queryByRole('button', { name: 'Undock Stories' })).toBeNull();
155
162
  });
156
163
 
157
- it('hands a render contribution the lab context', () => {
164
+ it('hands a render contribution the lab context', async () => {
158
165
  const seen: LabChromeContext[] = [];
159
166
  const probe: LabContribution = {
160
167
  id: 'probe',
@@ -164,13 +171,17 @@ describe('lab sidebar region', () => {
164
171
  return <p>probe</p>;
165
172
  },
166
173
  };
167
- render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />);
174
+ await renderSettled(
175
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" labChrome={[probe]} />,
176
+ );
168
177
  expect(screen.getByText('probe')).toBeInTheDocument();
169
178
  expect(seen[0]?.trials).toHaveLength(1);
170
179
  });
171
180
 
172
- it('adds no strip to a lab without sidebar chrome', () => {
173
- const { container } = render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
181
+ it('adds no strip to a lab without sidebar chrome', async () => {
182
+ const { container } = await renderSettled(
183
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" />,
184
+ );
174
185
  expect(container.querySelector('.lk-lab__panes')).toBeNull();
175
186
  });
176
187
  });
@@ -182,7 +193,7 @@ describe('lab chrome without <Lab>', () => {
182
193
  undo: () => void;
183
194
  }
184
195
 
185
- it('renders a contribution typed against a consumer context and hands it back', () => {
196
+ it('renders a contribution typed against a consumer context and hands it back', async () => {
186
197
  const undo = vi.fn();
187
198
  const ctx: RailCtx = { undo };
188
199
  const rail: readonly LabContribution<RailCtx>[] = [
@@ -192,7 +203,7 @@ describe('lab chrome without <Lab>', () => {
192
203
  item: { icon: Glyph, label: 'Undo', showLabel: true, onActivate: (c) => c.undo() },
193
204
  },
194
205
  ];
195
- render(
206
+ await renderSettled(
196
207
  <LabShell
197
208
  title="Palette lab"
198
209
  header={
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { RegionContribution, ToolSlotContext } from '../types';
4
4
  import { PaletteRegion } from './PaletteRegion';
@@ -65,7 +65,7 @@ describe('PaletteRegion', () => {
65
65
  const brush = screen.getByRole('button', { name: 'brush' });
66
66
  const eraser = screen.getByRole('button', { name: 'eraser' });
67
67
 
68
- brush.focus();
68
+ act(() => brush.focus());
69
69
  fireEvent.keyDown(brush, { key: 'ArrowDown' });
70
70
  expect(document.activeElement).toBe(eraser);
71
71
  expect(eraser).toHaveAttribute('tabindex', '0');
@@ -0,0 +1,245 @@
1
+ // A settings-by-scopes grid. Built to sit beside a ControlPanel in a ~360px
2
+ // sidebar: the label column takes what seven value columns leave, and every cell
3
+ // is a fixed-height button so the rows rule up evenly.
4
+ .lk-control-matrix {
5
+ min-width: 0;
6
+ }
7
+
8
+ .lk-control-matrix__table {
9
+ width: 100%;
10
+ table-layout: fixed;
11
+ border-collapse: collapse;
12
+ font-family: var(--wzl-font-ui);
13
+ font-size: var(--wzl-font-size-xs);
14
+ color: var(--wzl-fg);
15
+ }
16
+
17
+ .lk-control-matrix__corner {
18
+ width: var(--lk-control-matrix-label-width, 30%);
19
+ }
20
+
21
+ .lk-control-matrix__head,
22
+ .lk-control-matrix__label {
23
+ font-family: var(--wzl-font-display);
24
+ font-weight: var(--wzl-font-weight-light);
25
+ font-size: var(--wzl-font-size-sm);
26
+ line-height: var(--wzl-leading-snug);
27
+ color: var(--wzl-fg-muted);
28
+ text-transform: var(--wzl-params-label-case, uppercase);
29
+ letter-spacing: var(--wzl-params-label-tracking, var(--wzl-tracking-wide));
30
+ }
31
+
32
+ .lk-control-matrix__head {
33
+ padding: 0 0 var(--wzl-space-2);
34
+ text-align: center;
35
+ font-size: var(--wzl-font-size-2xs);
36
+ white-space: nowrap;
37
+ overflow: hidden;
38
+ text-overflow: ellipsis;
39
+ }
40
+
41
+ // The fallback scope every other column inherits from.
42
+ .lk-control-matrix__head.is-first {
43
+ color: var(--wzl-fg);
44
+ font-weight: var(--wzl-font-weight-medium);
45
+ }
46
+
47
+ .lk-control-matrix__head-text {
48
+ text-decoration: none;
49
+ cursor: default;
50
+ }
51
+
52
+ .lk-control-matrix__head-button {
53
+ width: 100%;
54
+ padding: 0;
55
+ border: 0;
56
+ border-radius: var(--wzl-radius-sm);
57
+ background: none;
58
+ color: inherit;
59
+ font: inherit;
60
+ letter-spacing: var(--wzl-params-label-tracking, var(--wzl-tracking-wide));
61
+ text-transform: var(--wzl-params-label-case, uppercase);
62
+ cursor: pointer;
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ }
66
+
67
+ .lk-control-matrix__head-button:hover {
68
+ color: var(--wzl-fg);
69
+ background: var(--wzl-surface-hover);
70
+ }
71
+
72
+ .lk-control-matrix__label {
73
+ padding: 0 var(--wzl-space-2) 0 0;
74
+ text-align: start;
75
+ white-space: nowrap;
76
+ }
77
+
78
+ // The text truncates; the help glyph after it never does.
79
+ .lk-control-matrix__label-text {
80
+ display: inline-block;
81
+ max-width: calc(100% - 1.4em);
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ vertical-align: middle;
85
+ }
86
+
87
+ .lk-control-matrix__cell {
88
+ padding: 1px;
89
+ height: var(--wzl-control-h-sm);
90
+ text-align: center;
91
+ vertical-align: middle;
92
+ }
93
+
94
+ .lk-control-matrix__cell.is-missing {
95
+ color: var(--wzl-fg-subtle);
96
+ }
97
+
98
+ // Inherited: the value in effect, but not chosen here.
99
+ .lk-control-matrix__cell.is-inherited > * {
100
+ opacity: 0.4;
101
+ }
102
+
103
+ // Every cell control fills its cell, so the cell is the click target.
104
+ .lk-control-matrix__value,
105
+ .lk-control-matrix__switch {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ width: 100%;
110
+ height: var(--wzl-control-h-sm);
111
+ padding: 0 var(--wzl-space-1);
112
+ border: 1px solid transparent;
113
+ border-radius: var(--wzl-radius-sm);
114
+ background: none;
115
+ color: inherit;
116
+ font: inherit;
117
+ cursor: pointer;
118
+ }
119
+
120
+ .lk-control-matrix__value {
121
+ display: block;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ white-space: nowrap;
125
+ line-height: calc(var(--wzl-control-h-sm) - 2px);
126
+ }
127
+
128
+ .lk-control-matrix__value.is-number {
129
+ .numeric();
130
+ text-align: end;
131
+ // A column of numbers is read down, so it keeps every digit: a glow of
132
+ // 0.35 cut to `0…` says nothing.
133
+ text-overflow: clip;
134
+ }
135
+
136
+ .lk-control-matrix__value:hover,
137
+ .lk-control-matrix__switch:hover {
138
+ border-color: var(--wzl-border);
139
+ background: var(--wzl-surface-hover);
140
+ }
141
+
142
+ .lk-control-matrix__value:focus-visible,
143
+ .lk-control-matrix__switch:focus-visible,
144
+ .lk-control-matrix__swatch:focus-visible,
145
+ .lk-control-matrix__head-button:focus-visible {
146
+ outline: 2px solid var(--wzl-focus-ring);
147
+ outline-offset: -1px;
148
+ }
149
+
150
+ // A small switch, drawn with the same parts as weasel-ui's.
151
+ .lk-control-matrix__track {
152
+ position: relative;
153
+ flex: 0 0 auto;
154
+ width: 20px;
155
+ height: 12px;
156
+ background: var(--wzl-surface-sunken);
157
+ border: 1px solid var(--wzl-border-strong);
158
+ border-radius: var(--wzl-radius-pill);
159
+ transition: background-color var(--wzl-motion-fast), border-color var(--wzl-motion-fast);
160
+ }
161
+
162
+ .lk-control-matrix__thumb {
163
+ position: absolute;
164
+ top: 1px;
165
+ left: 1px;
166
+ width: 8px;
167
+ height: 8px;
168
+ background: var(--wzl-fg);
169
+ border-radius: 50%;
170
+ transition: transform var(--wzl-motion-fast);
171
+ }
172
+
173
+ .lk-control-matrix__switch[aria-checked='true'] .lk-control-matrix__track {
174
+ background: var(--wzl-accent-strong);
175
+ border-color: var(--wzl-accent-strong);
176
+ }
177
+
178
+ .lk-control-matrix__switch[aria-checked='true'] .lk-control-matrix__thumb {
179
+ transform: translateX(8px);
180
+ background: var(--wzl-fg-on-accent);
181
+ }
182
+
183
+ .lk-control-matrix__swatch {
184
+ display: block;
185
+ width: 100%;
186
+ height: var(--wzl-control-h-xs);
187
+ padding: 0;
188
+ border: 1px solid var(--wzl-border);
189
+ border-radius: var(--wzl-radius-sm);
190
+ background: none;
191
+ cursor: pointer;
192
+ -webkit-appearance: none;
193
+ appearance: none;
194
+ }
195
+
196
+ .lk-control-matrix__swatch::-webkit-color-swatch-wrapper {
197
+ padding: 0;
198
+ }
199
+
200
+ .lk-control-matrix__swatch::-webkit-color-swatch {
201
+ border: 0;
202
+ border-radius: var(--wzl-radius-sm);
203
+ }
204
+
205
+ .lk-control-matrix__swatch::-moz-color-swatch {
206
+ border: 0;
207
+ border-radius: var(--wzl-radius-sm);
208
+ }
209
+
210
+ .lk-control-matrix__chip {
211
+ display: inline-block;
212
+ width: 22px;
213
+ height: var(--wzl-control-h-xs);
214
+ border: 1px solid var(--wzl-border);
215
+ border-radius: var(--wzl-radius-sm);
216
+ background: var(--lk-chip);
217
+ vertical-align: middle;
218
+ }
219
+
220
+ .lk-control-matrix__chip.is-unset {
221
+ border-style: dashed;
222
+ background: none;
223
+ }
224
+
225
+ .lk-control-matrix__popover {
226
+ outline: none;
227
+ }
228
+
229
+ .lk-control-matrix__editor {
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: var(--wzl-space-3);
233
+ inline-size: 240px;
234
+ padding: var(--wzl-space-4);
235
+ background: var(--wzl-surface-raised);
236
+ border: 1px solid var(--wzl-border);
237
+ border-radius: var(--wzl-radius-md);
238
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
239
+ outline: none;
240
+ }
241
+
242
+ .lk-control-matrix__editor-actions {
243
+ display: flex;
244
+ justify-content: flex-end;
245
+ }