@weasel-js/labkit 1.2.0 → 1.3.0-pre.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 (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -0,0 +1,190 @@
1
+ import type React from 'react';
2
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ type Corner,
5
+ DEFAULT_ANCHOR,
6
+ DEFAULT_INSET,
7
+ FLOATING_DRAG_PREFIX,
8
+ type FloatingPlacement,
9
+ floatingStrategy,
10
+ } from 'windease';
11
+
12
+ /** The strategy holds a map keyed by item id; a panel is always a lone item. */
13
+ const ITEM_ID = 'panel';
14
+
15
+ /** Props for `<FloatingPanel>`. */
16
+ export interface FloatingPanelProps {
17
+ children: ReactNode;
18
+ /** Corner it rests in before it has ever been dragged. Default `'bottom-left'`. */
19
+ anchor?: Corner;
20
+ /** Corners that may capture it. Default: all four. */
21
+ snapCorners?: readonly Corner[];
22
+ /** Pixels in from a corner when snapped. Default 12. */
23
+ inset?: number;
24
+ /** localStorage key to remember its position under. Omit to forget on reload. */
25
+ storageKey?: string;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * The panel's footprint is its border box — `contentRect` excludes padding and
31
+ * border, which would place a panel that then overflows its corner by exactly
32
+ * that much.
33
+ */
34
+ function borderBoxOf(entry: ResizeObserverEntry): { w: number; h: number } {
35
+ const box = entry.borderBoxSize?.[0];
36
+ if (box) return { w: box.inlineSize, h: box.blockSize };
37
+ return { w: entry.contentRect.width, h: entry.contentRect.height };
38
+ }
39
+
40
+ /** A pointerdown on one of these is the child's, not a drag. */
41
+ function isInteractive(target: EventTarget | null): boolean {
42
+ if (!(target instanceof Element)) return false;
43
+ return target.closest('input, button, a, select, textarea, [data-no-drag]') !== null;
44
+ }
45
+
46
+ function readStored(key: string | undefined): FloatingPlacement | null {
47
+ if (!key) return null;
48
+ try {
49
+ const raw = localStorage.getItem(key);
50
+ return raw ? (JSON.parse(raw) as FloatingPlacement) : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * A draggable box that floats over its offset parent and snaps to its corners.
58
+ *
59
+ * Drives windease's `floatingStrategy` as a pure function rather than mounting a
60
+ * windease container: a lab overlay has one item and no zone tree, so the node
61
+ * model would be all cost. Pointer handling is therefore this component's, and
62
+ * windease's own affordance rendering never participates.
63
+ */
64
+ export function FloatingPanel({
65
+ children,
66
+ anchor = DEFAULT_ANCHOR,
67
+ snapCorners,
68
+ inset = DEFAULT_INSET,
69
+ storageKey,
70
+ className,
71
+ }: FloatingPanelProps) {
72
+ const ref = useRef<HTMLDivElement>(null);
73
+ const strategy = useMemo(() => floatingStrategy(), []);
74
+ const options = useMemo(() => ({ defaultAnchor: anchor, inset }), [anchor, inset]);
75
+
76
+ const [container, setContainer] = useState({ w: 0, h: 0 });
77
+ const [size, setSize] = useState({ w: 0, h: 0 });
78
+ const [place, setPlace] = useState<FloatingPlacement>(
79
+ () => readStored(storageKey) ?? { x: 0, y: 0, anchor },
80
+ );
81
+
82
+ useEffect(() => {
83
+ if (!storageKey) return;
84
+ try {
85
+ localStorage.setItem(storageKey, JSON.stringify(place));
86
+ } catch {
87
+ // A full or disabled store is not worth failing a lab over.
88
+ }
89
+ }, [place, storageKey]);
90
+
91
+ const item = useMemo(
92
+ () => ({
93
+ id: ITEM_ID,
94
+ meta: { floating: true, ...(snapCorners ? { snapCorners: [...snapCorners] } : {}) },
95
+ natural: size,
96
+ }),
97
+ [size, snapCorners],
98
+ );
99
+
100
+ useEffect(() => {
101
+ const el = ref.current;
102
+ const parent = el?.offsetParent ?? el?.parentElement;
103
+ if (!el || !parent) return;
104
+ // Sizes come off the entry, not the DOM: jsdom reports 0 for every
105
+ // clientWidth/offsetWidth, and an unmeasured item is withheld from layout.
106
+ const observer = new ResizeObserver((entries) => {
107
+ for (const entry of entries) {
108
+ if (entry.target === parent) {
109
+ setContainer({ w: entry.contentRect.width, h: entry.contentRect.height });
110
+ } else {
111
+ setSize(borderBoxOf(entry));
112
+ }
113
+ }
114
+ });
115
+ observer.observe(parent);
116
+ observer.observe(el);
117
+ return () => observer.disconnect();
118
+ }, []);
119
+
120
+ const rect = strategy
121
+ .layout({
122
+ items: [item],
123
+ container,
124
+ state: { at: { [ITEM_ID]: place }, inner: undefined },
125
+ options,
126
+ })
127
+ .placements.get(ITEM_ID);
128
+
129
+ const dragging = useRef<{ x: number; y: number } | null>(null);
130
+ const [isDragging, setDragging] = useState(false);
131
+
132
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
133
+ if (isInteractive(e.target)) return;
134
+ // The canvas stack underneath owns pan/zoom on the same pointer events.
135
+ e.stopPropagation();
136
+ dragging.current = { x: e.clientX, y: e.clientY };
137
+ setDragging(true);
138
+ e.currentTarget.setPointerCapture?.(e.pointerId);
139
+ };
140
+
141
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
142
+ const from = dragging.current;
143
+ if (!from) return;
144
+ const dx = e.clientX - from.x;
145
+ const dy = e.clientY - from.y;
146
+ if (dx === 0 && dy === 0) return;
147
+ dragging.current = { x: e.clientX, y: e.clientY };
148
+ setPlace(
149
+ (prev) =>
150
+ strategy.reduce?.(
151
+ { at: { [ITEM_ID]: prev }, inner: undefined },
152
+ {
153
+ affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
154
+ kind: 'drag',
155
+ payload: { dx, dy },
156
+ },
157
+ { container, options, items: [item] },
158
+ ).at[ITEM_ID] ?? prev,
159
+ );
160
+ };
161
+
162
+ const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
163
+ if (!dragging.current) return;
164
+ dragging.current = null;
165
+ setDragging(false);
166
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
167
+ };
168
+
169
+ return (
170
+ <div
171
+ className={className ? `lk-floating-panel ${className}` : 'lk-floating-panel'}
172
+ data-dragging={isDragging ? 'true' : undefined}
173
+ data-placed={rect ? 'true' : undefined}
174
+ onPointerCancel={endDrag}
175
+ onPointerDown={onPointerDown}
176
+ onPointerMove={onPointerMove}
177
+ onPointerUp={endDrag}
178
+ ref={ref}
179
+ style={
180
+ {
181
+ position: 'absolute',
182
+ left: `${rect?.x ?? 0}px`,
183
+ top: `${rect?.y ?? 0}px`,
184
+ } as CSSProperties
185
+ }
186
+ >
187
+ {children}
188
+ </div>
189
+ );
190
+ }
@@ -35,4 +35,48 @@ describe('FpsMeter', () => {
35
35
 
36
36
  vi.unstubAllGlobals();
37
37
  });
