@weasel-js/labkit 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -1,94 +1,146 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
1
+ import 'fake-indexeddb/auto';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { describeAdapterContract } from './adapterContract';
2
4
  import {
5
+ createIndexedDbAdapter,
3
6
  createMemoryAdapter,
7
+ defaultStorage,
8
+ indexedDbAdapter,
4
9
  localStorageAdapter,
5
10
  noneAdapter,
11
+ resetDefaultStorage,
6
12
  sessionStorageAdapter,
7
13
  urlHashAdapter,
8
14
  } from './adapters';
15
+ import { decodeUrlHash, encodeUrlHash } from './helpers';
9
16
 
10
- describe('localStorageAdapter', () => {
11
- beforeEach(() => localStorage.clear());
17
+ describeAdapterContract('createMemoryAdapter', () => {
18
+ const backing = new Map<string, unknown>();
19
+ const peer = createMemoryAdapter(backing);
20
+ return { adapter: createMemoryAdapter(backing), binary: true, foreign: peer };
21
+ });
12
22
 
13
- it('writes and reads a value', () => {
14
- localStorageAdapter.write('k', 'v');
15
- expect(localStorageAdapter.read('k')).toBe('v');
16
- });
23
+ let database = 0;
24
+ describeAdapterContract('createIndexedDbAdapter', () => {
25
+ const name = `contract-${database++}`;
26
+ return {
27
+ adapter: createIndexedDbAdapter({ database: name }),
28
+ binary: true,
29
+ foreign: createIndexedDbAdapter({ database: name }),
30
+ };
31
+ });
17
32
 
18
- it('returns null for missing key', () => {
19
- expect(localStorageAdapter.read('missing')).toBeNull();
20
- });
33
+ /** jsdom has one window, so no `storage` event ever arrives from another tab.
34
+ * This writes the key and dispatches the event another tab's write would —
35
+ * a stand-in for the browser, not a test of it. */
36
+ function foreignWebStorage(area: Storage) {
37
+ const fire = (key: string, newValue: string | null): void => {
38
+ window.dispatchEvent(new StorageEvent('storage', { key, newValue, storageArea: area }));
39
+ };
40
+ return {
41
+ set: (key: string, value: unknown) => {
42
+ const raw = JSON.stringify(value);
43
+ area.setItem(key, raw);
44
+ fire(key, raw);
45
+ },
46
+ delete: (key: string) => {
47
+ area.removeItem(key);
48
+ fire(key, null);
49
+ },
50
+ };
51
+ }
21
52
 
22
- it('deletes a key', () => {
23
- localStorageAdapter.write('k', 'v');
24
- localStorageAdapter.delete?.('k');
25
- expect(localStorageAdapter.read('k')).toBeNull();
26
- });
53
+ describeAdapterContract('localStorageAdapter', () => {
54
+ localStorage.clear();
55
+ return { adapter: localStorageAdapter, binary: false, foreign: foreignWebStorage(localStorage) };
56
+ });
27
57
 
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
- });
58
+ describeAdapterContract('sessionStorageAdapter', () => {
59
+ sessionStorage.clear();
60
+ return { adapter: sessionStorageAdapter, binary: false };
37
61
  });
38
62
 
