@weasel-js/labkit 1.5.1 → 1.6.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 (160) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CJNMS6mz.d.ts → CanvasStackContext-DQgcH1ny.d.ts} +1 -1
  3. package/dist/_dts/LayerList.d-2c9yup5Y.d.ts +83 -0
  4. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  5. package/dist/_dts/{Select.d-Gwr8fLpK.d.ts → Select.d-BchGMC2Y.d.ts} +129 -12
  6. package/dist/_dts/{frac-B4okC2lX.d.ts → frac-DTRq7Xpb.d.ts} +31 -13
  7. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +23 -0
  8. package/dist/_dts/{index-CSJoj4LC.d.ts → index-B0--8PEM.d.ts} +5 -4
  9. package/dist/_dts/index-DINmCuLA.d.ts +453 -0
  10. package/dist/_dts/number.d-OeQYQoUO.d.ts +28 -0
  11. package/dist/_dts/{types-DzoNY1pO.d.ts → types-B7y8hX1D.d.ts} +6 -6
  12. package/dist/_dts/{usePanZoom-Pzf9f2jh.d.ts → usePanZoom-Bq73pwFe.d.ts} +4 -47
  13. package/dist/_dts/{useTrialState-uLlmaccn.d.ts → useTrialState-CP4jfuKl.d.ts} +1 -1
  14. package/dist/canvas/index.d.ts +6 -5
  15. package/dist/canvas/index.js +1 -1
  16. package/dist/chrome/index.d.ts +5 -4
  17. package/dist/chrome/index.js +7 -7
  18. package/dist/chunk-2RVCB2PT.js +3 -0
  19. package/dist/chunk-2RVCB2PT.js.map +1 -0
  20. package/dist/{chunk-UN4U4P63.js → chunk-3FI7Y4R4.js} +9 -9
  21. package/dist/chunk-3FI7Y4R4.js.map +1 -0
  22. package/dist/{chunk-7PFH3CNM.js → chunk-AWH3SRPW.js} +31 -5
  23. package/dist/chunk-AWH3SRPW.js.map +1 -0
  24. package/dist/{chunk-ZDHY47EV.js → chunk-BVL7Y574.js} +25 -9
  25. package/dist/chunk-BVL7Y574.js.map +1 -0
  26. package/dist/{chunk-IHUVQ3TX.js → chunk-H5VIV7MQ.js} +31 -27
  27. package/dist/chunk-H5VIV7MQ.js.map +1 -0
  28. package/dist/chunk-HFRE4QQV.js +400 -0
  29. package/dist/chunk-HFRE4QQV.js.map +1 -0
  30. package/dist/{chunk-IXTQI7JX.js → chunk-MOLIUNVH.js} +4 -4
  31. package/dist/{chunk-IXTQI7JX.js.map → chunk-MOLIUNVH.js.map} +1 -1
  32. package/dist/{chunk-CEIUBPPW.js → chunk-TT5C5S6R.js} +3 -86
  33. package/dist/chunk-TT5C5S6R.js.map +1 -0
  34. package/dist/chunk-USO6TERP.js +90 -0
  35. package/dist/chunk-USO6TERP.js.map +1 -0
  36. package/dist/{chunk-PHK4W562.js → chunk-VQ47KWD5.js} +3 -3
  37. package/dist/{chunk-PHK4W562.js.map → chunk-VQ47KWD5.js.map} +1 -1
  38. package/dist/chunk-WUJXJC5O.js +14568 -0
  39. package/dist/chunk-WUJXJC5O.js.map +1 -0
  40. package/dist/{chunk-EHGVROVV.js → chunk-ZORVNOKX.js} +6 -25
  41. package/dist/chunk-ZORVNOKX.js.map +1 -0
  42. package/dist/config/index.d.ts +53 -11
  43. package/dist/config/index.js +5 -5
  44. package/dist/controls/index.d.ts +6 -3
  45. package/dist/controls/index.js +4 -4
  46. package/dist/dragdrop/index.d.ts +4 -3
  47. package/dist/index.d.ts +139 -114
  48. package/dist/index.js +470 -479
  49. package/dist/index.js.map +1 -1
  50. package/dist/layers/index.d.ts +6 -35
  51. package/dist/layers/index.js +2 -3
  52. package/dist/loupe/index.d.ts +6 -5
  53. package/dist/passthrough/weasel-ui.d.ts +421 -215
  54. package/dist/passthrough/weasel-ui.js +1 -1
  55. package/dist/primitives/index.d.ts +2 -13
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +6 -5
  58. package/dist/state/index.js +5 -5
  59. package/dist/styles.css +410 -190
  60. package/dist/undo/index.d.ts +4 -3
  61. package/package.json +9 -13
  62. package/src/annotations/frac.test.ts +7 -0
  63. package/src/annotations/paint.test.ts +21 -0
  64. package/src/annotations/paint.ts +12 -0
  65. package/src/annotations/store.test.ts +31 -0
  66. package/src/annotations/types.ts +3 -2
  67. package/src/canvas/Stage.test.tsx +18 -0
  68. package/src/canvas/index.ts +0 -9
  69. package/src/chrome/labTypes.ts +1 -1
  70. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  71. package/src/chrome/regions/PaletteRegion.tsx +5 -4
  72. package/src/chrome/regions/SidebarRegion.less +197 -0
  73. package/src/chrome/regions/SidebarRegion.test.tsx +20 -0
  74. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  75. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  76. package/src/chrome/types.ts +13 -4
  77. package/src/config/autoConfig.ts +2 -2
  78. package/src/config/builder.ts +23 -5
  79. package/src/config/entry.test.ts +8 -0
  80. package/src/config/index.ts +2 -0
  81. package/src/config/rules.ts +2 -0
  82. package/src/config/sectionTree.test.ts +146 -0
  83. package/src/config/sectionTree.ts +124 -0
  84. package/src/config/types.ts +4 -4
  85. package/src/controls/AutoControls.stories.tsx +6 -6
  86. package/src/controls/ControlPanel.less +27 -0
  87. package/src/controls/ControlPanel.stories.tsx +95 -1
  88. package/src/controls/ControlPanel.test.tsx +82 -112
  89. package/src/controls/ControlPanel.tsx +159 -396
  90. package/src/controls/inDialog.test.tsx +82 -0
  91. package/src/controls/inDialog.tsx +46 -0
  92. package/src/controls/index.ts +1 -0
  93. package/src/index.test.ts +0 -2
  94. package/src/index.ts +14 -35
  95. package/src/instrument/types.ts +5 -2
  96. package/src/lab/Lab.nebula.test.tsx +31 -0
  97. package/src/lab/Lab.tsx +57 -18
  98. package/src/lab/LabBoundary.test.tsx +66 -0
  99. package/src/lab/LabBoundary.tsx +56 -0
  100. package/src/lab/LabHeader.tsx +4 -23
  101. package/src/lab/LabRoot.tsx +2 -3
  102. package/src/lab/LabShell.less +0 -1
  103. package/src/lab/LabShell.stories.tsx +15 -0
  104. package/src/lab/LabShell.test.tsx +18 -0
  105. package/src/lab/LabShell.tsx +14 -1
  106. package/src/lab/LabSwitcher.less +1 -1
  107. package/src/lab/LabSwitcher.test.tsx +41 -0
  108. package/src/lab/LabSwitcher.tsx +30 -8
  109. package/src/lab/index.ts +1 -0
  110. package/src/layers/AGENTS.md +18 -74
  111. package/src/layers/index.ts +4 -2
  112. package/src/passthrough/weasel-ui.ts +25 -10
  113. package/src/primitives/FpsMeter.less +6 -1
  114. package/src/primitives/FpsMeter.test.tsx +12 -4
  115. package/src/primitives/FpsMeter.tsx +6 -1
  116. package/src/primitives/JobProgress.stories.tsx +1 -1
  117. package/src/primitives/StatusBar.less +6 -0
  118. package/src/primitives/index.ts +0 -2
  119. package/src/specimen/Specimen.tsx +42 -56
  120. package/src/state/types.ts +8 -2
  121. package/src/state/view.test.ts +4 -4
  122. package/src/styles.less +4 -2
  123. package/src/theme/Interstellar.stories.tsx +8 -9
  124. package/src/theme/base.less +3 -20
  125. package/src/theme/interstellar.test.ts +6 -3
  126. package/src/theme/interstellar.theme.json +0 -56
  127. package/src/theme/interstellar.ts +2 -2
  128. package/src/trial/Trial.canvas.test.tsx +9 -5
  129. package/src/trial/Trial.config.test.tsx +1 -1
  130. package/src/trial/Trial.tsx +30 -8
  131. package/dist/_dts/index-BNdz-tNd.d.ts +0 -423
  132. package/dist/chunk-7PFH3CNM.js.map +0 -1
  133. package/dist/chunk-CEIUBPPW.js.map +0 -1
  134. package/dist/chunk-EH7UEQV7.js +0 -590
  135. package/dist/chunk-EH7UEQV7.js.map +0 -1
  136. package/dist/chunk-EHGVROVV.js.map +0 -1
  137. package/dist/chunk-IHUVQ3TX.js.map +0 -1
  138. package/dist/chunk-MDVTK5U3.js +0 -170
  139. package/dist/chunk-MDVTK5U3.js.map +0 -1
  140. package/dist/chunk-PJ7WEQBJ.js +0 -18
  141. package/dist/chunk-PJ7WEQBJ.js.map +0 -1
  142. package/dist/chunk-T3EC2DXD.js +0 -12628
  143. package/dist/chunk-T3EC2DXD.js.map +0 -1
  144. package/dist/chunk-UN4U4P63.js.map +0 -1
  145. package/dist/chunk-ZDHY47EV.js.map +0 -1
  146. package/dist/ui/layers/index.d.ts +0 -66
  147. package/dist/ui/layers/index.js +0 -3
  148. package/dist/ui/layers/index.js.map +0 -1
  149. package/src/canvas/useOrbit.test.ts +0 -167
  150. package/src/canvas/useOrbit.ts +0 -156
  151. package/src/lab/useSystemMode.ts +0 -30
  152. package/src/layers/LayerList.less +0 -93
  153. package/src/layers/LayerList.stories.tsx +0 -66
  154. package/src/layers/LayerList.test.tsx +0 -232
  155. package/src/layers/LayerList.tsx +0 -268
  156. package/src/primitives/Sidebar.less +0 -112
  157. package/src/primitives/Sidebar.stories.tsx +0 -38
  158. package/src/primitives/Sidebar.test.tsx +0 -74
  159. package/src/primitives/Sidebar.tsx +0 -35
  160. package/src/ui/layers/index.ts +0 -5
