@weasel-js/labkit 1.5.2 → 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 (139) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.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-ZTiwzYyT.d.ts → Select.d-BchGMC2Y.d.ts} +102 -11
  6. package/dist/_dts/{frac-B4hFJMiN.d.ts → frac-DTRq7Xpb.d.ts} +18 -10
  7. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +23 -0
  8. package/dist/_dts/{index-DlKolEIZ.d.ts → index-B0--8PEM.d.ts} +2 -2
  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-Db-3NNk1.d.ts → types-B7y8hX1D.d.ts} +2 -2
  12. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → usePanZoom-Bq73pwFe.d.ts} +2 -2
  13. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CP4jfuKl.d.ts} +1 -1
  14. package/dist/canvas/index.d.ts +6 -5
  15. package/dist/chrome/index.d.ts +5 -4
  16. package/dist/chrome/index.js +7 -7
  17. package/dist/chunk-2RVCB2PT.js +3 -0
  18. package/dist/chunk-2RVCB2PT.js.map +1 -0
  19. package/dist/{chunk-753Y6HE6.js → chunk-3FI7Y4R4.js} +4 -4
  20. package/dist/chunk-3FI7Y4R4.js.map +1 -0
  21. package/dist/{chunk-REOBGYMC.js → chunk-AWH3SRPW.js} +31 -5
  22. package/dist/chunk-AWH3SRPW.js.map +1 -0
  23. package/dist/{chunk-A2ACAGW6.js → chunk-BVL7Y574.js} +22 -5
  24. package/dist/chunk-BVL7Y574.js.map +1 -0
  25. package/dist/{chunk-CKUKF72R.js → chunk-H5VIV7MQ.js} +26 -22
  26. package/dist/chunk-H5VIV7MQ.js.map +1 -0
  27. package/dist/chunk-HFRE4QQV.js +400 -0
  28. package/dist/chunk-HFRE4QQV.js.map +1 -0
  29. package/dist/{chunk-LRI3MBGR.js → chunk-MOLIUNVH.js} +4 -4
  30. package/dist/{chunk-LRI3MBGR.js.map → chunk-MOLIUNVH.js.map} +1 -1
  31. package/dist/chunk-USO6TERP.js +90 -0
  32. package/dist/chunk-USO6TERP.js.map +1 -0
  33. package/dist/{chunk-EMXAIRUJ.js → chunk-VQ47KWD5.js} +3 -3
  34. package/dist/{chunk-EMXAIRUJ.js.map → chunk-VQ47KWD5.js.map} +1 -1
  35. package/dist/chunk-WUJXJC5O.js +14568 -0
  36. package/dist/chunk-WUJXJC5O.js.map +1 -0
  37. package/dist/{chunk-KJCUERQF.js → chunk-ZORVNOKX.js} +6 -25
  38. package/dist/chunk-ZORVNOKX.js.map +1 -0
  39. package/dist/config/index.d.ts +50 -7
  40. package/dist/config/index.js +5 -5
  41. package/dist/controls/index.d.ts +6 -3
  42. package/dist/controls/index.js +4 -4
  43. package/dist/dragdrop/index.d.ts +4 -3
  44. package/dist/index.d.ts +83 -102
  45. package/dist/index.js +430 -458
  46. package/dist/index.js.map +1 -1
  47. package/dist/layers/index.d.ts +6 -35
  48. package/dist/layers/index.js +2 -3
  49. package/dist/loupe/index.d.ts +6 -5
  50. package/dist/passthrough/weasel-ui.d.ts +407 -204
  51. package/dist/passthrough/weasel-ui.js +1 -1
  52. package/dist/primitives/index.d.ts +2 -13
  53. package/dist/primitives/index.js +4 -4
  54. package/dist/state/index.d.ts +6 -5
  55. package/dist/state/index.js +5 -5
  56. package/dist/styles.css +298 -174
  57. package/dist/undo/index.d.ts +4 -3
  58. package/package.json +9 -13
  59. package/src/annotations/frac.test.ts +7 -0
  60. package/src/annotations/paint.test.ts +21 -0
  61. package/src/annotations/paint.ts +12 -0
  62. package/src/annotations/store.test.ts +31 -0
  63. package/src/annotations/types.ts +3 -2
  64. package/src/canvas/Stage.test.tsx +18 -0
  65. package/src/chrome/regions/SidebarRegion.less +197 -0
  66. package/src/chrome/regions/SidebarRegion.test.tsx +20 -0
  67. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  68. package/src/chrome/types.ts +5 -2
  69. package/src/config/autoConfig.ts +2 -2
  70. package/src/config/builder.ts +20 -1
  71. package/src/config/entry.test.ts +8 -0
  72. package/src/config/index.ts +2 -0
  73. package/src/config/rules.ts +2 -0
  74. package/src/config/sectionTree.test.ts +146 -0
  75. package/src/config/sectionTree.ts +124 -0
  76. package/src/controls/ControlPanel.less +4 -0
  77. package/src/controls/ControlPanel.stories.tsx +55 -2
  78. package/src/controls/ControlPanel.test.tsx +30 -3
  79. package/src/controls/ControlPanel.tsx +116 -337
  80. package/src/controls/inDialog.test.tsx +82 -0
  81. package/src/controls/inDialog.tsx +46 -0
  82. package/src/controls/index.ts +1 -0
  83. package/src/index.ts +11 -26
  84. package/src/instrument/types.ts +5 -2
  85. package/src/lab/Lab.nebula.test.tsx +31 -0
  86. package/src/lab/Lab.tsx +38 -15
  87. package/src/lab/LabBoundary.test.tsx +66 -0
  88. package/src/lab/LabBoundary.tsx +56 -0
  89. package/src/lab/LabHeader.tsx +4 -23
  90. package/src/lab/LabRoot.tsx +2 -3
  91. package/src/lab/LabShell.less +0 -1
  92. package/src/lab/LabShell.stories.tsx +15 -0
  93. package/src/lab/LabShell.test.tsx +18 -0
  94. package/src/lab/LabShell.tsx +14 -1
  95. package/src/lab/LabSwitcher.less +1 -1
  96. package/src/lab/LabSwitcher.test.tsx +41 -0
  97. package/src/lab/LabSwitcher.tsx +30 -8
  98. package/src/lab/index.ts +1 -0
  99. package/src/layers/AGENTS.md +18 -74
  100. package/src/layers/index.ts +4 -2
  101. package/src/passthrough/weasel-ui.ts +25 -10
  102. package/src/primitives/index.ts +0 -2
  103. package/src/specimen/Specimen.tsx +42 -56
  104. package/src/state/types.ts +2 -1
  105. package/src/styles.less +4 -2
  106. package/src/theme/Interstellar.stories.tsx +8 -9
  107. package/src/theme/base.less +3 -20
  108. package/src/theme/interstellar.test.ts +6 -3
  109. package/src/theme/interstellar.theme.json +0 -56
  110. package/src/theme/interstellar.ts +2 -2
  111. package/src/trial/Trial.canvas.test.tsx +1 -1
  112. package/src/trial/Trial.tsx +30 -8
  113. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  114. package/dist/chunk-6BUQ77ZV.js +0 -18
  115. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  116. package/dist/chunk-753Y6HE6.js.map +0 -1
  117. package/dist/chunk-A2ACAGW6.js.map +0 -1
  118. package/dist/chunk-CKUKF72R.js.map +0 -1
  119. package/dist/chunk-D35PFTS7.js +0 -170
  120. package/dist/chunk-D35PFTS7.js.map +0 -1
  121. package/dist/chunk-FKLT3ITC.js +0 -590
  122. package/dist/chunk-FKLT3ITC.js.map +0 -1
  123. package/dist/chunk-KJCUERQF.js.map +0 -1
  124. package/dist/chunk-LASCBWW7.js +0 -13456
  125. package/dist/chunk-LASCBWW7.js.map +0 -1
  126. package/dist/chunk-REOBGYMC.js.map +0 -1
  127. package/dist/ui/layers/index.d.ts +0 -66
  128. package/dist/ui/layers/index.js +0 -3
  129. package/dist/ui/layers/index.js.map +0 -1
  130. package/src/lab/useSystemMode.ts +0 -30
  131. package/src/layers/LayerList.less +0 -93
  132. package/src/layers/LayerList.stories.tsx +0 -66
  133. package/src/layers/LayerList.test.tsx +0 -232
  134. package/src/layers/LayerList.tsx +0 -268
  135. package/src/primitives/Sidebar.less +0 -112
  136. package/src/primitives/Sidebar.stories.tsx +0 -38
  137. package/src/primitives/Sidebar.test.tsx +0 -74
  138. package/src/primitives/Sidebar.tsx +0 -35
  139. package/src/ui/layers/index.ts +0 -5
