@weasel-js/labkit 1.3.0 → 1.4.0-pre.1

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 (211) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/CanvasStackContext-LnCfqNBA.d.ts +42 -0
  3. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  4. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  5. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  6. package/dist/_dts/frac-C-2c72Ij.d.ts +735 -0
  7. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  8. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  9. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  10. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  11. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  12. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  13. package/dist/canvas/index.d.ts +110 -26
  14. package/dist/canvas/index.js +5 -4
  15. package/dist/chrome/index.d.ts +9 -6
  16. package/dist/chrome/index.js +6 -6
  17. package/dist/chunk-2AYGEN57.js +32 -0
  18. package/dist/chunk-2AYGEN57.js.map +1 -0
  19. package/dist/chunk-6OEYTYML.js +669 -0
  20. package/dist/chunk-6OEYTYML.js.map +1 -0
  21. package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
  22. package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
  23. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  24. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  25. package/dist/chunk-CTRKTLYZ.js +33 -0
  26. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  27. package/dist/chunk-E2UQVZ44.js +458 -0
  28. package/dist/chunk-E2UQVZ44.js.map +1 -0
  29. package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
  30. package/dist/chunk-ESIQNQ6K.js.map +1 -0
  31. package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
  32. package/dist/chunk-HHGVISVZ.js.map +1 -0
  33. package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
  34. package/dist/chunk-HMFODCOX.js.map +1 -0
  35. package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
  36. package/dist/chunk-L5QJOOLV.js.map +1 -0
  37. package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
  38. package/dist/chunk-M75ZU6ZZ.js.map +1 -0
  39. package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
  40. package/dist/chunk-QB5SM4BM.js.map +1 -0
  41. package/dist/chunk-RBGKL7NF.js +7128 -0
  42. package/dist/chunk-RBGKL7NF.js.map +1 -0
  43. package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
  44. package/dist/chunk-TO2FUOKF.js.map +1 -0
  45. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  46. package/dist/chunk-XJ6N32QP.js.map +1 -0
  47. package/dist/controls/index.d.ts +4 -28
  48. package/dist/controls/index.js +3 -3
  49. package/dist/dragdrop/index.d.ts +9 -5
  50. package/dist/dragdrop/index.js +3 -3
  51. package/dist/index.d.ts +517 -234
  52. package/dist/index.js +1769 -143
  53. package/dist/index.js.map +1 -1
  54. package/dist/layers/index.d.ts +6 -5
  55. package/dist/layers/index.js +3 -3
  56. package/dist/loupe/index.d.ts +187 -0
  57. package/dist/loupe/index.js +7 -0
  58. package/dist/loupe/index.js.map +1 -0
  59. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  60. package/dist/passthrough/weasel-canvas.js +1 -1
  61. package/dist/passthrough/weasel-ui.d.ts +138 -91
  62. package/dist/passthrough/weasel-ui.js +2 -2
  63. package/dist/primitives/index.d.ts +3 -1
  64. package/dist/primitives/index.js +5 -5
  65. package/dist/state/index.d.ts +3 -3
  66. package/dist/state/index.js +2 -2
  67. package/dist/styles.css +229 -1
  68. package/dist/surface/index.d.ts +18 -2
  69. package/dist/surface/index.js +2 -2
  70. package/dist/ui/layers/index.js +2 -2
  71. package/dist/undo/index.d.ts +5 -4
  72. package/package.json +14 -7
  73. package/src/annotations/AnnotationOverlay.tsx +205 -0
  74. package/src/annotations/AnnotationTargets.tsx +37 -0
  75. package/src/annotations/Annotations.less +129 -0
  76. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  78. package/src/annotations/AnnotationsContext.ts +49 -0
  79. package/src/annotations/ExportMenu.test.tsx +86 -0
  80. package/src/annotations/ExportMenu.tsx +182 -0
  81. package/src/annotations/MarkList.tsx +75 -0
  82. package/src/annotations/capture.test.ts +140 -0
  83. package/src/annotations/capture.ts +237 -0
  84. package/src/annotations/drawOne.test.ts +56 -0
  85. package/src/annotations/drawOne.ts +47 -0
  86. package/src/annotations/frac.test.ts +59 -0
  87. package/src/annotations/frac.ts +66 -0
  88. package/src/annotations/history.test.ts +82 -0
  89. package/src/annotations/history.ts +118 -0
  90. package/src/annotations/index.ts +37 -0
  91. package/src/annotations/paint.test.ts +125 -0
  92. package/src/annotations/paint.ts +130 -0
  93. package/src/annotations/staleness.test.ts +56 -0
  94. package/src/annotations/staleness.ts +41 -0
  95. package/src/annotations/store.test.ts +247 -0
  96. package/src/annotations/store.ts +347 -0
  97. package/src/annotations/svgNodes.test.ts +77 -0
  98. package/src/annotations/svgNodes.ts +74 -0
  99. package/src/annotations/toolMap.test.ts +36 -0
  100. package/src/annotations/toolMap.ts +54 -0
  101. package/src/annotations/types.ts +229 -0
  102. package/src/annotations/view.test.ts +37 -0
  103. package/src/annotations/view.ts +40 -0
  104. package/src/canvas/AGENTS.md +55 -5
  105. package/src/canvas/CanvasStack.test.tsx +27 -1
  106. package/src/canvas/CanvasStack.tsx +29 -4
  107. package/src/canvas/CanvasStackContext.ts +23 -4
  108. package/src/canvas/camera.test.ts +78 -0
  109. package/src/canvas/camera.ts +62 -0
  110. package/src/canvas/canvasCoords.test.ts +26 -0
  111. package/src/canvas/canvasCoords.ts +20 -7
  112. package/src/canvas/index.ts +16 -1
  113. package/src/canvas/useLayerScheduler.ts +7 -3
  114. package/src/canvas/usePanZoom.test.ts +36 -5
  115. package/src/canvas/usePanZoom.ts +20 -15
  116. package/src/canvas/worldSpec.test.ts +63 -0
  117. package/src/canvas/worldSpec.ts +48 -0
  118. package/src/chrome/builtins.test.ts +31 -1
  119. package/src/chrome/builtins.tsx +63 -19
  120. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  121. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  122. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  123. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  124. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  125. package/src/chrome/regions/regions.test.tsx +6 -1
  126. package/src/chrome/types.ts +20 -0
  127. package/src/controls/ControlPanel.test.tsx +38 -0
  128. package/src/controls/ControlPanel.tsx +50 -3
  129. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  130. package/src/dragdrop/dragDrop.test.tsx +29 -2
  131. package/src/index.test.ts +23 -0
  132. package/src/index.ts +93 -4
  133. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  134. package/src/instrument/types.ts +24 -1
  135. package/src/lab/Lab.surface.test.tsx +162 -0
  136. package/src/lab/Lab.tsx +102 -17
  137. package/src/lab/Lab.undock.test.tsx +85 -0
  138. package/src/lab/LabFullChrome.stories.tsx +20 -0
  139. package/src/lab/LabShell.less +11 -0
  140. package/src/lab/Workspace.less +73 -0
  141. package/src/lab/Workspace.surface.test.tsx +2 -0
  142. package/src/lab/Workspace.tsx +97 -8
  143. package/src/lab/index.ts +1 -1
  144. package/src/lab/panelHost.ts +37 -0
  145. package/src/loupe/AGENTS.md +49 -0
  146. package/src/loupe/CanvasLoupe.tsx +83 -0
  147. package/src/loupe/DomLoupe.tsx +62 -0
  148. package/src/loupe/Loupe.less +38 -0
  149. package/src/loupe/LoupeBubble.tsx +32 -0
  150. package/src/loupe/TrialLoupe.tsx +93 -0
  151. package/src/loupe/canvasLens.test.ts +205 -0
  152. package/src/loupe/canvasLens.ts +141 -0
  153. package/src/loupe/index.ts +19 -0
  154. package/src/loupe/types.test.ts +29 -0
  155. package/src/loupe/types.ts +90 -0
  156. package/src/loupe/useHostSize.ts +30 -0
  157. package/src/loupe/useLoupe.test.tsx +197 -0
  158. package/src/loupe/useLoupe.ts +189 -0
  159. package/src/passthrough/weasel-ui.ts +3 -0
  160. package/src/primitives/FloatingPanel.test.tsx +76 -1
  161. package/src/primitives/FloatingPanel.tsx +19 -3
  162. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  163. package/src/primitives/Sidebar.less +18 -0
  164. package/src/primitives/Toolbar.less +8 -0
  165. package/src/primitives/Toolbar.tsx +5 -0
  166. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  167. package/src/primitives/useRovingTabIndex.ts +26 -17
  168. package/src/state/document.test.ts +23 -0
  169. package/src/state/document.ts +14 -2
  170. package/src/state/index.ts +2 -0
  171. package/src/state/store.test.ts +24 -0
  172. package/src/state/store.ts +35 -1
  173. package/src/state/types.ts +11 -0
  174. package/src/state/undock.test.ts +40 -0
  175. package/src/state/undock.ts +39 -0
  176. package/src/styles.less +2 -0
  177. package/src/surface/SurfaceContext.ts +4 -0
  178. package/src/surface/index.ts +8 -3
  179. package/src/surface/useSurfaceTile.test.tsx +2 -0
  180. package/src/surface/useSurfaceTile.ts +7 -1
  181. package/src/surface/useTiledSurface.test.tsx +58 -0
  182. package/src/surface/useTiledSurface.ts +38 -3
  183. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  184. package/src/trial/Trial.annotations.test.tsx +121 -0
  185. package/src/trial/Trial.canvas.test.tsx +76 -1
  186. package/src/trial/Trial.less +19 -0
  187. package/src/trial/Trial.loupe.test.tsx +128 -0
  188. package/src/trial/Trial.stories.tsx +1 -0
  189. package/src/trial/Trial.test.tsx +12 -0
  190. package/src/trial/Trial.trialId.test.tsx +36 -0
  191. package/src/trial/Trial.tsx +215 -40
  192. package/src/trial/TrialChrome.tsx +35 -0
  193. package/src/trial/UndockedSections.tsx +65 -0
  194. package/src/trial/trialOps.test.ts +21 -0
  195. package/src/trial/trialOps.ts +5 -3
  196. package/dist/_dts/types-BttTIent.d.ts +0 -314
  197. package/dist/chunk-3XXPU73K.js.map +0 -1
  198. package/dist/chunk-4TMMVIDM.js.map +0 -1
  199. package/dist/chunk-6X5RBC6G.js +0 -366
  200. package/dist/chunk-6X5RBC6G.js.map +0 -1
  201. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  202. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  203. package/dist/chunk-JDROYQM3.js.map +0 -1
  204. package/dist/chunk-LGKRIUSW.js.map +0 -1
  205. package/dist/chunk-QCICIHEO.js.map +0 -1
  206. package/dist/chunk-QRYPXSGP.js.map +0 -1
  207. package/dist/chunk-RGT4EJIB.js +0 -6961
  208. package/dist/chunk-RGT4EJIB.js.map +0 -1
  209. package/dist/chunk-T66HU3HX.js +0 -17
  210. package/dist/chunk-T66HU3HX.js.map +0 -1
  211. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -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 { PaletteRegion } from './PaletteRegion';
