@weasel-js/labkit 1.5.1 → 1.6.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 (160) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CJNMS6mz.d.ts → CanvasStackContext-DQgcH1ny.d.ts} +1 -1
  3. package/dist/_dts/LayerList.d-2c9yup5Y.d.ts +83 -0
  4. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  5. package/dist/_dts/{Select.d-Gwr8fLpK.d.ts → Select.d-BchGMC2Y.d.ts} +129 -12
  6. package/dist/_dts/{frac-B4okC2lX.d.ts → frac-DTRq7Xpb.d.ts} +31 -13
  7. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +23 -0
  8. package/dist/_dts/{index-CSJoj4LC.d.ts → index-B0--8PEM.d.ts} +5 -4
  9. package/dist/_dts/index-DINmCuLA.d.ts +453 -0
  10. package/dist/_dts/number.d-OeQYQoUO.d.ts +28 -0
  11. package/dist/_dts/{types-DzoNY1pO.d.ts → types-B7y8hX1D.d.ts} +6 -6
  12. package/dist/_dts/{usePanZoom-Pzf9f2jh.d.ts → usePanZoom-Bq73pwFe.d.ts} +4 -47
  13. package/dist/_dts/{useTrialState-uLlmaccn.d.ts → useTrialState-CP4jfuKl.d.ts} +1 -1
  14. package/dist/canvas/index.d.ts +6 -5
  15. package/dist/canvas/index.js +1 -1
  16. package/dist/chrome/index.d.ts +5 -4
  17. package/dist/chrome/index.js +7 -7
  18. package/dist/chunk-2RVCB2PT.js +3 -0
  19. package/dist/chunk-2RVCB2PT.js.map +1 -0
  20. package/dist/{chunk-UN4U4P63.js → chunk-3FI7Y4R4.js} +9 -9
  21. package/dist/chunk-3FI7Y4R4.js.map +1 -0
  22. package/dist/{chunk-7PFH3CNM.js → chunk-AWH3SRPW.js} +31 -5
  23. package/dist/chunk-AWH3SRPW.js.map +1 -0
  24. package/dist/{chunk-ZDHY47EV.js → chunk-BVL7Y574.js} +25 -9
  25. package/dist/chunk-BVL7Y574.js.map +1 -0
  26. package/dist/{chunk-IHUVQ3TX.js → chunk-H5VIV7MQ.js} +31 -27
  27. package/dist/chunk-H5VIV7MQ.js.map +1 -0
  28. package/dist/chunk-HFRE4QQV.js +400 -0
  29. package/dist/chunk-HFRE4QQV.js.map +1 -0
  30. package/dist/{chunk-IXTQI7JX.js → chunk-MOLIUNVH.js} +4 -4
  31. package/dist/{chunk-IXTQI7JX.js.map → chunk-MOLIUNVH.js.map} +1 -1
  32. package/dist/{chunk-CEIUBPPW.js → chunk-TT5C5S6R.js} +3 -86
  33. package/dist/chunk-TT5C5S6R.js.map +1 -0
  34. package/dist/chunk-USO6TERP.js +90 -0
  35. package/dist/chunk-USO6TERP.js.map +1 -0
  36. package/dist/{chunk-PHK4W562.js → chunk-VQ47KWD5.js} +3 -3
  37. package/dist/{chunk-PHK4W562.js.map → chunk-VQ47KWD5.js.map} +1 -1
  38. package/dist/chunk-WUJXJC5O.js +14568 -0
  39. package/dist/chunk-WUJXJC5O.js.map +1 -0
  40. package/dist/{chunk-EHGVROVV.js → chunk-ZORVNOKX.js} +6 -25
  41. package/dist/chunk-ZORVNOKX.js.map +1 -0
  42. package/dist/config/index.d.ts +53 -11
  43. package/dist/config/index.js +5 -5
  44. package/dist/controls/index.d.ts +6 -3
  45. package/dist/controls/index.js +4 -4
  46. package/dist/dragdrop/index.d.ts +4 -3
  47. package/dist/index.d.ts +139 -114
  48. package/dist/index.js +470 -479
  49. package/dist/index.js.map +1 -1
  50. package/dist/layers/index.d.ts +6 -35
  51. package/dist/layers/index.js +2 -3
  52. package/dist/loupe/index.d.ts +6 -5
  53. package/dist/passthrough/weasel-ui.d.ts +421 -215
  54. package/dist/passthrough/weasel-ui.js +1 -1
  55. package/dist/primitives/index.d.ts +2 -13
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +6 -5
  58. package/dist/state/index.js +5 -5
  59. package/dist/styles.css +410 -190
  60. package/dist/undo/index.d.ts +4 -3
  61. package/package.json +9 -13
  62. package/src/annotations/frac.test.ts +7 -0
  63. package/src/annotations/paint.test.ts +21 -0
  64. package/src/annotations/paint.ts +12 -0
  65. package/src/annotations/store.test.ts +31 -0
  66. package/src/annotations/types.ts +3 -2
  67. package/src/canvas/Stage.test.tsx +18 -0
  68. package/src/canvas/index.ts +0 -9
  69. package/src/chrome/labTypes.ts +1 -1
  70. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  71. package/src/chrome/regions/PaletteRegion.tsx +5 -4
  72. package/src/chrome/regions/SidebarRegion.less +197 -0
  73. package/src/chrome/regions/SidebarRegion.test.tsx +20 -0
  74. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  75. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  76. package/src/chrome/types.ts +13 -4
  77. package/src/config/autoConfig.ts +2 -2
  78. package/src/config/builder.ts +23 -5
  79. package/src/config/entry.test.ts +8 -0
  80. package/src/config/index.ts +2 -0
  81. package/src/config/rules.ts +2 -0
  82. package/src/config/sectionTree.test.ts +146 -0
  83. package/src/config/sectionTree.ts +124 -0
  84. package/src/config/types.ts +4 -4
  85. package/src/controls/AutoControls.stories.tsx +6 -6
  86. package/src/controls/ControlPanel.less +27 -0
  87. package/src/controls/ControlPanel.stories.tsx +95 -1
  88. package/src/controls/ControlPanel.test.tsx +82 -112
  89. package/src/controls/ControlPanel.tsx +159 -396
  90. package/src/controls/inDialog.test.tsx +82 -0
  91. package/src/controls/inDialog.tsx +46 -0
  92. package/src/controls/index.ts +1 -0
  93. package/src/index.test.ts +0 -2
  94. package/src/index.ts +14 -35
  95. package/src/instrument/types.ts +5 -2
  96. package/src/lab/Lab.nebula.test.tsx +31 -0
  97. package/src/lab/Lab.tsx +57 -18
  98. package/src/lab/LabBoundary.test.tsx +66 -0
  99. package/src/lab/LabBoundary.tsx +56 -0
  100. package/src/lab/LabHeader.tsx +4 -23
  101. package/src/lab/LabRoot.tsx +2 -3
  102. package/src/lab/LabShell.less +0 -1
  103. package/src/lab/LabShell.stories.tsx +15 -0
  104. package/src/lab/LabShell.test.tsx +18 -0
  105. package/src/lab/LabShell.tsx +14 -1
  106. package/src/lab/LabSwitcher.less +1 -1
  107. package/src/lab/LabSwitcher.test.tsx +41 -0
  108. package/src/lab/LabSwitcher.tsx +30 -8
  109. package/src/lab/index.ts +1 -0
  110. package/src/layers/AGENTS.md +18 -74
  111. package/src/layers/index.ts +4 -2
  112. package/src/passthrough/weasel-ui.ts +25 -10
  113. package/src/primitives/FpsMeter.less +6 -1
  114. package/src/primitives/FpsMeter.test.tsx +12 -4
  115. package/src/primitives/FpsMeter.tsx +6 -1
  116. package/src/primitives/JobProgress.stories.tsx +1 -1
  117. package/src/primitives/StatusBar.less +6 -0
  118. package/src/primitives/index.ts +0 -2
  119. package/src/specimen/Specimen.tsx +42 -56
  120. package/src/state/types.ts +8 -2
  121. package/src/state/view.test.ts +4 -4
  122. package/src/styles.less +4 -2
  123. package/src/theme/Interstellar.stories.tsx +8 -9
  124. package/src/theme/base.less +3 -20
  125. package/src/theme/interstellar.test.ts +6 -3
  126. package/src/theme/interstellar.theme.json +0 -56
  127. package/src/theme/interstellar.ts +2 -2
  128. package/src/trial/Trial.canvas.test.tsx +9 -5
  129. package/src/trial/Trial.config.test.tsx +1 -1
  130. package/src/trial/Trial.tsx +30 -8
  131. package/dist/_dts/index-BNdz-tNd.d.ts +0 -423
  132. package/dist/chunk-7PFH3CNM.js.map +0 -1
  133. package/dist/chunk-CEIUBPPW.js.map +0 -1
  134. package/dist/chunk-EH7UEQV7.js +0 -590
  135. package/dist/chunk-EH7UEQV7.js.map +0 -1
  136. package/dist/chunk-EHGVROVV.js.map +0 -1
  137. package/dist/chunk-IHUVQ3TX.js.map +0 -1
  138. package/dist/chunk-MDVTK5U3.js +0 -170
  139. package/dist/chunk-MDVTK5U3.js.map +0 -1
  140. package/dist/chunk-PJ7WEQBJ.js +0 -18
  141. package/dist/chunk-PJ7WEQBJ.js.map +0 -1
  142. package/dist/chunk-T3EC2DXD.js +0 -12628
  143. package/dist/chunk-T3EC2DXD.js.map +0 -1
  144. package/dist/chunk-UN4U4P63.js.map +0 -1
  145. package/dist/chunk-ZDHY47EV.js.map +0 -1
  146. package/dist/ui/layers/index.d.ts +0 -66
  147. package/dist/ui/layers/index.js +0 -3
  148. package/dist/ui/layers/index.js.map +0 -1
  149. package/src/canvas/useOrbit.test.ts +0 -167
  150. package/src/canvas/useOrbit.ts +0 -156
  151. package/src/lab/useSystemMode.ts +0 -30
  152. package/src/layers/LayerList.less +0 -93
  153. package/src/layers/LayerList.stories.tsx +0 -66
  154. package/src/layers/LayerList.test.tsx +0 -232
  155. package/src/layers/LayerList.tsx +0 -268
  156. package/src/primitives/Sidebar.less +0 -112
  157. package/src/primitives/Sidebar.stories.tsx +0 -38
  158. package/src/primitives/Sidebar.test.tsx +0 -74
  159. package/src/primitives/Sidebar.tsx +0 -35
  160. package/src/ui/layers/index.ts +0 -5
