@weasel-js/labkit 1.4.1 → 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 (114) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-A4R6v4ld.d.ts} +27 -10
  4. package/dist/_dts/{index-FEa1MudK.d.ts → index-CsU9WhjM.d.ts} +89 -29
  5. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  6. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  7. package/dist/canvas/index.d.ts +5 -5
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +12 -6
  10. package/dist/chrome/index.js +5 -5
  11. package/dist/{chunk-DG2IFMLA.js → chunk-54ZWZ5FQ.js} +127 -100
  12. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  13. package/dist/{chunk-RLOXQZW3.js → chunk-D5KQ5OY6.js} +3 -3
  14. package/dist/{chunk-RLOXQZW3.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  15. package/dist/{chunk-PP7ZWAMV.js → chunk-FJG4PHTL.js} +3 -3
  16. package/dist/{chunk-PP7ZWAMV.js.map → chunk-FJG4PHTL.js.map} +1 -1
  17. package/dist/chunk-ISSVF5PT.js +7263 -0
  18. package/dist/chunk-ISSVF5PT.js.map +1 -0
  19. package/dist/chunk-NSOVI3AZ.js +170 -0
  20. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  21. package/dist/{chunk-D7QIPGEB.js → chunk-TN7YSJVU.js} +62 -60
  22. package/dist/chunk-TN7YSJVU.js.map +1 -0
  23. package/dist/{chunk-7TRFMIWX.js → chunk-UDXOYZEC.js} +4 -4
  24. package/dist/{chunk-7TRFMIWX.js.map → chunk-UDXOYZEC.js.map} +1 -1
  25. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  26. package/dist/chunk-ULDW42CR.js.map +1 -0
  27. package/dist/{chunk-MSR7EVS6.js → chunk-UTOEDPNU.js} +5 -5
  28. package/dist/{chunk-MSR7EVS6.js.map → chunk-UTOEDPNU.js.map} +1 -1
  29. package/dist/{chunk-BMQWLKOL.js → chunk-W2FJR5FF.js} +35 -22
  30. package/dist/chunk-W2FJR5FF.js.map +1 -0
  31. package/dist/{chunk-OFCBEZMU.js → chunk-WS6ZRV75.js} +3 -3
  32. package/dist/{chunk-OFCBEZMU.js.map → chunk-WS6ZRV75.js.map} +1 -1
  33. package/dist/{chunk-53AZ2O2N.js → chunk-XKENZTNE.js} +45 -14
  34. package/dist/chunk-XKENZTNE.js.map +1 -0
  35. package/dist/controls/index.d.ts +1 -1
  36. package/dist/controls/index.js +3 -3
  37. package/dist/dragdrop/index.d.ts +5 -5
  38. package/dist/dragdrop/index.js +2 -2
  39. package/dist/index.d.ts +71 -43
  40. package/dist/index.js +146 -113
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +23 -8
  43. package/dist/layers/index.js +3 -3
  44. package/dist/loupe/index.d.ts +4 -4
  45. package/dist/loupe/index.js +2 -2
  46. package/dist/passthrough/weasel-canvas.js +1 -1
  47. package/dist/passthrough/weasel-ui.d.ts +84 -3
  48. package/dist/passthrough/weasel-ui.js +2 -2
  49. package/dist/primitives/index.js +4 -4
  50. package/dist/state/index.d.ts +3 -3
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +20 -3
  53. package/dist/surface/index.js +2 -2
  54. package/dist/ui/layers/index.d.ts +22 -9
  55. package/dist/ui/layers/index.js +2 -2
  56. package/dist/undo/index.d.ts +2 -2
  57. package/package.json +7 -7
  58. package/src/annotations/AnnotationTargets.tsx +6 -1
  59. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  60. package/src/annotations/types.ts +4 -1
  61. package/src/canvas/useOrbit.test.ts +7 -3
  62. package/src/canvas/usePanZoom.test.ts +7 -3
  63. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  64. package/src/chrome/builtins.test.ts +4 -0
  65. package/src/chrome/builtins.tsx +18 -0
  66. package/src/chrome/index.ts +1 -1
  67. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  68. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  69. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  70. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  71. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  72. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  73. package/src/chrome/regions/regions.test.tsx +45 -1
  74. package/src/chrome/types.ts +21 -3
  75. package/src/controls/ControlPanel.test.tsx +38 -0
  76. package/src/controls/ControlPanel.tsx +51 -6
  77. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  78. package/src/dragdrop/Palette.tsx +4 -3
  79. package/src/dragdrop/dragDrop.test.tsx +35 -12
  80. package/src/index.ts +2 -1
  81. package/src/instrument/types.ts +4 -5
  82. package/src/lab/Lab.test.tsx +7 -0
  83. package/src/lab/Lab.tsx +2 -2
  84. package/src/lab/LabContext.ts +5 -1
  85. package/src/lab/LabShell.less +17 -2
  86. package/src/layers/AGENTS.md +29 -7
  87. package/src/layers/LayerList.less +16 -0
  88. package/src/layers/LayerList.stories.tsx +66 -0
  89. package/src/layers/LayerList.test.tsx +117 -5
  90. package/src/layers/LayerList.tsx +192 -53
  91. package/src/layers/index.ts +1 -1
  92. package/src/passthrough/weasel-ui.ts +5 -0
  93. package/src/primitives/FloatingPanel.test.tsx +8 -3
  94. package/src/state/store.test.ts +78 -0
  95. package/src/state/store.ts +27 -0
  96. package/src/state/types.ts +20 -0
  97. package/src/trial/Trial.less +4 -0
  98. package/src/trial/Trial.targets.test.tsx +96 -0
  99. package/src/trial/Trial.test.tsx +171 -14
  100. package/src/trial/Trial.tsx +4 -1
  101. package/src/trial/TrialChrome.tsx +22 -2
  102. package/src/trial/TrialTitleBar.tsx +7 -3
  103. package/src/trial/index.ts +1 -0
  104. package/src/trial/trialOps.test.ts +49 -1
  105. package/src/trial/trialOps.ts +28 -6
  106. package/dist/chunk-53AZ2O2N.js.map +0 -1
  107. package/dist/chunk-BMQWLKOL.js.map +0 -1
  108. package/dist/chunk-D7QIPGEB.js.map +0 -1
  109. package/dist/chunk-DG2IFMLA.js.map +0 -1
  110. package/dist/chunk-J6SBFZYT.js +0 -7121
  111. package/dist/chunk-J6SBFZYT.js.map +0 -1
  112. package/dist/chunk-LJSIUFYD.js.map +0 -1
  113. package/dist/chunk-MIT3ISW4.js +0 -100
  114. package/dist/chunk-MIT3ISW4.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import type { TrialInfo } from '../state/types';
1
2
  import { AnnotationOverlay } from './AnnotationOverlay';
2
3
  import type { AnnotationsApi, AnnotationsCapability } from './types';
3
4
 
@@ -6,6 +7,9 @@ export interface AnnotationTargetsProps {
6
7
  capability: AnnotationsCapability;
7
8
  state: unknown;
8
9
  config: unknown;
10
+ /** The trial these targets belong to. `capability.targets` is told it, so a
11
+ * consumer holding per-trial refs can hand back this trial's. */
12
+ trial: TrialInfo;
9
13
  annotations: AnnotationsApi;
10
14
  activeToolId: string | null;
11
15
  }
@@ -15,10 +19,11 @@ export function AnnotationTargets({
15
19
  capability,
16
20
  state,
17
21
  config,
22
+ trial,
18
23
  annotations,
19
24
  activeToolId,
20
25
  }: AnnotationTargetsProps) {
21
- const targets = capability.targets(state, config);
26
+ const targets = capability.targets(state, config, trial);
22
27
 
23
28
  return (
24
29
  <>
@@ -82,6 +82,7 @@ function Harness({ toolId = 'rect' }: { toolId?: string }) {
82
82
  capability={capability}
83
83
  state={{}}
84
84
  config={{}}
85
+ trial={{ id: 'harness', view: null }}
85
86
  annotations={annotations}
86
87
  activeToolId={toolId}
87
88
  />
@@ -170,6 +171,7 @@ function OneTrial({
170
171
  capability={capability}
171
172
  state={{}}
172
173
  config={{}}
174
+ trial={{ id: trialId, view: null }}
173
175
  annotations={annotations}
174
176
  activeToolId="rect"
175
177
  />
@@ -1,5 +1,6 @@
1
1
  import type { RefObject } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
+ import type { TrialInfo } from '../state/types';
3
4
  import type { MarkScene } from './store';
4
5
 
5
6
  /** A point in fractions of a target's content box. */
@@ -158,7 +159,9 @@ export interface AnnotationStorage {
158
159
  /** Declares that an instrument accepts marks: which regions take them,
159
160
  * optionally what a mark is allowed to mean, and optionally where they live. */
160
161
  export interface AnnotationsCapability<TS = unknown, TC = unknown> {
161
- targets: (state: TS, config: TC) => readonly AnnotationTarget[];
162
+ /** `trial` is which trial is asking: a declaration made once per instrument
163
+ * is called once per trial, and its targets are that trial's own. */
164
+ targets: (state: TS, config: TC, trial: TrialInfo) => readonly AnnotationTarget[];
162
165
  meaning?: AnnotationMeaning;
163
166
  storage?: AnnotationStorage;
164
167
  /** Fires after every finished export, labkit's own chrome included. A
@@ -133,14 +133,18 @@ describe('useOrbit dragging', () => {
133
133
  expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
134
134
  });
135
135
 
136
- it('ends the drag when capture is lost mid-gesture', () => {
136
+ // The other half of the rule — a detached origin does cancel — is owned by
137
+ // pointerSession.test.ts, which exercises it without React in the way.
138
+ it('keeps turning when capture is lost and the element is still in the document', () => {
137
139
  const { result, el, getView } = dragSetup();
138
140
  act(() => result.current.onPointerDown(press(el, 100, 100)));
139
141
  dispatchOn(document, 'pointermove', { buttons: 1, clientX: 200, clientY: 150 });
142
+ // Chrome releases capture a beat before it delivers pointerup, so ending
143
+ // here throws away a release that has already been dispatched.
140
144
  dispatchOn(el, 'lostpointercapture', {});
141
- expect(result.current.isDragging()).toBe(false);
145
+ expect(result.current.isDragging()).toBe(true);
142
146
  dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
143
- expect(getView()).toEqual(orbitAfterDrag(view, 100, 50));
147
+ expect(getView()).toEqual(orbitAfterDrag(view, 300, 300));
144
148
  });
145
149
 
146
150
  it('reads a move with nothing held as the release that never arrived', () => {
@@ -165,14 +165,18 @@ describe('usePanZoom dragging', () => {
165
165
  expect(getView().pan).toEqual({ x: 50, y: 20 });
166
166
  });
167
167
 
168
- it('ends the drag when capture is lost mid-gesture', () => {
168
+ // The other half of the rule — a detached origin does cancel — is owned by
169
+ // pointerSession.test.ts, which exercises it without React in the way.
170
+ it('keeps panning when capture is lost and the element is still in the document', () => {
169
171
  const { result, el, getView } = dragSetup();
170
172
  act(() => result.current.onPointerDown(press(el, 100, 100)));
171
173
  dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
174
+ // Chrome releases capture a beat before it delivers pointerup, so ending
175
+ // here throws away a release that has already been dispatched.
172
176
  dispatchOn(el, 'lostpointercapture', {});
173
- expect(result.current.isDragging()).toBe(false);
177
+ expect(result.current.isDragging()).toBe(true);
174
178
  dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
175
- expect(getView().pan).toEqual({ x: 50, y: 20 });
179
+ expect(getView().pan).toEqual({ x: 300, y: 300 });
176
180
  });
177
181
 
178
182
  it('reads a move with nothing held as the release that never arrived', () => {
@@ -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
+ });