39
- describe('sessionStorageAdapter', () => {
40
- beforeEach(() => sessionStorage.clear());
63
+ function foreignHash() {
64
+ const edit = (change: (map: Record<string, string>) => void): void => {
65
+ const raw = decodeUrlHash(window.location.hash.replace(/^#/, ''));
66
+ const map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
67
+ change(map);
68
+ window.location.hash = encodeUrlHash(JSON.stringify(map));
69
+ };
70
+ return {
71
+ set: (key: string, value: unknown) =>
72
+ edit((map) => {
73
+ map[key] = JSON.stringify(value);
74
+ }),
75
+ delete: (key: string) =>
76
+ edit((map) => {
77
+ delete map[key];
78
+ }),
79
+ };
80
+ }
41
81
 
42
- it('writes and reads a value', () => {
43
- sessionStorageAdapter.write('k', 'v');
44
- expect(sessionStorageAdapter.read('k')).toBe('v');
45
- });
82
+ describeAdapterContract('urlHashAdapter', () => {
83
+ window.history.replaceState(null, '', '#');
84
+ return { adapter: urlHashAdapter, binary: false, foreign: foreignHash() };
46
85
  });
47
86
 
48
- describe('urlHashAdapter', () => {
49
- beforeEach(() => {
50
- window.location.hash = '';
51
- });
87
+ describe('localStorageAdapter', () => {
88
+ beforeEach(() => localStorage.clear());
52
89
 
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');
90
+ it('reads a value stored before records existed, raw or JSON', async () => {
91
+ localStorage.setItem('lk:old:theme', 'dark');
92
+ localStorage.setItem('lk:old:doc', '{"version":3}');
93
+ expect(await localStorageAdapter.get('lk:old:theme')).toBe('dark');
94
+ expect(await localStorageAdapter.get('lk:old:doc')).toEqual({ version: 3 });
56
95
  });
57
96
 
58
- it('returns null when hash is empty', () => {
59
- expect(urlHashAdapter.read('any-key')).toBeNull();
97
+ it('rejects a write that does not land', async () => {
98
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
99
+ throw new DOMException('QuotaExceeded', 'QuotaExceededError');
100
+ });
101
+ await expect(localStorageAdapter.set('k', 'v')).rejects.toThrow('QuotaExceeded');
60
102
  });
61
103
 
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();
104
+ it('warns once per key when JSON would change the value, and stores what JSON keeps', async () => {
105
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
106
+ await localStorageAdapter.set('lk:w:a', { at: new Set([1]) });
107
+ await localStorageAdapter.set('lk:w:a', { at: new Set([2]) });
108
+ await localStorageAdapter.set('lk:w:b', { fine: [1, 'x', null], skipped: undefined });
109
+ expect(warn).toHaveBeenCalledTimes(1);
110
+ expect(await localStorageAdapter.get('lk:w:a')).toEqual({ at: {} });
111
+ warn.mockRestore();
67
112
  });
68
113
  });
69
114
 
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();
115
+ describe('noneAdapter', () => {
116
+ it('stores nothing', async () => {
117
+ await noneAdapter.set('k', 'v');
118
+ expect(await noneAdapter.get('k')).toBeUndefined();
119
+ expect(await noneAdapter.list('')).toEqual([]);
76
120
  });
121
+ });
77
122
 
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();
123
+ describe('defaultStorage', () => {
124
+ afterEach(() => {
125
+ resetDefaultStorage();
126
+ vi.unstubAllGlobals();
83
127
  });
84
- });
85
128
 
86
- describe('noneAdapter', () => {
87
- it('read always returns null', () => {
88
- expect(noneAdapter.read('anything')).toBeNull();
129
+ it('is IndexedDB where it opens', async () => {
130
+ expect(await defaultStorage()).toBe(indexedDbAdapter);
89
131
  });
90
132
 
91
- it('write is a no-op', () => {
92
- expect(() => noneAdapter.write('k', 'v')).not.toThrow();
133
+ it('falls back to localStorage, with a warning, where it will not', async () => {
134
+ vi.stubGlobal('indexedDB', undefined);
135
+ // The shared adapter may already hold an open database from the case above.
136
+ vi.resetModules();
137
+ const fresh = await import('./adapters');
138
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
139
+ expect(await fresh.defaultStorage()).toBe(fresh.localStorageAdapter);
140
+ expect(warn).toHaveBeenCalledWith(
141
+ expect.stringContaining('IndexedDB would not open'),
142
+ expect.anything(),
143
+ );
144
+ warn.mockRestore();
93
145
  });
94
146
  });
@@ -1,56 +1,104 @@
1
1
  import { decodeUrlHash, encodeUrlHash } from './helpers';
2
- import type { StorageAdapter } from './types';
3
-
4
- /** Persist to `localStorage` — state survives a reload and a new tab. Falls
5
- * back to no-ops when storage is unavailable. */
6
- export const localStorageAdapter: StorageAdapter = {
7
- read: (key) => {
8
- try {
9
- return localStorage.getItem(key);
10
- } catch {
11
- return null;
12
- }
13
- },
14
- write: (key, value) => {
15
- try {
16
- localStorage.setItem(key, value);
17
- } catch (e) {
18
- console.warn('[labkit] localStorage write failed:', e);
19
- }
20
- },
21
- delete: (key) => {
22
- try {
23
- localStorage.removeItem(key);
24
- } catch {
25
- /* ignore */
26
- }
27
- },
28
- };
2
+ import type { StorageAdapter, StorageChange } from './types';
29
3
 
30
- /** Persist to `sessionStorage` — state survives a reload but not a new tab. */
31
- export const sessionStorageAdapter: StorageAdapter = {
32
- read: (key) => {
33
- try {
34
- return sessionStorage.getItem(key);
35
- } catch {
36
- return null;
37
- }
38
- },
39
- write: (key, value) => {
40
- try {
41
- sessionStorage.setItem(key, value);
42
- } catch (e) {
43
- console.warn('[labkit] sessionStorage write failed:', e);
44
- }
45
- },
46
- delete: (key) => {
47
- try {
48
- sessionStorage.removeItem(key);
49
- } catch {
50
- /* ignore */
4
+ /** JSON-backed substrates held raw strings before records existed — a theme
5
+ * bucket stored `dark`, not `"dark"` — so an unparseable value comes back as
6
+ * the string it is. */
7
+ function parse(raw: string): unknown {
8
+ try {
9
+ return JSON.parse(raw);
10
+ } catch {
11
+ return raw;
12
+ }
13
+ }
14
+
15
+ /** Whether `JSON.stringify` would keep `value` exactly. An object key holding
16
+ * `undefined` counts as kept: JSON drops the key, and reading back an absent
17
+ * key yields the same `undefined`. */
18
+ function isJsonSafe(value: unknown, seen: Set<object> = new Set()): boolean {
19
+ if (value === null) return true;
20
+ switch (typeof value) {
21
+ case 'string':
22
+ case 'boolean':
23
+ return true;
24
+ case 'number':
25
+ return Number.isFinite(value);
26
+ case 'object': {
27
+ if (seen.has(value)) return false;
28
+ seen.add(value);
29
+ const proto = Object.getPrototypeOf(value);
30
+ const ok = Array.isArray(value)
31
+ ? value.every((v) => isJsonSafe(v, seen))
32
+ : (proto === Object.prototype || proto === null) &&
33
+ Object.values(value).every((v) => v === undefined || isJsonSafe(v, seen));
34
+ seen.delete(value);
35
+ return ok;
51
36
  }
52
- },
53
- };
37
+ default:
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function webStorageAdapter(area: () => Storage, name: string, crossTab: boolean): StorageAdapter {
43
+ const warned = new Set<string>();
44
+ const adapter: StorageAdapter = {
45
+ get: async (key) => {
46
+ const raw = area().getItem(key);
47
+ return raw === null ? undefined : parse(raw);
48
+ },
49
+ list: async (prefix) => {
50
+ const store = area();
51
+ const out: [string, unknown][] = [];
52
+ for (let i = 0; i < store.length; i++) {
53
+ const key = store.key(i);
54
+ if (key === null || !key.startsWith(prefix)) continue;
55
+ const raw = store.getItem(key);
56
+ if (raw !== null) out.push([key, parse(raw)]);
57
+ }
58
+ return out;
59
+ },
60
+ set: async (key, value) => {
61
+ if (!warned.has(key) && !isJsonSafe(value)) {
62
+ warned.add(key);
63
+ console.warn(
64
+ `[labkit] "${key}" holds a value JSON cannot keep exactly; ${name} stores what JSON keeps. Use IndexedDB for binary or non-JSON state.`,
65
+ );
66
+ }
67
+ area().setItem(key, JSON.stringify(value));
68
+ },
69
+ delete: async (key) => {
70
+ area().removeItem(key);
71
+ },
72
+ };
73
+ if (crossTab) {
74
+ adapter.subscribe = (prefix, on) => {
75
+ if (typeof window === 'undefined') return () => {};
76
+ const handler = (e: StorageEvent): void => {
77
+ if (e.key === null || !e.key.startsWith(prefix)) return;
78
+ if (e.storageArea !== area()) return;
79
+ on([[e.key, e.newValue === null ? undefined : parse(e.newValue)]]);
80
+ };
81
+ window.addEventListener('storage', handler);
82
+ return () => window.removeEventListener('storage', handler);
83
+ };
84
+ }
85
+ return adapter;
86
+ }
87
+
88
+ /** Persist to `localStorage`, one JSON value per record. Survives a reload and
89
+ * reaches other tabs through the `storage` event. */
90
+ export const localStorageAdapter: StorageAdapter = webStorageAdapter(
91
+ () => localStorage,
92
+ 'localStorage',
93
+ true,
94
+ );
95
+
96
+ /** Persist to `sessionStorage` — survives a reload but not a new tab. */
97
+ export const sessionStorageAdapter: StorageAdapter = webStorageAdapter(
98
+ () => sessionStorage,
99
+ 'sessionStorage',
100
+ false,
101
+ );
54
102
 
55
103
  const URL_HASH_GUARD = typeof window !== 'undefined';
56
104
 
@@ -65,46 +113,257 @@ function readHashMap(): Record<string, string> {
65
113
  }
66
114
  }
67
115
 
116
+ /** What each live subscription last saw, refreshed on this page's own writes
117
+ * so `hashchange` reports only somebody else's. */
118
+ const hashSubscribers = new Set<{ last: Record<string, string> }>();
119
+
68
120
  function writeHashMap(map: Record<string, string>): void {
69
121
  if (!URL_HASH_GUARD) return;
70
122
  const encoded = encodeUrlHash(JSON.stringify(map));
71
123
  window.history.replaceState(null, '', `#${encoded}`);
124
+ for (const sub of hashSubscribers) sub.last = { ...map };
72
125
  }
73
126
 
74
- /** Persist into the URL fragment, so the page's link carries its state and
75
- * can be shared or bookmarked. */
127
+ /** Persist into the URL fragment, so the page's link carries its state and can
128
+ * be shared or bookmarked. Changes to the fragment made elsewhere — a pasted
129
+ * link, back and forward — arrive through `hashchange`. */
76
130
  export const urlHashAdapter: StorageAdapter = {
77
- read: (key) => readHashMap()[key] ?? null,
78
- write: (key, value) => {
131
+ get: async (key) => {
132
+ const raw = readHashMap()[key];
133
+ return raw === undefined ? undefined : parse(raw);
134
+ },
135
+ list: async (prefix) =>
136
+ Object.entries(readHashMap())
137
+ .filter(([key]) => key.startsWith(prefix))
138
+ .map(([key, raw]): [string, unknown] => [key, parse(raw)]),
139
+ set: async (key, value) => {
79
140
  const map = readHashMap();
80
- map[key] = value;
141
+ map[key] = JSON.stringify(value);
81
142
  writeHashMap(map);
82
143
  },
83
- delete: (key) => {
144
+ delete: async (key) => {
84
145
  const map = readHashMap();
85
146
  delete map[key];
86
147
  writeHashMap(map);
87
148
  },
149
+ subscribe: (prefix, on) => {
150
+ if (!URL_HASH_GUARD) return () => {};
151
+ const sub = { last: readHashMap() };
152
+ hashSubscribers.add(sub);
153
+ const handler = (): void => {
154
+ const next = readHashMap();
155
+ const changes: StorageChange[] = [];
156
+ for (const key of new Set([...Object.keys(sub.last), ...Object.keys(next)])) {
157
+ if (!key.startsWith(prefix) || sub.last[key] === next[key]) continue;
158
+ const raw = next[key];
159
+ changes.push([key, raw === undefined ? undefined : parse(raw)]);
160
+ }
161
+ sub.last = next;
162
+ if (changes.length > 0) on(changes);
163
+ };
164
+ window.addEventListener('hashchange', handler);
165
+ return () => {
166
+ hashSubscribers.delete(sub);
167
+ window.removeEventListener('hashchange', handler);
168
+ };
169
+ },
88
170
  };
89
171
 
90
- /** An in-memory store, discarded on reload. For tests, and for labs that
91
- * should start fresh every time. */
92
- export function createMemoryAdapter(): StorageAdapter {
93
- const store = new Map<string, string>();
94
- return {
95
- read: (key) => store.get(key) ?? null,
96
- write: (key, value) => {
97
- store.set(key, value);
172
+ interface MemorySubscriber {
173
+ owner: StorageAdapter;
174
+ prefix: string;
175
+ on: (changes: StorageChange[]) => void;
176
+ }
177
+
178
+ const memorySubscribers = new WeakMap<Map<string, unknown>, Set<MemorySubscriber>>();
179
+
180
+ /** An in-memory store, discarded on reload. Values are copied in and out, as a
181
+ * real substrate would. Adapters built over one shared `backing` hear each
182
+ * other's writes — two tabs, for a test. */
183
+ export function createMemoryAdapter(backing: Map<string, unknown> = new Map()): StorageAdapter {
184
+ let subscribers = memorySubscribers.get(backing);
185
+ if (!subscribers) {
186
+ subscribers = new Set();
187
+ memorySubscribers.set(backing, subscribers);
188
+ }
189
+ const peers = subscribers;
190
+ const notify = (key: string, value: unknown): void => {
191
+ for (const sub of peers) {
192
+ if (sub.owner === adapter || !key.startsWith(sub.prefix)) continue;
193
+ const copy = value === undefined ? undefined : structuredClone(value);
194
+ queueMicrotask(() => sub.on([[key, copy]]));
195
+ }
196
+ };
197
+ const adapter: StorageAdapter = {
198
+ get: async (key) => (backing.has(key) ? structuredClone(backing.get(key)) : undefined),
199
+ list: async (prefix) =>
200
+ [...backing]
201
+ .filter(([key]) => key.startsWith(prefix))
202
+ .map(([key, value]): [string, unknown] => [key, structuredClone(value)]),
203
+ set: async (key, value) => {
204
+ const copy = structuredClone(value);
205
+ backing.set(key, copy);
206
+ notify(key, copy);
207
+ },
208
+ delete: async (key) => {
209
+ backing.delete(key);
210
+ notify(key, undefined);
98
211
  },
99
- delete: (key) => {
100
- store.delete(key);
212
+ subscribe: (prefix, on) => {
213
+ const sub = { owner: adapter, prefix, on };
214
+ peers.add(sub);
215
+ return () => {
216
+ peers.delete(sub);
217
+ };
101
218
  },
102
219
  };
220
+ return adapter;
103
221
  }
104
222
 
105
223
  /** Persists nothing and reads back nothing. */
106
224
  export const noneAdapter: StorageAdapter = {
107
- read: () => null,
108
- write: () => {},
109
- delete: () => {},
225
+ get: async () => undefined,
226
+ list: async () => [],
227
+ set: async () => {},
228
+ delete: async () => {},
110
229
  };
230
+
231
+ /** Options for `createIndexedDbAdapter`. */
232
+ export interface IndexedDbAdapterOptions {
233
+ /** Default `'labkit'`. */
234
+ database?: string;
235
+ /** Default `'records'`. One object store per database: a store added to a
236
+ * database that already exists is not created. */
237
+ store?: string;
238
+ }
239
+
240
+ interface NodeChannel extends BroadcastChannel {
241
+ unref?: () => void;
242
+ }
243
+
244
+ /** Persist to IndexedDB, which stores structured-clone values natively — typed
245
+ * arrays, `Map`s, `Blob`s — and holds far more than localStorage. Other tabs
246
+ * hear writes through a `BroadcastChannel`. The database opens on first use. */
247
+ export function createIndexedDbAdapter({
248
+ database = 'labkit',
249
+ store = 'records',
250
+ }: IndexedDbAdapterOptions = {}): StorageAdapter {
251
+ let opened: Promise<IDBDatabase> | null = null;
252
+ const open = (): Promise<IDBDatabase> => {
253
+ opened ??= new Promise((resolve, reject) => {
254
+ if (typeof indexedDB === 'undefined') {
255
+ reject(new Error('[labkit] IndexedDB is not available here'));
256
+ return;
257
+ }
258
+ const request = indexedDB.open(database, 1);
259
+ request.onupgradeneeded = () => {
260
+ if (!request.result.objectStoreNames.contains(store)) {
261
+ request.result.createObjectStore(store);
262
+ }
263
+ };
264
+ request.onsuccess = () => {
265
+ const db = request.result;
266
+ if (!db.objectStoreNames.contains(store)) {
267
+ reject(new Error(`[labkit] IndexedDB database "${database}" has no store "${store}"`));
268
+ return;
269
+ }
270
+ resolve(db);
271
+ };
272
+ request.onerror = () => reject(request.error);
273
+ request.onblocked = () => reject(new Error(`[labkit] IndexedDB "${database}" is blocked`));
274
+ });
275
+ return opened;
276
+ };
277
+
278
+ const run = <T>(mode: IDBTransactionMode, body: (s: IDBObjectStore) => () => T): Promise<T> =>
279
+ open().then(
280
+ (db) =>
281
+ new Promise<T>((resolve, reject) => {
282
+ const tx = db.transaction(store, mode);
283
+ const result = body(tx.objectStore(store));
284
+ tx.oncomplete = () => resolve(result());
285
+ tx.onerror = () => reject(tx.error);
286
+ tx.onabort = () => reject(tx.error);
287
+ }),
288
+ );
289
+
290
+ let channel: NodeChannel | null = null;
291
+ const getChannel = (): NodeChannel | null => {
292
+ if (typeof BroadcastChannel === 'undefined') return null;
293
+ if (!channel) {
294
+ channel = new BroadcastChannel(`labkit:${database}:${store}`) as NodeChannel;
295
+ // Node keeps its event loop alive for an open channel; a browser has no unref.
296
+ channel.unref?.();
297
+ }
298
+ return channel;
299
+ };
300
+ const announce = (changes: StorageChange[]): void => {
301
+ getChannel()?.postMessage(changes);
302
+ };
303
+
304
+ const adapter: StorageAdapter = {
305
+ get: (key) =>
306
+ run('readonly', (s) => {
307
+ const request = s.get(key);
308
+ return () => request.result as unknown;
309
+ }),
310
+ list: (prefix) =>
311
+ run('readonly', (s) => {
312
+ const range = IDBKeyRange.bound(prefix, `${prefix}￿`);
313
+ const keys = s.getAllKeys(range);
314
+ const values = s.getAll(range);
315
+ return () =>
316
+ keys.result.map((key, i): [string, unknown] => [String(key), values.result[i]]);
317
+ }),
318
+ set: async (key, value) => {
319
+ await run('readwrite', (s) => {
320
+ s.put(value, key);
321
+ return () => undefined;
322
+ });
323
+ announce([[key, value]]);
324
+ },
325
+ delete: async (key) => {
326
+ await run('readwrite', (s) => {
327
+ s.delete(key);
328
+ return () => undefined;
329
+ });
330
+ announce([[key, undefined]]);
331
+ },
332
+ };
333
+ if (typeof BroadcastChannel !== 'undefined') {
334
+ adapter.subscribe = (prefix, on) => {
335
+ const ch = getChannel();
336
+ if (!ch) return () => {};
337
+ const handler = (e: MessageEvent<StorageChange[]>): void => {
338
+ const mine = e.data.filter(([key]) => key.startsWith(prefix));
339
+ if (mine.length > 0) on(mine);
340
+ };
341
+ ch.addEventListener('message', handler);
342
+ return () => ch.removeEventListener('message', handler);
343
+ };
344
+ }
345
+ return adapter;
346
+ }
347
+
348
+ /** IndexedDB under the default database and store. */
349
+ export const indexedDbAdapter: StorageAdapter = createIndexedDbAdapter();
350
+
351
+ let resolvedDefault: Promise<StorageAdapter> | null = null;
352
+
353
+ /** What a lab given only a `storageKey` persists to: IndexedDB, or
354
+ * localStorage — with a warning — where IndexedDB will not open. */
355
+ export function defaultStorage(): Promise<StorageAdapter> {
356
+ resolvedDefault ??= indexedDbAdapter.list('').then(
357
+ () => indexedDbAdapter,
358
+ (error) => {
359
+ console.warn('[labkit] IndexedDB would not open; persisting to localStorage instead', error);
360
+ return localStorageAdapter;
361
+ },
362
+ );
363
+ return resolvedDefault;
364
+ }
365
+
366
+ /** Forget which default was chosen. Tests only. */
367
+ export function resetDefaultStorage(): void {
368
+ resolvedDefault = null;
369
+ }