@@ -0,0 +1,46 @@
1
+ import { DialogRow } from '@weasel-js/ui';
2
+ import type { ReactNode } from 'react';
3
+ import type { ControlRenderer } from '../config/types';
4
+
5
+ /** Options for {@link inDialog}. */
6
+ export interface InDialogOptions {
7
+ /** What the row's button shows of the value. Defaults to a list joined with
8
+ * commas, or the value itself. */
9
+ summary?: (value: unknown) => ReactNode;
10
+ /** The dialog's heading. Defaults to the row's label. */
11
+ title?: ReactNode;
12
+ }
13
+
14
+ /** The one-line summary a dialog row shows when it is given none. */
15
+ export function summarizeValue(value: unknown): string {
16
+ if (Array.isArray(value)) return value.length === 0 ? 'None' : value.join(', ');
17
+ if (value === undefined || value === null || value === '') return 'None';
18
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
19
+ }
20
+
21
+ /**
22
+ * Moves a control into a modal: the row becomes a button showing a summary of
23
+ * the value, and the button opens a dialog whose body is `body`, handed the
24
+ * same context a row renderer gets. For a leaf whose editor will never fit a
25
+ * sidebar row.
26
+ */
27
+ export function inDialog(body: ControlRenderer, opts: InDialogOptions = {}): ControlRenderer {
28
+ const summary = opts.summary ?? summarizeValue;
29
+ return (ctx) => {
30
+ const { pref, value, auto, setAuto } = ctx;
31
+ const manual = (pref as { manual?: boolean }).manual === true;
32
+ return (
33
+ <DialogRow
34
+ label={pref.name}
35
+ description={pref.description}
36
+ summary={summary(value)}
37
+ title={opts.title}
38
+ readout={auto ? 'auto' : undefined}
39
+ auto={auto}
40
+ onAutoChange={manual ? undefined : setAuto}
41
+ >
42
+ {() => body(ctx)}
43
+ </DialogRow>
44
+ );
45
+ };
46
+ }
@@ -1,4 +1,5 @@
1
1
  export { ControlPanel } from './ControlPanel';