@@ -1,22 +1,7 @@
1
- import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
2
- import { MenuButton, ToggleBar } from '../passthrough/weasel-ui';
3
- import type { LabMode } from '../state/types';
1
+ import { AddIcon } from '@weasel-js/ui';
2
+ import { ColorModeControl, MenuButton } from '../passthrough/weasel-ui';
4
3
  import { useLabContext } from './LabContext';
5
4
 
6
- // The glyph is the segment's content and the word is its accessible name, so
7
- // the bar stays a three-way radiogroup announcing Auto / Light / Dark.
8
- const MODES = [
9
- { value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
10
- { value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
11
- { value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
12
- ];
13
-
14
- const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
15
- value,
16
- label: glyph,
17
- ariaLabel: label,
18
- }));
19
-
20
5
  /** The controls `<Lab>` puts in its header: add a trial, and choose the color
21
6
  * mode. Both drive `LabContext`, which carried them with no UI at all — so
22
7
  * every consumer rebuilt these two. Rendered before a consumer's own header
@@ -45,15 +30,11 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
45
30
  />
46
31
  )}
47
32
 
48
- <ToggleBar
33
+ <ColorModeControl
49
34
  className="lk-lab-header__mode"
50
- ariaLabel="Color mode"
51
35
  variant="flat"
52
- items={MODE_ITEMS}
53
36
  value={lab.mode}
54
- onChange={(next) => {
55
- if (next) lab.setMode(next);
56
- }}
37
+ onChange={lab.setMode}
57
38
  />
58
39
  </>
59
40
  );
@@ -1,8 +1,7 @@
1
- import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
1
+ import { ThemeProvider, useResolvedColorMode, useThemeOptional } from '@weasel-js/theme/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { interstellarTheme } from '../theme/interstellar';
5
- import { useResolvedMode } from './useSystemMode';
6
5
 
7
6
  /** Props for `<LabRoot>`. */