@@ -0,0 +1,82 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { f } from '../config/builder';
5
+ import { withValueAtPath } from '../config/path';
6
+ import { resolveConfigSchema } from '../config/resolve';
7
+ import { ControlPanel } from './ControlPanel';
8
+ import { summarizeValue } from './inDialog';
9
+
10
+ function Panel({ schema }: { schema: Parameters<typeof resolveConfigSchema>[0] }) {
11
+ const resolved = resolveConfigSchema(schema);
12
+ const [config, setConfig] = useState<Record<string, unknown>>(
13
+ schema.defaults() as Record<string, unknown>,
14
+ );
15
+ return (
16
+ <>
17
+ <ControlPanel
18
+ schema={resolved}
19
+ config={config}
20
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
21
+ />
22
+ <output data-testid="config">{JSON.stringify(config)}</output>
23
+ </>
24
+ );
25
+ }
26
+
27
+ describe('f.list', () => {
28
+ it('summarizes the list on the row and edits it in a dialog', () => {
29
+ const schema = f.schema({
30
+ icons: f.list(['**/node_modules/**', 'dist']).label('Draw as icons'),
31
+ });
32
+ render(<Panel schema={schema} />);
33
+ const trigger = screen.getByRole('button', { name: 'Edit Draw as icons' });
34
+ expect(trigger.textContent).toContain('**/node_modules/**, dist');
35
+
36
+ fireEvent.click(trigger);
37
+ fireEvent.change(screen.getByRole('textbox', { name: 'Draw as icons 2' }), {
38
+ target: { value: 'build' },
39
+ });
40
+ expect(screen.getByTestId('config').textContent).toBe(
41
+ JSON.stringify({ icons: ['**/node_modules/**', 'build'] }),
42
+ );
43
+ });
44
+
45
+ it('is the kind an f.value with a string-array default takes', () => {
46
+ const resolved = resolveConfigSchema(f.schema({ names: f.value(['a']) }));
47
+ expect(resolved.group.children.names).toMatchObject({ kind: 'list' });
48
+ });
49
+ });
50
+
51
+ describe('.dialog', () => {
52
+ it('hosts any renderer in the dialog, handed the row context', () => {
53
+ const schema = f.schema({
54
+ note: f.string('hi').dialog(({ value, setValue }) => (
55
+ <button type="button" onClick={() => setValue(`${value}!`)}>
56
+ shout
57
+ </button>
58
+ )),
59
+ });
60
+ render(<Panel schema={schema} />);
61
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Note' }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'shout' }));
63
+ expect(screen.getByTestId('config').textContent).toBe(JSON.stringify({ note: 'hi!' }));
64
+ });
65
+
66
+ it('takes a summary of its own', () => {
67
+ const summary = vi.fn((v: unknown) => `${(v as number[]).length} points`);
68
+ const schema = f.schema({ pts: f.custom('points', [1, 2, 3]).dialog(() => null, { summary }) });
69
+ render(<Panel schema={schema} />);
70
+ expect(screen.getByRole('button', { name: 'Edit Pts' }).textContent).toContain('3 points');
71
+ });
72
+ });
73
+
74
+ describe('summarizeValue', () => {
75
+ it('joins lists and names emptiness', () => {
76
+ expect(summarizeValue(['a', 'b'])).toBe('a, b');
77
+ expect(summarizeValue([])).toBe('None');
78
+ expect(summarizeValue('')).toBe('None');
79
+ expect(summarizeValue(3)).toBe('3');
80
+ expect(summarizeValue({ a: 1 })).toBe('{"a":1}');
81
+ });
82
+ });
@@ -0,0 +1,46 @@
1
+ import { DialogRow } from '@weasel-js/ui';
2
+ import type { ReactNode } from 'react';
3
+ import type { ControlRenderer } from '../config/types';
4
+
5
+ /** Options for {@link inDialog}. */
6
+ export interface InDialogOptions {
7
+ /** What the row's button shows of the value. Defaults to a list joined with
8
+ * commas, or the value itself. */
9
+ summary?: (value: unknown) => ReactNode;
10
+ /** The dialog's heading. Defaults to the row's label. */
11
+ title?: ReactNode;
12
+ }
13
+
14
+ /** The one-line summary a dialog row shows when it is given none. */
15
+ export function summarizeValue(value: unknown): string {
16
+ if (Array.isArray(value)) return value.length === 0 ? 'None' : value.join(', ');
17
+ if (value === undefined || value === null || value === '') return 'None';
18
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
19
+ }
20
+
21
+ /**
22
+ * Moves a control into a modal: the row becomes a button showing a summary of
23
+ * the value, and the button opens a dialog whose body is `body`, handed the
24
+ * same context a row renderer gets. For a leaf whose editor will never fit a
25
+ * sidebar row.
26
+ */
27
+ export function inDialog(body: ControlRenderer, opts: InDialogOptions = {}): ControlRenderer {
28
+ const summary = opts.summary ?? summarizeValue;
29
+ return (ctx) => {
30
+ const { pref, value, auto, setAuto } = ctx;
31
+ const manual = (pref as { manual?: boolean }).manual === true;
32
+ return (
33
+ <DialogRow
34
+ label={pref.name}
35
+ description={pref.description}
36
+ summary={summary(value)}
37
+ title={opts.title}
38
+ readout={auto ? 'auto' : undefined}
39
+ auto={auto}
40
+ onAutoChange={manual ? undefined : setAuto}
41
+ >
42
+ {() => body(ctx)}
43
+ </DialogRow>
44
+ );
45
+ };
46
+ }
@@ -1,4 +1,5 @@
1
1
  export { ControlPanel } from './ControlPanel';
