@weasel-js/labkit 1.5.1 → 1.5.2

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 (92) hide show
  1. package/dist/_dts/{CanvasStackContext-CJNMS6mz.d.ts → CanvasStackContext-CMMo180l.d.ts} +1 -1
  2. package/dist/_dts/{Select.d-Gwr8fLpK.d.ts → Select.d-ZTiwzYyT.d.ts} +27 -1
  3. package/dist/_dts/{frac-B4okC2lX.d.ts → frac-B4hFJMiN.d.ts} +16 -6
  4. package/dist/_dts/{index-BNdz-tNd.d.ts → index-8LiKe3A-.d.ts} +30 -43
  5. package/dist/_dts/{index-CSJoj4LC.d.ts → index-DlKolEIZ.d.ts} +5 -4
  6. package/dist/_dts/{types-DzoNY1pO.d.ts → types-Db-3NNk1.d.ts} +4 -4
  7. package/dist/_dts/{usePanZoom-Pzf9f2jh.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  8. package/dist/_dts/{useTrialState-uLlmaccn.d.ts → useTrialState-pnAE-UBD.d.ts} +1 -1
  9. package/dist/canvas/index.d.ts +4 -4
  10. package/dist/canvas/index.js +1 -1
  11. package/dist/chrome/index.d.ts +3 -3
  12. package/dist/chrome/index.js +6 -6
  13. package/dist/{chunk-PJ7WEQBJ.js → chunk-6BUQ77ZV.js} +4 -4
  14. package/dist/{chunk-PJ7WEQBJ.js.map → chunk-6BUQ77ZV.js.map} +1 -1
  15. package/dist/{chunk-UN4U4P63.js → chunk-753Y6HE6.js} +9 -9
  16. package/dist/chunk-753Y6HE6.js.map +1 -0
  17. package/dist/{chunk-ZDHY47EV.js → chunk-A2ACAGW6.js} +6 -7
  18. package/dist/chunk-A2ACAGW6.js.map +1 -0
  19. package/dist/{chunk-IHUVQ3TX.js → chunk-CKUKF72R.js} +10 -10
  20. package/dist/chunk-CKUKF72R.js.map +1 -0
  21. package/dist/{chunk-MDVTK5U3.js → chunk-D35PFTS7.js} +3 -3
  22. package/dist/{chunk-MDVTK5U3.js.map → chunk-D35PFTS7.js.map} +1 -1
  23. package/dist/{chunk-PHK4W562.js → chunk-EMXAIRUJ.js} +3 -3
  24. package/dist/{chunk-PHK4W562.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  25. package/dist/{chunk-EH7UEQV7.js → chunk-FKLT3ITC.js} +69 -69
  26. package/dist/chunk-FKLT3ITC.js.map +1 -0
  27. package/dist/{chunk-EHGVROVV.js → chunk-KJCUERQF.js} +3 -3
  28. package/dist/{chunk-EHGVROVV.js.map → chunk-KJCUERQF.js.map} +1 -1
  29. package/dist/chunk-LASCBWW7.js +13456 -0
  30. package/dist/chunk-LASCBWW7.js.map +1 -0
  31. package/dist/{chunk-IXTQI7JX.js → chunk-LRI3MBGR.js} +4 -4
  32. package/dist/{chunk-IXTQI7JX.js.map → chunk-LRI3MBGR.js.map} +1 -1
  33. package/dist/{chunk-7PFH3CNM.js → chunk-REOBGYMC.js} +4 -4
  34. package/dist/{chunk-7PFH3CNM.js.map → chunk-REOBGYMC.js.map} +1 -1
  35. package/dist/{chunk-CEIUBPPW.js → chunk-TT5C5S6R.js} +3 -86
  36. package/dist/chunk-TT5C5S6R.js.map +1 -0
  37. package/dist/config/index.d.ts +7 -8
  38. package/dist/config/index.js +5 -5
  39. package/dist/controls/index.d.ts +2 -2
  40. package/dist/controls/index.js +4 -4
  41. package/dist/dragdrop/index.d.ts +2 -2
  42. package/dist/index.d.ts +72 -28
  43. package/dist/index.js +142 -123
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +3 -3
  46. package/dist/layers/index.js +2 -2
  47. package/dist/loupe/index.d.ts +4 -4
  48. package/dist/passthrough/weasel-ui.d.ts +33 -30
  49. package/dist/passthrough/weasel-ui.js +1 -1
  50. package/dist/primitives/index.js +3 -3
  51. package/dist/state/index.d.ts +4 -4
  52. package/dist/state/index.js +5 -5
  53. package/dist/styles.css +114 -18
  54. package/dist/ui/layers/index.js +1 -1
  55. package/dist/undo/index.d.ts +2 -2
  56. package/package.json +9 -9
  57. package/src/canvas/index.ts +0 -9
  58. package/src/chrome/labTypes.ts +1 -1
  59. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  60. package/src/chrome/regions/PaletteRegion.tsx +5 -4
  61. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  62. package/src/chrome/types.ts +8 -2
  63. package/src/config/builder.ts +3 -4
  64. package/src/config/types.ts +4 -4
  65. package/src/controls/AutoControls.stories.tsx +6 -6
  66. package/src/controls/ControlPanel.less +23 -0
  67. package/src/controls/ControlPanel.stories.tsx +41 -0
  68. package/src/controls/ControlPanel.test.tsx +53 -110
  69. package/src/controls/ControlPanel.tsx +61 -77
  70. package/src/index.test.ts +0 -2
  71. package/src/index.ts +4 -10
  72. package/src/lab/Lab.tsx +21 -5
  73. package/src/layers/LayerList.less +1 -1
  74. package/src/layers/LayerList.stories.tsx +1 -1
  75. package/src/primitives/FpsMeter.less +6 -1
  76. package/src/primitives/FpsMeter.test.tsx +12 -4
  77. package/src/primitives/FpsMeter.tsx +6 -1
  78. package/src/primitives/JobProgress.stories.tsx +1 -1
  79. package/src/primitives/StatusBar.less +6 -0
  80. package/src/state/types.ts +6 -1
  81. package/src/state/view.test.ts +4 -4
  82. package/src/trial/Trial.canvas.test.tsx +8 -4
  83. package/src/trial/Trial.config.test.tsx +1 -1
  84. package/dist/chunk-CEIUBPPW.js.map +0 -1
  85. package/dist/chunk-EH7UEQV7.js.map +0 -1
  86. package/dist/chunk-IHUVQ3TX.js.map +0 -1
  87. package/dist/chunk-T3EC2DXD.js +0 -12628
  88. package/dist/chunk-T3EC2DXD.js.map +0 -1
  89. package/dist/chunk-UN4U4P63.js.map +0 -1
  90. package/dist/chunk-ZDHY47EV.js.map +0 -1
  91. package/src/canvas/useOrbit.test.ts +0 -167
  92. package/src/canvas/useOrbit.ts +0 -156
@@ -23,6 +23,7 @@ import {
23
23
  Switch,
24
24
  SwitchRow,
25
25
  TextRow,
26
+ ToggleBar,
26
27
  ToggleRow,
27
28
  } from '@weasel-js/ui';
28
29
  import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -106,11 +107,11 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
106
107
  /** A fold moved. The key is a section's label — prefixed by its group's
107
108
  * dotted path when the section sits inside one — or a group's own path. */
108
109
  onCollapse?: (key: string, collapsed: boolean) => void;
109
- /** Dotted paths currently unpinned. A row in this set draws ghosted and its
110
- * dot reads as auto, and the dot writes the sentinel back through
111
- * `setConfig`. Omitted, the panel keeps the set itself: the dots still work
112
- * and the sentinel never reaches `setConfig`, which would otherwise store it
113
- * as the row's value. */
110
+ /** Dotted paths currently unpinned. A row in this set hides its control and
111
+ * reads out `auto`, and clicking its label writes the sentinel back through
112
+ * `setConfig`. Omitted, the panel keeps the set itself: the labels still
113
+ * toggle and the sentinel never reaches `setConfig`, which would otherwise
114
+ * store it as the row's value. */
114
115
  auto?: ReadonlySet<string>;
115
116
  /** Draw leaves marked `hidden`. */
116
117
  showHidden?: boolean;
@@ -170,28 +171,6 @@ export function ControlPanel<TC extends Record<string, unknown>>({
170
171
  [resolved, config, auto],
171
172
  );
172
173
 
173
- const listRef = useRef<HTMLDivElement>(null);
174
- const toggles = useRef(new Map<string, () => void>());
175
- // Capture phase, because `PropertyRow` is a <label>: a bubbled handler runs
176
- // after the browser has already begun a range drag or a native control's
177
- // activation, so the shift-click moves the very value it was meant to unpin.
178
- useEffect(() => {
179
- const host = listRef.current;
180
- if (!host) return;
181
- const onDown = (e: PointerEvent) => {
182
- if (!e.shiftKey) return;
183
- const row = (e.target as HTMLElement | null)?.closest('[data-auto-path]');
184
- const path = row?.getAttribute('data-auto-path');
185
- const toggle = path ? toggles.current.get(path) : undefined;
186
- if (!toggle) return;
187
- e.preventDefault();
188
- e.stopPropagation();
189
- toggle();
190
- };
191
- host.addEventListener('pointerdown', onDown, true);
192
- return () => host.removeEventListener('pointerdown', onDown, true);
193
- }, []);
194
-
195
174
  const gridPack = gridOf(pack);
196
175
  // A section that declares how it opens is foldable whether or not the lab
197
176
  // asked for folds — there is nothing else for the declaration to mean.
@@ -230,7 +209,6 @@ export function ControlPanel<TC extends Record<string, unknown>>({
230
209
  layout={rows.layout}
231
210
  auto={auto}
232
211
  setRowAuto={setRowAuto}
233
- toggles={toggles.current}
234
212
  />
235
213
  );
236
214
  }
@@ -238,7 +216,16 @@ export function ControlPanel<TC extends Record<string, unknown>>({
238
216
  // empty `PrefGroup.name` — so it contributes its rows and no chrome.
239
217
  if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
240
218
  return (
241
- <PropertyGroup key={path} title={found.name} {...fold(path, undefined, rows.grid)}>
219
+ <PropertyGroup
220
+ key={path}
221
+ title={found.name}
222
+ description={found.description}
223
+ // A nested group is a block of rows, not a control: half of a
224
+ // two-column grid leaves it laying its own rows out inside a cell,
225
+ // which overlaps them.
226
+ span
227
+ {...fold(path, undefined, rows.grid)}
228
+ >
242
229
  {body(found, path, rows)}
243
230
  </PropertyGroup>
244
231
  );
@@ -289,6 +276,9 @@ export function ControlPanel<TC extends Record<string, unknown>>({
289
276
  shown={shown}
290
277
  setConfig={setConfig}
291
278
  layout={rows.layout}
279
+ // A segmented cell carries every choice at once, so a pair holding
280
+ // one needs the whole row the way a segmented row of its own does.
281
+ span={cells.some(({ leaf }) => extra<string>(leaf, 'control') === 'radio')}
292
282
  />,
293
283
  );
294
284
  i = j;
@@ -324,16 +314,14 @@ export function ControlPanel<TC extends Record<string, unknown>>({
324
314
  };
325
315
 
326
316
  return (
327
- <div ref={listRef}>
328
- <PropertyList
329
- pack={gridPack}
330
- density={density}
331
- align={align}
332
- className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
333
- >
334
- {body(resolved.group, '', { pack, layout, grid: gridPack })}
335
- </PropertyList>
336
- </div>
317
+ <PropertyList
318
+ pack={gridPack}
319
+ density={density}
320
+ align={align}
321
+ className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
322
+ >
323
+ {body(resolved.group, '', { pack, layout, grid: gridPack })}
324
+ </PropertyList>
337
325
  );
338
326
  }
339
327
 
@@ -357,7 +345,6 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
357
345
  layout?: PropertyRowLayout;
358
346
  auto?: ReadonlySet<string>;
359
347
  setRowAuto: (path: string, next: boolean, value: unknown) => void;
360
- toggles: Map<string, () => void>;
361
348
  }
362
349
 
363
350
  /** Whether this leaf draws as a slider, mirroring the condition the `number`
@@ -390,7 +377,6 @@ function ControlRow<TC extends Record<string, unknown>>({
390
377
  layout,
391
378
  auto,
392
379
  setRowAuto,
393
- toggles,
394
380
  }: ControlRowProps<TC>) {
395
381
  const write = (value: unknown): void => setConfig(path, value);
396
382
  const fallback = extra<unknown>(leaf, 'default');
@@ -408,30 +394,9 @@ function ControlRow<TC extends Record<string, unknown>>({
408
394
  const value = resolvedValue ?? pinned;
409
395
  const setAuto = (next: boolean): void => setRowAuto(path, next, value);
410
396
  const onAutoChange = canAuto ? setAuto : undefined;
411
- // A slider's handle is a position, not a number, so the readout is the only
412
- // place its resolved value can be read. Every other control renders its own
413
- // value, and repeating it there says it twice and wraps the narrow slot.
414
- const readsItsOwnValue = !isSliderLeaf(leaf);
415
- // A readout says what the control would say, so it reads in the display
416
- // unit: a radian in a row that edits degrees is a different number.
417
- const readoutValue =
418
- leaf.kind === 'number' && typeof resolvedValue === 'number'
419
- ? numberField(leaf, resolvedValue).shown
420
- : resolvedValue;
421
- const autoReadout = !isAutoRow
422
- ? undefined
423
- : resolvedValue === undefined || readsItsOwnValue
424
- ? 'auto'
425
- : `auto · ${String(readoutValue)}`;
426
- const autoProps = { auto: isAutoRow, onAutoChange, 'data-auto-path': canAuto ? path : undefined };
427
-
428
- useEffect(() => {
429
- if (!canAuto) return;
430
- toggles.set(path, () => setAuto(!isAutoRow));
431
- return () => {
432
- toggles.delete(path);
433
- };
434
- });
397
+ // An auto row hides its control, so the word is all the row has to say.
398
+ const autoReadout = isAutoRow ? 'auto' : undefined;
399
+ const autoProps = { auto: isAutoRow, onAutoChange };
435
400
 
436
401
  // Most specific wins, and within a tier the lab's entry beats the
437
402
  // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
@@ -611,7 +576,6 @@ function DebouncedTextRow({
611
576
  readout,
612
577
  auto,
613
578
  onAutoChange,
614
- 'data-auto-path': autoPath,
615
579
  }: {
616
580
  leaf: PrefLeaf;
617
581
  label: string;
@@ -623,7 +587,6 @@ function DebouncedTextRow({
623
587
  readout?: ReactNode;
624
588
  auto?: boolean;
625
589
  onAutoChange?: (next: boolean) => void;
626
- 'data-auto-path'?: string;
627
590
  }) {
628
591
  const text = useDebouncedText(
629
592
  value,
@@ -640,7 +603,6 @@ function DebouncedTextRow({
640
603
  description={description}
641
604
  auto={auto}
642
605
  onAutoChange={onAutoChange}
643
- data-auto-path={autoPath}
644
606
  value={text.local}
645
607
  placeholder={extra<string>(leaf, 'placeholder')}
646
608
  maxLength={extra<number>(leaf, 'maxLength')}
@@ -735,8 +697,8 @@ interface PairCellSpec {
735
697
  *
736
698
  * The cells are bare controls: a row holds one label column, so each leaf's
737
699
  * own name stays with its control as the accessible name and its description
738
- * as a title. For the same reason the row carries no pin dot — a dot names one
739
- * path, and this row has several.
700
+ * as a title. For the same reason the row's label does not toggle auto — it
701
+ * names one path, and this row has several.
740
702
  */
741
703
  function PairedRow<TC extends Record<string, unknown>>({
742
704
  label,
@@ -744,22 +706,29 @@ function PairedRow<TC extends Record<string, unknown>>({
744
706
  shown,
745
707
  setConfig,
746
708
  layout,
709
+ span,
747
710
  }: {
748
711
  label: string;
749
712
  cells: readonly PairCellSpec[];
750
713
  shown: TC;
751
714
  setConfig: (path: string, value: unknown) => void;
752
715
  layout?: PropertyRowLayout;
716
+ span?: boolean;
753
717
  }) {
754
718
  return (
755
- <PropertyRow group label={label} layout={layout}>
719
+ <PropertyRow group span={span} label={label} layout={layout}>
756
720
  {cells.map(({ path, leaf }) => (
757
- <PairCell
758
- key={path}
759
- leaf={leaf}
760
- value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
761
- write={(value) => setConfig(path, value)}
762
- />
721
+ // The row is named by the pair, so a cell that shares it says which
722
+ // knob it is — without a caption a paired switch reads only to a
723
+ // screen reader. A cell whose leaf is deliberately unnamed keeps none.
724
+ <span key={path} className="lk-pair-cell">
725
+ {leaf.name ? <span className="lk-pair-label">{leaf.name}</span> : null}
726
+ <PairCell
727
+ leaf={leaf}
728
+ value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
729
+ write={(value) => setConfig(path, value)}
730
+ />
731
+ </span>
763
732
  ))}
764
733
  </PropertyRow>
765
734
  );
@@ -845,6 +814,21 @@ function PairCell({
845
814
  }
846
815
  case 'enum': {
847
816
  const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
817
+ // A leaf that asked to be segmented stays segmented when it shares a row:
818
+ // the choice is the control's shape, not a whole row's worth of chrome.
819
+ if (extra<string>(leaf, 'control') === 'radio') {
820
+ return (
821
+ <span title={title}>
822
+ <ToggleBar<string>
823
+ ariaLabel={name}
824
+ size="sm"
825
+ value={typeof value === 'string' ? value : null}
826
+ onChange={(next) => next !== null && write(next)}
827
+ items={options.map((o) => ({ value: o.value, label: o.label }))}
828
+ />
829
+ </span>
830
+ );
831
+ }
848
832
  // `Select` takes no `title`, so the cell's help hangs off a wrapper.
849
833
  return (
850
834
  <span title={title}>
package/src/index.test.ts CHANGED
@@ -72,8 +72,6 @@ describe('surface, job and orbit entry points', () => {
72
72
  expect(typeof kit.annotationsFromJSON).toBe('function');
73
73
  expect(typeof kit.seenFrom).toBe('function');
74
74
  expect(typeof kit.fracToWorld).toBe('function');
75
- expect(typeof kit.useOrbit).toBe('function');
76
- expect(typeof kit.orbitAfterDrag).toBe('function');
77
75
  expect(typeof kit.usePanZoom).toBe('function');
78
76
  expect(typeof kit.useJob).toBe('function');
79
77
  expect(typeof kit.as2DView).toBe('function');
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export type {
43
43
  TokenCategory,
44
44
  TokenEntry,
45
45
  TokenPanelProps,
46
+ TokenScale,
47
+ TokenScaleRule,
46
48
  } from '@weasel-js/ui';
47
49
  export {
48
50
  Button,
@@ -67,6 +69,7 @@ export {
67
69
  PropertyPanel,
68
70
  PropertyRow,
69
71
  PropertySpan,
72
+ refitScale,
70
73
  ResetIcon,
71
74
  Select,
72
75
  SelectRow,
@@ -148,13 +151,9 @@ export type {
148
151
  CanvasStackContextValue,
149
152
  CanvasStackProps,
150
153
  CanvasStackSurface,
151
- OrbitHandlers,
152
- OrbitView,
153
154
  PanZoomHandlers,
154
155
  StageProps,
155
- UseOrbitOptions,
156
156
  UsePanZoomOptions,
157
- Vec3,
158
157
  ViewportSize,
159
158
  WorldFrame,
160
159
  WorldSpec,
@@ -165,19 +164,13 @@ export {
165
164
  CanvasStack,
166
165
  CanvasStackContext,
167
166
  centerOn,
168
- clampPitch,
169
167
  DEFAULT_FRAME,
170
168
  fitStage,
171
- orbitAfterDrag,
172
- orbitAfterWheel,
173
- PITCH_LIMIT,
174
169
  resolveFrame,
175
170
  Stage,
176
171
  screenToWorld,
177
- useOrbit,
178
172
  usePanZoom,
179
173
  worldToScreen,
180
- wrapYaw,
181
174
  zoomAt,
182
175
  } from './canvas';
183
176
  export * from './chrome';
@@ -298,6 +291,7 @@ export { Persistence, type PersistenceProps } from './state/Persistence';
298
291
  export type { RecordCache, RecordChange } from './state/records';
299
292
  export type {
300
293
  CreateLabStoreOptions,
294
+ LabDensity,
301
295
  LabDocument,
302
296
  LabMode,
303
297
  LabStoreState,
package/src/lab/Lab.tsx CHANGED
@@ -30,7 +30,7 @@ import { type OpenedLabStore, openLabStore } from '../state/openLabStore';
30
30
  import { PersistenceContext } from '../state/Persistence';
31
31
  import { createRecordCache } from '../state/records';
32
32
  import { createLabStore, type LabStore } from '../state/store';
33
- import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
33
+ import type { LabDensity, LabMode, StorageAdapter, TrialRecord } from '../state/types';
34
34
  import { useOpenOnce, useWarnIgnoredChange } from '../state/useOpenOnce';
35
35
  import { usePersistedState } from '../state/usePersistedState';
36
36
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
@@ -64,6 +64,10 @@ interface LabBaseProps {
64
64
  instruments: InstrumentList;
65
65
  defaultInstrument: string;
66
66
  mode?: LabMode;
67
+ /** How much room the lab's chrome takes. Default `'comfortable'`; a lab whose
68
+ * window is the whole app, rather than a panel beside one, reads better at
69
+ * `'roomy'`. The trials' own contents are unaffected. */
70
+ density?: LabDensity;
67
71
  /** Rendered while a stored lab loads. Default: the lab's empty shell. */
68
72
  fallback?: ReactNode;
69
73
  /**
@@ -175,12 +179,17 @@ async function openStoredLab(
175
179
  function LabFallback({
176
180
  title,
177
181
  mode,
182
+ density,
178
183
  pages,
179
184
  path,
180
- }: Pick<LabBaseProps, 'title' | 'mode' | 'pages' | 'path'>) {
185
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'pages' | 'path'>) {
181
186
  const resolvedMode = useResolvedMode(mode ?? 'auto');
182
187
  return (
183
- <ThemeProvider theme={interstellarTheme} selection={{ mode: resolvedMode }} className="lk-lab">
188
+ <ThemeProvider
189
+ theme={interstellarTheme}
190
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
191
+ className="lk-lab"
192
+ >
184
193
  <LabShell
185
194
  title={title ?? 'Labkit'}
186
195
  mode={mode}
@@ -289,7 +298,13 @@ export function Lab(props: LabProps) {
289
298
  if (!opened) {
290
299
  if (props.fallback !== undefined) return props.fallback;
291
300
  return (
292
- <LabFallback title={props.title} mode={props.mode} pages={props.pages} path={props.path} />
301
+ <LabFallback
302
+ title={props.title}
303
+ mode={props.mode}
304
+ density={props.density}
305
+ pages={props.pages}
306
+ path={props.path}
307
+ />
293
308
  );
294
309
  }
295
310
  return <LabRuntime {...props} opened={opened} />;
@@ -298,6 +313,7 @@ export function Lab(props: LabProps) {
298
313
  function LabRuntime({
299
314
  instruments,
300
315
  mode,
316
+ density,
301
317
  nebula,
302
318
  title,
303
319
  pages,
@@ -575,7 +591,7 @@ function LabRuntime({
575
591
  <LabContext.Provider value={contextValue}>
576
592
  <ThemeProvider
577
593
  theme={interstellarTheme}
578
- selection={{ mode: resolvedMode }}
594
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
579
595
  className="lk-lab"
580
596
  style={backdropStyle}
581
597
  >
@@ -89,5 +89,5 @@
89
89
  // line up down the level.
90
90
  .lk-layer-list__twisty-gap {
91
91
  flex: 0 0 auto;
92
- width: var(--wzl-disclosure-target, 20px);
92
+ width: var(--wzl-disclosure-target, var(--wzl-control-h-sm));
93
93
  }
@@ -3,7 +3,7 @@ import { useState } from 'react';
3
3
  import { LayerList, type LayerTreeNode } from './LayerList';
4
4
 
5
5
  const meta: Meta<typeof LayerList> = {
6
- title: 'labkit/layers/LayerList',
6
+ title: 'labkit/Layers/LayerList',
7
7
  component: LayerList,
8
8
  };
9
9
  export default meta;
@@ -1,6 +1,7 @@
1
1
  .lk-fps-meter {
2
2
  display: inline-flex;
3
3
  align-items: center;
4
+ white-space: nowrap;
4
5
  gap: var(--wzl-space-xs);
5
6
  font-family: var(--wzl-font-mono);
6
7
  font-size: var(--wzl-font-size-sm);
@@ -9,6 +10,10 @@
9
10
 
10
11
  .lk-fps-meter-value {
11
12
  color: var(--wzl-fg);
12
- min-width: 2.5ch;
13
+ /* Sized for the widest rate it can hold rather than the one it holds now:
14
+ a 240Hz display reads three digits, and a readout that grows into them
15
+ pushes whatever shares its row. */
16
+ min-width: 3ch;
17
+ font-variant-numeric: tabular-nums;
13
18
  text-align: right;
14
19
  }
@@ -9,9 +9,17 @@ describe('FpsMeter', () => {
9
9
 
10
10
  test('renders an FPS label', () => {
11
11
  render(<FpsMeter />);
12
- expect(screen.getByText(/fps/i)).toBeInTheDocument();
12
+ expect(screen.getByText('FPS')).toBeInTheDocument();
13
13
  });
14
14
 
15
+ /* The label and the rate are separate elements so the rate can hold a fixed
16
+ width; a text query spanning both would go back to matching one box. */
17
+ const rate = (): HTMLElement => {
18
+ const el = document.querySelector('.lk-fps-meter-value');
19
+ if (!el) throw new Error('no rate readout');
20
+ return el as HTMLElement;
21
+ };
22
+
15
23
  test('uses lk-fps-meter class', () => {
16
24
  const { container } = render(<FpsMeter />);
17
25
  expect((container.firstChild as HTMLElement).className).toBe('lk-fps-meter');
@@ -31,7 +39,7 @@ describe('FpsMeter', () => {
31
39
  raf?.(16.67);
32
40
  raf?.(33.34);
33
41
  });
34
- expect(screen.getByText(/FPS\s+\d+/)).toBeInTheDocument();
42
+ expect(rate().textContent).toMatch(/^\d+$/);
35
43
 
36
44
  vi.unstubAllGlobals();
37
45
  });
@@ -59,7 +67,7 @@ describe('FpsMeter', () => {
59
67
  run(16.67);
60
68
  run(33.34);
61
69
  });
62
- const before = screen.getByText(/FPS\s+\d+/).textContent;
70
+ const before = rate().textContent;
63
71
 
64
72
  // An hour hidden. The frame that lands on resume is not a 60-minute frame,
65
73
  // and must not enter the rolling average as one.
@@ -74,7 +82,7 @@ describe('FpsMeter', () => {
74
82
  run(3_600_016.67);
75
83
  });
76
84
 
77
- expect(screen.getByText(/FPS\s+\d+/).textContent).toBe(before);
85
+ expect(rate().textContent).toBe(before);
78
86
 
79
87
  setHidden(false);
80
88
  vi.unstubAllGlobals();
@@ -42,7 +42,12 @@ export function FpsMeter() {
42
42
 
43
43
  return (
44
44
  <div className="lk-fps-meter" ref={rootRef}>
45
- <span className="lk-fps-meter-value">FPS {fps}</span>
45
+ <span className="lk-fps-meter-label">FPS</span>
46
+ {/* The number is its own cell because it is the only part that changes:
47
+ in one box with the label, every digit it gains or loses resizes the
48
+ readout, and the status bar it sits in wraps to a second line and
49
+ back — which in a small trial resizes the window. */}
50
+ <span className="lk-fps-meter-value">{fps}</span>
46
51
  </div>
47
52
  );
48
53
  }
@@ -13,7 +13,7 @@ const base: JobHandle = {
13
13
  };
14
14
 
15
15
  const meta: Meta<typeof JobProgress> = {
16
- title: 'labkit/primitives/JobProgress',
16
+ title: 'labkit/Primitives/JobProgress',
17
17
  component: JobProgress,
18
18
  };
19
19
  export default meta;
@@ -1,6 +1,10 @@
1
1
  .lk-status-bar {
2
2
  display: flex;
3
3
  align-items: center;
4
+ /* One line, always. A bar that grows a second line takes it from the canvas
5
+ above it, and a readout that changes width would do that on and off as it
6
+ ticks. Too narrow for its contents clips them instead. */
7
+ overflow: hidden;
4
8
  gap: var(--wzl-space-md);
5
9
  padding: var(--wzl-space-xs) var(--wzl-space-sm);
6
10
  background: var(--wzl-surface-raised);
@@ -13,6 +17,8 @@
13
17
  .lk-status-bar-section {
14
18
  display: inline-flex;
15
19
  align-items: center;
20
+ white-space: nowrap;
21
+ min-width: 0;
16
22
  gap: var(--wzl-space-xs);
17
23
  padding: 0 var(--wzl-space-xs);
18
24
  border-right: 1px solid var(--wzl-line-subtle);
@@ -80,6 +80,11 @@ export interface SavedSnapshot {
80
80
  /** `auto` follows the OS; the other two are an explicit choice. */
81
81
  export type LabMode = 'auto' | 'light' | 'dark';
82
82
 
83
+ /** How much room the lab's own chrome takes — the theme's density axis, named
84
+ * the way the theme names it. A lab's instruments are unaffected: this sizes
85
+ * the shell around them. */
86
+ export type LabDensity = 'compact' | 'comfortable' | 'roomy';
87
+
83
88
  /** Everything a lab persists: its trials, its saved snapshots, and the
84
89
  * chosen color mode. */
85
90
  export interface LabStoreState {
@@ -126,7 +131,7 @@ export interface TrialStateHandle<TS, TC> {
126
131
  setState: (next: TS | ((prev: TS) => TS)) => void;
127
132
  config: TC;
128
133
  /** The config as stored, before any auto path is resolved. What the control
129
- * panel renders, so a ghosted control sits at the value it pins back to. */
134
+ * panel renders, so an auto row pins back to the value it was holding. */
130
135
  raw: TC;
131
136
  /** Dotted paths this trial has unpinned. */
132
137
  auto: ReadonlySet<string>;
@@ -5,13 +5,13 @@ import { openLabStore } from './openLabStore';
5
5
  import { createLabStore } from './store';
6
6
  import { as2DView, DEFAULT_VIEW, normalize2DView, withZoom } from './view';
7
7
 
8
- interface OrbitView {
8
+ interface ArbitraryTrialView {
9
9
  yaw: number;
10
10
  pitch: number;
11
11
  distance: number;
12
12
  }
13
13
 
14
- const orbit: OrbitView = { yaw: 1.1, pitch: 0.3, distance: 9 };
14
+ const orbit: ArbitraryTrialView = { yaw: 1.1, pitch: 0.3, distance: 9 };
15
15
 
16
16
  describe('as2DView', () => {
17
17
  it('accepts the 2D shape', () => {
@@ -54,7 +54,7 @@ describe('a trial view labkit does not interpret', () => {
54
54
 
55
55
  store.getState().updateTrialView('w1', orbit);
56
56
 
57
- expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
57
+ expect(store.getState().trials[0]?.view as ArbitraryTrialView).toEqual(orbit);
58
58
  });
59
59
 
60
60
  it('round-trips that view through persistence', async () => {
@@ -76,7 +76,7 @@ describe('a trial view labkit does not interpret', () => {
76
76
  storageKey: 'view-b',
77
77
  storage: createMemoryAdapter(backing),
78
78
  });
79
- expect(hydrated.store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
79
+ expect(hydrated.store.getState().trials[0]?.view as ArbitraryTrialView).toEqual(orbit);
80
80
  });
81
81
  });
82
82
 
@@ -93,16 +93,20 @@ describe('an auto config path', () => {
93
93
  render: () => null,
94
94
  canvas: { layers: [{ id: 'main', draw }] },
95
95
  });
96
- render(<Lab instruments={[instrument as Instrument]} defaultInstrument="Auto" />);
96
+ const { container } = render(
97
+ <Lab instruments={[instrument as Instrument]} defaultInstrument="Auto" />,
98
+ );
97
99
  await new Promise((r) => requestAnimationFrame(() => r(null)));
98
100
 
99
101
  expect(draw).toHaveBeenCalledWith(
100
102
  expect.anything(),
101
103
  expect.objectContaining({ config: { gap: 18 } }),
102
104
  );
103
- // The panel is handed the raw config, but a ghosted control draws what the
104
- // resolver decided — a handle at 12 under a readout saying 18 reads as a bug.
105
- expect(screen.getByRole('slider', { name: 'Gap' })).toHaveValue('18');
105
+ // The panel is handed the raw config, but an auto row holds what the
106
+ // resolver decided, so pinning it keeps the number the instrument drew.
107
+ // The row hides its control while auto, which takes it out of the
108
+ // accessibility tree — so this reads the element rather than the role.
109
+ expect(container.querySelector('input[type=range]')).toHaveValue('18');
106
110
  });
107
111
  });
108
112
 
@@ -218,7 +218,7 @@ describe('a config written as auto', () => {
218
218
  const { container } = render(<Lab instruments={[instrument]} defaultInstrument="Ghosted" />);
219
219
  expect(screen.getByRole('button', { name: /Gap/ })).toHaveAttribute('aria-pressed', 'false');
220
220
  expect(screen.getByRole('button', { name: /Width/ })).toHaveAttribute('aria-pressed', 'true');
221
- // 432 / 24. A ghosted control draws what the resolver decided rather than
221
+ // 432 / 24. An auto row holds what the resolver decided rather than
222
222
  // the pinned 12 underneath it, and un-pinning writes back what it drew.
223
223
  expect((container.querySelector('input[type=range]') as HTMLInputElement).value).toBe('18');
224
224
  });