@weasel-js/labkit 1.4.0 → 1.4.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 (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { CrosshairIcon, HandIcon, PencilIcon } from '@weasel-js/ui';
2
+ import { CrosshairIcon, HandIcon, PencilIcon, SnapshotIcon } from '@weasel-js/ui';
3
3
  import type { ConfigField } from '../controls/types';
4
4
  import type { Instrument } from '../instrument/types';
5
5
  import { Lab } from '../lab/Lab';
@@ -104,3 +104,44 @@ export const SuppressedAndReplaced: Story = {
104
104
  ],
105
105
  },
106
106
  };
107
+
108
+ const SUBJECTS = ['Ash', 'Birch', 'Cedar'];
109
+
110
+ /** A trial titled by its subject rather than its instrument, with the leading
111
+ * end of the title bar carrying the control that renames it — and the
112
+ * snapshot built-in re-declared into the toolbar, which needs the context
113
+ * `onActivate` is handed. */
114
+ export const SubjectTitleAndLead: Story = {
115
+ args: {
116
+ instruments: [FullInstrument],
117
+ defaultInstrument: 'Every Region',
118
+ title: 'Title Bar Lead',
119
+ storage: null,
120
+ suppress: ['snapshot'],
121
+ chrome: [
122
+ {
123
+ id: 'subject',
124
+ region: 'titlebar',
125
+ item: {
126
+ icon: PencilIcon,
127
+ label: 'Next subject',
128
+ onActivate: (ctx) => {
129
+ const i = SUBJECTS.indexOf(ctx.title);
130
+ ctx.setTitle(SUBJECTS[(i + 1) % SUBJECTS.length]);
131
+ },
132
+ },
133
+ },
134
+ {
135
+ id: 'snapshot-in-toolbar',
136
+ region: 'toolbar',
137
+ group: 'trial',
138
+ item: {
139
+ icon: SnapshotIcon,
140
+ label: 'Snapshot',
141
+ showLabel: true,
142
+ onActivate: (ctx) => ctx.saveSnapshot(),
143
+ },
144
+ },
145
+ ],
146
+ },
147
+ };
@@ -9,9 +9,13 @@ import type { TrialChromeContext } from './types';
9
9
  const ctx: TrialChromeContext = {
10
10
  trialId: 't1',
11
11
  undockedPanels: [],
12
+ collapsedSections: {},
13
+ setSectionCollapsed: () => {},
12
14
  undockPanel: () => {},
13
15
  dockPanel: () => {},
14
16
  instrumentName: 'Stub',
17
+ title: 'Stub',
18
+ setTitle: () => {},
15
19
  isLastTrial: false,
16
20
  zoom: 1,
17
21
  setZoom: () => {},
@@ -24,6 +24,22 @@ import type { TrialChromeContext, TrialContribution } from './types';
24
24
 
25
25
  const ZOOM_STEP = 1.25;
26
26
 
27
+ /** The property groups inside a contribution are folded under keys prefixed
28
+ * with that contribution's id, so a group called `marks` cannot fold the
29
+ * sidebar section of the same name. `ControlPanel` keys by bare label, so the
30
+ * prefix goes on here and comes off again. */
31
+ function groupFolds(
32
+ collapsedSections: Readonly<Record<string, boolean>>,
33
+ id: string,
34
+ ): Record<string, boolean> {
35
+ const prefix = `${id}/`;
36
+ const out: Record<string, boolean> = {};
37
+ for (const [key, value] of Object.entries(collapsedSections)) {
38
+ if (key.startsWith(prefix)) out[key.slice(prefix.length)] = value;
39
+ }
40
+ return out;
41
+ }
42
+
27
43
  /**
28
44
  * The contributions a trial gets from what its instrument declared. This is
29
45
  * the whole "declaring a capability provides the chrome" rule — it replaces
@@ -180,6 +196,8 @@ export function builtinContributions(
180
196
  config={ctx.config as Record<string, unknown>}
181
197
  setConfig={(key, value) => ctx.setConfig(String(key), value)}
182
198
  renderers={controls}
199
+ collapsed={groupFolds(ctx.collapsedSections, 'settings')}
200
+ onCollapse={(label, next) => ctx.setSectionCollapsed(`settings/${label}`, next)}
183
201
  />
184
202
  ),
185
203
  },
@@ -6,7 +6,7 @@ export type { SidebarRegionProps } from './regions/SidebarRegion';
6
6
  export { SidebarRegion } from './regions/SidebarRegion';
7
7
  export type { StatusRegionProps } from './regions/StatusRegion';
8
8
  export { StatusRegion } from './regions/StatusRegion';
9
- export type { TitleBarRegionProps } from './regions/TitleBarRegion';
9
+ export type { TitleBarPlacement, TitleBarRegionProps } from './regions/TitleBarRegion';
10
10
  export { TitleBarRegion } from './regions/TitleBarRegion';
11
11
  export type { ToolbarRegionProps } from './regions/ToolbarRegion';
12
12
  export { ToolbarRegion } from './regions/ToolbarRegion';
@@ -1,5 +1,4 @@
1
1
  import type { ReactNode } from 'react';
2
- import { useState } from 'react';
3
2
  import type { SidebarSection, TrialChromeContext, TrialContribution } from '../types';
4
3
 
5
4
  /** Props for `<SidebarRegion>`. */
@@ -10,16 +9,17 @@ export interface SidebarRegionProps {
10
9
 
11
10
  function Section({
12
11
  title,
13
- defaultCollapsed,
12
+ collapsed,
13
+ onCollapsedChange,
14
14
  onUndock,
15
15
  children,
16
16
  }: {
17
17
  title: string;
18
- defaultCollapsed: boolean;
18
+ collapsed: boolean;
19
+ onCollapsedChange: (collapsed: boolean) => void;
19
20
  onUndock?: () => void;
20
21
  children: ReactNode;
21
22
  }) {
22
- const [collapsed, setCollapsed] = useState(defaultCollapsed);
23
23
  return (
24
24
  <section className="lk-sidebar-section">
25
25
  <div className="lk-sidebar-section__bar">
@@ -27,7 +27,7 @@ function Section({
27
27
  type="button"
28
28
  className="lk-sidebar-section__head"
29
29
  aria-expanded={!collapsed}
30
- onClick={() => setCollapsed((c) => !c)}
30
+ onClick={() => onCollapsedChange(!collapsed)}
31
31
  >
32
32
  {title}
33
33
  </button>
@@ -64,7 +64,8 @@ export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
64
64
  <Section
65
65
  key={c.id}
66
66
  title={item.title}
67
- defaultCollapsed={item.defaultCollapsed ?? false}
67
+ collapsed={ctx.collapsedSections[c.id] ?? item.defaultCollapsed ?? false}
68
+ onCollapsedChange={(next) => ctx.setSectionCollapsed(c.id, next)}
68
69
  onUndock={
69
70
  item.undockable === false
70
71
  ? undefined
@@ -0,0 +1,64 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { TrialChromeContext, TrialContribution } from '../types';
4
+ import { TitleBarRegion } from './TitleBarRegion';
5
+
6
+ const Glyph = () => <svg />;
7
+ const ctx = { trialId: 't1', saveSnapshot: vi.fn() } as unknown as TrialChromeContext;
8
+
9
+ function item(id: string, over: Partial<TrialContribution> = {}): TrialContribution {
10
+ return {
11
+ id,
12
+ region: 'titlebar',
13
+ item: { icon: Glyph, label: id, onActivate: () => {} },
14
+ ...over,
15
+ } as TrialContribution;
16
+ }
17
+
18
+ describe('TitleBarRegion', () => {
19
+ it('renders nothing when it has nothing for its placement', () => {
20
+ const { container } = render(<TitleBarRegion contributions={[item('lead')]} ctx={ctx} />);
21
+ expect(container).toBeEmptyDOMElement();
22
+ });
23
+
24
+ it('takes the contributions that set `end` into the actions span', () => {
25
+ const { container } = render(
26
+ <TitleBarRegion contributions={[item('lead'), item('close', { end: true })]} ctx={ctx} />,
27
+ );
28
+ expect(container.querySelector('.lk-trial__titlebar-actions')).toBeTruthy();
29
+ expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
30
+ expect(screen.queryByRole('button', { name: 'lead' })).toBeNull();
31
+ });
32
+
33
+ it('takes the rest into the lead span', () => {
34
+ const { container } = render(
35
+ <TitleBarRegion
36
+ placement="lead"
37
+ contributions={[item('lead'), item('close', { end: true })]}
38
+ ctx={ctx}
39
+ />,
40
+ );
41
+ expect(container.querySelector('.lk-trial__titlebar-lead')).toBeTruthy();
42
+ expect(screen.getByRole('button', { name: 'lead' })).toBeInTheDocument();
43
+ expect(screen.queryByRole('button', { name: 'close' })).toBeNull();
44
+ });
45
+
46
+ it('hands onActivate the chrome context', () => {
47
+ const onActivate = vi.fn();
48
+ render(
49
+ <TitleBarRegion
50
+ contributions={[
51
+ {
52
+ id: 'go',
53
+ region: 'titlebar',
54
+ end: true,
55
+ item: { icon: Glyph, label: 'Go', onActivate },
56
+ },
57
+ ]}
58
+ ctx={ctx}
59
+ />,
60
+ );
61
+ screen.getByRole('button', { name: 'Go' }).click();
62
+ expect(onActivate).toHaveBeenCalledWith(ctx);
63
+ });
64
+ });
@@ -1,16 +1,23 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { TrialChromeContext, TrialContribution } from '../types';
3
3
 
4
+ /** Which end of the title bar a `<TitleBarRegion>` lays out: `'lead'` renders
5
+ * the contributions that leave `end` unset, before the title; `'actions'`
6
+ * renders the ones that set it, after everything else. */
7
+ export type TitleBarPlacement = 'lead' | 'actions';
8
+
4
9
  /** Props for `<TitleBarRegion>`. */
5
10
  export interface TitleBarRegionProps {
6
11
  contributions: readonly TrialContribution[];
7
12
  ctx: TrialChromeContext;
13
+ /** Defaults to `'actions'`, where the built-ins live. */
14
+ placement?: TitleBarPlacement;
8
15
  }
9
16
 
10
17
  function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
11
18
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
12
19
  if (c.region !== 'titlebar' || !c.item) return null;
13
- const { icon: Icon, label, shortcut, disabled, danger } = c.item;
20
+ const { icon: Icon, label, shortcut, disabled, danger, onActivate } = c.item;
14
21
  return (
15
22
  <button
16
23
  key={c.id}
@@ -19,7 +26,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
19
26
  disabled={disabled}
20
27
  aria-label={label}
21
28
  title={shortcut ? `${label} (${shortcut})` : label}
22
- onClick={c.item.onActivate}
29
+ onClick={() => onActivate(ctx)}
23
30
  // The bar behind these buttons is the window drag surface, so a press
24
31
  // that lands here must not also start a drag.
25
32
  onPointerDown={(e) => e.stopPropagation()}
@@ -30,12 +37,14 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
30
37
  );
31
38
  }
32
39
 
33
- /** Lays a trial's `titlebar` contributions out, at the far end of the bar. */
34
- export function TitleBarRegion({ contributions, ctx }: TitleBarRegionProps) {
35
- if (contributions.length === 0) return null;
40
+ /** Lays a trial's `titlebar` contributions out at one end of the bar. */
41
+ export function TitleBarRegion({ contributions, ctx, placement = 'actions' }: TitleBarRegionProps) {
42
+ const wanted = placement === 'actions';
43
+ const mine = contributions.filter((c) => (c.end ?? false) === wanted);
44
+ if (mine.length === 0) return null;
36
45
  return (
37
- <span className="lk-trial__titlebar-actions">
38
- {contributions.map((c) => renderEntry(c, ctx))}
46
+ <span className={wanted ? 'lk-trial__titlebar-actions' : 'lk-trial__titlebar-lead'}>
47
+ {mine.map((c) => renderEntry(c, ctx))}
39
48
  </span>
40
49
  );
41
50
  }
@@ -42,6 +42,20 @@ describe('ToolbarRegion', () => {
42
42
  expect(onActivate).toHaveBeenCalledOnce();
43
43
  });
44
44
 
45
+ it('hands onActivate the chrome context', () => {
46
+ const onActivate = vi.fn();
47
+ render(
48
+ <ToolbarRegion
49
+ contributions={[
50
+ { id: 'snap', region: 'toolbar', item: { icon: Glyph, label: 'Snap', onActivate } },
51
+ ]}
52
+ ctx={ctx}
53
+ />,
54
+ );
55
+ screen.getByRole('button', { name: 'Snap' }).click();
56
+ expect(onActivate).toHaveBeenCalledWith(ctx);
57
+ });
58
+
45
59
  it('groups contributions sharing a group into one group element', () => {
46
60
  render(
47
61
  <ToolbarRegion
@@ -38,7 +38,7 @@ function groupsOf(contributions: readonly TrialContribution[]): Group[] {
38
38
  function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
39
39
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
40
40
  if (c.region !== 'toolbar' || !c.item) return null;
41
- const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed } = c.item;
41
+ const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed, onActivate } = c.item;
42
42
  return (
43
43
  <Toolbar.Button
44
44
  key={c.id}
@@ -48,7 +48,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
48
48
  pressed={pressed}
49
49
  aria-label={label}
50
50
  title={shortcut ? `${label} (${shortcut})` : label}
51
- onClick={c.item.onActivate}
51
+ onClick={() => onActivate(ctx)}
52
52
  >
53
53
  <Icon size={16} />
54
54
  {showLabel ? <span>{label}</span> : null}
@@ -25,7 +25,7 @@ export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
25
25
  {contributions.map((c) => {
26
26
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
27
27
  if (c.region !== 'viewport' || !c.item) return null;
28
- const { icon: Icon, label, disabled } = c.item;
28
+ const { icon: Icon, label, disabled, onActivate } = c.item;
29
29
  return (
30
30
  <button
31
31
  key={c.id}
@@ -34,7 +34,7 @@ export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
34
34
  aria-label={label}
35
35
  title={label}
36
36
  disabled={disabled}
37
- onClick={c.item.onActivate}
37
+ onClick={() => onActivate(ctx)}
38
38
  >
39
39
  <Icon size={16} />
40
40
  </button>
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { TrialChromeContext, TrialContribution } from '../types';
4
4
  import { SidebarRegion } from './SidebarRegion';
@@ -9,6 +9,8 @@ const Glyph = () => <svg />;
9
9
  const ctx = {
10
10
  trialId: 't1',
11
11
  undockedPanels: [],
12
+ collapsedSections: {},
13
+ setSectionCollapsed: () => {},
12
14
  undockPanel: () => {},
13
15
  dockPanel: () => {},
14
16
  } as unknown as TrialChromeContext;
@@ -45,6 +47,34 @@ describe('SidebarRegion', () => {
45
47
  );
46
48
  expect(screen.queryByText('hidden')).not.toBeInTheDocument();
47
49
  });
50
+
51
+ it('takes the trial’s remembered fold over the section’s default', () => {
52
+ render(
53
+ <SidebarRegion
54
+ contributions={[
55
+ {
56
+ id: 's',
57
+ region: 'sidebar',
58
+ item: { title: 'S', defaultCollapsed: true, body: <p>shown</p> },
59
+ },
60
+ ]}
61
+ ctx={{ ...ctx, collapsedSections: { s: false } }}
62
+ />,
63
+ );
64
+ expect(screen.getByText('shown')).toBeInTheDocument();
65
+ });
66
+
67
+ it('reports a toggle rather than keeping the fold itself', () => {
68
+ const setSectionCollapsed = vi.fn();
69
+ render(
70
+ <SidebarRegion
71
+ contributions={[{ id: 's', region: 'sidebar', item: { title: 'S', body: <p>body</p> } }]}
72
+ ctx={{ ...ctx, setSectionCollapsed }}
73
+ />,
74
+ );
75
+ fireEvent.click(screen.getByRole('button', { name: 'S' }));
76
+ expect(setSectionCollapsed).toHaveBeenCalledWith('s', true);
77
+ });
48
78
  });
49
79
 
50
80
  describe('StatusRegion', () => {
@@ -83,4 +113,18 @@ describe('ViewportRegion', () => {
83
113
  screen.getByRole('button', { name: 'Fit' }).click();
84
114
  expect(onActivate).toHaveBeenCalledOnce();
85
115
  });
116
+
117
+ it('hands onActivate the chrome context', () => {
118
+ const onActivate = vi.fn();
119
+ render(
120
+ <ViewportRegion
121
+ contributions={[
122
+ { id: 'fit', region: 'viewport', item: { icon: Glyph, label: 'Fit', onActivate } },
123
+ ]}
124
+ ctx={ctx}
125
+ />,
126
+ );
127
+ screen.getByRole('button', { name: 'Fit' }).click();
128
+ expect(onActivate).toHaveBeenCalledWith(ctx);
129
+ });
86
130
  });
@@ -24,7 +24,10 @@ export interface ToolbarItem {
24
24
  pressed?: boolean;
25
25
  /** Render the label beside the glyph rather than only in the tooltip. */
26
26
  showLabel?: boolean;
27
- onActivate: () => void;
27
+ /** Handed the trial's chrome context, so a contribution declared from the
28
+ * lab can reach `ctx.saveSnapshot()` and the rest without the `render`
29
+ * escape. A zero-argument handler stays valid. */
30
+ onActivate: (ctx: TrialChromeContext) => void;
28
31
  }
29
32
 
30
33
  /** A selectable tool in the palette region. */
@@ -38,7 +41,7 @@ export interface ToolItem {
38
41
  /** A titled block in the sidebar. */
39
42
  export interface SidebarSection {
40
43
  title: string;
41
- /** Starts collapsed. The open/closed state itself is the region's. */
44
+ /** Starts collapsed, until the trial remembers a fold of its own. */
42
45
  defaultCollapsed?: boolean;
43
46
  /** Offer the tear-out control. On by default; a section that only makes
44
47
  * sense beside its trial sets this false. */
@@ -53,7 +56,8 @@ export interface ViewportControl {
53
56
  icon: IconComponent;
54
57
  label: string;
55
58
  disabled?: boolean;
56
- onActivate: () => void;
59
+ /** Handed the trial's chrome context, as `ToolbarItem.onActivate` is. */
60
+ onActivate: (ctx: TrialChromeContext) => void;
57
61
  }
58
62
 
59
63
  /** A readout in the status bar. */
@@ -100,6 +104,12 @@ export type TrialContribution =
100
104
  export interface TrialChromeContext {
101
105
  trialId: string;
102
106
  instrumentName: string;
107
+ /** What the title bar reads, which is the instrument's name until something
108
+ * calls `setTitle`. */
109
+ title: string;
110
+ /** Retitle this trial; `null` restores the instrument name. Persisted with
111
+ * the trial, so a title survives a reload. */
112
+ setTitle: (title: string | null) => void;
103
113
  isLastTrial: boolean;
104
114
 
105
115
  /** Null when the trial holds a view that is not the 2D one. */
@@ -125,6 +135,14 @@ export interface TrialChromeContext {
125
135
  config: unknown;
126
136
  setConfig: (key: string, value: unknown) => void;
127
137
 
138
+ /** Which of this trial's collapsible sections are folded. A sidebar
139
+ * section's key is its contribution id; a section *inside* a contribution —
140
+ * a control panel's property group — is keyed `<contribution id>/<label>`.
141
+ * A section absent here is at its own default. Persisted with the trial. */
142
+ collapsedSections: Readonly<Record<string, boolean>>;
143
+ /** Fold or unfold one section, keyed as `collapsedSections` is. */
144
+ setSectionCollapsed: (key: string, collapsed: boolean) => void;
145
+
128
146
  /** Section ids this trial currently has torn out of its sidebar. */
129
147
  undockedPanels: readonly string[];
130
148
  /** Tear a sidebar section out into the workspace. */
@@ -443,3 +443,41 @@ describe('<ControlPanel> suffix', () => {
443
443
  expect(container.textContent).toContain('px');
444
444
  });
445
445
  });
446
+
447
+ describe('<ControlPanel> collapsible sections', () => {
448
+ const schema = resolveConfigSchema(
449
+ f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
450
+ [],
451
+ );
452
+ const config = { showGrid: true, seed: 0 };
453
+
454
+ it('draws no twisty by default', () => {
455
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
456
+ expect(screen.queryByRole('button', { name: 'Advanced' })).toBeNull();
457
+ expect(screen.getByLabelText('Seed')).toBeVisible();
458
+ });
459
+
460
+ it('folds a section away when the panel starts closed', () => {
461
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} collapse="closed" />);
462
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
463
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
464
+ expect(screen.getByLabelText('Seed')).toBeVisible();
465
+ });
466
+
467
+ it('hands a controlled panel its toggles instead of acting on them', () => {
468
+ const onCollapse = vi.fn();
469
+ render(
470
+ <ControlPanel
471
+ schema={schema}
472
+ config={config}
473
+ setConfig={vi.fn()}
474
+ collapsed={{ Advanced: true }}
475
+ onCollapse={onCollapse}
476
+ />,
477
+ );
478
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
479
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
480
+ expect(onCollapse).toHaveBeenCalledWith('Advanced', false);
481
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
482
+ });
483
+ });
@@ -4,6 +4,8 @@ import {
4
4
  ColorRow,
5
5
  NumberRow,
6
6
  type PrefLeaf,
7
+ type PropertyAlign,
8
+ type PropertyDensity,
7
9
  PropertyGroup,
8
10
  PropertyList,
9
11
  PropertyRow,
@@ -44,9 +46,28 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
44
46
  renderers?: Record<string, ControlRenderer>;
45
47
  /** How rows pack into the two-column grid. Defaults to `'pairs'`. */
46
48
  pack?: ControlPack;
47
- /** Where a row's label sits relative to its control. Defaults to `'block'`
48
- * — above it, which is what leaves a paired row room for its value. */
49
+ /**
50
+ * Where a row's label sits relative to its control. Unset takes each row's
51
+ * own orientation — `block` (label above, which is what leaves a paired row
52
+ * room for its value) for most, `inline` for colors and checkboxes.
53
+ */
49
54
  layout?: PropertyRowLayout;
55
+ /** Room the rows get. Passed straight to the property list. */
56
+ density?: PropertyDensity;
57
+ /** Cross-axis alignment of an inline row's label and control. */
58
+ align?: PropertyAlign;
59
+ /**
60
+ * Fold each section away behind a twisty, starting `'open'` or `'closed'`.
61
+ * Unset draws the heading alone, as the panel always has.
62
+ */
63
+ collapse?: 'open' | 'closed';
64
+ /**
65
+ * Which sections are folded, keyed by section label. Given, the panel keeps
66
+ * no state of its own: every toggle arrives at `onCollapse` instead, which
67
+ * is where a lab that remembers a trial's sections writes them.
68
+ */
69
+ collapsed?: Readonly<Record<string, boolean>>;
70
+ onCollapse?: (label: string, collapsed: boolean) => void;
50
71
  /** Draw leaves marked `hidden`. */
51
72
  showHidden?: boolean;
52
73
  className?: string;
@@ -62,7 +83,12 @@ export function ControlPanel<TC extends Record<string, unknown>>({
62
83
  setConfig,
63
84
  renderers,
64
85
  pack = 'pairs',
65
- layout = 'block',
86
+ layout,
87
+ density,
88
+ align,
89
+ collapse,
90
+ collapsed,
91
+ onCollapse,
66
92
  showHidden = false,
67
93
  className,
68
94
  }: ControlPanelProps<TC>) {
@@ -87,14 +113,26 @@ export function ControlPanel<TC extends Record<string, unknown>>({
87
113
  );
88
114
 
89
115
  const gridPack = pack === 'one-up' ? 'auto-color' : 'pairs';
116
+ const folds = collapse !== undefined || collapsed !== undefined || onCollapse !== undefined;
117
+ const startsFolded = collapse === 'closed';
90
118
  return (
91
119
  <PropertyList
92
120
  pack={gridPack}
121
+ density={density}
122
+ align={align}
93
123
  className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
94
124
  >
95
125
  {loose.map(row)}
96
126
  {resolved.sections.map((section) => (
97
- <PropertyGroup key={section.label} title={section.label} pack={gridPack}>
127
+ <PropertyGroup
128
+ key={section.label}
129
+ title={section.label}
130
+ pack={gridPack}
131
+ collapsible={folds}
132
+ defaultCollapsed={startsFolded}
133
+ collapsed={collapsed ? (collapsed[section.label] ?? startsFolded) : undefined}
134
+ onCollapsedChange={onCollapse ? (next) => onCollapse(section.label, next) : undefined}
135
+ >
98
136
  {section.paths.map(row)}
99
137
  </PropertyGroup>
100
138
  ))}
@@ -109,7 +147,7 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
109
147
  setConfig: (key: keyof TC, value: unknown) => void;
110
148
  renderers?: Record<string, ControlRenderer>;
111
149
  pack: ControlPack;
112
- layout: PropertyRowLayout;
150
+ layout?: PropertyRowLayout;
113
151
  showHidden: boolean;
114
152
  }
115
153
 
@@ -201,6 +239,7 @@ function ControlRow<TC extends Record<string, unknown>>({
201
239
  label={label}
202
240
  value={read<boolean>()}
203
241
  onChange={write}
242
+ layout={layout}
204
243
  description={description}
205
244
  />
206
245
  );
@@ -234,7 +273,13 @@ function ControlRow<TC extends Record<string, unknown>>({
234
273
  );
235
274
  case 'color':
236
275
  return (
237
- <ColorRow label={label} value={read<string>()} onChange={write} description={description} />
276
+ <ColorRow
277
+ label={label}
278
+ value={read<string>()}
279
+ onChange={write}
280
+ layout={layout}
281
+ description={description}
282
+ />
238
283
  );
239
284
  case 'paint':
240
285
  case 'object':