@@ -33,4 +33,25 @@ describe('PaletteRegion', () => {
33
33
  screen.getByRole('button', { name: 'brush' }).click();
34
34
  expect(setActiveTool).toHaveBeenCalledWith('brush');
35
35
  });
36
+
37
+ it('puts exactly one tool in the tab order', () => {
38
+ render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
39
+ expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('tabindex', '0');
40
+ expect(screen.getByRole('button', { name: 'eraser' })).toHaveAttribute('tabindex', '-1');
41
+ });
42
+
43
+ it('walks the vertical strip with ArrowDown, and leaves ArrowRight to the page', () => {
44
+ render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
45
+ const strip = screen.getByRole('toolbar', { name: 'Tools' });
46
+ const brush = screen.getByRole('button', { name: 'brush' });
47
+ const eraser = screen.getByRole('button', { name: 'eraser' });
48
+
49
+ brush.focus();
50
+ fireEvent.keyDown(strip, { key: 'ArrowDown' });
51
+ expect(document.activeElement).toBe(eraser);
52
+ expect(eraser).toHaveAttribute('tabindex', '0');
53
+
54
+ fireEvent.keyDown(strip, { key: 'ArrowRight' });
55
+ expect(document.activeElement).toBe(eraser);
56
+ });
36
57
  });