2
+ export { type InDialogOptions, inDialog, summarizeValue } from './inDialog';
2
3
  export type {
3
4
  CheckboxField,
4
5
  ColorField,
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The property-panel family, LayerStack, Input, Select, TokenPanel, ToggleBar and Button live in
1
+ // The property-panel family, LayerList, Input, Select, TokenPanel, ToggleBar and Button live in
2
2
  // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
3
  // direct ui dependency. Named, not `export *` — a star re-export of an external
4
4
  // package emits no binding in the bundle.
@@ -8,19 +8,13 @@ export type {
8
8
  ButtonProps,
9
9
  ButtonSize,
10
10
  ButtonVariant,
11
- CheckboxRowProps,
12
- ColorRowProps,
13
11
  CurveFieldProps,
14
12
  CurveMark,
15
- EffectCardListItem,
16
- EffectCardListProps,
17
- EffectCardProps,
18
13
  IconProps,
19
14
  InputProps,
20
15
  JogProps,
21
- LayerStackItem,
22
- LayerStackProps,
23
- NumberRowProps,
16
+ PropertyControlProps,
17
+ PropertyFieldProps,
24
18
  PropertyGroupProps,
25
19
  PropertyListPack,
26
20
  PropertyListProps,
@@ -31,15 +25,11 @@ export type {
31
25
  PropertyRowVariant,
32
26
  PropertySpanProps,
33
27
  SelectProps,
34
- SelectRowProps,
35
- SliderRowProps,
36
28
  SubpanelProps,
37
- TextRowProps,
38
29
  ToggleBarItem,
39
30
  ToggleBarProps,
40
31
  ToggleBarSize,
41
32
  ToggleBarVariant,
42
- ToggleRowProps,
43
33
  TokenCategory,
44
34
  TokenEntry,
45
35
  TokenPanelProps,
@@ -48,39 +38,31 @@ export type {
48
38
  } from '@weasel-js/ui';
49
39
  export {
50
40
  Button,
51
- CheckboxRow,
52
41
  CloseIcon,
53
42
  CollapseIcon,
54
- ColorRow,
55
43
  CurveField,
56
- EffectCard,
57
- EffectCardList,
58
44
  ExpandIcon,
59
45
  Icon,
60
46
  Input,
61
47
  inferTokenType,
62
48
  Jog,
63
- LayerStack,
64
- NumberRow,
65
49
  PauseIcon,
66
50
  PlayIcon,
51
+ PropertyControl,
52
+ PropertyField,
67
53
  PropertyGroup,
68
54
  PropertyList,
69
55
  PropertyPanel,
70
56
  PropertyRow,
71
57
  PropertySpan,
72
- refitScale,
73
58
  ResetIcon,
59
+ refitScale,
74
60
  Select,
75
- SelectRow,
76
- SliderRow,
77
61
  StepBackIcon,
78
62
  StepForwardIcon,
79
63
  StopIcon,
80
64
  Subpanel,
81
- TextRow,
82
65
  ToggleBar,
83
- ToggleRow,
84
66
  TokenPanel,
85
67
  } from '@weasel-js/ui';
86
68
  export type {
@@ -185,11 +167,13 @@ export {
185
167
  f,
186
168
  GroupNode,
187
169
  isConfigBranch,
170
+ ListNode,
188
171
  NumberNode,
189
172
  StringNode,
190
173
  ValueNode,
191
174
  } from './config/builder';
192
175
  export { fromConfigFields } from './config/fromConfigField';
176
+ export { type SectionTree, sectionTree } from './config/sectionTree';
193
177
  export {
194
178
  fillConfigDefaults,
195
179
  hasConfigPath,
@@ -227,6 +211,7 @@ export { useConfigSchema } from './config/useConfigSchema';
227
211
  export { useResolvedConfig } from './config/useResolvedConfig';
228
212
  export { isLeafVisible } from './config/visible';
229
213
  export { ControlPanel } from './controls/ControlPanel';
214
+ export { type InDialogOptions, inDialog, summarizeValue } from './controls/inDialog';
230
215
  export type {
231
216
  CheckboxField,
232
217
  ColorField,
@@ -262,8 +247,8 @@ export type { ValidationResult } from './instrument/validateConfigSchema';
262
247
  export { validateConfigSchema } from './instrument/validateConfigSchema';
263
248
  export * from './job';
264
249
  export * from './lab';
265
- export type { LayerListProps, LayerTreeNode } from './layers';
266
- export { LayerList } from './layers';
250
+ export type { LayerListItem, LayerListProps, LayerMove } from './layers';
251
+ export { LayerList, moveLayers } from './layers';
267
252
  export * from './loupe';
268
253
  export * from './primitives';
269
254
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
@@ -70,7 +70,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
70
70
  /** Declares that an instrument's DOM is content of a fixed size, which the
71
71
  * trial pans and zooms the way it does a canvas's layers. Not combined with
72
72
  * `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
73
- export interface StageCapability {
73
+ export interface StageCapability<TS = unknown, TC = unknown> {
74
74
  /** The content's own size in CSS pixels, at zoom 1. */
75
75
  size: ViewportSize;
76
76
  /** Where the view starts. Omitted, the content opens centered and shrunk to
@@ -78,6 +78,9 @@ export interface StageCapability {
78
78
  initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
79
79
  minZoom?: number;
80
80
  maxZoom?: number;
81
+ /** Drawn over the content in viewport pixels, outside the camera — a legend,
82
+ * a floating panel, anything that must not zoom with the picture. */
83
+ overlay?: (ctx: RenderContext<TS, TC>) => ReactNode;
81
84
  }
82
85
 
83
86
  /** Declares which of an instrument's layers the trial should offer
@@ -159,7 +162,7 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
159
162
  serialize?: (state: TS) => unknown;
160
163
  deserialize?: (data: unknown, config: TC) => TS;
161
164
  canvas?: CanvasCapability<TS, TC>;
162
- stage?: StageCapability;
165
+ stage?: StageCapability<TS, TC>;
163
166
  layers?: LayerCapability;
164
167
  dragDrop?: DragDropCapability<TS, TC>;
165
168
  undo?: UndoCapability;
@@ -0,0 +1,31 @@
1
+ import { render } from '@testing-library/react';
2
+ import { resolveTheme } from '@weasel-js/theme';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { interstellarTheme } from '../theme/interstellar';
6
+ import { Lab } from './Lab';
7
+
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ const backdrop = (container: HTMLElement) =>
16
+ [...container.querySelectorAll<HTMLElement>('[style]')]
17
+ .map((el) => el.style.getPropertyValue('--wzl-backdrop'))
18
+ .find(Boolean) ?? '';
19
+
20
+ describe('<Lab nebula>', () => {
21
+ it('draws one blob per literal color', () => {
22
+ const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={['#ff0000', '#00ff00']} />);
23
+ expect(backdrop(container).match(/radial-gradient/g)).toHaveLength(3);
24
+ });
25
+
26
+ it('resolves a theme ramp against the lab’s theme', () => {
27
+ const { container } = render(<Lab instruments={[bare]} defaultInstrument="Bare" mode="dark" nebula={{ ramp: 'swatch' }} />);
28
+ const sky = resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-swatch-sky'];
29
+ expect(backdrop(container)).toContain(sky);
30
+ });
31
+ });
package/src/lab/Lab.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { ThemeProvider } from '@weasel-js/theme/react';
1
+ import { type ColorList, colorAt, colorCount, resolveTheme, type Theme } from '@weasel-js/theme';
2
+ import { ThemeProvider, useResolvedColorMode } from '@weasel-js/theme/react';
2
3
  import {
3
4
  type CSSProperties,
4
5
  type ReactNode,
@@ -54,7 +55,6 @@ import { LabPalette } from './LabPalette';
54
55
  import { LabShell } from './LabShell';
55
56
  import type { LabPage } from './LabSwitcher';
56
57
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
57
- import { useResolvedMode } from './useSystemMode';
58
58
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
59
 
60
60
  interface LabBaseProps {
@@ -68,15 +68,20 @@ interface LabBaseProps {
68
68
  * window is the whole app, rather than a panel beside one, reads better at
69
69
  * `'roomy'`. The trials' own contents are unaffected. */
70
70
  density?: LabDensity;
71
+ /** The theme the lab's chrome resolves against, applied at `mode` and
72
+ * `density`. Default `interstellarTheme`; extend it rather than replace it to
73
+ * keep the lab's look while changing a few tokens. */
74
+ theme?: Theme;
71
75
  /** Rendered while a stored lab loads. Default: the lab's empty shell. */
72
76
  fallback?: ReactNode;
73
77
  /**
74
- * Optional list of CSS colors used to compose the interstellar theme's
75
- * cosmic backdrop. Each color becomes one radial-gradient blob on the
76
- * dark void base. Order maps to a fixed spread of positions; extras wrap
77
- * around. Ignored unless the resolved mode is dark.
78
+ * Colors composing the interstellar theme's cosmic backdrop. Each becomes
79
+ * one radial-gradient blob on the dark void base, in a fixed spread of
80
+ * positions; extras wrap around. A theme ramp resolves against the lab's
81
+ * theme; a function list gives as many blobs as there are positions.
82
+ * Ignored unless the resolved mode is dark.
78
83
  */
79
- nebula?: readonly string[];
84
+ nebula?: ColorList;
80
85
  title?: string;
81
86
  /** The project's other labs. Given two or more, the title becomes the way to
82
87
  * reach them — the same switcher `<LabShell>` renders, so a lab reached from
@@ -180,13 +185,14 @@ function LabFallback({
180
185
  title,
181
186
  mode,
182
187
  density,
188
+ theme = interstellarTheme,
183
189
  pages,
184
190
  path,
185
- }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'pages' | 'path'>) {
186
- const resolvedMode = useResolvedMode(mode ?? 'auto');
191
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'theme' | 'pages' | 'path'>) {
192
+ const resolvedMode = useResolvedColorMode(mode ?? 'auto');
187
193
  return (
188
194
  <ThemeProvider
189
- theme={interstellarTheme}
195
+ theme={theme}
190
196
  selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
191
197
  className="lk-lab"
192
198
  >
@@ -214,7 +220,15 @@ const NEBULA_SLOTS = [
214
220
  { cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
215
221
  ] as const;
216
222
 
217
- function buildNebula(colors: readonly string[]): string {
223
+ function buildNebula(
224
+ list: ColorList,
225
+ theme: Theme,
226
+ mode: 'light' | 'dark',
227
+ density: string,
228
+ ): string {
229
+ const ctx = { theme, resolved: resolveTheme(theme, { mode, density }) };
230
+ const n = colorCount(list, ctx) ?? NEBULA_SLOTS.length;
231
+ const colors = Array.from({ length: n }, (_, i) => colorAt(list, i, ctx));
218
232
  const blobs = colors.map((c, i) => {
219
233
  const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
220
234
  return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
@@ -302,6 +316,7 @@ export function Lab(props: LabProps) {
302
316
  title={props.title}
303
317
  mode={props.mode}
304
318
  density={props.density}
319
+ {...(props.theme ? { theme: props.theme } : {})}
305
320
  pages={props.pages}
306
321
  path={props.path}
307
322
  />
@@ -314,6 +329,7 @@ function LabRuntime({
314
329
  instruments,
315
330
  mode,
316
331
  density,
332
+ theme = interstellarTheme,
317
333
  nebula,
318
334
  title,
319
335
  pages,
@@ -455,7 +471,7 @@ function LabRuntime({
455
471
  })),
456
472
  [undockedPanels],
457
473
  );
458
- const resolvedMode = useResolvedMode(modeValue);
474
+ const resolvedMode = useResolvedColorMode(modeValue);
459
475
 
460
476
  // Tools and lab contributions share one id namespace, so they merge once
461
477
  // here — before any region renders — and a collision throws rather than one
@@ -564,8 +580,15 @@ function LabRuntime({
564
580
  // Only override the backdrop in dark, where there is one to override.
565
581
  // Setting a CSS custom property is the sanctioned use of inline style.
566
582
  const backdropStyle =
567
- resolvedMode === 'dark' && nebula && nebula.length > 0
568
- ? ({ ['--wzl-backdrop' as string]: buildNebula(nebula) } as CSSProperties)
583
+ resolvedMode === 'dark' && nebula && colorCount(nebula) !== 0
584
+ ? ({
585
+ ['--wzl-backdrop' as string]: buildNebula(
586
+ nebula,
587
+ theme,
588
+ resolvedMode,
589
+ density ?? 'comfortable',
590
+ ),
591
+ } as CSSProperties)
569
592
  : undefined;
570
593
 
571
594
  const workspace = (
@@ -590,7 +613,7 @@ function LabRuntime({
590
613
  <AnnotationPreloadContext.Provider value={opened.marks}>
591
614
  <LabContext.Provider value={contextValue}>
592
615
  <ThemeProvider
593
- theme={interstellarTheme}
616
+ theme={theme}
594
617
  selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
595
618
  className="lk-lab"
596
619
  style={backdropStyle}
@@ -0,0 +1,66 @@
1
+ import { render } from '@testing-library/react';
2
+ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
3
+ import { useContext } from 'react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { LabStoreContext, TrialIdContext } from '../state/context';
6
+ import { PersistenceContext } from '../state/Persistence';
7
+ import { interstellarTheme } from '../theme/interstellar';
8
+ import { LabBoundary } from './LabBoundary';
9
+ import { LabContext, type LabContextValue } from './LabContext';
10
+ import { LabRoot } from './LabRoot';
11
+
12
+ function Probe() {
13
+ const lab = useContext(LabContext);
14
+ const store = useContext(LabStoreContext);
15
+ const trial = useContext(TrialIdContext);
16
+ const records = useContext(PersistenceContext);
17
+ const theme = useThemeOptional();
18
+ return (
19
+ <output>
20
+ {JSON.stringify({
21
+ lab: lab !== null,
22
+ store: store !== null,
23
+ trial,
24
+ records: records !== null,
25
+ theme: theme !== null,
26
+ })}
27
+ </output>
28
+ );
29
+ }
30
+
31
+ const fakeLab = { trials: [], instruments: [] } as unknown as LabContextValue;
32
+
33
+ describe('LabBoundary', () => {
34
+ it('hides the enclosing lab, trial, records and theme from its children', () => {
35
+ const { container } = render(
36
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
37
+ <LabContext.Provider value={fakeLab}>
38
+ <TrialIdContext.Provider value="t1">
39
+ <Probe />
40
+ <LabBoundary>
41
+ <Probe />
42
+ </LabBoundary>
43
+ </TrialIdContext.Provider>
44
+ </LabContext.Provider>
45
+ </ThemeProvider>,
46
+ );
47
+ const [outside, inside] = Array.from(container.querySelectorAll('output')).map((el) =>
48
+ JSON.parse(el.textContent ?? ''),
49
+ );
50
+ expect(outside).toEqual({ lab: true, store: false, trial: 't1', records: false, theme: true });
51
+ expect(inside).toEqual({ lab: false, store: false, trial: null, records: false, theme: false });
52
+ });
53
+
54
+ it('lets a LabRoot under it apply its own theme instead of deferring to the one above', () => {
55
+ const { container } = render(
56
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
57
+ <LabBoundary>
58
+ <LabRoot mode="light">x</LabRoot>
59
+ </LabBoundary>
60
+ </ThemeProvider>,
61
+ );
62
+ const themed = container.querySelectorAll('[data-wzl-theme]');
63
+ expect(themed).toHaveLength(2);
64
+ expect(themed[1]?.getAttribute('data-wzl-mode')).toBe('light');
65
+ });
66
+ });
@@ -0,0 +1,56 @@
1
+ import { ThemeContext } from '@weasel-js/theme/react';
2
+ import type { ReactNode } from 'react';
3
+ import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
+ import { AnnotationPreloadContext } from '../annotations/preload';
5
+ import { CameraWheelContext } from '../canvas/CameraWheelContext';
6
+ import { CanvasStackContext } from '../canvas/CanvasStackContext';
7
+ import { LabStoreContext, TrialIdContext } from '../state/context';
8
+ import { PersistenceContext } from '../state/Persistence';
9
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
10
+ import { TrialDragContext } from '../trial/TrialDragContext';
11
+ import { LabContext } from './LabContext';
12
+ import { PanelHostContext } from './panelHost';
13
+
14
+ /** Props for `<LabBoundary>`. */
15
+ export interface LabBoundaryProps {
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Renders its children as though no lab, trial or theme were above them: every context a `<Lab>` or a
21
+ * `<Trial>` publishes reads as absent below it, so a labkit piece mounted inside a trial behaves the way it
22
+ * does on a bare page. A `<LabRoot>` under it applies its own theme instead of deferring to the lab's, and a
23
+ * `usePersistedState` under it keeps local state instead of writing into the enclosing lab's records.
24
+ *
25
+ * For a host that shows arbitrary content inside a trial, such as a component workshop; a lab's own chrome
26
+ * never needs it.
27
+ */
28
+ export function LabBoundary({ children }: LabBoundaryProps) {
29
+ return (
30
+ <LabStoreContext.Provider value={null}>
31
+ <PersistenceContext.Provider value={null}>
32
+ <AnnotationPreloadContext.Provider value={null}>
33
+ <LabContext.Provider value={null}>
34
+ <PanelHostContext.Provider value={null}>
35
+ <SurfaceContext.Provider value={null}>
36
+ <SurfaceCanvasContext.Provider value={{ over: null, under: null }}>
37
+ <TrialIdContext.Provider value={null}>
38
+ <AnnotationsContext.Provider value={null}>
39
+ <CameraWheelContext.Provider value={null}>
40
+ <CanvasStackContext.Provider value={null}>
41
+ <TrialDragContext.Provider value={null}>
42
+ <ThemeContext.Provider value={null}>{children}</ThemeContext.Provider>
43
+ </TrialDragContext.Provider>
44
+ </CanvasStackContext.Provider>
45
+ </CameraWheelContext.Provider>
46
+ </AnnotationsContext.Provider>
47
+ </TrialIdContext.Provider>
48
+ </SurfaceCanvasContext.Provider>
49
+ </SurfaceContext.Provider>
50
+ </PanelHostContext.Provider>
51
+ </LabContext.Provider>
52
+ </AnnotationPreloadContext.Provider>
53
+ </PersistenceContext.Provider>
54
+ </LabStoreContext.Provider>
55
+ );
56
+ }
@@ -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);