@weasel-js/labkit 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -1,14 +1,9 @@
1
- import { type ReactNode, useEffect, useMemo, useRef } from 'react';
2
- import { asNodeId, createNode, Store, stripStrategy } from 'windease';
3
- import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
4
-
5
- const ZONE_ID = asNodeId('lk-trial-body');
6
- const SIDEBAR_ID = asNodeId('lk-trial-sidebar');
7
- const CONTENT_ID = asNodeId('lk-trial-content');
8
- const STRATEGIES = { strip: stripStrategy as never };
1
+ import type { ReactNode } from 'react';
2
+ import { Split, type SplitProps } from '../primitives/Split';
9
3
 
10
4
  /** Props for `<TrialBody>`. */
11
- export interface TrialBodyProps {
5
+ export interface TrialBodyProps
6
+ extends Omit<SplitProps, 'sidebar' | 'children' | 'className' | 'sidebarClassName' | 'label'> {
12
7
  /** The sidebar region's sections. */
13
8
  sidebar: ReactNode;
14
9
  /** The instrument's own output. */
@@ -16,140 +11,21 @@ export interface TrialBodyProps {
16
11
  /** Extra class on the content pane — `Trial` marks a canvas instrument's
17
12
  * well flush. */
18
13
  contentClassName?: string;
19
- /** The sidebar's current extent in pixels. Omit for an unpersisted lab: the
20
- * seam still works, its result just does not outlive the mount. */
21
- width?: number;
22
- /** Where the sidebar starts before anyone drags it. */
23
- defaultWidth?: number;
24
- minWidth?: number;
25
- maxWidth?: number;
26
- /** How little the instrument may be squeezed to. Reported as the seam's
27
- * reachable maximum, so the drag stops there rather than clamping silently. */
28
- contentMinWidth?: number;
29
- /** Fires through the drag, not on release: the pane is already there. */
30
- onWidthChange?: (width: number) => void;
31
- /**
32
- * Fixed extent. Omit in an app — the strip measures its own box. Supply it
33
- * where nothing measures, notably jsdom, or both panes lay out at zero.
34
- */
35
- viewport?: { w: number; h: number };
36
14
  }
37
15
 
38
- /**
39
- * A trial's sidebar and content as a two-pane strip with a draggable seam.
40
- *
41
- * The seam is `stripStrategy`'s own resize affordance, so it arrives as a
42
- * `role="separator"` carrying the reachable range, operable by pointer and by
43
- * arrows / Home / End, and clamped against the content pane's floor rather
44
- * than against the sidebar's alone.
45
- */
46
- export function TrialBody({
47
- sidebar,
48
- children,
49
- contentClassName,
50
- width,
51
- defaultWidth = 320,
52
- minWidth = 140,
53
- maxWidth = 720,
54
- contentMinWidth = 160,
55
- onWidthChange,
56
- viewport,
57
- }: TrialBodyProps) {
58
- // The cross-axis half of every size hint below is inert: a horizontal strip
59
- // stretches its panes to the container's height and never reads `h`.
60
- const storeRef = useRef<Store | null>(null);
61
- if (storeRef.current === null) {
62
- const store = new Store();
63
- store.registerNode(
64
- createNode({
65
- kind: 'zone',
66
- id: ZONE_ID,
67
- container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
68
- }),
69
- );
70
- store.registerNode(
71
- createNode({
72
- kind: 'sidebar',
73
- id: SIDEBAR_ID,
74
- parentId: ZONE_ID,
75
- meta: { title: 'Sidebar' },
76
- placement: { size: { w: width ?? defaultWidth } },
77
- hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
78
- }),
79
- );
80
- store.registerNode(
81
- createNode({
82
- kind: 'content',
83
- id: CONTENT_ID,
84
- parentId: ZONE_ID,
85
- meta: { title: 'Content' },
86
- hints: { minSize: { w: contentMinWidth, h: 0 } },
87
- }),
88
- );
89
- store.showNode(SIDEBAR_ID);
90
- store.showNode(CONTENT_ID);
91
- storeRef.current = store;
92
- }
93
- const store = storeRef.current;
94
-
95
- // What the store last held, so the placement event can tell a drag from the
96
- // echo of our own write and a controlled `width` does not fight the seam.
97
- const widthRef = useRef(width ?? defaultWidth);
98
-
99
- useEffect(() => {
100
- store.setHints(SIDEBAR_ID, {
101
- minSize: { w: minWidth, h: 0 },
102
- maxSize: { w: maxWidth, h: 0 },
103
- });
104
- store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
105
- }, [store, minWidth, maxWidth, contentMinWidth]);
106
-
107
- useEffect(() => {
108
- if (width === undefined || width === widthRef.current) return;
109
- widthRef.current = width;
110
- store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
111
- }, [store, width]);
112
-
113
- const onWidthChangeRef = useRef(onWidthChange);
114
- onWidthChangeRef.current = onWidthChange;
115
- useEffect(
116
- () =>
117
- store.events.on('node.placementChanged', () => {
118
- const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
119
- if (typeof next !== 'number' || next === widthRef.current) return;
120
- widthRef.current = next;
121
- onWidthChangeRef.current?.(next);
122
- }),
123
- [store],
124
- );
125
-
126
- const chrome = useMemo<ChromeMap>(
127
- () => ({
128
- sidebar: () => <div className="lk-trial__sidebar">{sidebar}</div>,
129
- content: () => (
130
- <div
131
- className={
132
- contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
133
- }
134
- >
135
- {children}
136
- </div>
137
- ),
138
- }),
139
- [sidebar, children, contentClassName],
140
- );
141
-
16
+ /** A trial's sidebar and content as a `Split`, wearing the trial's classes. */
17
+ export function TrialBody({ sidebar, children, contentClassName, ...rest }: TrialBodyProps) {
142
18
  return (
143
- <Provider store={store}>
144
- <StrategyRegistryProvider strategies={STRATEGIES}>
145
- <Container
146
- parentId={ZONE_ID}
147
- chrome={chrome}
148
- className="lk-trial__panes windease-zone"
149
- affordances
150
- viewport={viewport}
151
- />
152
- </StrategyRegistryProvider>
153
- </Provider>
19
+ <Split
20
+ {...rest}
21
+ sidebar={sidebar}
22
+ className="lk-trial__panes"
23
+ sidebarClassName="lk-trial__sidebar"
24
+ contentClassName={
25
+ contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
26
+ }
27
+ >
28
+ {children}
29
+ </Split>
154
30
  );
155
31
  }
@@ -17,7 +17,7 @@ import { useLabContext } from '../lab/LabContext';
17
17
  import { JobProgress } from '../primitives/JobProgress';
18
18
  import { LabStoreContext } from '../state/context';
19
19
  import type { TrialRecord } from '../state/types';
20
- import { as2DView } from '../state/view';
20
+ import { as2DView, withZoom } from '../state/view';
21
21
  import { TrialBody } from './TrialBody';
22
22
  import { TrialTitleBar } from './TrialTitleBar';
23
23
  import { UndockedSections } from './UndockedSections';
@@ -111,13 +111,13 @@ export function TrialChrome({
111
111
 
112
112
  const configSchema = useConfigSchema(instrument);
113
113
 
114
- const title = record.title ?? record.instrumentName;
114
+ const title = record.title ?? instrument.title ?? instrument.name;
115
115
  const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
116
116
 
117
117
  const ctx = useMemo<TrialChromeContext>(() => {
118
118
  const setZoom = (z: number): void => {
119
119
  if (!view2d) return;
120
- updateTrialView(trialId, { ...view2d, zoom: z });
120
+ updateTrialView(trialId, withZoom(view2d, z));
121
121
  };
122
122
  return {
123
123
  trialId,
@@ -225,6 +225,7 @@ export function TrialChrome({
225
225
  return (
226
226
  <section
227
227
  className="lk-trial"
228
+ data-trial-id={trialId}
228
229
  aria-label={`Trial ${title}`}
229
230
  tabIndex={-1}
230
231
  onKeyDown={handleKeyDown}
@@ -243,7 +244,9 @@ export function TrialChrome({
243
244
  <TrialBody
244
245
  width={record.sidebarWidth}
245
246
  onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
246
- contentClassName={instrument.canvas ? 'lk-trial__content--flush' : undefined}
247
+ contentClassName={
248
+ instrument.canvas || instrument.stage ? 'lk-trial__content--flush' : undefined
249
+ }
247
250
  sidebar={
248
251
  <>
249
252
  <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
@@ -1,5 +1,6 @@
1
1
  import { fillConfigDefaults } from '../config/path';
2
2
  import type { Instrument, InstrumentList } from '../instrument/types';
3
+ import { newId } from '../state/helpers';
3
4
  import type { TrialRecord } from '../state/types';
4
5
 
5
6
  const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
@@ -13,10 +14,12 @@ function findInstrument(instruments: InstrumentList, name: string): Instrument {
13
14
  }
14
15
 
15
16
  function initialView(instrument: Instrument): TrialRecord['view'] {
16
- const declared = instrument.canvas?.initialView;
17
+ const stage = instrument.canvas ? undefined : instrument.stage;
18
+ const declared = instrument.canvas?.initialView ?? stage?.initialView;
17
19
  // A function needs the canvas size, which no trial has at creation. `null`
18
20
  // is the unplaced marker; `Trial` resolves it on the first non-empty size.
19
- if (typeof declared === 'function') return null;
21
+ // A stage that names no view is fitted, which needs that size too.
22
+ if (typeof declared === 'function' || (stage && declared === undefined)) return null;
20
23
  return declared ? structuredClone(declared) : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
21
24
  }
22
25
 
@@ -47,7 +50,7 @@ export function addTrial(
47
50
  const config = seedConfig(instrument.defaultConfig(), options.config);
48
51
  const state = instrument.initialState(config);
49
52
  const record: TrialRecord = {
50
- id: crypto.randomUUID(),
53
+ id: newId(),
51
54
  instrumentName,
52
55
  config,
53
56
  state,
@@ -66,7 +69,7 @@ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
66
69
  if (!source) return trials;
67
70
  const clone: TrialRecord = {
68
71
  ...source,
69
- id: crypto.randomUUID(),
72
+ id: newId(),
70
73
  config: structuredClone(source.config),
71
74
  state: structuredClone(source.state),
72
75
  view: structuredClone(source.view),
@@ -1,155 +0,0 @@
1
- import { C as ConfigPath, V as ValueAtPath } from './types-B9_zrHmb.js';
2
-
3
- /** A sidebar section torn out of its trial, and where it went. */
4
- interface UndockedPanel {
5
- trialId: string;
6
- sectionId: string;
7
- as: 'tile' | 'floating';
8
- }
9
- /** Undocked panels, keyed by {@link panelKey}. */
10
- type UndockedPanels = Record<string, UndockedPanel>;
11
- declare function panelKey(trialId: string, sectionId: string): string;
12
- declare function undockPanel(panels: UndockedPanels, trialId: string, sectionId: string, as?: UndockedPanel['as']): UndockedPanels;
13
- /** Dock one section back, or — with no `sectionId` — every panel the trial
14
- * owns, which is what closing a trial wants. */
15
- declare function dockPanel(panels: UndockedPanels, trialId: string, sectionId?: string): UndockedPanels;
16
-
17
- /** A trial's undo history, as snapshots of its state either side of the
18
- * present. */
19
- interface UndoStack {
20
- past: unknown[];
21
- future: unknown[];
22
- }
23
- /** The trial a per-trial call is coming from. `id` is the id `useTileId`
24
- * scopes a surface tile under, so a consumer keying its own per-trial
25
- * registry and labkit's tiles agree on the key. */
26
- interface TrialInfo<TV = unknown> {
27
- id: string;
28
- /** The trial's view, in whatever shape the instrument keeps it. */
29
- view: TV;
30
- }
31
- /** One trial as the store holds it: which instrument it runs, that
32
- * instrument's config and state, the camera, and the undo history. */
33
- interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
34
- id: string;
35
- instrumentName: string;
36
- config: TC;
37
- state: TS;
38
- /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
39
- * but never read into. A 3D lab puts an orbit here and keeps all three. */
40
- view: TV;
41
- /** The config `addTrial` opened this trial on, overlaid on the instrument's
42
- * defaults. Kept so Reset restores the trial's own subject rather than the
43
- * bare defaults. */
44
- configSeed?: Partial<TC>;
45
- /** This trial's own tool slot. Undefined means it reads the lab's. */
46
- activeToolId?: string | null;
47
- /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
48
- * until someone moves the seam. */
49
- sidebarWidth?: number;
50
- /** What the title bar reads. Undefined — the state a trial opens in, and the
51
- * one `setTitle(null)` returns it to — means the instrument's name. */
52
- title?: string | null;
53
- /** Which of the trial's collapsible sections are folded, keyed as
54
- * `TrialChromeContext.collapsedSections` describes. A key that is absent
55
- * takes the section's own default. */
56
- collapsedSections?: Record<string, boolean>;
57
- /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
58
- * wrote them. Opaque here, and absent for a trial whose instrument declares
59
- * no `annotations` — or declares its own `storage`. Not in `state`, which
60
- * belongs to the instrument and is typed as such. */
61
- annotations?: unknown;
62
- undoStack: UndoStack;
63
- }
64
- /** A named, saved copy of a trial's config and state, restorable into any
65
- * trial running the same instrument. */
66
- interface SavedSnapshot {
67
- id: string;
68
- name: string;
69
- trialId: string;
70
- instrumentName: string;
71
- config: unknown;
72
- state: unknown;
73
- savedAt: number;
74
- }
75
- /** `auto` follows the OS; the other two are an explicit choice. */
76
- type LabMode = 'auto' | 'light' | 'dark';
77
- /** Everything a lab persists: its trials, its saved snapshots, and the
78
- * chosen color mode. */
79
- interface LabStoreState {
80
- trials: TrialRecord[];
81
- savedSnapshots: SavedSnapshot[];
82
- mode: LabMode;
83
- /** The lab's tool slot — what a trial with no slot of its own resolves to. */
84
- activeToolId: string | null;
85
- /** Per-trial tile extents, keyed by trial id. Opaque here — the
86
- * shape belongs to whatever lays the trials out. */
87
- layout: Record<string, unknown>;
88
- /** Sidebar sections torn out of their trial. A panel here is not rendered in
89
- * its trial's sidebar; the workspace renders it instead. */
90
- undockedPanels: UndockedPanels;
91
- }
92
- /** Where a lab persists itself. Implementations are keyed string storage and
93
- * nothing more, so the same store works against localStorage, the URL hash,
94
- * or memory. */
95
- interface StorageAdapter {
96
- read(key: string): string | null;
97
- write(key: string, value: string): void;
98
- delete?(key: string): void;
99
- }
100
- /** What `useTrialState` hands an instrument: its state and config, with
101
- * a setter for each. */
102
- interface TrialStateHandle<TS, TC> {
103
- state: TS;
104
- setState: (next: TS | ((prev: TS) => TS)) => void;
105
- config: TC;
106
- /** Write one config value, by dotted path — `'grid.size'` for a leaf under
107
- * an `f.group`, `'cellSize'` for one at the root. */
108
- setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
109
- }
110
- /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
111
- * two labs on one origin do not collide. */
112
- interface CreateLabStoreOptions {
113
- storageKey: string;
114
- storage: StorageAdapter;
115
- initialMode?: LabMode;
116
- /** Each instrument's default config, keyed by instrument name, used to fill
117
- * the gaps in a stored one. A config saved before its schema grew a branch
118
- * arrives holding that branch's defaults rather than `undefined`, and keeps
119
- * whatever keys the schema has since stopped naming. `<Lab>` collects these
120
- * off its `instruments`. */
121
- configDefaults?: Record<string, () => unknown>;
122
- /** How each instrument's state survives a reload. Hydration is the first
123
- * thing `createLabStore` does, so these have to arrive with the store —
124
- * anything registered afterwards is already too late to read the document
125
- * it was built from. `<Lab>` collects them off its `instruments`. */
126
- serializers?: InstrumentSerializers;
127
- }
128
- /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
129
- * instrument whose state is already JSON-safe needs no entry. `deserialize`
130
- * is handed the config the state was saved against — a trial's own for a
131
- * reload, the snapshot's for a load — since a state rebuilt without it can
132
- * disagree with the settings sitting next to it. */
133
- type InstrumentSerializers = Record<string, {
134
- serialize?: (state: unknown) => unknown;
135
- deserialize?: (data: unknown, config: unknown) => unknown;
136
- } | undefined>;
137
- /** A trial as it is persisted: everything but the undo history, which is
138
- * session-only. */
139
- type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
140
- /** Everything a lab persists, under one key, at a known version. */
141
- interface LabDocument {
142
- version: number;
143
- trials: SerializedTrial[];
144
- saves: SavedSnapshot[];
145
- layout: Record<string, unknown>;
146
- /** Sidebar sections torn out of their trial, keyed by trial and section. */
147
- undockedPanels: UndockedPanels;
148
- mode: LabMode;
149
- }
150
- /** Migrates a document one version forward. Index `i` in the chain takes a
151
- * version-`i` document to version `i + 1`. */
152
- type Migration = (doc: Record<string, unknown>) => Record<string, unknown>;
153
-
154
- export { dockPanel as g, panelKey as p, undockPanel as u };
155
- export type { CreateLabStoreOptions as C, InstrumentSerializers as I, LabDocument as L, Migration as M, SerializedTrial as S, TrialRecord as T, UndoStack as U, StorageAdapter as a, LabStoreState as b, SavedSnapshot as c, TrialStateHandle as d, UndockedPanel as e, UndockedPanels as f, LabMode as h, TrialInfo as i };
@@ -1,62 +0,0 @@
1
- /** Which control a config field is edited with. */
2
- type ConfigFieldType = 'slider' | 'checkbox' | 'select' | 'number' | 'text' | 'color';
3
- /** What every config field carries: the config key it writes, the label shown
4
- * beside it, and which control renders it. */
5
- interface ConfigFieldBase {
6
- key: string;
7
- label: string;
8
- type: ConfigFieldType;
9
- }
10
- /** A bounded number edited by dragging. */
11
- interface SliderField extends ConfigFieldBase {
12
- type: 'slider';
13
- default: number;
14
- min: number;
15
- max: number;
16
- step?: number;
17
- }
18
- /** A boolean. */
19
- interface CheckboxField extends ConfigFieldBase {
20
- type: 'checkbox';
21
- default: boolean;
22
- }
23
- /** One choice in a select field. */
24
- interface SelectOption {
25
- value: string;
26
- label: string;
27
- }
28
- /** A fixed set of labeled choices. */
29
- interface SelectField extends ConfigFieldBase {
30
- type: 'select';
31
- default: string;
32
- options: SelectOption[];
33
- }
34
- /** A number typed directly, optionally bounded. Use a slider field instead
35
- * when the range matters more than the exact value. */
36
- interface NumberField extends ConfigFieldBase {
37
- type: 'number';
38
- default: number;
39
- min?: number;
40
- max?: number;
41
- step?: number;
42
- }
43
- /** A free-text string. Writes are debounced so typing does not re-run the
44
- * instrument on every keystroke. */
45
- interface TextField extends ConfigFieldBase {
46
- type: 'text';
47
- default: string;
48
- placeholder?: string;
49
- maxLength?: number;
50
- /** Milliseconds to debounce live setConfig calls. Default 150 ms. Set to 0 to disable. */
51
- debounceMs?: number;
52
- }
53
- /** A color, as a CSS color string. */
54
- interface ColorField extends ConfigFieldBase {
55
- type: 'color';
56
- default: string;
57
- }
58
- /** One field of an instrument's config schema. The schema is what the control
59
- * panel renders, and what `validateConfigSchema` checks. */
60
- type ConfigField = SliderField | CheckboxField | SelectField | NumberField | TextField | ColorField;
61
-
62
- export type { ConfigField as C, NumberField as N, SelectField as S, TextField as T, CheckboxField as a, ColorField as b, ConfigFieldBase as c, ConfigFieldType as d, SelectOption as e, SliderField as f };
@@ -1,99 +0,0 @@
1
- import { a as StorageAdapter, b as LabStoreState, T as TrialRecord, c as SavedSnapshot, h as LabMode, e as UndockedPanel, C as CreateLabStoreOptions, d as TrialStateHandle } from './types-BP2OCcpg.js';
2
- import * as react from 'react';
3
- import { ReactNode, ReactElement } from 'react';
4
- import { StoreApi } from 'zustand/vanilla';
5
-
6
- /** Persist to `localStorage` — state survives a reload and a new tab. Falls
7
- * back to no-ops when storage is unavailable. */
8
- declare const localStorageAdapter: StorageAdapter;
9
- /** Persist to `sessionStorage` — state survives a reload but not a new tab. */
10
- declare const sessionStorageAdapter: StorageAdapter;
11
- /** Persist into the URL fragment, so the page's link carries its state and
12
- * can be shared or bookmarked. */
13
- declare const urlHashAdapter: StorageAdapter;
14
- /** An in-memory store, discarded on reload. For tests, and for labs that
15
- * should start fresh every time. */
16
- declare function createMemoryAdapter(): StorageAdapter;
17
- /** Persists nothing and reads back nothing. */
18
- declare const noneAdapter: StorageAdapter;
19
-
20
- /** Every mutation a lab store supports: managing trials, saving and
21
- * restoring snapshots, and setting the color mode. */
22
- interface LabStoreActions {
23
- addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
24
- removeTrial: (id: string) => void;
25
- updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
26
- /** Write one config value. `path` is dotted for a value nested under an
27
- * `f.group`, and the bare key for one at the root. */
28
- updateTrialConfig: (id: string, path: string, value: unknown) => void;
29
- updateTrialView: (id: string, view: unknown) => void;
30
- updateTrialSidebarWidth: (id: string, width: number) => void;
31
- /** Retitle a trial. `null` returns it to its instrument's name. */
32
- setTrialTitle: (id: string, title: string | null) => void;
33
- /** Fold or unfold one of a trial's sections. */
34
- setTrialSectionCollapsed: (id: string, key: string, collapsed: boolean) => void;
35
- updateTrialAnnotations: (id: string, doc: unknown) => void;
36
- updateTrialUndoStack: (id: string, next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])) => void;
37
- setTrialInstrument: (id: string, instrumentName: string) => void;
38
- saveSnapshot: (trialId: string, name: string) => void;
39
- loadSnapshot: (snapshotId: string, trialId: string) => void;
40
- deleteSnapshot: (snapshotId: string) => void;
41
- listSnapshots: (trialId?: string) => SavedSnapshot[];
42
- setMode: (mode: LabMode) => void;
43
- setLabTool: (id: string | null) => void;
44
- setTrialTool: (trialId: string, id: string | null) => void;
45
- setLayout: (layout: Record<string, unknown>) => void;
46
- /** Tear a sidebar section out of its trial. Default target is a workspace tile. */
47
- undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
48
- /** Put one section back, or — with no `sectionId` — every panel the trial owns. */
49
- dockPanel: (trialId: string, sectionId?: string) => void;
50
- }
51
- /** A lab's store: its state and its actions. How each instrument's state is
52
- * serialized comes in through `CreateLabStoreOptions.serializers`. */
53
- type LabStore = StoreApi<LabStoreState & LabStoreActions>;
54
- /** Build a lab store, hydrating from storage if anything was saved under the
55
- * same key. Writes back are debounced. */
56
- declare function createLabStore(options: CreateLabStoreOptions): LabStore;
57
-
58
- type LabStoreCtx = {
59
- store: LabStore;
60
- } | null;
61
- /** Context carrying the lab store. Prefer `useLabStore`; this is exported for
62
- * code that needs to read the context without subscribing. */
63
- declare const LabStoreContext: react.Context<LabStoreCtx>;
64
- /** Provides a lab store to its subtree. */
65
- declare function LabStoreProvider({ store, children, }: {
66
- store: LabStore;
67
- children: ReactNode;
68
- }): ReactElement;
69
- /** Subscribe to the whole lab store. Throws outside a `<LabStoreProvider>`. */
70
- declare function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>;
71
- /** Context carrying which trial the subtree belongs to. */
72
- declare const TrialIdContext: react.Context<string | null>;
73
- /** Names the trial its subtree belongs to, so an instrument's hooks can
74
- * find their own record in the store without being passed an id. */
75
- declare function TrialIdProvider({ trialId, children, }: {
76
- trialId: string;
77
- children: ReactNode;
78
- }): ReactElement;
79
- /** The id of the trial this component is inside. Throws outside a
80
- * `<TrialIdProvider>`. */
81
- declare function useTrialId(): string;
82
-
83
- /** Bumped whenever the persisted shape changes; every bump needs a migration. */
84
- declare const CURRENT_DOCUMENT_VERSION = 3;
85
- /** The one key a lab persists under. The `:doc` suffix keeps it out of the
86
- * legacy bucket namespace, where a lab named `a:saves` would otherwise write
87
- * its document over lab `a`'s saves bucket. */
88
- declare function labDocumentKey(storageKey: string): string;
89
- /** Where a document that failed to migrate is set aside, so a bad migration
90
- * loses state loudly rather than silently. */
91
- declare function quarantineKey(storageKey: string): string;
92
-
93
- /** An instrument's own state and config, plus setters. Reads the surrounding
94
- * trial id, so an instrument never has to know which trial it is
95
- * running in. Throws outside a lab store and trial. */
96
- declare function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS, TC>;
97
-
98
- export { CURRENT_DOCUMENT_VERSION as C, TrialIdContext as T, LabStoreContext as b, LabStoreProvider as c, TrialIdProvider as d, createLabStore as e, createMemoryAdapter as f, localStorageAdapter as g, useLabStore as h, useTrialId as i, useTrialState as j, labDocumentKey as l, noneAdapter as n, quarantineKey as q, sessionStorageAdapter as s, urlHashAdapter as u };
99
- export type { LabStore as L, LabStoreActions as a };
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/primitives/useRovingTabIndex.ts","../src/primitives/Toolbar.tsx","../src/primitives/fpsAverage.ts","../src/primitives/FpsMeter.tsx","../src/primitives/ScaleIndicator.tsx","../src/primitives/ZoomControl.tsx","../src/primitives/StatusBar.tsx"],"names":["useRef","useEffect","jsx","jsxs","y","f"],"mappings":"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","file":"chunk-2RQPGOQF.js","sourcesContent":["import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';\n\n/** Which axis a toolbar's arrow keys walk. Matches `aria-orientation`. */\nexport type RovingOrientation = 'horizontal' | 'vertical';\n\n/** Where a key takes focus within `count` items, or null if it does not move it.\n *\n * Only the arrows on the toolbar's own axis move focus — APG leaves the\n * cross-axis arrows to the page, so a vertical strip does not swallow\n * ArrowLeft. `Home` / `End` work on both. */\nexport function nextIndex(\n current: number,\n key: string,\n count: number,\n orientation: RovingOrientation = 'horizontal',\n): number | null {\n if (count === 0) return null;\n const forward = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';\n const back = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';\n if (key === forward) return (current + 1) % count;\n if (key === back) return (current - 1 + count) % count;\n if (key === 'Home') return 0;\n if (key === 'End') return count - 1;\n return null;\n}\n\n/**\n * The APG roving-tabindex contract: exactly one item in the tab order, arrows\n * moving focus within. `tabIndex` is written onto the DOM nodes rather than\n * threaded through props, because the items are arbitrary children — the\n * container never sees them as a list it could index.\n */\nexport function useRovingTabIndex<T extends HTMLElement>(\n orientation: RovingOrientation = 'horizontal',\n) {\n const ref = useRef<T | null>(null);\n const stop = useRef(0);\n\n const items = useCallback(\n (): HTMLElement[] =>\n ref.current\n ? Array.from(\n ref.current.querySelectorAll<HTMLElement>('button:not([disabled]), [role=\"button\"]'),\n )\n : [],\n [],\n );\n\n const setTabStop = useCallback(\n (index: number) => {\n const found = items();\n const at = Math.min(Math.max(index, 0), Math.max(found.length - 1, 0));\n stop.current = at;\n for (const [i, el] of found.entries()) el.tabIndex = i === at ? 0 : -1;\n },\n [items],\n );\n\n // Re-establish the tab stop whenever the item set changes — a trial's toolbar\n // contributions are dynamic, and `disabled` moves a button in and out of the\n // set without touching the child list. The focused item keeps the stop.\n useEffect(() => {\n const resync = () => {\n const focused = items().indexOf(document.activeElement as HTMLElement);\n setTabStop(focused === -1 ? stop.current : focused);\n };\n resync();\n if (!ref.current) return;\n const mo = new MutationObserver(resync);\n mo.observe(ref.current, { childList: true, subtree: true, attributeFilter: ['disabled'] });\n return () => mo.disconnect();\n }, [items, setTabStop]);\n\n const onKeyDown = useCallback(\n (e: KeyboardEvent<T>) => {\n const found = items();\n const current = found.indexOf(document.activeElement as HTMLElement);\n const next = nextIndex(current === -1 ? 0 : current, e.key, found.length, orientation);\n if (next === null) return;\n e.preventDefault();\n found[next]?.focus();\n setTabStop(next);\n },\n [items, setTabStop, orientation],\n );\n\n return { ref, onKeyDown };\n}\n","import type { MouseEventHandler, ReactNode } from 'react';\nimport { useRovingTabIndex } from './useRovingTabIndex';\n\n/** Props for `<Toolbar>`. */\nexport interface ToolbarProps {\n children: ReactNode;\n /** Names the toolbar for assistive tech. Required by the APG pattern when a\n * view holds more than one. */\n 'aria-label'?: string;\n}\n\n/** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,\n * `<Toolbar.Group>`, `<Toolbar.Button>` and `<Toolbar.Spacer>`. */\nexport function Toolbar({ children, 'aria-label': ariaLabel }: ToolbarProps) {\n const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();\n return (\n <div\n ref={ref}\n className=\"lk-toolbar\"\n role=\"toolbar\"\n aria-label={ariaLabel}\n onKeyDown={onKeyDown}\n >\n {children}\n </div>\n );\n}\n\ninterface TitleProps {\n children: ReactNode;\n}\nfunction Title({ children }: TitleProps) {\n return <span className=\"lk-toolbar-title\">{children}</span>;\n}\n\n/** Props for `<Toolbar.Group>`. */\nexport interface ToolbarGroupProps {\n children: ReactNode;\n /** Pushes this group, and everything after it, to the far end. */\n end?: boolean;\n 'aria-label'?: string;\n}\n\n/** Related controls, kept tight and ruled off from their neighbors. Group\n * rather than separate with repeated `<Toolbar.Spacer>`: consecutive spacers\n * share the free space, so every gap collapses to the same slack and the bar\n * reads as one undifferentiated run. */\nfunction Group({ children, end, 'aria-label': ariaLabel }: ToolbarGroupProps) {\n return (\n // biome-ignore lint/a11y/useSemanticElements: a group inside role=\"toolbar\" is the APG pattern; the rule is local and cannot see the parent. <fieldset> means form controls, needs a <legend> to be named, and its UA min-width breaks flex children.\n <div\n className={`lk-toolbar-group${end ? ' lk-toolbar-group--end' : ''}`}\n role=\"group\"\n aria-label={ariaLabel}\n >\n {children}\n </div>\n );\n}\n\n/** Props for `<Toolbar.Button>`. */\nexport interface ToolbarButtonProps {\n children: ReactNode;\n onClick: MouseEventHandler<HTMLButtonElement>;\n disabled?: boolean;\n title?: string;\n /** Required when `children` is an icon with no text of its own. */\n 'aria-label'?: string;\n /** `danger` reddens on hover — for actions that discard work. */\n variant?: 'default' | 'danger';\n /** Square, for a button whose whole content is one glyph. */\n iconOnly?: boolean;\n /** Makes this a toggle: `aria-pressed`, and held down while true. */\n pressed?: boolean;\n}\n\nfunction Button({\n children,\n onClick,\n disabled,\n title,\n 'aria-label': ariaLabel,\n variant = 'default',\n iconOnly,\n pressed,\n}: ToolbarButtonProps) {\n const cls = [\n 'lk-toolbar-button',\n variant === 'danger' && 'lk-toolbar-button--danger',\n iconOnly && 'lk-toolbar-button--icon',\n pressed && 'lk-toolbar-button--pressed',\n ]\n .filter(Boolean)\n .join(' ');\n return (\n <button\n type=\"button\"\n className={cls}\n onClick={onClick}\n disabled={disabled}\n title={title}\n aria-label={ariaLabel ?? title}\n aria-pressed={pressed}\n >\n {children}\n </button>\n );\n}\n\nfunction Spacer() {\n return <span className=\"lk-toolbar-spacer\" aria-hidden=\"true\" />;\n}\n\n/** A label within a toolbar. */\nToolbar.Title = Title;\n/** A ruled-off run of related controls. */\nToolbar.Group = Group;\n/** A button within a toolbar. */\nToolbar.Button = Button;\n/** Flexible space that pushes what follows to the far end of the toolbar. */\nToolbar.Spacer = Spacer;\n","export function rollingAverage(samples: readonly number[]): number {\n if (samples.length === 0) return 0;\n const sum = samples.reduce((acc, n) => acc + n, 0);\n return Math.round(sum / samples.length);\n}\n","import { useVisibleRaf } from '@weasel-js/core';\nimport { useEffect, useRef, useState } from 'react';\nimport { rollingAverage } from './fpsAverage';\n\nconst SAMPLE_WINDOW = 30;\n\n/** A live frame-rate readout, averaged over the last 30 frames. */\nexport function FpsMeter() {\n const [fps, setFps] = useState(0);\n const samplesRef = useRef<number[]>([]);\n const lastTimeRef = useRef<number | null>(null);\n const rootRef = useRef<HTMLDivElement | null>(null);\n\n const loop = useVisibleRaf(\n (time) => {\n const last = lastTimeRef.current;\n if (last !== null) {\n const delta = time - last;\n if (delta > 0) {\n const samples = samplesRef.current;\n samples.push(1000 / delta);\n if (samples.length > SAMPLE_WINDOW) samples.shift();\n setFps(rollingAverage(samples));\n }\n }\n lastTimeRef.current = time;\n loop.request();\n },\n {\n target: rootRef,\n // The gap spent suspended is not a frame that took an hour to draw.\n onResume: () => {\n lastTimeRef.current = null;\n },\n },\n );\n\n useEffect(() => {\n loop.request();\n return () => loop.cancel();\n }, [loop]);\n\n return (\n <div className=\"lk-fps-meter\" ref={rootRef}>\n <span className=\"lk-fps-meter-value\">FPS {fps}</span>\n </div>\n );\n}\n","import { useContext } from 'react';\nimport { CanvasStackContext } from '../canvas/CanvasStackContext';\n\n/** Props for `<ScaleIndicator>`. */\nexport interface ScaleIndicatorProps {\n /** Current view zoom factor. If omitted, reads from CanvasStackContext. Defaults to 1. */\n zoom?: number;\n /** World pixels per unit at zoom=1. Default: 1. */\n pixelsPerUnit?: number;\n /** Unit label. Default: 'px'. */\n unit?: string;\n /** Target bar width in display pixels. Default: 100. */\n targetWidth?: number;\n}\n\n// Rounds n to a \"nice\" number — 1, 2, 5, 10, 20, 50, ...\nfunction niceNumber(n: number): number {\n if (n <= 0) return 1;\n const exp = Math.floor(Math.log10(n));\n const fraction = n / 10 ** exp;\n let nice: number;\n if (fraction < 1.5) nice = 1;\n else if (fraction < 3.5) nice = 2;\n else if (fraction < 7.5) nice = 5;\n else nice = 10;\n return nice * 10 ** exp;\n}\n\n/** A scale bar: a labeled rule showing how far a round number of world units\n * is at the current zoom. Reads the zoom from the surrounding canvas stack\n * unless one is passed. */\nexport function ScaleIndicator({\n zoom,\n pixelsPerUnit = 1,\n unit = 'px',\n targetWidth = 100,\n}: ScaleIndicatorProps) {\n const ctxValue = useContext(CanvasStackContext);\n const effectiveZoom = zoom ?? ctxValue?.view.zoom ?? 1;\n const effectivePxPerUnit = pixelsPerUnit * effectiveZoom;\n const targetUnits = targetWidth / effectivePxPerUnit;\n const niceUnits = niceNumber(targetUnits);\n const barWidth = niceUnits * effectivePxPerUnit;\n return (\n <div className=\"lk-scale-indicator\" role=\"img\" aria-label={`Scale: ${niceUnits} ${unit}`}>\n <div className=\"lk-scale-indicator-bar\" style={{ width: `${barWidth}px` }} />\n <span className=\"lk-scale-indicator-label\">\n {niceUnits} {unit}\n </span>\n </div>\n );\n}\n","import { NumberField, Slider } from '../passthrough/weasel-ui';\n\n/** Props for `<ZoomControl>`. */\nexport interface ZoomControlProps {\n /** Current zoom as a scale factor: 1 is actual size. */\n zoom: number;\n /** Fired continuously while the slider drags. */\n onZoomChange: (zoom: number) => void;\n /** Fired when a drag settles or the field is committed. Defaults to\n * `onZoomChange`. */\n onZoomCommit?: (zoom: number) => void;\n /** Smallest selectable zoom. Defaults to 0.1 (10%). */\n min?: number;\n /** Largest selectable zoom. Defaults to 8 (800%). */\n max?: number;\n className?: string;\n}\n\n// Zoom is perceptually geometric — halving is as big a step as doubling — so\n// the slider runs on log2 and 100% lands mid-track for a symmetric range.\nconst toTrack = (zoom: number): number => Math.log2(zoom);\nconst fromTrack = (v: number): number => 2 ** v;\n\nconst clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));\n\n/** Zoom as a log-scaled slider with an editable percentage beside it. Pair it\n * with zoom-in / zoom-out buttons in a `<Toolbar.Group>`; this component owns\n * only the continuous part. */\nexport function ZoomControl({\n zoom,\n onZoomChange,\n onZoomCommit,\n min = 0.1,\n max = 8,\n className,\n}: ZoomControlProps) {\n const commit = onZoomCommit ?? onZoomChange;\n const emit = (track: number, done: boolean): void => {\n const next = clamp(fromTrack(track), min, max);\n (done ? commit : onZoomChange)(next);\n };\n\n return (\n <div className={`lk-zoom${className ? ` ${className}` : ''}`}>\n <Slider\n className=\"lk-zoom__slider\"\n ariaLabel=\"Zoom\"\n density=\"slim\"\n min={toTrack(min)}\n max={toTrack(max)}\n // Detents at the octaves plus actual size, so 25/50/100/200/400% are\n // reachable by drag rather than only by typing.\n stops={[-2, -1, 0, 1, 2]}\n thumbs={[{ value: clamp(toTrack(zoom), toTrack(min), toTrack(max)) }]}\n onInput={(next) => emit(next[0].value, false)}\n onChange={(next) => emit(next[0].value, true)}\n readoutPlacement=\"none\"\n />\n {/* `style: 'percent'` formats a fraction, so the field carries the scale\n factor itself and renders 0.5 as \"50%\". */}\n <NumberField\n className=\"lk-zoom__field\"\n width=\"fit\"\n aria-label=\"Zoom\"\n value={zoom}\n minValue={min}\n maxValue={max}\n step={0.01}\n hideSteppers\n ghost\n formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}\n onChange={(next) => {\n if (!Number.isFinite(next)) return;\n commit(clamp(next, min, max));\n }}\n />\n </div>\n );\n}\n","import type { ReactNode } from 'react';\n\n/** Props for `<StatusBar>`. */\nexport interface StatusBarProps {\n children: ReactNode;\n}\n\n/** A footer strip for readouts. Fill it with `<StatusBar.Section>`. */\nexport function StatusBar({ children }: StatusBarProps) {\n return <div className=\"lk-status-bar\">{children}</div>;\n}\n\n/** Props for `<StatusBar.Section>`. */\nexport interface StatusBarSectionProps {\n children: ReactNode;\n /** Pushes this section, and everything after it, to the far end. */\n end?: boolean;\n}\nfunction Section({ children, end }: StatusBarSectionProps) {\n return (\n <span className={`lk-status-bar-section${end ? ' lk-status-bar-section--end' : ''}`}>\n {children}\n </span>\n );\n}\n\n/** One readout within a status bar. */\nStatusBar.Section = Section;\n"]}