@weasel-js/labkit 0.1.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 (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,43 @@
1
+ .lk-toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--lk-spacing-sm);
5
+ padding: var(--lk-spacing-xs) var(--lk-spacing-sm);
6
+ background: var(--lk-bg-elevated);
7
+ border-bottom: 1px solid var(--lk-border);
8
+ min-height: calc(var(--lk-control-height) + 2 * var(--lk-spacing-xs));
9
+ }
10
+
11
+ .lk-toolbar-title {
12
+ font-weight: 600;
13
+ color: var(--lk-text);
14
+ margin-right: var(--lk-spacing-sm);
15
+ }
16
+
17
+ .lk-toolbar-spacer {
18
+ flex: 1;
19
+ }
20
+
21
+ .lk-toolbar-button {
22
+ height: var(--lk-control-height);
23
+ padding: 0 var(--lk-spacing-sm);
24
+ font-size: var(--lk-font-size);
25
+ color: var(--lk-text);
26
+ background: transparent;
27
+ border: 1px solid transparent;
28
+ border-radius: var(--lk-radius);
29
+ cursor: pointer;
30
+
31
+ &:hover:not(:disabled) {
32
+ background: var(--lk-bg);
33
+ border-color: var(--lk-border);
34
+ }
35
+ &:focus-visible {
36
+ outline: 2px solid var(--lk-focus-ring);
37
+ outline-offset: 1px;
38
+ }
39
+ &:disabled {
40
+ color: var(--lk-text-disabled);
41
+ cursor: not-allowed;
42
+ }
43
+ }
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Toolbar } from './Toolbar';
3
+
4
+ const meta: Meta<typeof Toolbar> = {
5
+ title: 'labkit/Primitives/Toolbar',
6
+ component: Toolbar,
7
+ };
8
+ export default meta;
9
+
10
+ type Story = StoryObj<typeof Toolbar>;
11
+
12
+ export const Default: Story = {
13
+ render: () => (
14
+ <Toolbar>
15
+ <Toolbar.Title>My Workspace</Toolbar.Title>
16
+ <Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
17
+ <Toolbar.Button onClick={() => {}}>Redo</Toolbar.Button>
18
+ <Toolbar.Spacer />
19
+ <Toolbar.Button onClick={() => {}}>Save</Toolbar.Button>
20
+ </Toolbar>
21
+ ),
22
+ };
23
+
24
+ export const WithDisabled: Story = {
25
+ render: () => (
26
+ <Toolbar>
27
+ <Toolbar.Title>Empty workspace</Toolbar.Title>
28
+ <Toolbar.Button onClick={() => {}} disabled>
29
+ Undo
30
+ </Toolbar.Button>
31
+ <Toolbar.Button onClick={() => {}} disabled>
32
+ Redo
33
+ </Toolbar.Button>
34
+ </Toolbar>
35
+ ),
36
+ };
@@ -0,0 +1,65 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { Toolbar } from './Toolbar';
4
+
5
+ describe('Toolbar', () => {
6
+ test('renders children in a horizontal toolbar', () => {
7
+ const { container } = render(
8
+ <Toolbar>
9
+ <Toolbar.Title>My Lab</Toolbar.Title>
10
+ <Toolbar.Button onClick={() => {}}>Save</Toolbar.Button>
11
+ </Toolbar>,
12
+ );
13
+ expect(screen.getByText('My Lab')).toBeInTheDocument();
14
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
15
+ expect((container.firstChild as HTMLElement).className).toBe('lk-toolbar');
16
+ });
17
+
18
+ test('Title uses lk-toolbar-title class', () => {
19
+ const { container } = render(
20
+ <Toolbar>
21
+ <Toolbar.Title>X</Toolbar.Title>
22
+ </Toolbar>,
23
+ );
24
+ expect(container.querySelector('.lk-toolbar-title')).not.toBeNull();
25
+ });
26
+
27
+ test('Spacer fills available space', () => {
28
+ const { container } = render(
29
+ <Toolbar>
30
+ <Toolbar.Title>L</Toolbar.Title>
31
+ <Toolbar.Spacer />
32
+ <span>R</span>
33
+ </Toolbar>,
34
+ );
35
+ expect(container.querySelector('.lk-toolbar-spacer')).not.toBeNull();
36
+ });
37
+
38
+ test('Button passes onClick and disabled', () => {
39
+ let clicked = false;
40
+ render(
41
+ <Toolbar>
42
+ <Toolbar.Button
43
+ onClick={() => {
44
+ clicked = true;
45
+ }}
46
+ >
47
+ Go
48
+ </Toolbar.Button>
49
+ </Toolbar>,
50
+ );
51
+ screen.getByRole('button', { name: 'Go' }).click();
52
+ expect(clicked).toBe(true);
53
+ });
54
+
55
+ test('Button respects disabled', () => {
56
+ render(
57
+ <Toolbar>
58
+ <Toolbar.Button onClick={() => {}} disabled>
59
+ X
60
+ </Toolbar.Button>
61
+ </Toolbar>,
62
+ );
63
+ expect(screen.getByRole('button', { name: 'X' })).toBeDisabled();
64
+ });
65
+ });
@@ -0,0 +1,44 @@
1
+ import type { MouseEventHandler, ReactNode } from 'react';
2
+
3
+ export interface ToolbarProps {
4
+ children: ReactNode;
5
+ }
6
+
7
+ export function Toolbar({ children }: ToolbarProps) {
8
+ return <div className="lk-toolbar">{children}</div>;
9
+ }
10
+
11
+ interface TitleProps {
12
+ children: ReactNode;
13
+ }
14
+ function Title({ children }: TitleProps) {
15
+ return <span className="lk-toolbar-title">{children}</span>;
16
+ }
17
+
18
+ interface ButtonProps {
19
+ children: ReactNode;
20
+ onClick: MouseEventHandler<HTMLButtonElement>;
21
+ disabled?: boolean;
22
+ title?: string;
23
+ }
24
+ function Button({ children, onClick, disabled, title }: ButtonProps) {
25
+ return (
26
+ <button
27
+ type="button"
28
+ className="lk-toolbar-button"
29
+ onClick={onClick}
30
+ disabled={disabled}
31
+ title={title}
32
+ >
33
+ {children}
34
+ </button>
35
+ );
36
+ }
37
+
38
+ function Spacer() {
39
+ return <span className="lk-toolbar-spacer" aria-hidden="true" />;
40
+ }
41
+
42
+ Toolbar.Title = Title;
43
+ Toolbar.Button = Button;
44
+ Toolbar.Spacer = Spacer;
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { rollingAverage } from './fpsAverage';
3
+
4
+ describe('rollingAverage', () => {
5
+ test('returns the input when only one sample', () => {
6
+ expect(rollingAverage([60])).toBe(60);
7
+ });
8
+
9
+ test('averages multiple samples', () => {
10
+ expect(rollingAverage([30, 60, 90])).toBe(60);
11
+ });
12
+
13
+ test('returns 0 for empty array', () => {
14
+ expect(rollingAverage([])).toBe(0);
15
+ });
16
+
17
+ test('rounds to nearest integer', () => {
18
+ expect(rollingAverage([59, 60, 61])).toBe(60);
19
+ expect(rollingAverage([58, 59, 61])).toBe(59);
20
+ });
21
+ });
@@ -0,0 +1,5 @@
1
+ export function rollingAverage(samples: readonly number[]): number {
2
+ if (samples.length === 0) return 0;
3
+ const sum = samples.reduce((acc, n) => acc + n, 0);
4
+ return Math.round(sum / samples.length);
5
+ }
@@ -0,0 +1,9 @@
1
+ export { FpsMeter } from './FpsMeter';
2
+ export type { ScaleIndicatorProps } from './ScaleIndicator';
3
+ export { ScaleIndicator } from './ScaleIndicator';
4
+ export type { SidebarProps } from './Sidebar';
5
+ export { Sidebar } from './Sidebar';
6
+ export type { StatusBarProps } from './StatusBar';
7
+ export { StatusBar } from './StatusBar';
8
+ export type { ToolbarProps } from './Toolbar';
9
+ export { Toolbar } from './Toolbar';
@@ -0,0 +1,101 @@
1
+ import { act, render, renderHook } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { createMemoryAdapter } from './adapters';
4
+ import { SingletonExperimentProvider } from './SingletonExperiment';
5
+ import { useExperimentState } from './useExperimentState';
6
+
7
+ interface Config {
8
+ width: number;
9
+ bg: string;
10
+ }
11
+ interface State {
12
+ zoom: number;
13
+ }
14
+
15
+ describe('SingletonExperimentProvider', () => {
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it('exposes config and state via useExperimentState', () => {
21
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
22
+ <SingletonExperimentProvider<State, Config>
23
+ id="test"
24
+ initialConfig={{ width: 100, bg: '#000' }}
25
+ initialState={{ zoom: 1 }}
26
+ storage={createMemoryAdapter()}
27
+ storageKey="test"
28
+ >
29
+ {children}
30
+ </SingletonExperimentProvider>
31
+ );
32
+ const { result } = renderHook(() => useExperimentState<State, Config>(), { wrapper });
33
+ expect(result.current.config).toEqual({ width: 100, bg: '#000' });
34
+ expect(result.current.state).toEqual({ zoom: 1 });
35
+ });
36
+
37
+ it('persists config changes to storage', () => {
38
+ vi.useFakeTimers();
39
+ const storage = createMemoryAdapter();
40
+ const Probe = () => {
41
+ const h = useExperimentState<State, Config>();
42
+ return (
43
+ <button type="button" onClick={() => h.setConfig('width', 200)}>
44
+ go
45
+ </button>
46
+ );
47
+ };
48
+ const { getByText } = render(
49
+ <SingletonExperimentProvider<State, Config>
50
+ id="test"
51
+ initialConfig={{ width: 100, bg: '#000' }}
52
+ initialState={{ zoom: 1 }}
53
+ storage={storage}
54
+ storageKey="test"
55
+ >
56
+ <Probe />
57
+ </SingletonExperimentProvider>,
58
+ );
59
+ act(() => {
60
+ getByText('go').click();
61
+ });
62
+ act(() => {
63
+ vi.advanceTimersByTime(400);
64
+ });
65
+ // createLabStore uses labStorageKey which prefixes with "lk:"
66
+ const raw = storage.read('lk:test:workspaces');
67
+ expect(raw).toBeTruthy();
68
+ expect(raw).toContain('"width":200');
69
+ });
70
+
71
+ it('rehydrates from storage on mount', () => {
72
+ const storage = createMemoryAdapter();
73
+ // createLabStore uses labStorageKey which prefixes with "lk:"
74
+ storage.write(
75
+ 'lk:test:workspaces',
76
+ JSON.stringify([
77
+ {
78
+ id: 'test',
79
+ instrumentName: '__singleton__',
80
+ config: { width: 999, bg: '#fff' },
81
+ state: { zoom: 2 },
82
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
83
+ },
84
+ ]),
85
+ );
86
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
87
+ <SingletonExperimentProvider<State, Config>
88
+ id="test"
89
+ initialConfig={{ width: 100, bg: '#000' }}
90
+ initialState={{ zoom: 1 }}
91
+ storage={storage}
92
+ storageKey="test"
93
+ >
94
+ {children}
95
+ </SingletonExperimentProvider>
96
+ );
97
+ const { result } = renderHook(() => useExperimentState<State, Config>(), { wrapper });
98
+ expect(result.current.config).toEqual({ width: 999, bg: '#fff' });
99
+ expect(result.current.state).toEqual({ zoom: 2 });
100
+ });
101
+ });
@@ -0,0 +1,52 @@
1
+ import { type ReactNode, useRef } from 'react';
2
+ import { LabStoreProvider, WorkspaceIdProvider } from './context';
3
+ import { createLabStore, type LabStore } from './store';
4
+ import type { StorageAdapter } from './types';
5
+
6
+ const SINGLETON_INSTRUMENT = '__singleton__';
7
+
8
+ export interface SingletonExperimentProviderProps<TS, TC> {
9
+ /** Stable id for the synthetic workspace; also doubles as the
10
+ * WorkspaceIdContext value. */
11
+ id: string;
12
+ initialConfig: TC;
13
+ initialState: TS;
14
+ storage: StorageAdapter;
15
+ storageKey: string;
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * One-workspace `<Lab>` substitute for single-screen experiments. Mounts
21
+ * a `LabStoreProvider` + `WorkspaceIdProvider` with one synthetic
22
+ * workspace, so `useExperimentState` works without going through the
23
+ * full `<Lab instruments={...}>` runtime.
24
+ */
25
+ export function SingletonExperimentProvider<TS, TC>({
26
+ id,
27
+ initialConfig,
28
+ initialState,
29
+ storage,
30
+ storageKey,
31
+ children,
32
+ }: SingletonExperimentProviderProps<TS, TC>) {
33
+ const storeRef = useRef<LabStore | null>(null);
34
+ if (storeRef.current === null) {
35
+ const store = createLabStore({ storageKey, storage });
36
+ if (!store.getState().workspaces.some((w) => w.id === id)) {
37
+ store.getState().addWorkspace({
38
+ id,
39
+ instrumentName: SINGLETON_INSTRUMENT,
40
+ config: initialConfig,
41
+ state: initialState,
42
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
43
+ });
44
+ }
45
+ storeRef.current = store;
46
+ }
47
+ return (
48
+ <LabStoreProvider store={storeRef.current}>
49
+ <WorkspaceIdProvider workspaceId={id}>{children}</WorkspaceIdProvider>
50
+ </LabStoreProvider>
51
+ );
52
+ }
@@ -0,0 +1,94 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createMemoryAdapter,
4
+ localStorageAdapter,
5
+ noneAdapter,
6
+ sessionStorageAdapter,
7
+ urlHashAdapter,
8
+ } from './adapters';
9
+
10
+ describe('localStorageAdapter', () => {
11
+ beforeEach(() => localStorage.clear());
12
+
13
+ it('writes and reads a value', () => {
14
+ localStorageAdapter.write('k', 'v');
15
+ expect(localStorageAdapter.read('k')).toBe('v');
16
+ });
17
+
18
+ it('returns null for missing key', () => {
19
+ expect(localStorageAdapter.read('missing')).toBeNull();
20
+ });
21
+
22
+ it('deletes a key', () => {
23
+ localStorageAdapter.write('k', 'v');
24
+ localStorageAdapter.delete?.('k');
25
+ expect(localStorageAdapter.read('k')).toBeNull();
26
+ });
27
+
28
+ it('handles QuotaExceededError gracefully', () => {
29
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
30
+ throw new DOMException('QuotaExceeded', 'QuotaExceededError');
31
+ });
32
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
33
+ expect(() => localStorageAdapter.write('k', 'v')).not.toThrow();
34
+ expect(warn).toHaveBeenCalled();
35
+ warn.mockRestore();
36
+ });
37
+ });
38
+
39
+ describe('sessionStorageAdapter', () => {
40
+ beforeEach(() => sessionStorage.clear());
41
+
42
+ it('writes and reads a value', () => {
43
+ sessionStorageAdapter.write('k', 'v');
44
+ expect(sessionStorageAdapter.read('k')).toBe('v');
45
+ });
46
+ });
47
+
48
+ describe('urlHashAdapter', () => {
49
+ beforeEach(() => {
50
+ window.location.hash = '';
51
+ });
52
+
53
+ it('writes and reads back through the hash', () => {
54
+ urlHashAdapter.write('any-key', 'hello world');
55
+ expect(urlHashAdapter.read('any-key')).toBe('hello world');
56
+ });
57
+
58
+ it('returns null when hash is empty', () => {
59
+ expect(urlHashAdapter.read('any-key')).toBeNull();
60
+ });
61
+
62
+ it('uses replaceState (not pushState)', () => {
63
+ const spy = vi.spyOn(window.history, 'replaceState');
64
+ urlHashAdapter.write('k', 'v');
65
+ expect(spy).toHaveBeenCalled();
66
+ spy.mockRestore();
67
+ });
68
+ });
69
+
70
+ describe('createMemoryAdapter', () => {
71
+ it('two instances are isolated', () => {
72
+ const a = createMemoryAdapter();
73
+ const b = createMemoryAdapter();
74
+ a.write('k', 'from-a');
75
+ expect(b.read('k')).toBeNull();
76
+ });
77
+
78
+ it('delete removes the key', () => {
79
+ const m = createMemoryAdapter();
80
+ m.write('k', 'v');
81
+ m.delete?.('k');
82
+ expect(m.read('k')).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe('noneAdapter', () => {
87
+ it('read always returns null', () => {
88
+ expect(noneAdapter.read('anything')).toBeNull();
89
+ });
90
+
91
+ it('write is a no-op', () => {
92
+ expect(() => noneAdapter.write('k', 'v')).not.toThrow();
93
+ });
94
+ });
@@ -0,0 +1,102 @@
1
+ import { decodeUrlHash, encodeUrlHash } from './helpers';
2
+ import type { StorageAdapter } from './types';
3
+
4
+ export const localStorageAdapter: StorageAdapter = {
5
+ read: (key) => {
6
+ try {
7
+ return localStorage.getItem(key);
8
+ } catch {
9
+ return null;
10
+ }
11
+ },
12
+ write: (key, value) => {
13
+ try {
14
+ localStorage.setItem(key, value);
15
+ } catch (e) {
16
+ console.warn('[labkit] localStorage write failed:', e);
17
+ }
18
+ },
19
+ delete: (key) => {
20
+ try {
21
+ localStorage.removeItem(key);
22
+ } catch {
23
+ /* ignore */
24
+ }
25
+ },
26
+ };
27
+
28
+ export const sessionStorageAdapter: StorageAdapter = {
29
+ read: (key) => {
30
+ try {
31
+ return sessionStorage.getItem(key);
32
+ } catch {
33
+ return null;
34
+ }
35
+ },
36
+ write: (key, value) => {
37
+ try {
38
+ sessionStorage.setItem(key, value);
39
+ } catch (e) {
40
+ console.warn('[labkit] sessionStorage write failed:', e);
41
+ }
42
+ },
43
+ delete: (key) => {
44
+ try {
45
+ sessionStorage.removeItem(key);
46
+ } catch {
47
+ /* ignore */
48
+ }
49
+ },
50
+ };
51
+
52
+ const URL_HASH_GUARD = typeof window !== 'undefined';
53
+
54
+ function readHashMap(): Record<string, string> {
55
+ if (!URL_HASH_GUARD) return {};
56
+ const raw = decodeUrlHash(window.location.hash.replace(/^#/, ''));
57
+ if (!raw) return {};
58
+ try {
59
+ return JSON.parse(raw) as Record<string, string>;
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ function writeHashMap(map: Record<string, string>): void {
66
+ if (!URL_HASH_GUARD) return;
67
+ const encoded = encodeUrlHash(JSON.stringify(map));
68
+ window.history.replaceState(null, '', `#${encoded}`);
69
+ }
70
+
71
+ export const urlHashAdapter: StorageAdapter = {
72
+ read: (key) => readHashMap()[key] ?? null,
73
+ write: (key, value) => {
74
+ const map = readHashMap();
75
+ map[key] = value;
76
+ writeHashMap(map);
77
+ },
78
+ delete: (key) => {
79
+ const map = readHashMap();
80
+ delete map[key];
81
+ writeHashMap(map);
82
+ },
83
+ };
84
+
85
+ export function createMemoryAdapter(): StorageAdapter {
86
+ const store = new Map<string, string>();
87
+ return {
88
+ read: (key) => store.get(key) ?? null,
89
+ write: (key, value) => {
90
+ store.set(key, value);
91
+ },
92
+ delete: (key) => {
93
+ store.delete(key);
94
+ },
95
+ };
96
+ }
97
+
98
+ export const noneAdapter: StorageAdapter = {
99
+ read: () => null,
100
+ write: () => {},
101
+ delete: () => {},
102
+ };
@@ -0,0 +1,42 @@
1
+ import { createContext, type ReactElement, type ReactNode, useContext } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import type { LabStore } from './store';
4
+ import type { LabStoreState } from './types';
5
+
6
+ type LabStoreCtx = { store: LabStore } | null;
7
+
8
+ export const LabStoreContext = createContext<LabStoreCtx>(null);
9
+
10
+ export function LabStoreProvider({
11
+ store,
12
+ children,
13
+ }: {
14
+ store: LabStore;
15
+ children: ReactNode;
16
+ }): ReactElement {
17
+ return <LabStoreContext.Provider value={{ store }}>{children}</LabStoreContext.Provider>;
18
+ }
19
+
20
+ export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']> {
21
+ const ctx = useContext(LabStoreContext);
22
+ if (!ctx) throw new Error('[labkit] useLabStore must be used inside <LabStoreProvider>');
23
+ return useStore(ctx.store);
24
+ }
25
+
26
+ export const WorkspaceIdContext = createContext<string | null>(null);
27
+
28
+ export function WorkspaceIdProvider({
29
+ workspaceId,
30
+ children,
31
+ }: {
32
+ workspaceId: string;
33
+ children: ReactNode;
34
+ }): ReactElement {
35
+ return <WorkspaceIdContext.Provider value={workspaceId}>{children}</WorkspaceIdContext.Provider>;
36
+ }
37
+
38
+ export function useWorkspaceId(): string {
39
+ const id = useContext(WorkspaceIdContext);
40
+ if (!id) throw new Error('[labkit] useWorkspaceId must be used inside <WorkspaceIdProvider>');
41
+ return id;
42
+ }