8
7
  export interface LabRootProps {
@@ -24,7 +23,7 @@ export interface LabRootProps {
24
23
  * Pair it with `import '@weasel-js/labkit/styles.css'`, which is what defines
25
24
  * the rules this element scopes. */
26
25
  export function LabRoot({ children, mode = 'auto', className }: LabRootProps) {
27
- const resolved = useResolvedMode(mode);
26
+ const resolved = useResolvedColorMode(mode);
28
27
  const outer = useThemeOptional();
29
28
 
30
29
  // Overlays portal here rather than to the themed wrapper above: `.lk-root`
@@ -64,7 +64,6 @@
64
64
  min-height: 0;
65
65
  min-width: 0;
66
66
  overflow: auto;
67
- padding: var(--lk-workspace-pad);
68
67
  }
69
68
 
70
69
  .lk-lab__body {
@@ -86,3 +86,18 @@ export const HeaderControlsLineUp: Story = {
86
86
  await expect(middles).toEqual(middles.map(() => middles[0]));
87
87
  },
88
88
  };
89
+
90
+ // One document serving its pages from the hash: the open route is the one the
91
+ // switcher marks, and each entry is a link to its fragment.
92
+ export const HashRoutedPages: Story = {
93
+ args: {
94
+ title: 'Registry',
95
+ pages: [
96
+ { href: '/app/', label: 'App' },
97
+ { href: '#/dev/tools', label: 'Tools' },
98
+ { href: '#/dev/registry', label: 'Registry' },
99
+ ],
100
+ path: '/app/#/dev/registry?entry=rect',
101
+ children: <p>Open the title to see the current route marked.</p>,
102
+ },
103
+ };
@@ -47,6 +47,24 @@ describe('LabShell', () => {
47
47
  expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
48
48
  });
49
49
 
50
+ test('holds the document title while mounted when asked to', () => {
51
+ document.title = 'before';
52
+ const { unmount } = render(
53
+ <LabShell title="t" documentTitle="Page title">
54
+ x
55
+ </LabShell>,
56
+ );
57
+ expect(document.title).toBe('Page title');
58
+ unmount();
59
+ expect(document.title).toBe('before');
60
+ });
61
+
62
+ test('leaves the document title alone by default', () => {
63
+ document.title = 'before';
64
+ render(<LabShell title="t">x</LabShell>);
65
+ expect(document.title).toBe('before');
66
+ });
67
+
50
68
  test('always applies lk-root class', () => {
51
69
  const { container } = render(<LabShell title="t">x</LabShell>);
52
70
  expect(container.querySelector('.lk-root')).not.toBeNull();
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode, useEffect } from 'react';
2
2
  import type { LabMode } from '../state/types';
3
3
  import { LabRoot } from './LabRoot';
4
4
  import type { LabPage } from './LabSwitcher';
@@ -20,6 +20,9 @@ export interface LabShellProps {
20
20
  /** The path the switcher reads to mark the open page. Defaults to the
21
21
  * current location. */
22
22
  path?: string;
23
+ /** Set as `document.title` while the shell is mounted, and the previous
24
+ * title restored after. Omitted, the document's title is left alone. */
25
+ documentTitle?: string;
23
26
  }
24
27
 
25
28
  /** Page frame for a lab: a titled header, a body, and an optional footer,
@@ -33,7 +36,17 @@ export function LabShell({
33
36
  mode = 'auto',
34
37
  pages,
35
38
  path,
39
+ documentTitle,
36
40
  }: LabShellProps) {
41
+ useEffect(() => {
42
+ if (documentTitle === undefined) return;
43
+ const previous = document.title;
44
+ document.title = documentTitle;
45
+ return () => {
46
+ document.title = previous;
47
+ };
48
+ }, [documentTitle]);
49
+
37
50
  return (
38
51
  <LabRoot mode={mode} className="lk-shell">
39
52
  <header className="lk-shell-header">
@@ -26,7 +26,7 @@
26
26
  // hyphen and the control becomes three ragged lines with a caret adrift.
27
27
  white-space: nowrap;
28
28
 
29
- &:hover { color: var(--wzl-accent); }
29
+ &:hover { color: var(--wzl-accent-fg); }
30
30
 
31
31
  &:focus-visible {
32
32
  outline: 2px solid var(--wzl-focus-ring, currentColor);
@@ -9,6 +9,8 @@ const PAGES = [
9
9
  { href: '/ingest', label: 'Ingestion' },
10
10
  ];
11
11
 
12
+ const open2 = (name: string) =>
13
+ fireEvent.click(screen.getByRole('button', { name: new RegExp(name) }));
12
14
  const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
13
15
 
14
16
  describe('currentPage', () => {
@@ -27,6 +29,45 @@ describe('currentPage', () => {
27
29
  it('reports -1 for a page that is not one of them', () => {
28
30
  expect(currentPage('/bench', PAGES)).toBe(-1);
29
31
  });
32
+
33
+ // One document serving its pages from the hash: every href shares a path, so
34
+ // only the fragment can tell them apart.
35
+ const ROUTES = [
36
+ { href: '/app/', label: 'App' },
37
+ { href: '#/dev/tools', label: 'Tools' },
38
+ { href: '#/dev/registry', label: 'Registry' },
39
+ ];
40
+
41
+ it('tells hash routes on one document apart', () => {
42
+ expect(currentPage('/app/#/dev/registry', ROUTES)).toBe(2);
43
+ expect(currentPage('/app/#/dev/tools', ROUTES)).toBe(1);
44
+ });
45
+
46
+ it('matches a hash route through its own query and sub-routes', () => {
47
+ expect(currentPage('/app/#/dev/tools?bundle=minimal', ROUTES)).toBe(1);
48
+ expect(currentPage('/app/#/dev/tools/', ROUTES)).toBe(1);
49
+ expect(currentPage('/app/#/dev/registry/tool/rect', ROUTES)).toBe(2);
50
+ expect(currentPage('/app/#/dev/toolsets', ROUTES)).toBe(0);
51
+ });
52
+
53
+ it('falls back to the page without a hash when no route matches', () => {
54
+ expect(currentPage('/app/', ROUTES)).toBe(0);
55
+ expect(currentPage('/app/#/elsewhere', ROUTES)).toBe(0);
56
+ });
57
+
58
+ it('reads the hash off the current location by default', () => {
59
+ window.history.replaceState(null, '', '/app/#/dev/registry');
60
+ try {
61
+ render(<LabSwitcher title="Registry" pages={ROUTES} />);
62
+ open2('Registry');
63
+ expect(screen.getByRole('menuitem', { name: 'Registry' })).toHaveAttribute(
64
+ 'aria-current',
65
+ 'page',
66
+ );
67
+ } finally {
68
+ window.history.replaceState(null, '', '/');
69
+ }
70
+ });
30
71
  });
31
72
 
32
73
  describe('<LabSwitcher>', () => {
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
2
 
3
3
  /** One page in a project's set of labs. */
4
4
  export interface LabPage {
5
- /** The page's own URL, and how the switcher knows which page is open. */
5
+ /** The page's own URL, and how the switcher knows which page is open. A
6
+ * hash route (`#/dev/tools`) names a page on the current document. */
6
7
  href: string;
7
8
  label: string;
8
9
  }
@@ -18,18 +19,39 @@ export interface LabSwitcherProps {
18
19
  className?: string;
19
20
  }
20
21
 
22
+ /** A URL split into the document it names and the hash route on it, each
23
+ * without its query, trailing slash or `.html`. */
24
+ function locate(url: string): { doc: string; route: string } {
25
+ const hash = url.indexOf('#');
26
+ const trim = (part: string) => part.replace(/\?.*$/, '').replace(/\/$/, '');
27
+ return {
28
+ doc: trim(hash < 0 ? url : url.slice(0, hash)).replace(/\.html$/, ''),
29
+ route: hash < 0 ? '' : trim(url.slice(hash + 1)),
30
+ };
31
+ }
32
+
21
33
  /** Which of `pages` a path is on, or -1.
22
34
  *
23
35
  * Matched on the end of the path so a query string, a trailing slash or a
24
36
  * leftover `.html` cannot lose it: a project whose dev server maps `/stats`
25
37
  * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
- * lost their extension still resolves to the same page. */
38
+ * lost their extension still resolves to the same page.
39
+ *
40
+ * A page whose `href` carries a hash is a route on a single document and wins
41
+ * when the path's hash is that route or one under it. A page without one
42
+ * ignores the hash, so an in-page anchor does not lose it. */
27
43
  export function currentPage(path: string, pages: readonly LabPage[]): number {
28
- const here = path
29
- .replace(/[?#].*$/, '')
30
- .replace(/\/$/, '')
31
- .replace(/\.html$/, '');
32
- return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
44
+ const here = locate(path);
45
+ const onDoc = (doc: string) => here.doc.endsWith(doc);
46
+ const routed = pages.findIndex((p) => {
47
+ if (!p.href.includes('#')) return false;
48
+ const page = locate(p.href);
49
+ return (
50
+ onDoc(page.doc) && (here.route === page.route || here.route.startsWith(`${page.route}/`))
51
+ );
52
+ });
53
+ if (routed >= 0) return routed;
54
+ return pages.findIndex((p) => !p.href.includes('#') && onDoc(locate(p.href).doc));
33
55
  }
34
56
 
35
57
  /** A project's lab title, doubling as the way to reach its other labs.
@@ -47,7 +69,7 @@ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps)
47
69
  const root = useRef<HTMLDivElement>(null);
48
70
  const menuId = useId();
49
71
  const here = currentPage(
50
- path ?? (typeof window === 'undefined' ? '' : window.location.pathname),
72
+ path ?? (typeof window === 'undefined' ? '' : window.location.pathname + window.location.hash),
51
73
  pages,
52
74
  );
53
75
 
package/src/lab/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { LabProps } from './Lab';
2
2
  export { Lab } from './Lab';
3
+ export { LabBoundary, type LabBoundaryProps } from './LabBoundary';
3
4
  export type { LabContextValue } from './LabContext';
4
5
  export { LabContext, useLabContext } from './LabContext';
5
6
  export type { LabPaletteProps } from './LabPalette';
@@ -1,33 +1,11 @@
1
1
  # Layers — Agent Guide
2
2
 
3
- The `src/layers/` directory implements the `<LayerList>` UI used by the `layers` instrument capability.
3
+ `src/layers/` re-exports `<LayerList>` from `@weasel-js/ui`, along with the
4
+ `layers` capability types. The component itself — rows, cards, nesting,
5
+ selection, visibility, reorder — is documented at its source in
6
+ `packages/ui/src/components/LayerList/`.
4
7
 
5
- ## Files
6
-
7
- | File | Role |
8
- |---|---|
9
- | `LayerList.tsx` | Layer toggle/reorder list, flat or nested (sidebar widget) |
10
- | `LayerList.less` | Row styling, drag handle, pinned-row variant, subtree indent |
11
- | `LayerList.stories.tsx` | Flat and nested renderings |
12
-
13
- ## Props
14
-
15
- ```ts
16
- interface LayerListProps {
17
- layers: LayerTreeNode[]; // LayerDescriptor + { children?, defaultCollapsed? }
18
- visibility: Record<string, boolean>; // missing key → visible
19
- onReorder: (newOrder: LayerTreeNode[]) => void;
20
- onToggle: (id: string, visible: boolean) => void;
21
- collapsedIds?: readonly string[]; // supplying it makes collapse controlled
22
- onCollapsedChange?: (ids: string[]) => void;
23
- className?: string;
24
- }
25
- ```
26
-
27
- `LayerDescriptor` lives in `src/instrument/types.ts`; `LayerTreeNode` extends it
28
- in `LayerList.tsx`, so a plain `LayerDescriptor[]` is still a valid `layers`.
29
-
30
- ## How it integrates
8
+ ## How the capability uses it
31
9
 
32
10
  The instrument declares layer ids:
33
11
 
@@ -35,53 +13,19 @@ The instrument declares layer ids:
35
13
  layers: { ids: ['grid', 'plants'] }
36
14
  ```
37
15
 
38
- `Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
39
-
40
- There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
41
-
42
- ## `alwaysOn` semantics
43
-
44
- Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
45
-
46
- 1. The row renders with a 🔒 badge instead of a drag handle and visibility checkbox.
47
- 2. The row is excluded from reorder operations — pinned rows always sort to the bottom of the rendered list.
48
-
49
- This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
50
-
51
- ## Nesting
52
-
53
- A node with `children` renders an expandable subtree. The expand control is
54
- `<Disclosure>` from `@weasel-js/ui` — the same twisty every other collapsible
55
- surface uses.
56
-
57
- Reordering is scoped to siblings: a drag moves a row within its own parent's
58
- child list and never reparents it. `onReorder` still receives the whole tree,
59
- with only that sibling group's order changed.
60
-
61
- The twisty column appears only when some node in the tree has children — a flat
62
- list renders exactly the rows it always did. Within a tree, childless rows get
63
- `.lk-layer-list__twisty-gap` so labels stay aligned down a level.
64
-
65
- Collapse is uncontrolled by default, seeded once from each node's
66
- `defaultCollapsed`. Pass `collapsedIds` to own it; `onCollapsedChange` fires
67
- either way.
68
-
69
- ## Drag handle customization
70
-
71
- The drag handle is a button with class `lk-layer-list__handle` holding `<DragHandleGlyph>` (from `@weasel-js/ui`), the same grip `LayerStack` uses. Its padding is transparent and cancelled by an equal negative margin, so the grab target is larger than the drawn dots without widening the row. Row pitch — height plus row gap — is measured from the DOM when a drag starts, so restyling the row does not skew drag distance. It used to be a hardcoded `28`, which had already drifted from the rendered height by the time it was found.
72
-
73
- Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dragging works across the document without requiring window-level listeners.
74
-
75
- ## Empty state
76
-
77
- If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
16
+ `Trial.tsx` turns each id into a `LayerDescriptor` (`label === id` unless the
17
+ entry is a descriptor), keeps `visibility` and `layerOrder` in local state, and
18
+ builds the list's items from them in the order the canvas draws. The order a
19
+ drag produces is applied to `instrument.canvas.layers` before they reach
20
+ `<CanvasStack>`, and each toggle and reorder emits `layers.toggle` /
21
+ `layers.reorder` on the bus.
78
22
 
79
- ## When to fork
23
+ `layers.ids` and `canvas.layers[].id` are assumed to match. A canvas layer not
24
+ in `layers.ids` keeps its default order and is always visible.
80
25
 
81
- Fork this component if you need:
82
- - Multi-select reorder (current implementation is single-row)
83
- - Right-click context menu
84
- - Reparenting by drag (nesting renders, but a drag stays within one parent)
85
- - Solo/mute UI common in DAW-style apps
26
+ ## `alwaysOn`
86
27
 
87
- It is self-contained: `LayerDescriptor` from instrument types and `Disclosure`/`DragHandleGlyph` from `@weasel-js/ui`.
28
+ A descriptor with `alwaysOn: true` becomes a `locked` item, pinned below the
29
+ rest: it shows a lock in place of the grip and has no visibility checkbox,
30
+ cannot be dragged, and no drag can cross it. It always renders, whatever
31
+ `visibility` says. Intended for legend and HUD layers.
@@ -1,3 +1,5 @@
1
+ // LayerList lives in `@weasel-js/ui`. Named, not `export *` — a star re-export
2
+ // of an external package emits no binding in the bundle.
3
+ export type { LayerListItem, LayerListProps, LayerMove } from '@weasel-js/ui';
4
+ export { LayerList, moveLayers } from '@weasel-js/ui';
1
5
  export type { LayerCapability, LayerDescriptor } from '../instrument/types';
2
- export type { LayerListProps, LayerTreeNode } from './LayerList';
3
- export { LayerList } from './LayerList';
@@ -8,11 +8,6 @@
8
8
  export {
9
9
  ActionBar,
10
10
  type ActionBarProps,
11
- ActionsBar,
12
- type ActionsBarItem,
13
- type ActionsBarProps,
14
- type ActionsBarSize,
15
- type ActionsBarVariant,
16
11
  type AddPointMode,
17
12
  type AnchorRenderProps,
18
13
  type AsyncOption,
@@ -22,12 +17,17 @@ export {
22
17
  type BadgeProps,
23
18
  type BadgeShape,
24
19
  type BadgeSize,
25
- type BadgeTone,
20
+ type BadgeStatus,
26
21
  type BadgeVariant,
27
22
  type BoundsCtx,
28
23
  type BuiltInEdgeName,
29
24
  type BuiltinPref,
30
25
  Button,
26
+ ButtonBar,
27
+ type ButtonBarItem,
28
+ type ButtonBarProps,
29
+ type ButtonBarSize,
30
+ type ButtonBarVariant,
31
31
  type ButtonProps,
32
32
  type ButtonSize,
33
33
  type ButtonVariant,
@@ -38,6 +38,8 @@ export {
38
38
  type CheckboxProps,
39
39
  type ChromaCurve,
40
40
  type ChromaCurvePoint,
41
+ ColorModeControl,
42
+ type ColorModeControlProps,
41
43
  ComboBox,
42
44
  type ComboBoxCommit,
43
45
  type ComboBoxFilter,
@@ -56,10 +58,13 @@ export {
56
58
  Dialog,
57
59
  type DialogProps,
58
60
  Disclosure,
59
- type DisclosureDirection,
61
+ DisclosureMark,
62
+ type DisclosureMarkProps,
60
63
  type DisclosureProps,
61
64
  DisclosureRow,
62
65
  type DisclosureRowProps,
66
+ DragGhost,
67
+ type DragGhostProps,
63
68
  DragHandleGlyph,
64
69
  type DragHandleGlyphProps,
65
70
  detectPlatform,
@@ -98,16 +103,20 @@ export {
98
103
  keyGlyph,
99
104
  keySpecFromKey,
100
105
  keySpecsFromMods,
106
+ keySpecsFromShortcut,
107
+ LayerList,
101
108
  type LayerListItem,
102
- LayerStack,
103
- type LayerStackItem,
104
- type LayerStackProps,
109
+ type LayerListProps,
110
+ type LayerMove,
111
+ ListEditor,
112
+ type ListEditorProps,
105
113
  type LogicalMod,
106
114
  type LogicalModSpec,
107
115
  MenuButton,
108
116
  type MenuButtonItem,
109
117
  type MenuButtonProps,
110
118
  MINUS_SIGN,
119
+ moveLayers,
111
120
  NumberField,
112
121
  type NumberFieldProps,
113
122
  OptionsBar,
@@ -145,6 +154,11 @@ export {
145
154
  type PrefRenderer,
146
155
  type PrefString,
147
156
  type PrefStringControl,
157
+ PrefsDialog,
158
+ type PrefsDialogProps,
159
+ PrefsForm,
160
+ type PrefsFormProps,
161
+ type PrefsLayout,
148
162
  paintGradientTrack,
149
163
  prefValueAtPath,
150
164
  Radio,
@@ -155,6 +169,7 @@ export {
155
169
  type RangeSliderProps,
156
170
  type ReorderDragHandlers,
157
171
  type ReorderDragState,
172
+ type ReorderGhost,
158
173
  type RovingItem,
159
174
  type RovingTabIndex,
160
175
  Select,
@@ -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);
@@ -7,8 +7,6 @@ export type { LegendEntry, LegendMark, LegendProps } from './Legend';
7
7
  export { Legend } from './Legend';
8
8
  export type { ScaleIndicatorProps } from './ScaleIndicator';
9
9
  export { ScaleIndicator } from './ScaleIndicator';
10
- export type { SidebarProps } from './Sidebar';
11
- export { Sidebar } from './Sidebar';
12
10
  export type { SplitProps } from './Split';
13
11
  export { Split } from './Split';
14
12
  export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';