@@ -1,4 +1,5 @@
1
1
  import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
+ import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
2
3
  import type { TrialChromeContext, TrialContribution } from '../types';
3
4
 
4
5
  /** Props for `<PaletteRegion>`. */
@@ -10,13 +11,16 @@ export interface PaletteRegionProps {
10
11
  /** A trial's tool strip. Selection lives in the trial's or the lab's tool
11
12
  * slot; this region only reflects it. */
12
13
  export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
14
+ const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>('vertical');
13
15
  if (contributions.length === 0) return null;
14
16
  return (
15
17
  <div
18
+ ref={ref}
16
19
  className="lk-palette-region"
17
20
  role="toolbar"
18
21
  aria-label="Tools"
19
22
  aria-orientation="vertical"
23
+ onKeyDown={onKeyDown}
20
24
  >
21
25
  <ToolGroup orientation="vertical">
22
26
  {contributions.map((c) => {
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { useState } from 'react';
3
- import type { TrialChromeContext, TrialContribution } from '../types';
3
+ import type { SidebarSection, TrialChromeContext, TrialContribution } from '../types';
4
4
 
5
5
  /** Props for `<SidebarRegion>`. */
6
6
  export interface SidebarRegionProps {
@@ -11,29 +11,46 @@ export interface SidebarRegionProps {
11
11
  function Section({
12
12
  title,
13
13
  defaultCollapsed,
14
+ onUndock,
14
15
  children,
15
16
  }: {
16
17
  title: string;
17
18
  defaultCollapsed: boolean;
19
+ onUndock?: () => void;
18
20
  children: ReactNode;
19
21
  }) {
20
22
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
21
23
  return (
22
24
  <section className="lk-sidebar-section">
23
- <button
24
- type="button"
25
- className="lk-sidebar-section__head"
26
- aria-expanded={!collapsed}
27
- onClick={() => setCollapsed((c) => !c)}
28
- >
29
- {title}
30
- </button>
25
+ <div className="lk-sidebar-section__bar">
26
+ <button
27
+ type="button"
28
+ className="lk-sidebar-section__head"
29
+ aria-expanded={!collapsed}
30
+ onClick={() => setCollapsed((c) => !c)}
31
+ >
32
+ {title}
33
+ </button>
34
+ {onUndock ? (
35
+ <button
36
+ type="button"
37
+ className="lk-sidebar-section__undock"
38
+ aria-label={`Undock ${title}`}
39
+ title={`Undock ${title}`}
40
+ onClick={onUndock}
41
+ >
42
+ ⧉
43
+ </button>
44
+ ) : null}
45
+ </div>
31
46
  {collapsed ? null : <div className="lk-sidebar-section__body">{children}</div>}
32
47
  </section>
33
48
  );
34
49
  }
35
50
 
36
- /** Lays a trial's `sidebar` contributions out as titled, collapsible sections. */
51
+ /** Lays a trial's `sidebar` contributions out as titled, collapsible sections.
52
+ * A section the trial has torn out is not rendered here — `Trial` portals its
53
+ * body into the workspace instead. */
37
54
  export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
38
55
  if (contributions.length === 0) return null;
39
56
  return (
@@ -41,13 +58,20 @@ export function SidebarRegion({ contributions, ctx }: SidebarRegionProps) {
41
58
  {contributions.map((c) => {
42
59
  if (c.render) return <div key={c.id}>{c.render(ctx)}</div>;
43
60
  if (c.region !== 'sidebar' || !c.item) return null;
61
+ if (ctx.undockedPanels.includes(c.id)) return null;
62
+ const item = c.item as SidebarSection;
44
63
  return (
45
64
  <Section
46
65
  key={c.id}
47
- title={c.item.title}
48
- defaultCollapsed={c.item.defaultCollapsed ?? false}
66
+ title={item.title}
67
+ defaultCollapsed={item.defaultCollapsed ?? false}
68
+ onUndock={
69
+ item.undockable === false
70
+ ? undefined
71
+ : () => ctx.undockPanel(c.id, item.undockAs ?? 'tile')
72
+ }
49
73
  >
50
- {c.item.body}
74
+ {item.body}
51
75
  </Section>
52
76
  );
53
77
  })}
@@ -38,13 +38,14 @@ 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 } = c.item;
41
+ const { icon: Icon, label, shortcut, disabled, danger, showLabel, pressed } = c.item;
42
42
  return (
43
43
  <Toolbar.Button
44
44
  key={c.id}
45
45
  iconOnly={!showLabel}
46
46
  variant={danger ? 'danger' : 'default'}
47
47
  disabled={disabled}
48
+ pressed={pressed}
48
49
  aria-label={label}
49
50
  title={shortcut ? `${label} (${shortcut})` : label}
50
51
  onClick={c.item.onActivate}
@@ -1,3 +1,4 @@
1
+ import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
1
2
  import type { TrialChromeContext, TrialContribution } from '../types';
2
3
 
3
4
  /** Props for `<ViewportRegion>`. */
@@ -11,9 +12,16 @@ export interface ViewportRegionProps {
11
12
  * rather than in the toolbar, which acts on the trial itself.
12
13
  */
13
14
  export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
15
+ const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
14
16
  if (contributions.length === 0) return null;
15
17
  return (
16
- <div className="lk-viewport-controls" role="toolbar" aria-label="View">
18
+ <div
19
+ ref={ref}
20
+ className="lk-viewport-controls"
21
+ role="toolbar"
22
+ aria-label="View"
23
+ onKeyDown={onKeyDown}
24
+ >
17
25
  {contributions.map((c) => {
18
26
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
19
27
  if (c.region !== 'viewport' || !c.item) return null;
@@ -6,7 +6,12 @@ import { StatusRegion } from './StatusRegion';
6
6
  import { ViewportRegion } from './ViewportRegion';
7
7
 
8
8
  const Glyph = () => <svg />;
9
- const ctx = { trialId: 't1' } as unknown as TrialChromeContext;
9
+ const ctx = {
10
+ trialId: 't1',
11
+ undockedPanels: [],
12
+ undockPanel: () => {},
13
+ dockPanel: () => {},
14
+ } as unknown as TrialChromeContext;
10
15
 
11
16
  describe('SidebarRegion', () => {
12
17
  it('renders nothing when empty', () => {
@@ -19,6 +19,9 @@ export interface ToolbarItem {
19
19
  disabled?: boolean;
20
20
  /** Reddens on hover. For actions that discard work. */
21
21
  danger?: boolean;
22
+ /** A toggle rather than a command: renders `aria-pressed` and reads as held
23
+ * down while true. */
24
+ pressed?: boolean;
22
25
  /** Render the label beside the glyph rather than only in the tooltip. */
23
26
  showLabel?: boolean;
24
27
  onActivate: () => void;
@@ -37,6 +40,11 @@ export interface SidebarSection {
37
40
  title: string;
38
41
  /** Starts collapsed. The open/closed state itself is the region's. */
39
42
  defaultCollapsed?: boolean;
43
+ /** Offer the tear-out control. On by default; a section that only makes
44
+ * sense beside its trial sets this false. */
45
+ undockable?: boolean;
46
+ /** Where the tear-out control sends it. Default `'tile'`. */
47
+ undockAs?: 'tile' | 'floating';
40
48
  body: ReactNode;
41
49
  }
42
50
 
@@ -103,6 +111,11 @@ export interface TrialChromeContext {
103
111
  undo: () => void;
104
112
  redo: () => void;
105
113
 
114
+ /** Whether the trial's loupe is turned on. False for an instrument that
115
+ * declares none, whose chrome offers no way to turn one on. */
116
+ loupeOn: boolean;
117
+ toggleLoupe: () => void;
118
+
106
119
  /** The instrument's controls, resolved against the lab's rules. Always
107
120
  * populated: a legacy `configSchema()` is adapted into the same shape. */
108
121
  configSchema: ResolvedConfig;
@@ -112,6 +125,13 @@ export interface TrialChromeContext {
112
125
  config: unknown;
113
126
  setConfig: (key: string, value: unknown) => void;
114
127
 
128
+ /** Section ids this trial currently has torn out of its sidebar. */
129
+ undockedPanels: readonly string[];
130
+ /** Tear a sidebar section out into the workspace. */
131
+ undockPanel: (sectionId: string, as?: 'tile' | 'floating') => void;
132
+ /** Put a torn-out section back in the sidebar. */
133
+ dockPanel: (sectionId: string) => void;
134
+
115
135
  savedSnapshots: SavedSnapshot[];
116
136
  saveSnapshot: (name?: string) => void;
117
137
  loadSnapshot: (snapshotId: string) => void;
@@ -382,3 +382,41 @@ describe('<ControlPanel> built-in kind coverage', () => {
382
382
  ).toThrow(/spline/);
383
383
  });
384
384
  });
385
+
386
+ describe('<ControlPanel> packing', () => {
387
+ const schema = resolveConfigSchema(
388
+ f.schema({ width: f.number(256), title: f.string('a'), tint: f.color('#ff0000') }),
389
+ [],
390
+ );
391
+ const config = { width: 256, title: 'a', tint: '#ff0000' };
392
+ // jsdom lays out no grid, so the class the grid rules key off is the proxy
393
+ // for "these rows pair" — a layout assertion is not available here.
394
+ const list = (container: HTMLElement) => container.querySelector('.lk-control-panel');
395
+ const rowOf = (name: string) => screen.getByLabelText(name).closest('label');
396
+
397
+ it('packs two rows to a line by default', () => {
398
+ const { container } = render(
399
+ <ControlPanel schema={schema} config={config} setConfig={vi.fn()} />,
400
+ );
401
+ expect(list(container)?.className).toMatch(/listPairs/);
402
+ expect(rowOf('Width')?.className).not.toMatch(/span/);
403
+ });
404
+
405
+ it('gives every row its own line under one-up', () => {
406
+ const { container } = render(
407
+ <ControlPanel schema={schema} config={config} setConfig={vi.fn()} pack="one-up" />,
408
+ );
409
+ expect(list(container)?.className).not.toMatch(/listPairs/);
410
+ });
411
+
412
+ it('spans the rows that need the width under auto, and pairs the rest', () => {
413
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} pack="auto" />);
414
+ expect(rowOf('Title')?.className).toMatch(/span/);
415
+ expect(rowOf('Width')?.className).not.toMatch(/span/);
416
+ });
417
+
418
+ it('passes its layout down to the rows', () => {
419
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} layout="inline" />);
420
+ expect(rowOf('Width')?.className).toMatch(/rowInline/);
421
+ });
422
+ });
@@ -7,6 +7,7 @@ import {
7
7
  PropertyGroup,
8
8
  PropertyList,
9
9
  PropertyRow,
10
+ type PropertyRowLayout,
10
11
  SelectRow,
11
12
  SliderRow,
12
13
  TextRow,
@@ -18,6 +19,16 @@ import type { ControlRenderer, ResolvedConfig } from '../config/types';
18
19
  import { isLeafVisible } from '../config/visible';
19
20
  import type { ConfigField } from './types';
20
21
 
22
+ /** How a panel packs its rows into the two-column property grid.
23
+ * - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colours) sit
24
+ * two per row; the ones that need the width — text, sliders, segmented
25
+ * toggles — span it.
26
+ * - `'pairs'`: every row pairs. A control that needs the full width says so
27
+ * itself, with `<PropertyRow span>`.
28
+ * - `'one-up'`: one control per row, colours excepted.
29
+ */
30
+ export type ControlPack = 'auto' | 'pairs' | 'one-up';
31
+
21
32
  export interface ControlPanelProps<TC extends Record<string, unknown>> {
22
33
  /** The instrument's resolved config schema. */
23
34
  schema?: ResolvedConfig;
@@ -31,6 +42,11 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
31
42
  * the row; entries override the built-in rows on collision.
32
43
  */
33
44
  renderers?: Record<string, ControlRenderer>;
45
+ /** How rows pack into the two-column grid. Defaults to `'pairs'`. */
46
+ 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
+ layout?: PropertyRowLayout;
34
50
  /** Draw leaves marked `hidden`. */
35
51
  showHidden?: boolean;
36
52
  className?: string;
@@ -45,6 +61,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
45
61
  config,
46
62
  setConfig,
47
63
  renderers,
64
+ pack = 'pairs',
65
+ layout = 'block',
48
66
  showHidden = false,
49
67
  className,
50
68
  }: ControlPanelProps<TC>) {
@@ -62,15 +80,21 @@ export function ControlPanel<TC extends Record<string, unknown>>({
62
80
  config={config}
63
81
  setConfig={setConfig}
64
82
  renderers={renderers}
83
+ pack={pack}
84
+ layout={layout}
65
85
  showHidden={showHidden}
66
86
  />
67
87
  );
68
88
 
89
+ const gridPack = pack === 'one-up' ? 'auto-color' : 'pairs';
69
90
  return (
70
- <PropertyList className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}>
91
+ <PropertyList
92
+ pack={gridPack}
93
+ className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
94
+ >
71
95
  {loose.map(row)}
72
96
  {resolved.sections.map((section) => (
73
- <PropertyGroup key={section.label} title={section.label}>
97
+ <PropertyGroup key={section.label} title={section.label} pack={gridPack}>
74
98
  {section.paths.map(row)}
75
99
  </PropertyGroup>
76
100
  ))}
@@ -84,6 +108,8 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
84
108
  config: TC;
85
109
  setConfig: (key: keyof TC, value: unknown) => void;
86
110
  renderers?: Record<string, ControlRenderer>;
111
+ pack: ControlPack;
112
+ layout: PropertyRowLayout;
87
113
  showHidden: boolean;
88
114
  }
89
115
 
@@ -99,6 +125,8 @@ function ControlRow<TC extends Record<string, unknown>>({
99
125
  config,
100
126
  setConfig,
101
127
  renderers,
128
+ pack,
129
+ layout,
102
130
  showHidden,
103
131
  }: ControlRowProps<TC>) {
104
132
  const leaf = resolved.group.children[path] as PrefLeaf | undefined;
@@ -112,11 +140,16 @@ function ControlRow<TC extends Record<string, unknown>>({
112
140
  // Most specific wins, and within a tier the lab's entry beats the
113
141
  // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
114
142
  const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
143
+ // A custom row places itself like any other; one that needs the full width
144
+ // says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
115
145
  if (custom) return custom({ path, pref: leaf, value, setValue: write });
116
146
 
117
147
  const label = leaf.name;
118
148
  const description = leaf.description;
119
149
  const read = <T,>(): T => value as T;
150
+ // `auto` gives the whole width to the controls that read badly at half of a
151
+ // sidebar's: free text, a slider track, a segmented toggle. `pairs` doesn't.
152
+ const wide = pack === 'auto';
120
153
 
121
154
  if (!isBuiltinToolPref(leaf))
122
155
  return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
@@ -135,6 +168,8 @@ function ControlRow<TC extends Record<string, unknown>>({
135
168
  max={max}
136
169
  step={step}
137
170
  onChange={write}
171
+ layout={layout}
172
+ span={wide}
138
173
  description={description}
139
174
  />
140
175
  );
@@ -152,6 +187,7 @@ function ControlRow<TC extends Record<string, unknown>>({
152
187
  max={max}
153
188
  step={step}
154
189
  onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
190
+ layout={layout}
155
191
  description={description}
156
192
  />
157
193
  );
@@ -167,13 +203,16 @@ function ControlRow<TC extends Record<string, unknown>>({
167
203
  );
168
204
  case 'enum': {
169
205
  const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
170
- const Row = extra<string>(leaf, 'control') === 'radio' ? ToggleRow : SelectRow;
206
+ const segmented = extra<string>(leaf, 'control') === 'radio';
207
+ const Row = segmented ? ToggleRow : SelectRow;
171
208
  return (
172
209
  <Row
173
210
  label={label}
174
211
  value={read<string>()}
175
212
  options={options}
176
213
  onChange={write}
214
+ layout={layout}
215
+ span={segmented && wide}
177
216
  description={description}
178
217
  />
179
218
  );
@@ -185,6 +224,8 @@ function ControlRow<TC extends Record<string, unknown>>({
185
224
  label={label}
186
225
  value={read<string>()}
187
226
  write={write}
227
+ layout={layout}
228
+ span={wide}
188
229
  description={description}
189
230
  />
190
231
  );
@@ -235,12 +276,16 @@ function DebouncedTextRow({
235
276
  label,
236
277
  value,
237
278
  write,
279
+ layout,
280
+ span,
238
281
  description,
239
282
  }: {
240
283
  leaf: PrefLeaf;
241
284
  label: string;
242
285
  value: string;
243
286
  write: (value: unknown) => void;
287
+ layout?: PropertyRowLayout;
288
+ span?: boolean;
244
289
  description?: string;
245
290
  }) {
246
291
  const debounceMs = extra<number>(leaf, 'debounceMs') ?? 150;
@@ -265,6 +310,8 @@ function DebouncedTextRow({
265
310
  return (
266
311
  <TextRow
267
312
  label={label}
313
+ layout={layout}
314
+ span={span}
268
315
  description={description}
269
316
  value={local}
270
317
  placeholder={extra<string>(leaf, 'placeholder')}
@@ -1,6 +1,7 @@
1
1
  import { useVisibleRaf } from '@weasel-js/core';
2
2
  import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { screenToWorld } from '../canvas/canvasCoords';
4
+ import { resolveFrame, type WorldSpec } from '../canvas/worldSpec';
4
5
  import type {
5
6
  DragDropCapability,
6
7
  DragFeedback,
@@ -20,6 +21,9 @@ export interface UseDragDropArgs<TS, TC> {
20
21
  capability: DragDropCapability<TS, TC>;
21
22
  canvasContainerRef: RefObject<HTMLElement | null>;
22
23
  view: ViewTransform;
24
+ /** The instrument's coordinate system, resolved against the container each
25
+ * time a screen point is converted — the container is what knows its size. */
26
+ worldSpec?: WorldSpec;
23
27
  state: TS;
24
28
  config: TC;
25
29
  setState: (next: TS | ((prev: TS) => TS)) => void;
@@ -39,6 +43,7 @@ export function useDragDrop<TS, TC>({
39
43
  config,
40
44
  setState,
41
45
  emit,
46
+ worldSpec,
42
47
  }: UseDragDropArgs<TS, TC>): UseDragDropResult {
43
48
  const [drag, setDrag] = useState<DragState | null>(null);
44
49
  const dragRef = useRef<DragState | null>(null);
@@ -67,9 +72,10 @@ export function useDragDrop<TS, TC>({
67
72
  const el = canvasContainerRef.current;
68
73
  if (!el) return null;
69
74
  const r = el.getBoundingClientRect();
70
- return screenToWorld({ x: screenPos.x - r.left, y: screenPos.y - r.top }, view);
75
+ const frame = resolveFrame(worldSpec, { width: r.width, height: r.height });
76
+ return screenToWorld({ x: screenPos.x - r.left, y: screenPos.y - r.top }, view, frame);
71
77
  },
72
- [canvasContainerRef, view],
78
+ [canvasContainerRef, view, worldSpec],
73
79
  );
74
80
 
75
81
  const frameLoop = useVisibleRaf(() => {
@@ -41,9 +41,19 @@ function StateProbe({ onState }: { onState: (state: DropState) => void }) {
41
41
  return null;
42
42
  }
43
43
 
44
- function renderLab(probe?: (state: DropState) => void) {
44
+ const centredInstrument = defineInstrument<DropState, Record<string, never>>({
45
+ ...testInstrument,
46
+ name: 'CentredDrop',
47
+ canvas: {
48
+ worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
49
+ initialView: { zoom: 1, pan: { x: 0, y: 0 } },
50
+ layers: [{ id: 'main', draw: () => undefined }],
51
+ },
52
+ });
53
+
54
+ function renderLab(probe?: (state: DropState) => void, instrument = testInstrument) {
45
55
  return render(
46
- <Lab instruments={[testInstrument]} defaultInstrument="TestDrop">
56
+ <Lab instruments={[instrument]} defaultInstrument={instrument.name}>
47
57
  {probe ? <StateProbe onState={probe} /> : null}
48
58
  </Lab>,
49
59
  );
@@ -139,4 +149,21 @@ describe('DragDrop integration', () => {
139
149
  windowPointerUp(250, 250);
140
150
  expect(document.querySelector('.lk-drag-ghost')).toBeNull();
141
151
  });
152
+
153
+ it('drops through the instrument world spec, not the canvas top-left', () => {
154
+ let latest: DropState = { items: [] };
155
+ renderLab((s) => {
156
+ latest = s;
157
+ }, centredInstrument);
158
+ const palette = screen.getByRole('button', { name: 'Item A' });
159
+
160
+ act(() => {
161
+ pointerDown(palette, 0, 0);
162
+ });
163
+ // The host spans (100,100)-(500,500), so its centre is at (300,300).
164
+ // Dropping 50px right of centre and 50px above it, at zoom 1, y up.
165
+ windowPointerUp(350, 250);
166
+
167
+ expect(latest.items[0]).toMatchObject({ x: 50, y: 50 });
168
+ });
142
169
  });
package/src/index.test.ts CHANGED
@@ -60,11 +60,34 @@ describe('surface, job and orbit entry points', () => {
60
60
  expect(typeof kit.useSurfaceOptional).toBe('function');
61
61
  expect(typeof kit.toDeviceRect).toBe('function');
62
62
  expect(typeof kit.composeRects).toBe('function');
63
+ expect(typeof kit.createAnnotationStore).toBe('function');
64
+ expect(typeof kit.createAnnotationScene).toBe('function');
65
+ expect(typeof kit.annotationsFromJSON).toBe('function');
66
+ expect(typeof kit.seenFrom).toBe('function');
67
+ expect(typeof kit.fracToWorld).toBe('function');
63
68
  expect(typeof kit.useOrbit).toBe('function');
64
69
  expect(typeof kit.orbitAfterDrag).toBe('function');
70
+ expect(typeof kit.usePanZoom).toBe('function');
65
71
  expect(typeof kit.useJob).toBe('function');
66
72
  expect(typeof kit.as2DView).toBe('function');
67
73
  expect(kit.SurfaceContext).toBeDefined();
74
+ expect(kit.SurfaceCanvasContext).toBeDefined();
75
+ expect(typeof kit.useSurfaceCanvas).toBe('function');
76
+ });
77
+ });
78
+
79
+ describe('the annotations capability', () => {
80
+ it('reaches a host through the package root', async () => {
81
+ const kit = await import('./index');
82
+ expect(typeof kit.useAnnotations).toBe('function');
83
+ expect(typeof kit.useAnnotationsOptional).toBe('function');
84
+ expect(typeof kit.AnnotationTargets).toBe('function');
85
+ expect(typeof kit.AnnotationOverlay).toBe('function');
86
+ expect(typeof kit.markCommands).toBe('function');
87
+ expect(typeof kit.annotationToolInfo).toBe('function');
88
+ expect(typeof kit.fitView).toBe('function');
89
+ expect(kit.ANNOTATION_TOOLS.map((t) => t.id)).toContain('arrow');
90
+ expect(typeof kit.MarkList).toBe('function');
68
91
  expect(kit.DEFAULT_VIEW).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
69
92
  });
70
93
  });