38
+
39
+ test('does not report a fabricated rate after a spell in a background tab', () => {
40
+ const frames: FrameRequestCallback[] = [];
41
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
42
+ frames.push(cb);
43
+ return frames.length;
44
+ });
45
+ vi.stubGlobal('cancelAnimationFrame', () => {});
46
+ const setHidden = (next: boolean) => {
47
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => next });
48
+ document.dispatchEvent(new Event('visibilitychange'));
49
+ };
50
+
51
+ const run = (t: number) => {
52
+ const due = frames.splice(0, frames.length);
53
+ for (const cb of due) cb(t);
54
+ };
55
+
56
+ render(<FpsMeter />);
57
+ act(() => {
58
+ run(0);
59
+ run(16.67);
60
+ run(33.34);
61
+ });
62
+ const before = screen.getByText(/FPS\s+\d+/).textContent;
63
+
64
+ // An hour hidden. The frame that lands on resume is not a 60-minute frame,
65
+ // and must not enter the rolling average as one.
66
+ act(() => {
67
+ setHidden(true);
68
+ });
69
+ act(() => {
70
+ setHidden(false);
71
+ });
72
+ act(() => {
73
+ run(3_600_000);
74
+ run(3_600_016.67);
75
+ });
76
+
77
+ expect(screen.getByText(/FPS\s+\d+/).textContent).toBe(before);
78
+
79
+ setHidden(false);
80
+ vi.unstubAllGlobals();
81
+ });
38
82
  });
