@weasel-js/labkit 1.4.3 → 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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  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-QAGAHJKZ.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-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.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-C42T6DJT.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-ASWKLRKJ.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 +123 -348
  53. package/dist/index.js +794 -837
  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 +169 -117
  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 +163 -62
  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/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -10,11 +10,10 @@ import type {
10
10
  import type { UndockedPanels } from './undock';
11
11
 
12
12
  /** Bumped whenever the persisted shape changes; every bump needs a migration. */
13
- export const CURRENT_DOCUMENT_VERSION = 3;
13
+ export const CURRENT_DOCUMENT_VERSION = 4;
14
14
 
15
- /** The one key a lab persists under. The `:doc` suffix keeps it out of the
16
- * legacy bucket namespace, where a lab named `a:saves` would otherwise write
17
- * its document over lab `a`'s saves bucket. */
15
+ /** Where a version-3 lab kept its one document. Read once, to fold it into
16
+ * records, then deleted. */
18
17
  export function labDocumentKey(storageKey: string): string {
19
18
  return `lk:${storageKey}:doc`;
20
19
  }
@@ -69,83 +68,92 @@ export function runMigrations(
69
68
 
70
69
  const LEGACY_BUCKETS = ['workspaces', 'saves', 'layout', 'theme'] as const;
71
70
 
72
- function parseOr<T>(raw: string | null, fallback: T, bucket: string): T {
73
- if (raw === null) return fallback;
71
+ /** The four keys a lab used before it was one document. */
72
+ export function legacyKeys(storageKey: string): string[] {
73
+ return LEGACY_BUCKETS.map((bucket) => labStorageKey(storageKey, bucket));
74
+ }
75
+
76
+ /** A value some substrate returned for an old key: already parsed, or a JSON
77
+ * string from a store that does not parse. `undefined` when it is neither. */
78
+ export function parseStored(value: unknown): unknown {
79
+ if (typeof value !== 'string') return value;
74
80
  try {
75
- return JSON.parse(raw) as T;
81
+ return JSON.parse(value);
76
82
  } catch {
77
- console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
78
- return fallback;
83
+ return undefined;
79
84
  }
80
85
  }
81
86
 
87
+ function bucketOr<T>(value: unknown, fallback: T, bucket: string, shape: 'array' | 'object'): T {
88
+ if (value === undefined) return fallback;
89
+ const parsed = parseStored(value);
90
+ const fits = shape === 'array' ? Array.isArray(parsed) : !!parsed && typeof parsed === 'object';
91
+ if (fits) return parsed as T;
92
+ console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
93
+ return fallback;
94
+ }
95
+
82
96
  /** Assemble a version-0 document out of the four pre-document keys, or null
83
97
  * if none of them is present. `fallbackMode` is used when the theme key is
84
98
  * absent or holds something unrecognized; `interstellar` passes through
85
99
  * untouched for `migrateV0toV1` to coerce. */
86
- export function readLegacyDocument(
87
- storage: StorageAdapter,
100
+ export async function readLegacyDocument(
101
+ read: (key: string) => Promise<unknown>,
88
102
  storageKey: string,
89
103
  fallbackMode: LabMode,
90
- ): Record<string, unknown> | null {
91
- const present = LEGACY_BUCKETS.some((b) => storage.read(labStorageKey(storageKey, b)) !== null);
92
- if (!present) return null;
104
+ ): Promise<Record<string, unknown> | null> {
105
+ const [workspaces, saves, layout, theme] = await Promise.all(
106
+ LEGACY_BUCKETS.map((bucket) => read(labStorageKey(storageKey, bucket))),
107
+ );
108
+ if ([workspaces, saves, layout, theme].every((v) => v === undefined)) return null;
93
109
 
94
- const rawMode = storage.read(labStorageKey(storageKey, 'theme'));
95
110
  const mode =
96
- rawMode === 'light' || rawMode === 'dark' || rawMode === 'auto' || rawMode === 'interstellar'
97
- ? rawMode
111
+ theme === 'light' || theme === 'dark' || theme === 'auto' || theme === 'interstellar'
112
+ ? theme
98
113
  : fallbackMode;
99
114
 
100
115
  return {
101
116
  version: 0,
102
- workspaces: parseOr(storage.read(labStorageKey(storageKey, 'workspaces')), [], 'workspaces'),
103
- saves: parseOr(storage.read(labStorageKey(storageKey, 'saves')), [], 'saves'),
104
- layout: parseOr(storage.read(labStorageKey(storageKey, 'layout')), {}, 'layout'),
117
+ workspaces: bucketOr(workspaces, [], 'workspaces', 'array'),
118
+ saves: bucketOr(saves, [], 'saves', 'array'),
119
+ layout: bucketOr(layout, {}, 'layout', 'object'),
105
120
  mode,
106
121
  };
107
122
  }
108
123
 
109
- /** Delete the four pre-document keys, but only once `expectedDocument` is
110
- * confirmed to be exactly what is stored under the document key — a
111
- * read-back, not a trust of the write that produced it. On any mismatch
112
- * (write failed, landed partially, or never happened) it deletes nothing,
113
- * warns, and returns `false` so the legacy buckets stay as a recoverable
114
- * copy. Deletion itself is read back too — `StorageAdapter.delete` is
115
- * optional, so an adapter without it leaves the keys in place — and `true`
116
- * is returned only once all four are actually gone. */
117
- export function deleteLegacyKeys(
118
- storage: StorageAdapter,
119
- storageKey: string,
120
- expectedDocument: string,
121
- ): boolean {
122
- const actual = storage.read(labDocumentKey(storageKey));
123
- if (actual !== expectedDocument) {
124
- console.warn(
125
- `[labkit] keeping legacy keys for "${storageKey}": could not confirm the migrated document was written`,
126
- );
124
+ /** Whether two stored values hold the same data. Stored values are
125
+ * structured-clone copies, so identity says nothing. */
126
+ export function sameStored(a: unknown, b: unknown): boolean {
127
+ return JSON.stringify(a) === JSON.stringify(b);
128
+ }
129
+
130
+ /** Delete `keys` and read each back, so an adapter whose delete silently did
131
+ * nothing is caught. `true` only once all of them are gone. */
132
+ export async function deleteConfirmed(storage: StorageAdapter, keys: string[]): Promise<boolean> {
133
+ try {
134
+ await Promise.all(keys.map((key) => storage.delete(key)));
135
+ const left = await Promise.all(keys.map((key) => storage.get(key)));
136
+ return left.every((v) => v === undefined);
137
+ } catch {
127
138
  return false;
128
139
  }
129
- let allGone = true;
130
- for (const bucket of LEGACY_BUCKETS) {
131
- const key = labStorageKey(storageKey, bucket);
132
- storage.delete?.(key);
133
- if (storage.read(key) !== null) allGone = false;
134
- }
135
- return allGone;
136
140
  }
137
141
 
138
- /** Set `raw` aside under the quarantine key, reading it back to confirm it
142
+ /** Set `value` aside under the quarantine key, reading it back to confirm it
139
143
  * landed. Returns `false` when it did not — a full disk is exactly when a
140
144
  * document goes unreadable — so the caller can leave the original alone
141
145
  * rather than overwrite the only copy of it. */
142
- export function quarantineDocument(
146
+ export async function quarantineDocument(
143
147
  storage: StorageAdapter,
144
148
  storageKey: string,
145
- raw: string,
146
- ): boolean {
147
- storage.write(quarantineKey(storageKey), raw);
148
- return storage.read(quarantineKey(storageKey)) === raw;
149
+ value: unknown,
150
+ ): Promise<boolean> {
151
+ try {
152
+ await storage.set(quarantineKey(storageKey), value);
153
+ return sameStored(await storage.get(quarantineKey(storageKey)), value);
154
+ } catch {
155
+ return false;
156
+ }
149
157
  }
150
158
 
151
159
  /** Fill in whatever a document is missing or holds the wrong shape of, so a
@@ -202,5 +210,11 @@ export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unkn
202
210
  return { ...doc, undockedPanels: {}, version: 3 };
203
211
  }
204
212
 
213
+ /** Version 3 to version 4: a lab is stored as records under one prefix
214
+ * instead of one document. The joined shape does not change. */
215
+ export function migrateV3toV4(doc: Record<string, unknown>): Record<string, unknown> {
216
+ return { ...doc, version: 4 };
217
+ }
218
+
205
219
  /** Index `i` migrates a version-`i` document to version `i + 1`. */
206
- export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
220
+ export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3, migrateV3toV4];
@@ -5,6 +5,7 @@ import {
5
5
  emptyUndoStack,
6
6
  encodeUrlHash,
7
7
  labStorageKey,
8
+ newId,
8
9
  serializeTrials,
9
10
  } from './helpers';
10
11
 
@@ -114,4 +115,23 @@ describe('deserializeTrials', () => {
114
115
  it('returns an empty list when given something that is not an array', () => {
115
116
  expect(deserializeTrials(undefined as never, {})).toEqual([]);
116
117
  });
118
+
119
+ describe('newId', () => {
120
+ it('mints distinct ids', () => {
121
+ expect(newId()).not.toBe(newId());
122
+ });
123
+
124
+ it('falls back where crypto.randomUUID is absent', () => {
125
+ // What a lab reached by LAN IP gets: not a secure context, so the
126
+ // function is not there. It used to throw on the first render and show a
127
+ // blank page.
128
+ const real = globalThis.crypto;
129
+ Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
130
+ try {
131
+ expect(newId()).not.toBe(newId());
132
+ } finally {
133
+ Object.defineProperty(globalThis, 'crypto', { value: real, configurable: true });
134
+ }
135
+ });
136
+ });
117
137
  });
@@ -25,6 +25,22 @@ export function decodeUrlHash(hash: string): string | null {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * An id for a trial or a snapshot.
30
+ *
31
+ * `crypto.randomUUID` exists only in a secure context, and a LAN address is
32
+ * not one — so a lab opened on a phone or a tablet by IP throws on its first
33
+ * render and shows a blank page with nothing in reach to say why. The counter
34
+ * is per page load, which is all these ids have to survive: they name records
35
+ * inside one session's store, and a saved snapshot carries its own name.
36
+ */
37
+ let idCounter = 0;
38
+ export function newId(): string {
39
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
40
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
41
+ return `lk-${Date.now().toString(36)}-${idCounter++}`;
42
+ }
43
+
28
44
  /** A fresh, empty undo history. */
29
45
  export function emptyUndoStack(): UndoStack {
30
46
  return { past: [], future: [] };
@@ -1,5 +1,8 @@
1
1
  export {
2
+ createIndexedDbAdapter,
2
3
  createMemoryAdapter,
4
+ type IndexedDbAdapterOptions,
5
+ indexedDbAdapter,
3
6
  localStorageAdapter,
4
7
  noneAdapter,
5
8
  sessionStorageAdapter,
@@ -26,6 +29,9 @@ export {
26
29
  labStorageKey,
27
30
  serializeTrials,
28
31
  } from './helpers';
32
+ export { type OpenedLabStore, type OpenLabStoreOptions, openLabStore } from './openLabStore';
33
+ export { Persistence, type PersistenceProps } from './Persistence';
34
+ export type { RecordCache, RecordChange } from './records';
29
35
  export {
30
36
  SingletonExperimentProvider,
31
37
  type SingletonExperimentProviderProps,
@@ -41,10 +47,12 @@ export type {
41
47
  SavedSnapshot,
42
48
  SerializedTrial,
43
49
  StorageAdapter,
50
+ StorageChange,
44
51
  TrialRecord,
45
52
  TrialStateHandle,
46
53
  UndoStack,
47
54
  } from './types';
48
55
  export type { UndockedPanel, UndockedPanels } from './undock';
49
56
  export { dockPanel, panelKey, undockPanel } from './undock';
57
+ export { type PersistedStateOptions, usePersistedState } from './usePersistedState';
50
58
  export { useTrialState } from './useTrialState';
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CURRENT_DOCUMENT_VERSION } from './document';
3
+ import {
4
+ documentOfRecords,
5
+ labPrefix,
6
+ parseRecordName,
7
+ recordsOfDocument,
8
+ trialRecord,
9
+ valueRecord,
10
+ } from './labRecords';
11
+ import type { LabDocument } from './types';
12
+
13
+ const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
14
+
15
+ describe('record names', () => {
16
+ it('encodes the storage key, so no lab prefix contains another', () => {
17
+ expect(labPrefix('drag-lab')).toBe('lk:drag-lab:');
18
+ expect(labPrefix('a:trial:x')).toBe('lk:a%3Atrial%3Ax:');
19
+ expect(labPrefix('a:trial:x').startsWith(labPrefix('a'))).toBe(false);
20
+ });
21
+
22
+ it('round-trips ids holding the separator', () => {
23
+ expect(parseRecordName(trialRecord('odd:id'))).toEqual({ kind: 'trial', id: 'odd:id' });
24
+ });
25
+
26
+ it('scopes a value to its trial or to the lab', () => {
27
+ expect(valueRecord('t:1', 'tab')).toBe('value:trial:t%3A1:tab');
28
+ expect(valueRecord(null, 'tab')).toBe('value:lab:tab');
29
+ expect(parseRecordName(valueRecord('t:1', 'a:b'))).toEqual({ kind: 'value' });
30
+ });
31
+
32
+ it('answers null for a name no lab writes', () => {
33
+ for (const name of [
34
+ 'doc',
35
+ 'meta:x',
36
+ 'trial:',
37
+ 'trial:a:b',
38
+ 'value:lab',
39
+ 'value:x:y',
40
+ 'trial:%E0%A4%A',
41
+ ]) {
42
+ expect(parseRecordName(name)).toBeNull();
43
+ }
44
+ });
45
+ });
46
+
47
+ describe('documents and records', () => {
48
+ const doc: LabDocument = {
49
+ version: CURRENT_DOCUMENT_VERSION,
50
+ trials: [
51
+ { id: 'b', instrumentName: 'T', config: {}, state: { n: 2 }, view: VIEW },
52
+ { id: 'a', instrumentName: 'T', config: {}, state: { n: 1 }, view: VIEW },
53
+ ],
54
+ saves: [
55
+ {
56
+ id: 's2',
57
+ name: 'later',
58
+ trialId: 'a',
59
+ instrumentName: 'T',
60
+ config: {},
61
+ state: {},
62
+ savedAt: 2,
63
+ },
64
+ {
65
+ id: 's1',
66
+ name: 'earlier',
67
+ trialId: 'a',
68
+ instrumentName: 'T',
69
+ config: {},
70
+ state: {},
71
+ savedAt: 1,
72
+ },
73
+ ],
74
+ layout: { a: { h: 1 } },
75
+ undockedPanels: {},
76
+ mode: 'dark',
77
+ };
78
+
79
+ it('writes meta last', () => {
80
+ const names = recordsOfDocument(doc).map(([name]) => name);
81
+ expect(names.at(-1)).toBe('meta');
82
+ });
83
+
84
+ it('joins back into the document it came from, trials in stored order', () => {
85
+ const joined = documentOfRecords(recordsOfDocument(doc).reverse());
86
+ expect(joined?.doc).toEqual({ ...doc, saves: [doc.saves[1], doc.saves[0]] });
87
+ expect([...(joined?.orders ?? [])]).toEqual([
88
+ ['b', 0],
89
+ ['a', 1],
90
+ ]);
91
+ });
92
+
93
+ it('breaks an order tie by id', () => {
94
+ const joined = documentOfRecords([
95
+ ['meta', { version: 4, mode: 'auto' }],
96
+ ['trial:z', { instrumentName: 'T', order: 0 }],
97
+ ['trial:m', { instrumentName: 'T', order: 0 }],
98
+ ]);
99
+ expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
100
+ });
101
+
102
+ it('is null without a meta record', () => {
103
+ expect(documentOfRecords([['trial:a', { instrumentName: 'T', order: 0 }]])).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,160 @@
1
+ import type { LabDocument, SavedSnapshot, SerializedTrial } from './types';
2
+
3
+ /** Every record of the lab stored under `storageKey` starts with this. The key
4
+ * is URI-encoded, so it holds no `:` and one lab's prefix can never reach into
5
+ * another lab's records. */
6
+ export function labPrefix(storageKey: string): string {
7
+ return `lk:${encodeURIComponent(storageKey)}:`;
8
+ }
9
+
10
+ export const META_RECORD = 'meta';
11
+ export const LAYOUT_RECORD = 'layout';
12
+ export const UNDOCK_RECORD = 'undock';
13
+
14
+ export function trialRecord(id: string): string {
15
+ return `trial:${encodeURIComponent(id)}`;
16
+ }
17
+
18
+ export function saveRecord(id: string): string {
19
+ return `save:${encodeURIComponent(id)}`;
20
+ }
21
+
22
+ /** The prefix every value scoped to one trial shares. */
23
+ export function trialValuesPrefix(trialId: string): string {
24
+ return `value:trial:${encodeURIComponent(trialId)}:`;
25
+ }
26
+
27
+ /** Where a `usePersistedState` value lives: under its trial, or — with a null
28
+ * trial — under the lab. */
29
+ export function valueRecord(trialId: string | null, name: string): string {
30
+ const encoded = encodeURIComponent(name);
31
+ return trialId === null ? `value:lab:${encoded}` : `${trialValuesPrefix(trialId)}${encoded}`;
32
+ }
33
+
34
+ /** What a record name addresses, or null for a name no lab writes. */
35
+ export type RecordName =
36
+ | { kind: 'meta' | 'layout' | 'undock' | 'value' }
37
+ | { kind: 'trial' | 'save'; id: string };
38
+
39
+ export function parseRecordName(name: string): RecordName | null {
40
+ const parts = name.split(':');
41
+ try {
42
+ switch (parts[0]) {
43
+ case 'meta':
44
+ case 'layout':
45
+ case 'undock':
46
+ return parts.length === 1 ? { kind: parts[0] } : null;
47
+ case 'trial':
48
+ case 'save':
49
+ return parts.length === 2 && parts[1]
50
+ ? { kind: parts[0], id: decodeURIComponent(parts[1]) }
51
+ : null;
52
+ case 'value':
53
+ return (parts[1] === 'lab' && parts.length === 3) ||
54
+ (parts[1] === 'trial' && parts.length === 4)
55
+ ? { kind: 'value' }
56
+ : null;
57
+ default:
58
+ return null;
59
+ }
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** The fields of the meta record. */
66
+ export interface LabMeta {
67
+ version: number;
68
+ mode: unknown;
69
+ }
70
+
71
+ function isObject(value: unknown): value is Record<string, unknown> {
72
+ return !!value && typeof value === 'object' && !Array.isArray(value);
73
+ }
74
+
75
+ /** A document as the records a lab stores it in, `meta` last — so a write in
76
+ * this order that stops partway leaves no `meta`, and the next open folds the
77
+ * old storage again rather than trusting a partial set. */
78
+ export function recordsOfDocument(doc: LabDocument): [string, unknown][] {
79
+ return [
80
+ ...doc.trials.map((t, order): [string, unknown] => [trialRecord(t.id), { ...t, order }]),
81
+ ...doc.saves.map((s): [string, unknown] => [saveRecord(s.id), s]),
82
+ [LAYOUT_RECORD, doc.layout],
83
+ [UNDOCK_RECORD, doc.undockedPanels],
84
+ [META_RECORD, { version: doc.version, mode: doc.mode } satisfies LabMeta],
85
+ ];
86
+ }
87
+
88
+ /** Records joined back into an unmigrated document, with each trial's stored
89
+ * order. Null when there is no `meta` record — no lab has been stored here in
90
+ * this form. */
91
+ export function documentOfRecords(
92
+ entries: Iterable<[string, unknown]>,
93
+ ): { doc: Record<string, unknown>; orders: Map<string, number> } | null {
94
+ let meta: unknown;
95
+ let layout: unknown;
96
+ let undock: unknown;
97
+ const trials: { trial: SerializedTrial; order: number }[] = [];
98
+ const saves: SavedSnapshot[] = [];
99
+
100
+ for (const [name, value] of entries) {
101
+ const parsed = parseRecordName(name);
102
+ if (!parsed) continue;
103
+ switch (parsed.kind) {
104
+ case 'meta':
105
+ meta = value;
106
+ break;
107
+ case 'layout':
108
+ layout = value;
109
+ break;
110
+ case 'undock':
111
+ undock = value;
112
+ break;
113
+ case 'trial':
114
+ if (isObject(value)) {
115
+ const { order, ...rest } = value;
116
+ trials.push({
117
+ trial: { ...rest, id: parsed.id } as SerializedTrial,
118
+ order: typeof order === 'number' && Number.isFinite(order) ? order : Infinity,
119
+ });
120
+ }
121
+ break;
122
+ case 'save':
123
+ if (isObject(value)) saves.push({ ...value, id: parsed.id } as SavedSnapshot);
124
+ break;
125
+ }
126
+ }
127
+ if (!isObject(meta)) return null;
128
+
129
+ trials.sort(compareTrialOrder);
130
+ const orders = new Map<string, number>();
131
+ let next = 0;
132
+ for (const { trial, order } of trials) {
133
+ const at = Number.isFinite(order) ? order : next;
134
+ orders.set(trial.id, at);
135
+ next = Math.max(next, at + 1);
136
+ }
137
+ saves.sort((a, b) => a.savedAt - b.savedAt);
138
+
139
+ return {
140
+ doc: {
141
+ version: meta.version,
142
+ mode: meta.mode,
143
+ trials: trials.map((t) => t.trial),
144
+ saves,
145
+ layout,
146
+ undockedPanels: undock,
147
+ },
148
+ orders,
149
+ };
150
+ }
151
+
152
+ /** Stored order first; two trials added at once in two tabs can share an
153
+ * order, so the id breaks the tie the same way everywhere. */
154
+ export function compareTrialOrder(
155
+ a: { trial: { id: string }; order: number },
156
+ b: { trial: { id: string }; order: number },
157
+ ): number {
158
+ if (a.order !== b.order) return a.order < b.order ? -1 : 1;
159
+ return a.trial.id < b.trial.id ? -1 : a.trial.id > b.trial.id ? 1 : 0;
160
+ }