@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.
- package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
- package/dist/canvas/index.d.ts +7 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +4 -2
- package/dist/index.d.ts +246 -106
- package/dist/index.js +673 -559
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +5 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3799 -3672
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +542 -153
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +4 -2
- package/package.json +16 -7
- package/src/canvas/AGENTS.md +4 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.ts +25 -3
- package/src/instrument/SineWave.smoke.test.tsx +8 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +27 -4
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +5 -8
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +11 -3
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +4 -0
- package/src/styles.less +6 -0
- package/src/surface/useTiledSurface.ts +14 -10
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +13 -15
- package/src/trial/Trial.less +115 -10
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +80 -39
- package/src/trial/TrialChrome.tsx +99 -80
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-73KA7WBO.js +0 -5992
- package/dist/chunk-73KA7WBO.js.map +0 -1
- package/dist/chunk-BOHF3PQO.js.map +0 -1
- package/dist/chunk-G5TJVQQT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/dist/chunk-THBG7FQZ.js.map +0 -1
- package/dist/chunk-VUU5UXHE.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- 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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|