@weasel-js/labkit 1.6.0 → 1.7.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 (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
@@ -0,0 +1,137 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import '@weasel-js/theme/fonts.css';
3
+ import '../styles.less';
4
+ import { cleanup, render } from '@testing-library/react';
5
+ import { afterEach, expect, test } from 'vitest';
6
+ import { Readout, type ReadoutRow } from './Readout';
7
+
8
+ function must<T>(el: T | null): T {
9
+ if (el == null) throw new Error('expected an element');
10
+ return el;
11
+ }
12
+
13
+ // Right alignment, the figure column's minimum width and a height that holds
14
+ // while values come and go are all layout, which only a real browser has.
15
+
16
+ afterEach(cleanup);
17
+
18
+ const FIGURE_SPACE = ' ';
19
+
20
+ const rows = (withValues: boolean): ReadoutRow[] => [
21
+ {
22
+ label: 'Lock margin',
23
+ value: withValues ? <span data-v>locked +47.9°</span> : undefined,
24
+ status: 'success',
25
+ },
26
+ { label: 'Score', value: withValues ? <span data-v>{`${FIGURE_SPACE}0.145`}</span> : undefined },
27
+ { label: 'Error', value: withValues ? <span data-v>12.345</span> : undefined, status: 'danger' },
28
+ ];
29
+
30
+ const box = (el: Element) => el.getBoundingClientRect();
31
+
32
+ test('values sit right-aligned at the edge of the list', () => {
33
+ const { container } = render(
34
+ <div style={{ width: 320 }}>
35
+ <Readout rows={rows(true)} />
36
+ </div>,
37
+ );
38
+ const dl = must(container.querySelector('dl'));
39
+ const values = [...container.querySelectorAll('[data-v]')];
40
+ expect(values).toHaveLength(3);
41
+ for (const v of values) expect(Math.abs(box(v).right - box(dl).right)).toBeLessThan(0.5);
42
+ expect(box(values[0]).width).not.toBeCloseTo(box(values[2]).width, 0);
43
+ });
44
+
45
+ test('figures are equal width, so padding lines a column up on the decimal point', async () => {
46
+ // 1 and 8 differ in width wherever digits are proportional, as they are in
47
+ // the theme's display face, which has no tabular figures to switch on.
48
+ const { container } = render(
49
+ <div style={{ width: 320, fontFamily: 'var(--wzl-font-display)' }}>
50
+ <Readout
51
+ rows={[
52
+ { label: 'a', value: <span data-v>{`${FIGURE_SPACE}1.111`}</span> },
53
+ { label: 'b', value: <span data-v>88.888</span> },
54
+ ]}
55
+ />
56
+ </div>,
57
+ );
58
+ await document.fonts.ready;
59
+ const [narrow, wide] = [...container.querySelectorAll('[data-v]')];
60
+ expect(Math.abs(box(narrow).width - box(wide).width)).toBeLessThan(0.5);
61
+ });
62
+
63
+ test('the value column holds a minimum width in a shrink-wrapped readout', () => {
64
+ const { container } = render(
65
+ <div style={{ width: 'max-content' }}>
66
+ <Readout rows={[{ label: 'n', value: <span data-v>1</span> }]} />
67
+ </div>,
68
+ );
69
+ const digit = box(must(container.querySelector('[data-v]'))).width;
70
+ const dd = box(must(container.querySelector('dd'))).width;
71
+ expect(dd).toBeGreaterThanOrEqual(8 * digit - 0.5);
72
+ });
73
+
74
+ test('rows keep their place and the list its height while values are absent', () => {
75
+ const { container, rerender } = render(
76
+ <div style={{ width: 320 }}>
77
+ <Readout rows={rows(true)} />
78
+ </div>,
79
+ );
80
+ const dl = () => must(container.querySelector('dl'));
81
+ const rowHeights = () => [...dl().children].map((r) => box(r).height);
82
+ const withValues = { list: box(dl()).height, rows: rowHeights() };
83
+ rerender(
84
+ <div style={{ width: 320 }}>
85
+ <Readout rows={rows(false)} />
86
+ </div>,
87
+ );
88
+ expect(dl().querySelectorAll('[data-empty]')).toHaveLength(3);
89
+ expect(box(dl()).height).toBeCloseTo(withValues.list, 1);
90
+ expect(rowHeights()).toEqual(withValues.rows);
91
+ });
92
+
93
+ test('a status dot is drawn before its value, on the same line', () => {
94
+ const { container } = render(
95
+ <div style={{ width: 320 }}>
96
+ <Readout rows={rows(true)} />
97
+ </div>,
98
+ );
99
+ const dd = must(container.querySelector('dd'));
100
+ const dot = box(must(dd.querySelector('[aria-hidden]')));
101
+ const value = box(must(dd.querySelector('[data-v]')));
102
+ expect(dot.width).toBeGreaterThan(0);
103
+ expect(dot.right).toBeLessThanOrEqual(value.left);
104
+ expect(dot.top).toBeGreaterThanOrEqual(value.top - 1);
105
+ expect(dot.bottom).toBeLessThanOrEqual(value.bottom + 1);
106
+ });
107
+
108
+ test('a text row wraps inside a figures readout instead of overflowing it', () => {
109
+ const { container } = render(
110
+ <div style={{ width: 300, fontFamily: 'var(--wzl-font-display)' }}>
111
+ <Readout
112
+ rows={[
113
+ { label: 'Score', value: <span data-v>0.145</span> },
114
+ {
115
+ label: 'Sides',
116
+ value: (
117
+ <span data-t>
118
+ 1 flat, 2 tab, 3 blank, 4 blank, 5 flat, 6 tab, 7 blank, 8 blank, 9 flat
119
+ </span>
120
+ ),
121
+ values: 'text',
122
+ },
123
+ ]}
124
+ />
125
+ </div>,
126
+ );
127
+ const dl = must(container.querySelector('dl'));
128
+ expect(dl.scrollWidth).toBeLessThanOrEqual(dl.clientWidth);
129
+ const text = must(container.querySelector('[data-t]'));
130
+ expect(box(text).right).toBeLessThanOrEqual(box(dl).right + 0.5);
131
+ expect(box(text).height).toBeGreaterThan(
132
+ box(must(container.querySelector('[data-v]'))).height * 1.5,
133
+ );
134
+ expect(
135
+ Math.abs(box(must(container.querySelector('[data-v]'))).right - box(dl).right),
136
+ ).toBeLessThan(0.5);
137
+ });
@@ -0,0 +1,12 @@
1
+ .lk-readout {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--wzl-space-4);
5
+ min-width: 0;
6
+ }
7
+
8
+ .lk-readout__footer {
9
+ min-width: 0;
10
+ font-size: var(--wzl-font-size-sm);
11
+ color: var(--wzl-fg-muted);
12
+ }
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { useState } from 'react';
3
+ import { Readout } from './Readout';
4
+
5
+ const meta: Meta<typeof Readout> = {
6
+ title: 'labkit/Primitives/Readout',
7
+ component: Readout,
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof Readout>;
12
+
13
+ const FIGURE_SPACE = ' ';
14
+
15
+ export const Measurements: Story = {
16
+ tags: ['gallery'],
17
+ args: {
18
+ rows: [
19
+ { label: 'Lock margin', value: 'locked +47.9°', status: 'success' },
20
+ { label: 'Score', value: `${FIGURE_SPACE}0.145` },
21
+ { label: 'Residual', value: `${FIGURE_SPACE}3.020` },
22
+ { label: 'Overshoot', value: 'over 12.345', status: 'danger' },
23
+ ],
24
+ },
25
+ };
26
+
27
+ export const WithTitleAndNote: Story = {
28
+ args: {
29
+ title: 'Fit',
30
+ rows: [
31
+ { label: 'Score', value: '0.145' },
32
+ { label: 'Iterations', value: `${FIGURE_SPACE}${FIGURE_SPACE}42` },
33
+ ],
34
+ children: 'Scores under 0.2 converge within one pass.',
35
+ },
36
+ };
37
+
38
+ /** Values vanish while the cursor is off the picture; the rows hold their
39
+ * place and the readout its height. */
40
+ export const ValuesComeAndGo: Story = {
41
+ render: () => {
42
+ const [on, setOn] = useState(true);
43
+ return (
44
+ <div>
45
+ <button type="button" onClick={() => setOn((v) => !v)}>
46
+ {on ? 'Hide values' : 'Show values'}
47
+ </button>
48
+ <Readout
49
+ rows={[
50
+ { label: 'x', value: on ? `${FIGURE_SPACE}12.50` : undefined },
51
+ { label: 'y', value: on ? '-3.75' : undefined },
52
+ { label: 'Hit', value: on ? 'inside' : undefined, status: on ? 'success' : undefined },
53
+ ]}
54
+ />
55
+ </div>
56
+ );
57
+ },
58
+ };
@@ -0,0 +1,93 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import * as labkit from '../index';
4
+ import { Readout } from './Readout';
5
+
6
+ function must<T>(el: T | null): T {
7
+ if (el == null) throw new Error('expected an element');
8
+ return el;
9
+ }
10
+
11
+ describe('Readout', () => {
12
+ it('renders its rows as one <dl>, in order, each a label and a value', () => {
13
+ const { container } = render(
14
+ <Readout
15
+ rows={[
16
+ { label: 'Lock margin', value: '+47.9°' },
17
+ { label: 'Score', value: '0.145' },
18
+ ]}
19
+ />,
20
+ );
21
+ const dl = must(container.querySelector('dl'));
22
+ expect([...dl.querySelectorAll('dt')].map((d) => d.textContent)).toEqual([
23
+ 'Lock margin',
24
+ 'Score',
25
+ ]);
26
+ expect([...dl.querySelectorAll('dd')].map((d) => d.textContent)).toEqual(['+47.9°', '0.145']);
27
+ });
28
+
29
+ it('sets its values as figures', () => {
30
+ const { container } = render(<Readout rows={[{ label: 'a', value: '1' }]} />);
31
+ expect(must(container.querySelector('dl')).getAttribute('data-values')).toBe('figures');
32
+ });
33
+
34
+ it('keeps a row whose value is absent, showing a dash', () => {
35
+ const { container } = render(
36
+ <Readout
37
+ rows={[
38
+ { label: 'Lock margin', value: undefined },
39
+ { label: 'Score', value: '0.145' },
40
+ ]}
41
+ />,
42
+ );
43
+ expect(container.querySelectorAll('dl > div')).toHaveLength(2);
44
+ expect(must(container.querySelector('dd')).textContent).toBe('–');
45
+ });
46
+
47
+ it('marks a row with its status', () => {
48
+ const { container } = render(
49
+ <Readout rows={[{ label: 'Lock', value: 'locked', status: 'success' }]} />,
50
+ );
51
+ expect(must(container.querySelector('dl > div')).getAttribute('data-status')).toBe('success');
52
+ });
53
+
54
+ it('puts its children below the rows', () => {
55
+ const { container } = render(
56
+ <Readout rows={[{ label: 'a', value: '1' }]}>
57
+ <p>note</p>
58
+ </Readout>,
59
+ );
60
+ const root = must(container.firstElementChild);
61
+ expect(root.className).toContain('lk-readout');
62
+ const note = screen.getByText('note');
63
+ expect(
64
+ must(root.querySelector('dl')).compareDocumentPosition(note) &
65
+ Node.DOCUMENT_POSITION_FOLLOWING,
66
+ ).toBeTruthy();
67
+ expect(note.closest('dl')).toBeNull();
68
+ });
69
+
70
+ it('draws no footer without children', () => {
71
+ const { container } = render(<Readout rows={[{ label: 'a', value: '1' }]} />);
72
+ expect(container.querySelector('.lk-readout__footer')).toBeNull();
73
+ });
74
+
75
+ it('names the list by its title', () => {
76
+ render(<Readout title="Measure" rows={[{ label: 'a', value: '1' }]} />);
77
+ const heading = screen.getByRole('heading', { name: 'Measure' });
78
+ expect(
79
+ must(must(heading.parentElement).querySelector('dl')).getAttribute('aria-labelledby'),
80
+ ).toBe(heading.id);
81
+ });
82
+
83
+ it('merges a consumer className on its root', () => {
84
+ const { container } = render(<Readout className="mine" rows={[]} />);
85
+ expect(must(container.firstElementChild).className).toMatch(/\blk-readout\b.*\bmine\b/);
86
+ });
87
+
88
+ it('is exported from the labkit barrel, with the list it is built on', () => {
89
+ expect(labkit.Readout).toBe(Readout);
90
+ expect(typeof labkit.DetailList).toBe('function');
91
+ expect(typeof labkit.DetailRow).toBe('function');
92
+ });
93
+ });
@@ -0,0 +1,56 @@
1
+ import { DetailList, type DetailListValues, DetailRow, type DetailRowStatus } from '@weasel-js/ui';
2
+ import type { ReactNode } from 'react';
3
+
4
+ /** One measurement in a {@link Readout}. */
5
+ export interface ReadoutRow {
6
+ /** React key. Defaults to `label` when that is a string, else the row's index. */
7
+ key?: string;
8
+ label: ReactNode;
9
+ /** `null` or `undefined` keeps the row in place showing an en dash, so the
10
+ * readout holds its height while a measurement comes and goes. */
11
+ value?: ReactNode;
12
+ /** A dot in the status color before the value. Color is never the only
13
+ * signal: the value has to say the same thing in words. */
14
+ status?: DetailRowStatus;
15
+ /** `text` sets this row's value as wrapping prose instead of a figure. */
16
+ values?: DetailListValues;
17
+ }
18
+
19
+ /** Props for {@link Readout}. */
20
+ export interface ReadoutProps {
21
+ rows: readonly ReadoutRow[];
22
+ /** Heading above the rows, which also names the list. */
23
+ title?: ReactNode;
24
+ /** Drawn below the rows — a small table, a note. */
25
+ children?: ReactNode;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * Values a lab only reads — measurements shown beside its picture, such as
31
+ * "Lock margin +47.9°". A {@link DetailList} set as figures: labels take the
32
+ * params label recipe and `--wzl-params-label-width` rail, so a readout lines
33
+ * up with a `ControlPanel` beside it, and values sit right-aligned in the mono
34
+ * face with spaces kept, so figure-space padding aligns a column on its
35
+ * decimal point. Rows compose by hand as `DetailRow`s inside a `DetailList`
36
+ * with `values="figures"`.
37
+ */
38
+ export function Readout({ rows, title, children, className }: ReadoutProps) {
39
+ return (
40
+ <div className={className ? `lk-readout ${className}` : 'lk-readout'}>
41
+ <DetailList title={title} values="figures">
42
+ {rows.map((row, i) => (
43
+ <DetailRow
44
+ key={row.key ?? (typeof row.label === 'string' ? row.label : i)}
45
+ label={row.label}
46
+ status={row.status}
47
+ values={row.values}
48
+ >
49
+ {row.value}
50
+ </DetailRow>
51
+ ))}
52
+ </DetailList>
53
+ {children != null && <div className="lk-readout__footer">{children}</div>}
54
+ </div>
55
+ );
56
+ }
@@ -5,6 +5,8 @@ export type { JobProgressProps } from './JobProgress';
5
5
  export { JobProgress } from './JobProgress';
6
6
  export type { LegendEntry, LegendMark, LegendProps } from './Legend';
7
7
  export { Legend } from './Legend';
8
+ export type { ReadoutProps, ReadoutRow } from './Readout';
9
+ export { Readout } from './Readout';
8
10
  export type { ScaleIndicatorProps } from './ScaleIndicator';
9
11
  export { ScaleIndicator } from './ScaleIndicator';
10
12
  export type { SplitProps } from './Split';
@@ -3,6 +3,7 @@ import { Specimen } from './Specimen';
3
3
 
4
4
  const meta: Meta<typeof Specimen> = {
5
5
  title: 'labkit/Specimen',
6
+ tags: ['gallery'],
6
7
  component: Specimen,
7
8
  parameters: { layout: 'fullscreen' },
8
9
  };
@@ -1,4 +1,4 @@
1
- import type { FillStyle, GradientFill, Track } from '@weasel-js/core';
1
+ import type { FillStyle, Track } from '@weasel-js/core';
2
2
  import {
3
3
  Badge,
4
4
  type Band,
@@ -321,7 +321,7 @@ function FieldsAndPickers() {
321
321
  const [rate, setRate] = useState(1);
322
322
  const [width, setWidth] = useState(6);
323
323
  const [color, setColor] = useState('#b08adbff');
324
- const [gradient, setGradient] = useState<GradientFill>({
324
+ const [gradient, setGradient] = useState<FillStyle>({
325
325
  fill: 'linear-gradient',
326
326
  from: { x: 0.1, y: 0.1 },
327
327
  to: { x: 0.9, y: 0.9 },
@@ -468,7 +468,15 @@ function PanelsAndRows() {
468
468
  onAlphaChange={setAlpha}
469
469
  />
470
470
  <PropertyField kind="color" label="Stroke" value={stroke} onChange={setStroke} />
471
- <PropertyField kind="number" label="Count" value={count} min={0} max={100} step={1} onChange={setCount} />
471
+ <PropertyField
472
+ kind="number"
473
+ label="Count"
474
+ value={count}
475
+ min={0}
476
+ max={100}
477
+ step={1}
478
+ onChange={setCount}
479
+ />
472
480
  <PropertyField
473
481
  kind="enum"
474
482
  label="Line cap"
@@ -494,7 +502,16 @@ function PanelsAndRows() {
494
502
  <PropertyField kind="string" label="Label" value={label} onChange={setLabel} />
495
503
  <PropertyField kind="boolean" label="Visible" value={visible} onChange={setVisible} />
496
504
  <PropertyGroup title="Bevel" defaultCollapsed>
497
- <PropertyField kind="number" control="slider" label="Rings" value={32} min={4} max={96} step={1} onChange={() => {}} />
505
+ <PropertyField
506
+ kind="number"
507
+ control="slider"
508
+ label="Rings"
509
+ value={32}
510
+ min={4}
511
+ max={96}
512
+ step={1}
513
+ onChange={() => {}}
514
+ />
498
515
  </PropertyGroup>
499
516
  </PropertyList>
500
517
  </PropertyPanel>
@@ -0,0 +1,47 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { afterEach, expect, test } from 'vitest';
3
+ import { createIndexedDbAdapter } from './adapters';
4
+ import { Persistence } from './Persistence';
5
+ import { usePersistedState } from './usePersistedState';
6
+
7
+ // jsdom only has fake-indexeddb, an imitation; this goes through the browser's own.
8
+
9
+ afterEach(cleanup);
10
+
11
+ function Tab() {
12
+ const [tab, setTab] = usePersistedState('tab', 'shape');
13
+ return (
14
+ <p>
15
+ <output aria-label="tab">{tab}</output>
16
+ <button type="button" onClick={() => setTab('color')}>
17
+ choose color
18
+ </button>
19
+ </p>
20
+ );
21
+ }
22
+
23
+ const mount = (storage: ReturnType<typeof createIndexedDbAdapter>) =>
24
+ render(
25
+ <Persistence storageKey="test" storage={storage} fallback={<p>loading</p>}>
26
+ <Tab />
27
+ </Persistence>,
28
+ );
29
+
30
+ test('a value survives a remount through real IndexedDB', async () => {
31
+ const database = `labkit-browser-test-${Date.now()}`;
32
+
33
+ const first = mount(createIndexedDbAdapter({ database }));
34
+ await waitFor(() => expect(screen.getByLabelText('tab').textContent).toBe('shape'));
35
+ fireEvent.click(screen.getByText('choose color'));
36
+ await waitFor(() => expect(screen.getByLabelText('tab').textContent).toBe('color'));
37
+
38
+ // Unmounting closes the provider, whose deferred close sends the queued write.
39
+ first.unmount();
40
+ await new Promise((r) => setTimeout(r, 100));
41
+
42
+ // A fresh adapter, so nothing it reads can come from the first one's memory.
43
+ mount(createIndexedDbAdapter({ database }));
44
+ await waitFor(() => expect(screen.getByLabelText('tab').textContent).toBe('color'), {
45
+ timeout: 3000,
46
+ });
47
+ });
@@ -96,7 +96,10 @@ describe('documents and records', () => {
96
96
  ['trial:z', { instrumentName: 'T', order: 0 }],
97
97
  ['trial:m', { instrumentName: 'T', order: 0 }],
98
98
  ]);
99
- expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
99
+ expect((joined?.doc.trials as { id: string }[] | undefined)?.map((t) => t.id)).toEqual([
100
+ 'm',
101
+ 'z',
102
+ ]);
100
103
  });
101
104
 
102
105
  it('is null without a meta record', () => {
@@ -84,7 +84,7 @@ describe('updateTrialState', () => {
84
84
  s.getState().updateTrialState('w1', (prev: unknown) => ({
85
85
  n: (prev as { n: number }).n + 1,
86
86
  }));
87
- expect((s.getState().trials[0]?.state as { n: number }).n).toBe(2);
87
+ expect((s.getState().trials[0]?.state as { n: number } | undefined)?.n).toBe(2);
88
88
  });
89
89
  });
90
90
 
@@ -99,8 +99,8 @@ describe('updateTrialConfig', () => {
99
99
  view: { zoom: 1, pan: { x: 0, y: 0 } },
100
100
  });
101
101
  s.getState().updateTrialConfig('w1', 'x', 99);
102
- expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
103
- expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
102
+ expect((s.getState().trials[0]?.config as { x: number } | undefined)?.x).toBe(99);
103
+ expect((s.getState().trials[0]?.config as { y: number } | undefined)?.y).toBe(2);
104
104
  });
105
105
 
106
106
  it('writes down a dotted path without disturbing its siblings', () => {
@@ -161,7 +161,7 @@ describe('save/load/delete snapshots', () => {
161
161
  s.getState().updateTrialState('w1', { n: 99 });
162
162
  const snapId = s.getState().savedSnapshots[0]?.id ?? '';
163
163
  s.getState().loadSnapshot(snapId, 'w1');
164
- expect((s.getState().trials[0]?.state as { n: number }).n).toBe(5);
164
+ expect((s.getState().trials[0]?.state as { n: number } | undefined)?.n).toBe(5);
165
165
  });
166
166
 
167
167
  it('loadSnapshot restores the auto paths the trial had when saved', () => {
@@ -420,7 +420,11 @@ describe('setInstruments', () => {
420
420
  store.getState().addTrial(mappedTrial);
421
421
  store.getState().setInstruments([{ ...small, name: 'Mapped' }]);
422
422
  store.getState().saveSnapshot('m', 'one');
423
- expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: new Set(['a']) });
423
+ // By tag, not `toEqual(new Set(…))`: under jsdom structuredClone hands back Node's Set.
424
+ const seen = (store.getState().savedSnapshots[0]?.state as { seen: Set<string> } | undefined)
425
+ ?.seen;
426
+ expect(Object.prototype.toString.call(seen)).toBe('[object Set]');
427
+ expect([...seen!]).toEqual(['a']);
424
428
  });
425
429
 
426
430
  it('changes neither hooks nor state when a new default config throws', () => {
@@ -109,7 +109,7 @@ describe('usePersistedState', () => {
109
109
  );
110
110
  await screen.findByLabelText('a');
111
111
  act(() => screen.getByText('set a').click());
112
- expect(shown('b')).toBe('color');
112
+ await waitFor(() => expect(shown('b')).toBe('color'));
113
113
  });
114
114
 
115
115
  it("re-renders with another tab's write", async () => {
@@ -173,7 +173,7 @@ describe('auto paths', () => {
173
173
  store.getState().updateTrialConfig('w1', 'gap', 30);
174
174
  const rec = store.getState().trials[0];
175
175
  expect(rec?.auto).toBeUndefined();
176
- expect((rec?.config as { gap: number }).gap).toBe(30);
176
+ expect((rec?.config as { gap: number } | undefined)?.gap).toBe(30);
177
177
  });
178
178
 
179
179
  it('does not allocate a new record when the path is already auto', () => {
package/src/styles.less CHANGED
@@ -14,6 +14,7 @@
14
14
  @import './primitives/FpsMeter.less';
15
15
  @import './primitives/ScaleIndicator.less';
16
16
  @import './primitives/Legend.less';
17
+ @import './primitives/Readout.less';
17
18
  @import './primitives/FloatingPanel.less';
18
19
  @import './lab/Lab.less';
19
20
  @import './trial/Trial.less';
@@ -23,6 +24,8 @@
23
24
  @import './controls/ControlPanel.less';
24
25
  @import './canvas/CanvasStack.less';
25
26
  @import './canvas/Stage.less';
27
+ @import './canvas/LinkedCursor.less';
28
+ @import './overview/TrialOverview.less';
26
29
  @import './loupe/Loupe.less';
27
30
  @import './annotations/Annotations.less';
28
31
  @import './dragdrop/Palette.less';
@@ -1,15 +1,13 @@
1
1
  import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import type { TokenName } from '@weasel-js/theme';
3
3
  import { useTheme } from '@weasel-js/theme/react';
4
- import {
5
- PropertyField,
6
- PropertyList,
7
- PropertyPanel,
8
- } from '@weasel-js/ui';
4
+ import { PropertyField, PropertyList, PropertyPanel } from '@weasel-js/ui';
9
5
  import './Interstellar.stories.less';
6
+ import { decimal } from '@weasel-js/quantity';
10
7
 
11
8
  const meta: Meta = {
12
9
  title: 'labkit/Themes/Interstellar',
10
+ tags: ['gallery'],
13
11
  parameters: {
14
12
  docs: {
15
13
  description: {
@@ -154,7 +152,7 @@ function LivePanelPreview() {
154
152
  max={1}
155
153
  step={0.01}
156
154
  onChange={() => {}}
157
- format={(v) => v.toFixed(2)}
155
+ display={decimal({ places: 2 })}
158
156
  />
159
157
  <PropertyField kind="color" label="Fill" value="#b08adb" onChange={() => {}} />
160
158
  <PropertyField kind="color" label="Stroke" value="#1a1428" onChange={() => {}} />