@weasel-js/labkit 1.2.0 → 1.3.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { f } from '../config/builder';
3
+ import type { ConfigOf } from '../config/types';
2
4
  import { defineInstrument } from './defineInstrument';
3
5
  import type { Instrument } from './types';
4
6
 
5
7
  describe('defineInstrument', () => {
6
- it('returns the same object reference passed in', () => {
8
+ it('returns a legacy spec by reference', () => {
7
9
  const spec: Instrument<{ n: number }, { x: number }> = {
8
10
  name: 'Test',
9
11
  defaultConfig: () => ({ x: 1 }),
@@ -24,4 +26,39 @@ describe('defineInstrument', () => {
24
26
  expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{ flag: boolean }>();
25
27
  expectTypeOf(inst.initialState({ flag: true })).toEqualTypeOf<{ count: number }>();
26
28
  });
29
+
30
+ it('synthesizes defaultConfig from a builder schema', () => {
31
+ const inst = defineInstrument({
32
+ name: 'Schema',
33
+ config: f.schema({ showGrid: f.boolean(true), cellSize: f.number(20) }),
34
+ initialState: () => ({}),
35
+ render: () => null,
36
+ });
37
+ expect(inst.defaultConfig()).toEqual({ showGrid: true, cellSize: 20 });
38
+ });
39
+
40
+ it('infers TC from the schema', () => {
41
+ const config = f.schema({ showGrid: f.boolean(true), mode: f.enum('fast', ['fast', 'slow']) });
42
+ const inst = defineInstrument<{ n: number }, ConfigOf<typeof config>>({
43
+ name: 'Schema',
44
+ config,
45
+ initialState: () => ({ n: 0 }),
46
+ render: () => null,
47
+ });
48
+ expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{
49
+ showGrid: boolean;
50
+ mode: 'fast' | 'slow';
51
+ }>();
52
+ });
53
+
54
+ it('leaves an explicit defaultConfig alone even alongside a schema', () => {
55
+ const inst = defineInstrument({
56
+ name: 'Both',
57
+ config: f.schema({ a: f.number(1) }),
58
+ defaultConfig: () => ({ a: 99 }),
59
+ initialState: () => ({}),
60
+ render: () => null,
61
+ });
62
+ expect(inst.defaultConfig()).toEqual({ a: 99 });
63
+ });
27
64
  });
@@ -1,7 +1,34 @@
1
+ import type { ConfigSchema } from '../config/types';
1
2
  import type { Instrument } from './types';
2
3
 
3
- /** Identity at runtime; exists so an instrument's state and config types are
4
- * inferred from the spec rather than having to be written out. */
5
- export function defineInstrument<TS, TC>(spec: Instrument<TS, TC>): Instrument<TS, TC> {
6
- return spec;
4
+ /** An instrument as it is written: either the legacy `defaultConfig` (plus an
5
+ * optional `configSchema`), or a `config` schema that supplies both. */
6
+ export type InstrumentSpec<TS, TC, TItem = unknown> =
7
+ | Instrument<TS, TC, TItem>
8
+ | (Omit<Instrument<TS, TC, TItem>, 'defaultConfig'> & { config: ConfigSchema<TC> });
9
+
10
+ /**
11
+ * Normalizes an instrument spec, and infers its state and config types from
12
+ * the literal rather than making them be written out.
13
+ *
14
+ * A spec declaring `config` gets its `defaultConfig` synthesized from the
15
+ * schema, so nothing downstream branches on which way the config was
16
+ * declared. A spec written the legacy way is returned unchanged, by
17
+ * reference.
18
+ *
19
+ * Resolving the schema into controls does not happen here: rules are
20
+ * lab-scoped and this runs at module load. `useConfigSchema` does that.
21
+ *
22
+ * TypeScript infers all three type arguments or none, so a call that names
23
+ * `TS` and `TC` leaves `TItem` at `unknown` and a job's `onItem` needs a cast.
24
+ * Name the item type too, or pass no type arguments at all.
25
+ */
26
+ export function defineInstrument<TS, TC, TItem = unknown>(
27
+ spec: InstrumentSpec<TS, TC, TItem>,
28
+ ): Instrument<TS, TC, TItem> {
29
+ if (spec.config && !('defaultConfig' in spec && spec.defaultConfig)) {
30
+ const schema = spec.config;
31
+ return { ...spec, defaultConfig: () => schema.defaults() } as Instrument<TS, TC, TItem>;
32
+ }
33
+ return spec as Instrument<TS, TC, TItem>;
7
34
  }
@@ -1,3 +1,4 @@
1
+ export type { ToolCapability, TrialTool } from '../tools/types';
1
2
  export { defineInstrument } from './defineInstrument';
2
3
  export type {
3
4
  CanvasCapability,
@@ -1,6 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { TrialContribution } from '../chrome/types';
3
+ import type { ConfigSchema } from '../config/types';
2
4
  import type { ConfigField } from '../controls/types';
3
5
  import type { JobCapability, JobHandle } from '../job/types';
6
+ import type { ToolCapability } from '../tools/types';
4
7
 
5
8
  /** What an instrument's `render` is handed: its state and config, the setters
6
9
  * for both, the trial it is mounted in, and a way to emit named events. */
@@ -19,6 +22,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
19
22
  * a view that is not the 2D one. */
20
23
  zoom: number;
21
24
  setZoom: (z: number) => void;
25
+ /** Resolved active tool: this trial's slot, or the lab's. Null when neither
26
+ * holds one. */
27
+ activeToolId: string | null;
22
28
  };
23
29
  emit: (event: string) => void;
24
30
  /** Present only when the instrument declares a `job`. */
@@ -40,12 +46,18 @@ export interface CanvasLayer<TS = unknown, TC = unknown> {
40
46
  export interface CanvasCapability<TS = unknown, TC = unknown> {
41
47
  layers: CanvasLayer<TS, TC>[];
42
48
  initialView?: { zoom: number; pan: { x: number; y: number } };
49
+ /** Widens `usePanZoom`'s default clamp; the opening zoom stays reachable
50
+ * regardless of these. */
51
+ minZoom?: number;
52
+ maxZoom?: number;
43
53
  }
44
54
 
45
55
  /** Declares which of an instrument's layers the trial should offer
46
56
  * show/hide controls for. */
47
57
  export interface LayerCapability {
48
- ids: string[];
58
+ /** In list order. A bare string is a layer id that doubles as its own label;
59
+ * give a descriptor instead to label a layer or mark it `alwaysOn`. */
60
+ ids: readonly (string | LayerDescriptor)[];
49
61
  }
50
62
 
51
63
  /** Declares that an instrument accepts items dragged from a palette: what the
@@ -91,10 +103,16 @@ export type DragFeedback = { ok: boolean; reason?: string };
91
103
  * a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
92
104
  * is what makes the trial provide the corresponding chrome.
93
105
  */
94
- export interface Instrument<TS = unknown, TC = unknown> {
106
+ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
95
107
  name: string;
96
108
  defaultConfig: () => TC;
97
109
  initialState: (config: TC) => TS;
110
+ /** The instrument's config, declared once: values, types and controls.
111
+ * Supplying this makes `defaultConfig` optional — `defineInstrument`
112
+ * synthesizes it. Prefer it over `defaultConfig` + `configSchema`. */
113
+ config?: ConfigSchema<TC>;
114
+ /** @deprecated Declare `config` instead; this repeats what `TC` already
115
+ * says and nothing holds the two to one answer. */
98
116
  configSchema?: () => ConfigField[];
99
117
  /** The instrument's DOM. With `canvas`, this renders as an overlay above the
100
118
  * layers rather than instead of them; return `null` for canvas only. */
@@ -106,13 +124,18 @@ export interface Instrument<TS = unknown, TC = unknown> {
106
124
  layers?: LayerCapability;
107
125
  dragDrop?: DragDropCapability<TS, TC>;
108
126
  undo?: UndoCapability;
127
+ /** Tools this instrument offers. Declaring them gives the trial a palette
128
+ * region and its own tool slot. */
129
+ tools?: ToolCapability;
130
+ /** Chrome this instrument contributes beyond what its capabilities imply. */
131
+ chrome?: TrialContribution[];
109
132
  /** Work too slow to do during a render. The runtime starts it, aborts it on
110
133
  * unmount and on a `key` change, and renders progress into the trial. */
111
- job?: JobCapability<TS, TC, never>;
134
+ job?: JobCapability<TS, TC, TItem>;
112
135
  }
113
136
 
114
137
  /** Instruments as a lab receives them. `any` rather than `unknown` because
115
138
  * parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
116
139
  * an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
117
140
  // biome-ignore lint/suspicious/noExplicitAny: see above
118
- export type InstrumentList = readonly Instrument<any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
141
+ export type InstrumentList = readonly Instrument<any, any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
@@ -21,12 +21,22 @@ describe('validateConfigSchema', () => {
21
21
  expect(r.errors).toContain('Duplicate config key: "a"');
22
22
  });
23
23
 
24
- it('flags unknown field type', () => {
24
+ it('accepts an unknown field type — a lab may supply its renderer', () => {
25
25
  const fields = [
26
- { key: 'x', label: 'X', type: 'mystery', default: 1 },
26
+ { key: 'x', label: 'X', type: 'vector2', default: 1 },
27
27
  ] as unknown as ConfigField[];
28
28
  const r = validateConfigSchema(fields);
29
- expect(r.errors).toContain('Unknown field type: "mystery" on key "x"');
29
+ expect(r.valid).toBe(true);
30
+ expect(r.errors).toEqual([]);
31
+ });
32
+
33
+ it('still checks keys and labels on an unknown field type', () => {
34
+ const fields = [
35
+ { key: '', label: '', type: 'vector2', default: 1 },
36
+ ] as unknown as ConfigField[];
37
+ const r = validateConfigSchema(fields);
38
+ expect(r.errors).toContain('Field has empty key');
39
+ expect(r.errors).toContain('Field "" has empty label');
30
40
  });
31
41
 
32
42
  it('flags slider min >= max', () => {
@@ -1,4 +1,4 @@
1
- import type { ConfigField, ConfigFieldType } from '../controls/types';
1
+ import type { ConfigField } from '../controls/types';
2
2
 
3
3
  /** Whether a config schema is usable, and every problem found rather than
4
4
  * just the first. */
@@ -7,11 +7,13 @@ export interface ValidationResult {
7
7
  errors: string[];
8
8
  }
9
9
 
10
- const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
11
-
12
10
  /** Check a config schema for the mistakes that would otherwise surface as a
13
- * silently broken control: empty or duplicate keys, unknown field types, and
14
- * fields whose own constraints do not hold. */
11
+ * silently broken control: empty or duplicate keys, and fields whose own
12
+ * constraints do not hold.
13
+ *
14
+ * An unrecognized type is not an error — a lab supplies controls for its own
15
+ * kinds through `ControlPanel`'s `renderers`, and this cannot see them. Such
16
+ * a field simply has no constraints to check here. */
15
17
  export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
16
18
  const errors: string[] = [];
17
19
  const seenKeys = new Set<string>();
@@ -29,11 +31,6 @@ export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
29
31
  errors.push(`Field "${field.key}" has empty label`);
30
32
  }
31
33
 
32
- if (!KNOWN_TYPES.includes(field.type)) {
33
- errors.push(`Unknown field type: "${field.type}" on key "${field.key}"`);
34
- continue;
35
- }
36
-
37
34
  if (field.type === 'slider') {
38
35
  if (!Number.isFinite(field.default)) {
39
36
  errors.push(`Field "${field.key}": default must be a finite number`);
@@ -0,0 +1,65 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { TrialContribution } from '../chrome/types';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { Lab } from './Lab';
6
+
7
+ const Glyph = () => <svg />;
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ /** React logs the error it re-throws from a failed render; the assertions are
16
+ * on the throw itself, so the console noise is not informative. */
17
+ function silenceRenderError(): void {
18
+ vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ }
20
+
21
+ describe('<Lab> chrome', () => {
22
+ it('renders a consumer contribution', () => {
23
+ const extra: TrialContribution = {
24
+ id: 'export',
25
+ region: 'toolbar',
26
+ item: { icon: Glyph, label: 'Export', onActivate: () => {} },
27
+ };
28
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />);
29
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
30
+ });
31
+
32
+ it('suppresses a built-in by id', () => {
33
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />);
34
+ expect(screen.queryByRole('button', { name: 'Save snapshot' })).toBeNull();
35
+ expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
36
+ });
37
+
38
+ it('throws when a consumer id collides with a built-in', () => {
39
+ silenceRenderError();
40
+ const clash: TrialContribution = {
41
+ id: 'clone',
42
+ region: 'toolbar',
43
+ item: { icon: Glyph, label: 'Mine', onActivate: () => {} },
44
+ };
45
+ expect(() =>
46
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[clash]} />),
47
+ ).toThrow(/duplicate contribution id "clone"/);
48
+ });
49
+
50
+ it('throws when suppressing an id that is not there', () => {
51
+ silenceRenderError();
52
+ expect(() =>
53
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['nope']} />),
54
+ ).toThrow(/cannot suppress "nope"/);
55
+ });
56
+
57
+ it('renders an instrument-declared contribution', () => {
58
+ const withChrome: Instrument = {
59
+ ...bare,
60
+ chrome: [{ id: 'mine', region: 'status', item: { text: 'ready' } }],
61
+ };
62
+ render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
63
+ expect(screen.getByText('ready')).toBeInTheDocument();
64
+ });
65
+ });
package/src/lab/Lab.tsx CHANGED
@@ -1,12 +1,15 @@
1
1
  import { ThemeProvider } from '@weasel-js/theme/react';
2
2
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
3
3
  import { useStore } from 'zustand/react';
4
+ import type { TrialContribution } from '../chrome/types';
5
+ import type { ConfigRule, ControlRenderer } from '../config/types';
4
6
  import type { InstrumentList } from '../instrument/types';
5
7
  import { noneAdapter } from '../state/adapters';
6
8
  import { LabStoreContext } from '../state/context';
7
9
  import { createLabStore, type LabStore } from '../state/store';
8
10
  import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
9
11
  import { interstellarTheme } from '../theme/interstellar';
12
+ import type { TrialTool } from '../tools/types';
10
13
  import { Trial } from '../trial/Trial';
11
14
  import {
12
15
  addTrial as addTrialOp,
@@ -16,6 +19,8 @@ import {
16
19
  resetTrial as resetTrialOp,
17
20
  } from '../trial/trialOps';
18
21
  import { LabContext, type LabContextValue } from './LabContext';
22
+ import { LabHeader } from './LabHeader';
23
+ import { LabPalette } from './LabPalette';
19
24
  import { LabShell } from './LabShell';
20
25
  import { useResolvedMode } from './useSystemMode';
21
26
  import { type TrialLayout, Workspace } from './Workspace';
@@ -35,6 +40,22 @@ export interface LabProps {
35
40
  */
36
41
  nebula?: readonly string[];
37
42
  title?: string;
43
+ /** Rendered in the shell's footer, below the workspace. */
44
+ footer?: ReactNode;
45
+ /** Contributions added to every trial's chrome, after the instrument's own. */
46
+ chrome?: readonly TrialContribution[];
47
+ /** Built-in contribution ids to drop. Throws on an id that is not there. */
48
+ suppress?: readonly string[];
49
+ /** Tools offered lab-wide. A trial whose instrument declares none of its own
50
+ * reflects and writes this slot. */
51
+ tools?: readonly TrialTool[];
52
+ /** Rules run over every instrument's config leaves, before labkit's own
53
+ * inference — where a lab states a convention once instead of annotating
54
+ * each field. Memoize or hoist. */
55
+ configRules?: readonly ConfigRule[];
56
+ /** Control overrides and app-defined kinds for every trial's settings panel.
57
+ * Keys are config paths (checked first) or leaf kinds. Memoize or hoist. */
58
+ controls?: Record<string, ControlRenderer>;
38
59
  children?: ReactNode;
39
60
  }
40
61
 
@@ -88,6 +109,12 @@ export function Lab({
88
109
  mode,
89
110
  nebula,
90
111
  title,
112
+ footer,
113
+ chrome,
114
+ suppress,
115
+ tools,
116
+ configRules,
117
+ controls,
91
118
  children,
92
119
  }: LabProps) {
93
120
  if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
@@ -160,7 +187,8 @@ export function Lab({
160
187
  },
161
188
  savedSnapshots,
162
189
  saveSnapshot: (trialId, name) => {
163
- store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
190
+ const taken = store.getState().savedSnapshots.filter((s) => s.trialId === trialId).length;
191
+ store.getState().saveSnapshot(trialId, name ?? `Snapshot ${taken + 1}`);
164
192
  },
165
193
  loadSnapshot: (trialId, snapshotId) => {
166
194
  store.getState().loadSnapshot(snapshotId, trialId);
@@ -172,8 +200,10 @@ export function Lab({
172
200
  setMode: (m) => {
173
201
  store.getState().setMode(m);
174
202
  },
203
+ configRules,
204
+ controls,
175
205
  };
176
- }, [instruments, trials, savedSnapshots, modeValue, store]);
206
+ }, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
177
207
 
178
208
  // Only override the backdrop in dark, where there is one to override.
179
209
  // Setting a CSS custom property is the sanctioned use of inline style.
@@ -191,19 +221,32 @@ export function Lab({
191
221
  className="lk-lab"
192
222
  style={backdropStyle}
193
223
  >
194
- <LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
195
- <Workspace
196
- ids={trials.map((w) => w.id)}
197
- resizable
198
- reorderable
199
- onReorder={(ids) => contextValue.reorderTrials(ids)}
200
- layout={layout as TrialLayout}
201
- onLayoutChange={(next) => store.getState().setLayout(next)}
202
- >
203
- {trials.map((w) => (
204
- <Trial key={w.id} id={w.id} />
205
- ))}
206
- </Workspace>
224
+ <LabShell
225
+ title={title ?? 'Labkit'}
226
+ mode={modeValue}
227
+ footer={footer}
228
+ header={
229
+ <>
230
+ <LabHeader />
231
+ {children}
232
+ </>
233
+ }
234
+ >
235
+ <div className="lk-lab__body">
236
+ {tools ? <LabPalette tools={tools} /> : null}
237
+ <Workspace
238
+ ids={trials.map((w) => w.id)}
239
+ resizable
240
+ reorderable
241
+ onReorder={(ids) => contextValue.reorderTrials(ids)}
242
+ layout={layout as TrialLayout}
243
+ onLayoutChange={(next) => store.getState().setLayout(next)}
244
+ >
245
+ {trials.map((w) => (
246
+ <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
247
+ ))}
248
+ </Workspace>
249
+ </div>
207
250
  </LabShell>
208
251
  </ThemeProvider>
209
252
  </LabContext.Provider>
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import type { ConfigRule, ControlRenderer } from '../config/types';
2
3
  import type { InstrumentList } from '../instrument/types';
3
4
  import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
4
5
 
@@ -19,6 +20,11 @@ export interface LabContextValue {
19
20
  deleteSnapshot: (snapshotId: string) => void;
20
21
  mode: LabMode;
21
22
  setMode: (m: LabMode) => void;
23
+ /** Lab-wide rules run over every instrument's config leaves, before
24
+ * labkit's own inference. */
25
+ configRules?: readonly ConfigRule[];
26
+ /** Lab-wide control overrides, keyed by config path or by leaf kind. */
27
+ controls?: Record<string, ControlRenderer>;
22
28
  }
23
29
 
24
30
  /** Context carrying the surrounding lab. Prefer `useLabContext`. */