2
+ export { type InDialogOptions, inDialog, summarizeValue } from './inDialog';
2
3
  export type {
3
4
  CheckboxField,
4
5
  ColorField,
package/src/index.test.ts CHANGED
@@ -72,8 +72,6 @@ describe('surface, job and orbit entry points', () => {
72
72
  expect(typeof kit.annotationsFromJSON).toBe('function');
73
73
  expect(typeof kit.seenFrom).toBe('function');
74
74
  expect(typeof kit.fracToWorld).toBe('function');
75
- expect(typeof kit.useOrbit).toBe('function');
76
- expect(typeof kit.orbitAfterDrag).toBe('function');
77
75
  expect(typeof kit.usePanZoom).toBe('function');
78
76
  expect(typeof kit.useJob).toBe('function');
79
77
  expect(typeof kit.as2DView).toBe('function');
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The property-panel family, LayerStack, Input, Select, TokenPanel, ToggleBar and Button live in
1
+ // The property-panel family, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
2
2
  // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
3
  // direct ui dependency. Named, not `export *` — a star re-export of an external
4
4
  // package emits no binding in the bundle.
@@ -8,19 +8,13 @@ export type {
8
8
  ButtonProps,
9
9
  ButtonSize,
10
10
  ButtonVariant,
11
- CheckboxRowProps,
12
- ColorRowProps,
13
11
  CurveFieldProps,
14
12
  CurveMark,
15
- EffectCardListItem,
16
- EffectCardListProps,
17
- EffectCardProps,
18
13
  IconProps,
19
14
  InputProps,
20
15
  JogProps,
21
- LayerStackItem,
22
- LayerStackProps,
23
- NumberRowProps,
16
+ PropertyControlProps,
17
+ PropertyFieldProps,
24
18
  PropertyGroupProps,
25
19
  PropertyListPack,
26
20
  PropertyListProps,
@@ -31,53 +25,44 @@ export type {
31
25
  PropertyRowVariant,
32
26
  PropertySpanProps,
33
27
  SelectProps,
34
- SelectRowProps,
35
- SliderRowProps,
36
28
  SubpanelProps,
37
- TextRowProps,
38
29
  ToggleBarItem,
39
30
  ToggleBarProps,
40
31
  ToggleBarSize,
41
32
  ToggleBarVariant,
42
- ToggleRowProps,
43
33
  TokenCategory,
44
34
  TokenEntry,
45
35
  TokenPanelProps,
36
+ TokenScale,
37
+ TokenScaleRule,
46
38
  } from '@weasel-js/ui';
47
39
  export {
48
40
  Button,
49
- CheckboxRow,
50
41
  CloseIcon,
51
42
  CollapseIcon,
52
- ColorRow,
53
43
  CurveField,
54
- EffectCard,
55
- EffectCardList,
56
44
  ExpandIcon,
57
45
  Icon,
58
46
  Input,
59
47
  inferTokenType,
60
48
  Jog,
61
- LayerStack,
62
- NumberRow,
63
49
  PauseIcon,
64
50
  PlayIcon,
51
+ PropertyControl,
52
+ PropertyField,
65
53
  PropertyGroup,
66
54
  PropertyList,
67
55
  PropertyPanel,
68
56
  PropertyRow,
69
57
  PropertySpan,
70
58
  ResetIcon,
59
+ refitScale,
71
60
  Select,
72
- SelectRow,
73
- SliderRow,
74
61
  StepBackIcon,
75
62
  StepForwardIcon,
76
63
  StopIcon,
77
64
  Subpanel,
78
- TextRow,
79
65
  ToggleBar,
80
- ToggleRow,
81
66
  TokenPanel,
82
67
  } from '@weasel-js/ui';
83
68
  export type {
@@ -148,13 +133,9 @@ export type {
148
133
  CanvasStackContextValue,
149
134
  CanvasStackProps,
150
135
  CanvasStackSurface,
151
- OrbitHandlers,
152
- OrbitView,
153
136
  PanZoomHandlers,
154
137
  StageProps,
155
- UseOrbitOptions,
156
138
  UsePanZoomOptions,
157
- Vec3,
158
139
  ViewportSize,
159
140
  WorldFrame,
160
141
  WorldSpec,
@@ -165,19 +146,13 @@ export {
165
146
  CanvasStack,
166
147
  CanvasStackContext,
167
148
  centerOn,
168
- clampPitch,
169
149
  DEFAULT_FRAME,
170
150
  fitStage,
171
- orbitAfterDrag,
172
- orbitAfterWheel,
173
- PITCH_LIMIT,
174
151
  resolveFrame,
175
152
  Stage,
176
153
  screenToWorld,
177
- useOrbit,
178
154
  usePanZoom,
179
155
  worldToScreen,
180
- wrapYaw,
181
156
  zoomAt,
182
157
  } from './canvas';
183
158
  export * from './chrome';
@@ -192,11 +167,13 @@ export {
192
167
  f,
193
168
  GroupNode,
194
169
  isConfigBranch,
170
+ ListNode,
195
171
  NumberNode,
196
172
  StringNode,
197
173
  ValueNode,
198
174
  } from './config/builder';
199
175
  export { fromConfigFields } from './config/fromConfigField';
176
+ export { type SectionTree, sectionTree } from './config/sectionTree';
200
177
  export {
201
178
  fillConfigDefaults,
202
179
  hasConfigPath,
@@ -234,6 +211,7 @@ export { useConfigSchema } from './config/useConfigSchema';
234
211
  export { useResolvedConfig } from './config/useResolvedConfig';
235
212
  export { isLeafVisible } from './config/visible';
236
213
  export { ControlPanel } from './controls/ControlPanel';
214
+ export { type InDialogOptions, inDialog, summarizeValue } from './controls/inDialog';
237
215
  export type {
238
216
  CheckboxField,
239
217
  ColorField,
@@ -269,8 +247,8 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
269
247
  export { validateConfigSchema } from './instrument/validateConfigSchema';
270
248
  export * from './job';
271
249
  export * from './lab';
272
- export type { LayerListProps, LayerTreeNode } from './layers';
273
- export { LayerList } from './layers';
250
+ export type { LayerListItem, LayerListProps, LayerMove } from './layers';
251
+ export { LayerList, moveLayers } from './layers';
274
252
  export * from './loupe';
275
253
  export * from './primitives';
276
254
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
@@ -298,6 +276,7 @@ export { Persistence, type PersistenceProps } from './state/Persistence';
298
276
  export type { RecordCache, RecordChange } from './state/records';
299
277
  export type {
300
278
  CreateLabStoreOptions,
279
+ LabDensity,
301
280
  LabDocument,
302
281
  LabMode,
303
282
  LabStoreState,
@@ -70,7 +70,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
70
70
  /** Declares that an instrument's DOM is content of a fixed size, which the
71
71
  * trial pans and zooms the way it does a canvas's layers. Not combined with
72
72
  * `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
73
- export interface StageCapability {
73
+ export interface StageCapability<TS = unknown, TC = unknown> {
74
74
  /** The content's own size in CSS pixels, at zoom 1. */
75
75
  size: ViewportSize;
76
76
  /** Where the view starts. Omitted, the content opens centered and shrunk to
@@ -78,6 +78,9 @@ export interface StageCapability {
78
78
  initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
79
79
  minZoom?: number;
80
80
  maxZoom?: number;
81
+ /** Drawn over the content in viewport pixels, outside the camera — a legend,
82
+ * a floating panel, anything that must not zoom with the picture. */
83
+ overlay?: (ctx: RenderContext<TS, TC>) => ReactNode;
81
84
  }
82
85
 
83
86
  /** Declares which of an instrument's layers the trial should offer
@@ -159,7 +162,7 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
159
162
  serialize?: (state: TS) => unknown;
160
163
  deserialize?: (data: unknown, config: TC) => TS;
161
164
  canvas?: CanvasCapability<TS, TC>;
162
- stage?: StageCapability;
165
+ stage?: StageCapability<TS, TC>;
163
166
  layers?: LayerCapability;
164
167
  dragDrop?: DragDropCapability<TS, TC>;
165
168
  undo?: UndoCapability;
@@ -0,0 +1,31 @@
1
+ import { render } from '@testing-library/react';
2
+ import { resolveTheme } from '@weasel-js/theme';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { interstellarTheme } from '../theme/interstellar';
6
+ import { Lab } from './Lab';
7
+
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ const backdrop = (container: HTMLElement) =>
16
+ [...container.querySelectorAll<HTMLElement>('[style]')]
17
+ .map((el) => el.style.getPropertyValue('--wzl-backdrop'))
18
+ .find(Boolean) ?? '';
19
+
20
+ describe('<Lab nebula>', () => {
21
+ it('draws one blob per literal color', () => {
22
+ const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={['#ff0000', '#00ff00']} />);
23
+ expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
24
+ });
25
+
26
+ it('resolves a theme ramp against the lab’s theme', () => {
27
+ const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />);
28
+ const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
29
+ expect(backdrop(container)).toContain(sky);
30
+ });
31
+ });
package/src/lab/Lab.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { ThemeProvider } from '@weasel-js/theme/react';
1
+ import { type ColorList, colorAt, colorCount, resolveTheme, type Theme } from '@weasel-js/theme';
2
+ import { ThemeProvider, useResolvedColorMode } from '@weasel-js/theme/react';
2
3
  import {
3
4
  type CSSProperties,
4
5
  type ReactNode,
@@ -30,7 +31,7 @@ import { type OpenedLabStore, openLabStore } from '../state/openLabStore';
30
31
  import { PersistenceContext } from '../state/Persistence';
31
32
  import { createRecordCache } from '../state/records';
32
33
  import { createLabStore, type LabStore } from '../state/store';
33
- import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
34
+ import type { LabDensity, LabMode, StorageAdapter, TrialRecord } from '../state/types';
34
35
  import { useOpenOnce, useWarnIgnoredChange } from '../state/useOpenOnce';
35
36
  import { usePersistedState } from '../state/usePersistedState';
36
37
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
@@ -54,7 +55,6 @@ import { LabPalette } from './LabPalette';
54
55
  import { LabShell } from './LabShell';
55
56
  import type { LabPage } from './LabSwitcher';
56
57
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
57
- import { useResolvedMode } from './useSystemMode';
58
58
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
59
 
60
60
  interface LabBaseProps {
@@ -64,15 +64,24 @@ interface LabBaseProps {
64
64
  instruments: InstrumentList;
65
65
  defaultInstrument: string;
66
66
  mode?: LabMode;
67
+ /** How much room the lab's chrome takes. Default `'comfortable'`; a lab whose
68
+ * window is the whole app, rather than a panel beside one, reads better at
69
+ * `'roomy'`. The trials' own contents are unaffected. */
70
+ density?: LabDensity;
71
+ /** The theme the lab's chrome resolves against, applied at `mode` and
72
+ * `density`. Default `interstellarTheme`; extend it rather than replace it to
73
+ * keep the lab's look while changing a few tokens. */
74
+ theme?: Theme;
67
75
  /** Rendered while a stored lab loads. Default: the lab's empty shell. */
68
76
  fallback?: ReactNode;
69
77
  /**
70
- * Optional list of CSS colors used to compose the interstellar theme's
71
- * cosmic backdrop. Each color becomes one radial-gradient blob on the
72
- * dark void base. Order maps to a fixed spread of positions; extras wrap
73
- * around. Ignored unless the resolved mode is dark.
78
+ * Colors composing the interstellar theme's cosmic backdrop. Each becomes
79
+ * one radial-gradient blob on the dark void base, in a fixed spread of
80
+ * positions; extras wrap around. A theme ramp resolves against the lab's
81
+ * theme; a function list gives as many blobs as there are positions.
82
+ * Ignored unless the resolved mode is dark.
74
83
  */
75
- nebula?: readonly string[];
84
+ nebula?: ColorList;
76
85
  title?: string;
77
86
  /** The project's other labs. Given two or more, the title becomes the way to
78
87
  * reach them — the same switcher `<LabShell>` renders, so a lab reached from
@@ -175,12 +184,18 @@ async function openStoredLab(
175
184
  function LabFallback({
176
185
  title,
177
186
  mode,
187
+ density,
188
+ theme = interstellarTheme,
178
189
  pages,
179
190
  path,
180
- }: Pick<LabBaseProps, 'title' | 'mode' | 'pages' | 'path'>) {
181
- const resolvedMode = useResolvedMode(mode ?? 'auto');
191
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'theme' | 'pages' | 'path'>) {
192
+ const resolvedMode = useResolvedColorMode(mode ?? 'auto');
182
193
  return (
183
- <ThemeProvider theme={interstellarTheme} selection={{ mode: resolvedMode }} className="lk-lab">
194
+ <ThemeProvider
195
+ theme={theme}
196
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
197
+ className="lk-lab"
198
+ >
184
199
  <LabShell
185
200
  title={title ?? 'Labkit'}
186
201
  mode={mode}
@@ -205,7 +220,15 @@ const NEBULA_SLOTS = [
205
220
  { cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
206
221
  ] as const;
207
222
 
208
- function buildNebula(colors: readonly string[]): string {
223
+ function buildNebula(
224
+ list: ColorList,
225
+ theme: Theme,
226
+ mode: 'light' | 'dark',
227
+ density: string,
228
+ ): string {
229
+ const ctx = { theme, resolved: resolveTheme(theme, { mode, density }) };
230
+ const n = colorCount(list, ctx) ?? NEBULA_SLOTS.length;
231
+ const colors = Array.from({ length: n }, (_, i) => colorAt(list, i, ctx));
209
232
  const blobs = colors.map((c, i) => {
210
233
  const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
211
234
  return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
@@ -289,7 +312,14 @@ export function Lab(props: LabProps) {
289
312
  if (!opened) {
290
313
  if (props.fallback !== undefined) return props.fallback;
291
314
  return (
292
- <LabFallback title={props.title} mode={props.mode} pages={props.pages} path={props.path} />
315
+ <LabFallback
316
+ title={props.title}
317
+ mode={props.mode}
318
+ density={props.density}
319
+ {...(props.theme ? { theme: props.theme } : {})}
320
+ pages={props.pages}
321
+ path={props.path}
322
+ />
293
323
  );
294
324
  }
295
325
  return <LabRuntime {...props} opened={opened} />;
@@ -298,6 +328,8 @@ export function Lab(props: LabProps) {
298
328
  function LabRuntime({
299
329
  instruments,
300
330
  mode,
331
+ density,
332
+ theme = interstellarTheme,
301
333
  nebula,
302
334
  title,
303
335
  pages,
@@ -439,7 +471,7 @@ function LabRuntime({
439
471
  })),
440
472
  [undockedPanels],
441
473
  );
442
- const resolvedMode = useResolvedMode(modeValue);
474
+ const resolvedMode = useResolvedColorMode(modeValue);
443
475
 
444
476
  // Tools and lab contributions share one id namespace, so they merge once
445
477
  // here — before any region renders — and a collision throws rather than one
@@ -548,8 +580,15 @@ function LabRuntime({
548
580
  // Only override the backdrop in dark, where there is one to override.
549
581
  // Setting a CSS custom property is the sanctioned use of inline style.
550
582
  const backdropStyle =
551
- resolvedMode === 'dark' && nebula && nebula.length > 0
552
- ? ({ ['--wzl-backdrop' as string]: buildNebula(nebula) } as CSSProperties)
583
+ resolvedMode === 'dark' && nebula && colorCount(nebula) !== 0
584
+ ? ({
585
+ ['--wzl-backdrop' as string]: buildNebula(
586
+ nebula,
587
+ theme,
588
+ resolvedMode,
589
+ density ?? 'comfortable',
590
+ ),
591
+ } as CSSProperties)
553
592
  : undefined;
554
593
 
555
594
  const workspace = (
@@ -574,8 +613,8 @@ function LabRuntime({
574
613
  <AnnotationPreloadContext.Provider value={opened.marks}>
575
614
  <LabContext.Provider value={contextValue}>
576
615
  <ThemeProvider
577
- theme={interstellarTheme}
578
- selection={{ mode: resolvedMode }}
616
+ theme={theme}
617
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
579
618
  className="lk-lab"
580
619
  style={backdropStyle}
581
620
  >
@@ -0,0 +1,66 @@
1
+ import { render } from '@testing-library/react';
2
+ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
3
+ import { useContext } from 'react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { LabStoreContext, TrialIdContext } from '../state/context';
6
+ import { PersistenceContext } from '../state/Persistence';
7
+ import { interstellarTheme } from '../theme/interstellar';
8
+ import { LabBoundary } from './LabBoundary';
9
+ import { LabContext, type LabContextValue } from './LabContext';
10
+ import { LabRoot } from './LabRoot';
11
+
12
+ function Probe() {
13
+ const lab = useContext(LabContext);
14
+ const store = useContext(LabStoreContext);
15
+ const trial = useContext(TrialIdContext);
16
+ const records = useContext(PersistenceContext);
17
+ const theme = useThemeOptional();
18
+ return (
19
+ <output>
20
+ {JSON.stringify({
21
+ lab: lab !== null,
22
+ store: store !== null,
23
+ trial,
24
+ records: records !== null,
25
+ theme: theme !== null,
26
+ })}
27
+ </output>
28
+ );
29
+ }
30
+
31
+ const fakeLab = { trials: [], instruments: [] } as unknown as LabContextValue;
32
+
33
+ describe('LabBoundary', () => {
34
+ it('hides the enclosing lab, trial, records and theme from its children', () => {
35
+ const { container } = render(
36
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
37
+ <LabContext.Provider value={fakeLab}>
38
+ <TrialIdContext.Provider value="t1">
39
+ <Probe />
40
+ <LabBoundary>
41
+ <Probe />
42
+ </LabBoundary>
43
+ </TrialIdContext.Provider>
44
+ </LabContext.Provider>
45
+ </ThemeProvider>,
46
+ );
47
+ const [outside, inside] = Array.from(container.querySelectorAll('output')).map((el) =>
48
+ JSON.parse(el.textContent ?? ''),
49
+ );
50
+ expect(outside).toEqual({ lab: true, store: false, trial: 't1', records: false, theme: true });
51
+ expect(inside).toEqual({ lab: false, store: false, trial: null, records: false, theme: false });
52
+ });
53
+
54
+ it('lets a LabRoot under it apply its own theme instead of deferring to the one above', () => {
55
+ const { container } = render(
56
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
57
+ <LabBoundary>
58
+ <LabRoot mode="light">x</LabRoot>
59
+ </LabBoundary>
60
+ </ThemeProvider>,
61
+ );
62
+ const themed = container.querySelectorAll('[data-wzl-theme]');
63
+ expect(themed).toHaveLength(2);
64
+ expect(themed[1]?.getAttribute('data-wzl-mode')).toBe('light');
65
+ });
66
+ });
@@ -0,0 +1,56 @@
1
+ import { ThemeContext } from '@weasel-js/theme/react';
2
+ import type { ReactNode } from 'react';
3
+ import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
+ import { AnnotationPreloadContext } from '../annotations/preload';
5
+ import { CameraWheelContext } from '../canvas/CameraWheelContext';
6
+ import { CanvasStackContext } from '../canvas/CanvasStackContext';
7
+ import { LabStoreContext, TrialIdContext } from '../state/context';
8
+ import { PersistenceContext } from '../state/Persistence';
9
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
10
+ import { TrialDragContext } from '../trial/TrialDragContext';
11
+ import { LabContext } from './LabContext';
12
+ import { PanelHostContext } from './panelHost';
13
+
14
+ /** Props for `<LabBoundary>`. */
15
+ export interface LabBoundaryProps {
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Renders its children as though no lab, trial or theme were above them: every context a `<Lab>` or a
21
+ * `<Trial>` publishes reads as absent below it, so a labkit piece mounted inside a trial behaves the way it
22
+ * does on a bare page. A `<LabRoot>` under it applies its own theme instead of deferring to the lab's, and a
23
+ * `usePersistedState` under it keeps local state instead of writing into the enclosing lab's records.
24
+ *
25
+ * For a host that shows arbitrary content inside a trial, such as a component workshop; a lab's own chrome
26
+ * never needs it.
27
+ */
28
+ export function LabBoundary({ children }: LabBoundaryProps) {
29
+ return (
30
+ <LabStoreContext.Provider value={null}>
31
+ <PersistenceContext.Provider value={null}>
32
+ <AnnotationPreloadContext.Provider value={null}>
33
+ <LabContext.Provider value={null}>
34
+ <PanelHostContext.Provider value={null}>
35
+ <SurfaceContext.Provider value={null}>
36
+ <SurfaceCanvasContext.Provider value={{ over: null, under: null }}>
37
+ <TrialIdContext.Provider value={null}>
38
+ <AnnotationsContext.Provider value={null}>
39
+ <CameraWheelContext.Provider value={null}>
40
+ <CanvasStackContext.Provider value={null}>
41
+ <TrialDragContext.Provider value={null}>
42
+ <ThemeContext.Provider value={null}>{children}</ThemeContext.Provider>
43
+ </TrialDragContext.Provider>
44
+ </CanvasStackContext.Provider>
45
+ </CameraWheelContext.Provider>
46
+ </AnnotationsContext.Provider>
47
+ </TrialIdContext.Provider>
48
+ </SurfaceCanvasContext.Provider>
49
+ </SurfaceContext.Provider>
50
+ </PanelHostContext.Provider>
51
+ </LabContext.Provider>
52
+ </AnnotationPreloadContext.Provider>
53
+ </PersistenceContext.Provider>
54
+ </LabStoreContext.Provider>
55
+ );
56
+ }