@@ -1,3 +1,4 @@
1
+ import { useVisibleRaf } from '@weasel-js/core';
1
2
  import { useEffect, useRef, useState } from 'react';
2
3
  import { rollingAverage } from './fpsAverage';
3
4
 
@@ -8,10 +9,10 @@ export function FpsMeter() {
8
9
  const [fps, setFps] = useState(0);
9
10
  const samplesRef = useRef<number[]>([]);
10
11
  const lastTimeRef = useRef<number | null>(null);
12
+ const rootRef = useRef<HTMLDivElement | null>(null);
11
13
 
12
- useEffect(() => {
13
- let rafId = 0;
14
- const tick = (time: number) => {
14
+ const loop = useVisibleRaf(
15
+ (time) => {
15
16
  const last = lastTimeRef.current;
16
17
  if (last !== null) {
17
18
  const delta = time - last;
@@ -23,14 +24,24 @@ export function FpsMeter() {
23
24
  }
24
25
  }
25
26
  lastTimeRef.current = time;
26
- rafId = requestAnimationFrame(tick);
27
- };
28
- rafId = requestAnimationFrame(tick);
29
- return () => cancelAnimationFrame(rafId);
30
- }, []);
27
+ loop.request();
28
+ },
29
+ {
30
+ target: rootRef,
31
+ // The gap spent suspended is not a frame that took an hour to draw.
32
+ onResume: () => {
33
+ lastTimeRef.current = null;
34
+ },
35
+ },
36
+ );
37
+
38
+ useEffect(() => {
39
+ loop.request();
40
+ return () => loop.cancel();
41
+ }, [loop]);
31
42
 
32
43
  return (
33
- <div className="lk-fps-meter">
44
+ <div className="lk-fps-meter" ref={rootRef}>
34
45
  <span className="lk-fps-meter-value">FPS {fps}</span>
35
46
  </div>
36
47
  );
@@ -0,0 +1,69 @@
1
+ .lk-job {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--wzl-space-sm);
5
+ min-width: 0;
6
+ font-size: var(--wzl-font-size-sm);
7
+ color: var(--wzl-fg-muted);
8
+
9
+ &__track {
10
+ flex: 0 1 120px;
11
+ height: 3px;
12
+ border-radius: var(--wzl-radius-pill);
13
+ overflow: hidden;
14
+ background: color-mix(in srgb, currentColor 18%, transparent);
15
+ }
16
+
17
+ &__fill {
18
+ height: 100%;
19
+ background: var(--wzl-accent);
20
+ transform-origin: left center;
21
+ transition: transform 120ms linear;
22
+ }
23
+
24
+ // A job that never reports a total sweeps instead of filling, so the bar
25
+ // says "working" without implying a position.
26
+ &__track--indeterminate &__fill,
27
+ &__track--indeterminate > .lk-job__fill {
28
+ animation: lk-job-sweep 1.1s ease-in-out infinite;
29
+ transform-origin: center;
30
+ }
31
+
32
+ &__count {
33
+ font-variant-numeric: tabular-nums;
34
+ white-space: nowrap;
35
+ }
36
+
37
+ &__failures,
38
+ &__error {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: 4px;
42
+ min-width: 0;
43
+ overflow: hidden;
44
+ text-overflow: ellipsis;
45
+ white-space: nowrap;
46
+ }
47
+
48
+ &__failures { color: var(--wzl-warning); }
49
+ &__error { color: var(--wzl-danger); }
50
+
51
+ &__cancel {
52
+ height: 20px;
53
+ padding: 0 var(--wzl-space-sm);
54
+ font-size: var(--wzl-font-size-sm);
55
+ }
56
+
57
+ &--error &__fill { background: var(--wzl-danger); }
58
+ &--done &__fill { background: color-mix(in srgb, var(--wzl-accent) 55%, transparent); }
59
+ }
60
+
61
+ @keyframes lk-job-sweep {
62
+ 0% { transform: scaleX(0.15) translateX(-260%); }
63
+ 50% { transform: scaleX(0.5) translateX(0); }
64
+ 100% { transform: scaleX(0.15) translateX(260%); }
65
+ }
66
+
67
+ @media (prefers-reduced-motion: reduce) {
68
+ .lk-job__track--indeterminate > .lk-job__fill { animation: none; transform: scaleX(0.35); }
69
+ }
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import type { JobHandle } from '../job/types';
3
+ import { JobProgress } from './JobProgress';
4
+
5
+ const base: JobHandle = {
6
+ status: 'running',
7
+ done: 0,
8
+ total: null,
9
+ failures: [],
10
+ error: null,
11
+ start: () => {},
12
+ cancel: () => {},
13
+ };
14
+
15
+ const meta: Meta<typeof JobProgress> = {
16
+ title: 'labkit/primitives/JobProgress',
17
+ component: JobProgress,
18
+ };
19
+ export default meta;
20
+ type Story = StoryObj<typeof JobProgress>;
21
+
22
+ /** A job that reported a total drives a determinate bar. */
23
+ export const Determinate: Story = {
24
+ args: { job: { ...base, done: 34, total: 120 } },
25
+ };
26
+
27
+ /** A job that never reports a total sweeps instead, so the bar says "working"
28
+ * without implying a position. */
29
+ export const Indeterminate: Story = { args: { job: { ...base, done: 34 } } };
30
+
31
+ export const WithFailures: Story = {
32
+ args: {
33
+ job: {
34
+ ...base,
35
+ done: 120,
36
+ total: 120,
37
+ status: 'done',
38
+ failures: [
39
+ { index: 3, error: 'timed out' },
40
+ { index: 91, error: 'bad input' },
41
+ ],
42
+ },
43
+ },
44
+ };
45
+
46
+ export const Failed: Story = {
47
+ args: { job: { ...base, done: 12, total: 120, status: 'error', error: 'worker crashed' } },
48
+ };
@@ -0,0 +1,65 @@
1
+ import { ErrorIcon, WarningIcon } from '@weasel-js/ui';
2
+ import type { JobHandle } from '../job/types';
3
+
4
+ /** Props for `<JobProgress>`. */
5
+ export interface JobProgressProps {
6
+ job: JobHandle;
7
+ className?: string;
8
+ }
9
+
10
+ /** A running job's progress, failures and outcome. Determinate once the job
11
+ * reports a total; a job that cannot count up front never sends one, so the
12
+ * bar stays indeterminate rather than inventing a denominator. */
13
+ export function JobProgress({ job, className }: JobProgressProps) {
14
+ const { status, done, total, failures, error } = job;
15
+ if (status === 'idle') return null;
16
+
17
+ const determinate = total !== null && total > 0;
18
+ const fraction = determinate ? Math.min(1, done / total) : 0;
19
+ const cls = ['lk-job', `lk-job--${status}`, className].filter(Boolean).join(' ');
20
+
21
+ return (
22
+ <div className={cls}>
23
+ <div
24
+ className={`lk-job__track${determinate ? '' : ' lk-job__track--indeterminate'}`}
25
+ role="progressbar"
26
+ aria-label="Job progress"
27
+ aria-valuenow={determinate ? done : undefined}
28
+ aria-valuemin={determinate ? 0 : undefined}
29
+ aria-valuemax={determinate ? total : undefined}
30
+ >
31
+ {/* Scaling a full-width bar keeps the fill off the layout path, so
32
+ progress updates never reflow the status bar. */}
33
+ <div
34
+ className="lk-job__fill"
35
+ style={{ transform: `scaleX(${determinate ? fraction : 1})` }}
36
+ />
37
+ </div>
38
+
39
+ <span className="lk-job__count">
40
+ {done}
41
+ {determinate ? ` / ${total}` : ''}
42
+ </span>
43
+
44
+ {failures.length > 0 && (
45
+ <span className="lk-job__failures" title={failures.map((f) => f.error).join('\n')}>
46
+ <WarningIcon size={14} />
47
+ {failures.length} failed
48
+ </span>
49
+ )}
50
+
51
+ {error && (
52
+ <span className="lk-job__error" title={error}>
53
+ <ErrorIcon size={14} />
54
+ {error}
55
+ </span>
56
+ )}
57
+
58
+ {status === 'running' && (
59
+ <button type="button" className="lk-job__cancel" onClick={job.cancel}>
60
+ Cancel
61
+ </button>
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,47 @@
1
+ .lk-legend {
2
+ margin: 0;
3
+ padding: 0;
4
+ list-style: none;
5
+ font-family: var(--wzl-font-mono);
6
+ font-size: var(--wzl-font-size-sm);
7
+ line-height: 1.4;
8
+ color: var(--wzl-fg-muted);
9
+ }
10
+
11
+ .lk-legend__row {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--wzl-space-sm);
15
+ padding: 1px 0;
16
+ white-space: nowrap;
17
+ }
18
+
19
+ .lk-legend__swatch {
20
+ flex: none;
21
+ width: 15px;
22
+ }
23
+
24
+ .lk-legend__swatch--line {
25
+ height: 4px;
26
+ border-radius: var(--wzl-radius-sm);
27
+ background: var(--lk-legend-ink);
28
+ }
29
+
30
+ .lk-legend__swatch--dash {
31
+ height: 0;
32
+ border-top: 2px dashed var(--lk-legend-ink);
33
+ }
34
+
35
+ .lk-legend__swatch--dot {
36
+ width: 6px;
37
+ height: 6px;
38
+ margin: 0 4px 0 5px;
39
+ border-radius: 50%;
40
+ background: var(--lk-legend-ink);
41
+ }
42
+
43
+ .lk-legend__swatch--band {
44
+ height: 9px;
45
+ border-radius: var(--wzl-radius-sm);
46
+ background: var(--lk-legend-ink);
47
+ }
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Legend } from './Legend';
3
+
4
+ const meta: Meta<typeof Legend> = {
5
+ title: 'labkit/Primitives/Legend',
6
+ component: Legend,
7
+ };
8
+ export default meta;
9
+
10
+ type Story = StoryObj<typeof Legend>;
11
+
12
+ export const AllMarks: Story = {
13
+ args: {
14
+ entries: [
15
+ { key: 'contour', label: 'contour', color: '#7d7f86' },
16
+ { key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' },
17
+ { key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' },
18
+ { key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' },
19
+ ],
20
+ },
21
+ };
22
+
23
+ export const DefaultMark: Story = {
24
+ args: {
25
+ entries: [
26
+ { key: 'a', label: 'input', color: '#4c9be8' },
27
+ { key: 'b', label: 'output', color: '#e8a04c' },
28
+ ],
29
+ },
30
+ };
31
+
32
+ export const Empty: Story = { args: { entries: [] } };
@@ -0,0 +1,57 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Legend } from './Legend';
4
+
5
+ const entries = [
6
+ { key: 'contour', label: 'contour', color: '#7d7f86' },
7
+ { key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' as const },
8
+ { key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' as const },
9
+ { key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' as const },
10
+ ];
11
+
12
+ describe('Legend', () => {
13
+ it('renders one row per entry', () => {
14
+ const { container } = render(<Legend entries={entries} />);
15
+ expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(4);
16
+ });
17
+
18
+ it('shows every label as text', () => {
19
+ render(<Legend entries={entries} />);
20
+ for (const e of entries) expect(screen.getByText(e.label)).toBeInTheDocument();
21
+ });
22
+
23
+ it('marks each swatch with its shape, defaulting to a line', () => {
24
+ const { container } = render(<Legend entries={entries} />);
25
+ const swatches = container.querySelectorAll('.lk-legend__swatch');
26
+ expect(swatches[0]?.className).toContain('lk-legend__swatch--line');
27
+ expect(swatches[1]?.className).toContain('lk-legend__swatch--dash');
28
+ expect(swatches[2]?.className).toContain('lk-legend__swatch--dot');
29
+ expect(swatches[3]?.className).toContain('lk-legend__swatch--band');
30
+ });
31
+
32
+ it('hides swatches from assistive tech, leaving the label to carry meaning', () => {
33
+ const { container } = render(<Legend entries={entries} />);
34
+ for (const s of container.querySelectorAll('.lk-legend__swatch')) {
35
+ expect(s.getAttribute('aria-hidden')).toBe('true');
36
+ }
37
+ });
38
+
39
+ it('takes its color from the entry', () => {
40
+ const { container } = render(
41
+ <Legend entries={[{ key: 'contour', label: 'contour', color: '#7d7f86' }]} />,
42
+ );
43
+ const swatch = container.querySelector('.lk-legend__swatch') as HTMLElement;
44
+ expect(swatch.style.getPropertyValue('--lk-legend-ink')).toBe('#7d7f86');
45
+ });
46
+
47
+ it('renders nothing but an empty list for no entries', () => {
48
+ const { container } = render(<Legend entries={[]} />);
49
+ expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(0);
50
+ expect(container.querySelector('.lk-legend')).toBeInTheDocument();
51
+ });
52
+
53
+ it('accepts an extra class name', () => {
54
+ const { container } = render(<Legend entries={[]} className="is-mine" />);
55
+ expect(container.querySelector('.lk-legend')?.className).toContain('is-mine');
56
+ });
57
+ });
@@ -0,0 +1,40 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /** How a legend entry's swatch is drawn, matching how the thing looks on canvas. */
4
+ export type LegendMark = 'line' | 'dash' | 'dot' | 'band';
5
+
6
+ /** One row of a legend: a swatch and what it means. */
7
+ export interface LegendEntry {
8
+ key: string;
9
+ label: string;
10
+ color: string;
11
+ /** Defaults to `'line'`. */
12
+ mark?: LegendMark;
13
+ }
14
+
15
+ /** Props for `<Legend>`. */
16
+ export interface LegendProps {
17
+ entries: readonly LegendEntry[];
18
+ className?: string;
19
+ }
20
+
21
+ /** A color key. Presentational only — no handlers, no state, no hover behavior. */
22
+ export function Legend({ entries, className }: LegendProps) {
23
+ return (
24
+ <ul className={className ? `lk-legend ${className}` : 'lk-legend'}>
25
+ {entries.map((entry) => {
26
+ const mark = entry.mark ?? 'line';
27
+ return (
28
+ <li className="lk-legend__row" key={entry.key}>
29
+ <span
30
+ aria-hidden="true"
31
+ className={`lk-legend__swatch lk-legend__swatch--${mark}`}
32
+ style={{ '--lk-legend-ink': entry.color } as CSSProperties}
33
+ />
34
+ <span className="lk-legend__label">{entry.label}</span>
35
+ </li>
36
+ );
37
+ })}
38
+ </ul>
39
+ );
40
+ }