@weasel-js/labkit 0.7.2 → 1.0.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -3
  3. package/dist/_dts/{DrawCommand-CEvS5AdV.d.ts → DrawCommand-BwRCZYuo.d.ts} +106 -52
  4. package/dist/_dts/{useExperimentState-eSvpwV_P.d.ts → useExperimentState-CJn2hHzd.d.ts} +6 -4
  5. package/dist/{chunk-SSMHPMDV.js → chunk-53XSBIUK.js} +12 -11
  6. package/dist/chunk-53XSBIUK.js.map +1 -0
  7. package/dist/{chunk-BG6NLMOL.js → chunk-FP5LYDVX.js} +1229 -1227
  8. package/dist/chunk-FP5LYDVX.js.map +1 -0
  9. package/dist/{chunk-ERZQAU4N.js → chunk-HNXVLKBG.js} +3 -3
  10. package/dist/{chunk-ERZQAU4N.js.map → chunk-HNXVLKBG.js.map} +1 -1
  11. package/dist/{chunk-5XDFILHZ.js → chunk-XVBXYKFJ.js} +1675 -670
  12. package/dist/chunk-XVBXYKFJ.js.map +1 -0
  13. package/dist/fonts/inter-latin.woff2 +0 -0
  14. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  15. package/dist/index.d.ts +37 -13
  16. package/dist/index.js +776 -27
  17. package/dist/index.js.map +1 -1
  18. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  19. package/dist/passthrough/weasel-canvas.js +1 -1
  20. package/dist/passthrough/weasel-ui.d.ts +332 -189
  21. package/dist/passthrough/weasel-ui.js +2 -2
  22. package/dist/state/index.d.ts +2 -2
  23. package/dist/state/index.js +2 -2
  24. package/dist/styles.css +229 -288
  25. package/dist/ui/layers/index.js +3 -3
  26. package/package.json +5 -6
  27. package/src/canvas/CanvasStack.less +1 -1
  28. package/src/controls/ControlPanel.less +12 -12
  29. package/src/dragdrop/DragGhost.less +2 -2
  30. package/src/dragdrop/Palette.less +6 -6
  31. package/src/index.ts +2 -0
  32. package/src/lab/Lab.test.tsx +20 -15
  33. package/src/lab/Lab.tsx +33 -32
  34. package/src/lab/LabContext.ts +3 -3
  35. package/src/lab/LabShell.less +14 -14
  36. package/src/lab/LabShell.test.tsx +7 -20
  37. package/src/lab/LabShell.tsx +22 -13
  38. package/src/lab/WorkspaceGrid.less +1 -1
  39. package/src/lab/WorkspaceGrid.stories.tsx +4 -4
  40. package/src/lab/index.ts +1 -1
  41. package/src/lab/useSystemMode.ts +30 -0
  42. package/src/layers/LayerList.less +9 -9
  43. package/src/primitives/FpsMeter.less +5 -5
  44. package/src/primitives/ScaleIndicator.less +7 -7
  45. package/src/primitives/Sidebar.less +12 -12
  46. package/src/primitives/StatusBar.less +10 -10
  47. package/src/primitives/Toolbar.less +16 -16
  48. package/src/state/store.test.ts +17 -9
  49. package/src/state/store.ts +13 -10
  50. package/src/state/types.ts +5 -2
  51. package/src/theme/Interstellar.stories.less +20 -20
  52. package/src/theme/Interstellar.stories.tsx +44 -59
  53. package/src/theme/base.less +29 -28
  54. package/src/theme/interstellar.test.ts +30 -0
  55. package/src/theme/interstellar.tokens.json +57 -0
  56. package/src/theme/interstellar.ts +9 -0
  57. package/src/ui/layers/LayerStack.less +21 -21
  58. package/src/ui/properties/CurveField.less +5 -5
  59. package/src/ui/properties/EffectCard.tsx +2 -2
  60. package/src/ui/properties/PropertyGroup.less +6 -6
  61. package/src/ui/properties/PropertyPanel.less +70 -70
  62. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +1 -1
  63. package/src/ui/properties/storyLayouts.tsx +1 -1
  64. package/src/workspace/Workspace.less +14 -14
  65. package/src/workspace/Workspace.stories.tsx +2 -2
  66. package/src/workspace/Workspace.test.tsx +2 -2
  67. package/dist/chunk-5XDFILHZ.js.map +0 -1
  68. package/dist/chunk-BG6NLMOL.js.map +0 -1
  69. package/dist/chunk-SSMHPMDV.js.map +0 -1
  70. package/dist/theme-interstellar.css +0 -25
  71. package/dist/theme-light.css +0 -13
  72. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  73. package/src/theme/interstellar.less +0 -34
  74. package/src/theme/light.less +0 -14
  75. package/src/theme/tokens.less +0 -85
@@ -1,5 +1,5 @@
1
- export { LayerStack } from '../../chunk-ERZQAU4N.js';
2
- import '../../chunk-BG6NLMOL.js';
3
- import '../../chunk-5XDFILHZ.js';
1
+ export { LayerStack } from '../../chunk-HNXVLKBG.js';
2
+ import '../../chunk-FP5LYDVX.js';
3
+ import '../../chunk-XVBXYKFJ.js';
4
4
  //# sourceMappingURL=index.js.map
5
5
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "0.7.2",
3
+ "version": "1.0.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -67,9 +67,7 @@
67
67
  "types": "./dist/ui/layers/index.d.ts",
68
68
  "import": "./dist/ui/layers/index.js"
69
69
  },
70
- "./styles.css": "./dist/styles.css",
71
- "./theme-light.css": "./dist/theme-light.css",
72
- "./theme-interstellar.css": "./dist/theme-interstellar.css"
70
+ "./styles.css": "./dist/styles.css"
73
71
  },
74
72
  "scripts": {
75
73
  "dev": "vite",
@@ -78,8 +76,8 @@
78
76
  "build": "tsup && npm run build:dts && npm run build:css && npm run build:fonts",
79
77
  "build:dts": "tsx scripts/build-dts.mts",
80
78
  "prepublishOnly": "npm run build",
81
- "build:css": "lessc src/styles.less dist/styles.css && lessc src/theme/light.less dist/theme-light.css && lessc src/theme/interstellar.less dist/theme-interstellar.css",
82
- "build:fonts": "mkdir -p dist/fonts && cp src/fonts/*.woff2 dist/fonts/",
79
+ "build:css": "lessc src/styles.less dist/styles.css",
80
+ "build:fonts": "mkdir -p dist/fonts && cp ../theme/fonts/*.woff2 dist/fonts/",
83
81
  "test": "vitest run",
84
82
  "test:watch": "vitest",
85
83
  "test:smoke:consumer": "node scripts/smoke-consumer-bundle.mjs",
@@ -95,6 +93,7 @@
95
93
  "react-dom": "^19.0.0"
96
94
  },
97
95
  "dependencies": {
96
+ "@weasel-js/theme": "*",
98
97
  "earcut": "2.2.4",
99
98
  "polygon-clipping": "^0.15.7",
100
99
  "react-aria-components": "^1.5.0",
@@ -1,7 +1,7 @@
1
1
  .lk-canvas-stack {
2
2
  position: relative;
3
3
  overflow: hidden;
4
- background: var(--lk-color-canvas-bg, #f6f6f6);
4
+ background: var(--wzl-surface-sunken);
5
5
  touch-action: none;
6
6
  }
7
7
 
@@ -1,32 +1,32 @@
1
1
  .lk-control-panel {
2
2
  display: flex;
3
3
  flex-direction: column;
4
- gap: var(--lk-spacing-sm, 8px);
5
- padding: var(--lk-spacing-sm, 8px);
4
+ gap: var(--wzl-space-sm);
5
+ padding: var(--wzl-space-sm);
6
6
  }
7
7
 
8
8
  .lk-control-row {
9
9
  display: flex;
10
10
  flex-direction: column;
11
- gap: var(--lk-spacing-xs, 4px);
11
+ gap: var(--wzl-space-xs);
12
12
  }
13
13
 
14
14
  .lk-control-row--inline {
15
15
  flex-direction: row;
16
16
  align-items: center;
17
- gap: var(--lk-spacing-sm, 8px);
17
+ gap: var(--wzl-space-sm);
18
18
  }
19
19
 
20
20
  .lk-control-label {
21
- font-size: var(--lk-font-size-sm, 12px);
22
- color: var(--lk-color-text, #222);
21
+ font-size: var(--wzl-font-size-sm);
22
+ color: var(--wzl-fg);
23
23
  }
24
24
 
25
25
  .lk-control-input {
26
26
  width: 100%;
27
- background: var(--lk-color-surface, #fff);
28
- color: var(--lk-color-text, #222);
29
- border: 1px solid var(--lk-color-border, #ccc);
27
+ background: var(--wzl-surface-raised);
28
+ color: var(--wzl-fg);
29
+ border: 1px solid var(--wzl-border);
30
30
  border-radius: 4px;
31
31
  padding: 2px 6px;
32
32
  }
@@ -47,9 +47,9 @@
47
47
  }
48
48
 
49
49
  .lk-control-slider-value {
50
- font-size: var(--lk-font-size-sm, 12px);
50
+ font-size: var(--wzl-font-size-sm);
51
51
  font-variant-numeric: tabular-nums;
52
- color: var(--lk-color-text-muted, #666);
52
+ color: var(--wzl-fg-muted);
53
53
  min-width: 3ch;
54
54
  text-align: right;
55
55
  }
@@ -57,5 +57,5 @@
57
57
  .lk-control-slider-row {
58
58
  display: flex;
59
59
  align-items: center;
60
- gap: var(--lk-spacing-sm, 8px);
60
+ gap: var(--wzl-space-sm);
61
61
  }
@@ -3,8 +3,8 @@
3
3
  pointer-events: none;
4
4
  z-index: 9999;
5
5
  padding: 4px 8px;
6
- background: var(--lk-color-surface, #fff);
7
- border: 1px solid var(--lk-color-border, #999);
6
+ background: var(--wzl-surface-raised);
7
+ border: 1px solid var(--wzl-border);
8
8
  border-radius: 4px;
9
9
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
10
10
  font: inherit;
@@ -1,8 +1,8 @@
1
1
  .lk-palette {
2
2
  display: grid;
3
3
  grid-template-columns: repeat(2, 1fr);
4
- gap: var(--lk-spacing-sm, 8px);
5
- padding: var(--lk-spacing-sm, 8px);
4
+ gap: var(--wzl-space-sm);
5
+ padding: var(--wzl-space-sm);
6
6
  }
7
7
 
8
8
  .lk-palette__item {
@@ -10,9 +10,9 @@
10
10
  align-items: center;
11
11
  justify-content: center;
12
12
  min-height: 48px;
13
- padding: var(--lk-spacing-sm, 8px);
14
- background: var(--lk-color-surface, #fff);
15
- border: 1px solid var(--lk-color-border, #d0d0d0);
13
+ padding: var(--wzl-space-sm);
14
+ background: var(--wzl-surface-raised);
15
+ border: 1px solid var(--wzl-border);
16
16
  border-radius: 4px;
17
17
  cursor: grab;
18
18
  font: inherit;
@@ -21,7 +21,7 @@
21
21
  touch-action: none;
22
22
 
23
23
  &:hover {
24
- background: var(--lk-color-surface-hover, #f4f4f4);
24
+ background: var(--wzl-surface-hover);
25
25
  }
26
26
 
27
27
  &:active {
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export {
56
56
  export type {
57
57
  CreateLabStoreOptions,
58
58
  ExperimentStateHandle,
59
+ LabMode,
59
60
  LabStoreState,
60
61
  SavedSnapshot,
61
62
  StorageAdapter,
@@ -63,6 +64,7 @@ export type {
63
64
  WorkspaceRecord,
64
65
  } from './state/types';
65
66
  export { useExperimentState } from './state/useExperimentState';
67
+ export { interstellarTheme } from './theme/interstellar';
66
68
  export * from './ui/layers';
67
69
  export * from './ui/properties';
68
70
  export type { EventBus, EventListener } from './undo';
@@ -89,27 +89,32 @@ describe('<Lab>', () => {
89
89
  expect(reset?.state).toEqual({ value: 0 });
90
90
  });
91
91
 
92
- it('theme="light" applies lk-theme-light class', () => {
93
- const { container } = mountLab({ theme: 'light' });
94
- expect(container.querySelector('.lk-lab')?.className).toMatch(/lk-theme-light/);
92
+ it('mode="light" stamps light on the lab root', () => {
93
+ const { container } = mountLab({ mode: 'light' });
94
+ expect(container.querySelector('.lk-lab')?.getAttribute('data-wzl-mode')).toBe('light');
95
95
  });
96
96
 
97
- it('theme="interstellar" applies lk-theme-interstellar class', () => {
98
- const { container } = mountLab({ theme: 'interstellar' });
99
- expect(container.querySelector('.lk-lab')?.className).toMatch(/lk-theme-interstellar/);
97
+ it('mode="dark" stamps dark on the lab root', () => {
98
+ const { container } = mountLab({ mode: 'dark' });
99
+ expect(container.querySelector('.lk-lab')?.getAttribute('data-wzl-mode')).toBe('dark');
100
100
  });
101
101
 
102
- it('theme="auto" applies neither class', () => {
103
- const { container } = mountLab({ theme: 'auto' });
104
- const cls = container.querySelector('.lk-lab')?.className ?? '';
105
- expect(cls).not.toMatch(/lk-theme-light/);
106
- expect(cls).not.toMatch(/lk-theme-interstellar/);
102
+ it('mode="auto" resolves to a concrete mode', () => {
103
+ const { container } = mountLab({ mode: 'auto' });
104
+ expect(container.querySelector('.lk-lab')?.getAttribute('data-wzl-mode')).toMatch(
105
+ /^(light|dark)$/,
106
+ );
107
+ });
108
+
109
+ it('applies the interstellar theme in every mode', () => {
110
+ const { container } = mountLab({ mode: 'light' });
111
+ expect(container.querySelector('.lk-lab')?.getAttribute('data-wzl-theme')).toBe('interstellar');
107
112
  });
108
113
 
109
- it('setTheme updates class at runtime', () => {
110
- const { container } = mountLab({ theme: 'auto' });
111
- act(() => labRef?.setTheme('light'));
112
- expect(container.querySelector('.lk-lab')?.className).toMatch(/lk-theme-light/);
114
+ it('setMode updates the stamped mode at runtime', () => {
115
+ const { container } = mountLab({ mode: 'auto' });
116
+ act(() => labRef?.setMode('light'));
117
+ expect(container.querySelector('.lk-lab')?.getAttribute('data-wzl-mode')).toBe('light');
113
118
  });
114
119
 
115
120
  it('throws when instruments is empty', () => {
package/src/lab/Lab.tsx CHANGED
@@ -1,10 +1,13 @@
1
1
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
+ import { ThemeProvider } from '@weasel-js/theme/react';
3
4
  import type { Instrument } from '../instrument/types';
4
5
  import { noneAdapter } from '../state/adapters';
5
6
  import { LabStoreContext } from '../state/context';
6
7
  import { createLabStore, type LabStore } from '../state/store';
7
- import type { StorageAdapter, WorkspaceRecord } from '../state/types';
8
+ import type { LabMode, StorageAdapter, WorkspaceRecord } from '../state/types';
9
+ import { interstellarTheme } from '../theme/interstellar';
10
+ import { useResolvedMode } from './useSystemMode';
8
11
  import { Workspace } from '../workspace/Workspace';
9
12
  import {
10
13
  addWorkspace as addWorkspaceOp,
@@ -21,12 +24,12 @@ export interface LabProps {
21
24
  defaultInstrument: string;
22
25
  storage?: StorageAdapter | null;
23
26
  storageKey?: string;
24
- theme?: 'auto' | 'light' | 'interstellar';
27
+ mode?: LabMode;
25
28
  /**
26
29
  * Optional list of CSS colors used to compose the interstellar theme's
27
30
  * cosmic backdrop. Each color becomes one radial-gradient blob on the
28
31
  * dark void base. Order maps to a fixed spread of positions; extras wrap
29
- * around. Ignored unless `theme="interstellar"` is active.
32
+ * around. Ignored unless the resolved mode is dark.
30
33
  */
31
34
  nebula?: readonly string[];
32
35
  title?: string;
@@ -38,9 +41,9 @@ function buildStore(
38
41
  defaultInstrument: string,
39
42
  storage: StorageAdapter,
40
43
  storageKey: string,
41
- initialTheme: 'auto' | 'light' | 'interstellar',
44
+ initialMode: LabMode,
42
45
  ): LabStore {
43
- const store = createLabStore({ storageKey, storage, initialTheme });
46
+ const store = createLabStore({ storageKey, storage, initialMode });
44
47
  if (store.getState().workspaces.length === 0) {
45
48
  const seeded = addWorkspaceOp([], instruments, defaultInstrument);
46
49
  const record = seeded[0];
@@ -78,7 +81,7 @@ export function Lab({
78
81
  defaultInstrument,
79
82
  storage,
80
83
  storageKey,
81
- theme,
84
+ mode,
82
85
  nebula,
83
86
  title,
84
87
  children,
@@ -94,20 +97,21 @@ export function Lab({
94
97
  defaultInstrument,
95
98
  storage ?? noneAdapter,
96
99
  storageKey ?? 'labkit',
97
- theme ?? 'auto',
100
+ mode ?? 'auto',
98
101
  );
99
102
  }
100
103
  const store = storeRef.current;
101
104
 
102
105
  const workspaces = useStore(store, (s) => s.workspaces);
103
106
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
104
- const themeValue = useStore(store, (s) => s.theme);
107
+ const modeValue = useStore(store, (s) => s.mode);
108
+ const resolvedMode = useResolvedMode(modeValue);
105
109
 
106
110
  useEffect(() => {
107
- if (theme && theme !== store.getState().theme) {
108
- store.getState().setTheme(theme);
111
+ if (mode && mode !== store.getState().mode) {
112
+ store.getState().setMode(mode);
109
113
  }
110
- }, [theme, store]);
114
+ }, [mode, store]);
111
115
 
112
116
  const contextValue = useMemo<LabContextValue>(() => {
113
117
  const replaceWorkspaces = (next: WorkspaceRecord[]): void => {
@@ -115,7 +119,7 @@ export function Lab({
115
119
  const merged = next.map((w) => currentById.get(w.id) ?? w);
116
120
  store.setState({ workspaces: merged });
117
121
  // Trigger persistence flush via a tracked action.
118
- store.getState().setTheme(store.getState().theme);
122
+ store.getState().setMode(store.getState().mode);
119
123
  };
120
124
 
121
125
  return {
@@ -156,40 +160,37 @@ export function Lab({
156
160
  deleteSnapshot: (snapshotId) => {
157
161
  store.getState().deleteSnapshot(snapshotId);
158
162
  },
159
- theme: themeValue,
160
- setTheme: (t) => {
161
- store.getState().setTheme(t);
163
+ mode: modeValue,
164
+ setMode: (m) => {
165
+ store.getState().setMode(m);
162
166
  },
163
167
  };
164
- }, [instruments, workspaces, savedSnapshots, themeValue, store]);
168
+ }, [instruments, workspaces, savedSnapshots, modeValue, store]);
165
169
 
166
- const themeClass =
167
- themeValue === 'light'
168
- ? 'lk-theme-light'
169
- : themeValue === 'interstellar'
170
- ? 'lk-theme-interstellar'
171
- : '';
172
-
173
- // Only apply the nebula override when interstellar is active and at
174
- // least one color is provided. Setting a CSS custom property is the
175
- // sanctioned use of inline style.
176
- const nebulaStyle =
177
- themeValue === 'interstellar' && nebula && nebula.length > 0
178
- ? ({ ['--lk-space-nebula' as string]: buildNebula(nebula) } as CSSProperties)
170
+ // Only override the backdrop in dark, where there is one to override.
171
+ // Setting a CSS custom property is the sanctioned use of inline style.
172
+ const backdropStyle =
173
+ resolvedMode === 'dark' && nebula && nebula.length > 0
174
+ ? ({ ['--wzl-backdrop' as string]: buildNebula(nebula) } as CSSProperties)
179
175
  : undefined;
180
176
 
181
177
  return (
182
178
  <LabStoreContext.Provider value={{ store }}>
183
179
  <LabContext.Provider value={contextValue}>
184
- <div className={`lk-lab ${themeClass}`.trim()} style={nebulaStyle}>
185
- <LabShell title={title ?? 'Labkit'} theme={themeValue} header={children}>
180
+ <ThemeProvider
181
+ theme={interstellarTheme}
182
+ mode={resolvedMode}
183
+ className="lk-lab"
184
+ style={backdropStyle}
185
+ >
186
+ <LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
186
187
  <WorkspaceGrid>
187
188
  {workspaces.map((w) => (
188
189
  <Workspace key={w.id} id={w.id} />
189
190
  ))}
190
191
  </WorkspaceGrid>
191
192
  </LabShell>
192
- </div>
193
+ </ThemeProvider>
193
194
  </LabContext.Provider>
194
195
  </LabStoreContext.Provider>
195
196
  );
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext } from 'react';
2
2
  import type { Instrument } from '../instrument/types';
3
- import type { SavedSnapshot, WorkspaceRecord } from '../state/types';
3
+ import type { LabMode, SavedSnapshot, WorkspaceRecord } from '../state/types';
4
4
 
5
5
  export interface LabContextValue {
6
6
  instruments: Instrument[];
@@ -13,8 +13,8 @@ export interface LabContextValue {
13
13
  saveSnapshot: (workspaceId: string, name?: string) => void;
14
14
  loadSnapshot: (workspaceId: string, snapshotId: string) => void;
15
15
  deleteSnapshot: (snapshotId: string) => void;
16
- theme: 'auto' | 'light' | 'interstellar';
17
- setTheme: (t: 'auto' | 'light' | 'interstellar') => void;
16
+ mode: LabMode;
17
+ setMode: (m: LabMode) => void;
18
18
  }
19
19
 
20
20
  export const LabContext = createContext<LabContextValue | null>(null);
@@ -3,43 +3,43 @@
3
3
  flex-direction: column;
4
4
  width: 100%;
5
5
  height: 100vh;
6
- background: var(--lk-bg);
6
+ background: var(--wzl-surface);
7
7
  }
8
8
 
9
9
  .lk-shell-header {
10
10
  display: flex;
11
11
  align-items: center;
12
- gap: var(--lk-spacing-md);
13
- padding: var(--lk-spacing-sm) var(--lk-spacing-md);
14
- background: var(--lk-bg-elevated);
15
- border-bottom: 1px solid var(--lk-border);
16
- z-index: var(--lk-z-toolbar);
12
+ gap: var(--wzl-space-md);
13
+ padding: var(--wzl-space-sm) var(--wzl-space-md);
14
+ background: var(--wzl-surface-raised);
15
+ border-bottom: 1px solid var(--wzl-border);
16
+ z-index: var(--wzl-z-toolbar);
17
17
  }
18
18
 
19
19
  .lk-shell-title {
20
20
  margin: 0;
21
21
  font-size: 16px;
22
22
  font-weight: 600;
23
- color: var(--lk-text);
23
+ color: var(--wzl-fg);
24
24
  }
25
25
 
26
26
  .lk-shell-header-actions {
27
27
  margin-left: auto;
28
28
  display: flex;
29
- gap: var(--lk-spacing-sm);
29
+ gap: var(--wzl-space-sm);
30
30
  }
31
31
 
32
32
  .lk-shell-body {
33
33
  flex: 1;
34
34
  min-height: 0;
35
35
  overflow: auto;
36
- padding: var(--lk-spacing-md);
36
+ padding: var(--wzl-space-md);
37
37
  }
38
38
 
39
39
  .lk-shell-footer {
40
- border-top: 1px solid var(--lk-border);
41
- padding: var(--lk-spacing-xs) var(--lk-spacing-md);
42
- background: var(--lk-bg-elevated);
43
- color: var(--lk-text-muted);
44
- font-size: var(--lk-font-size-sm);
40
+ border-top: 1px solid var(--wzl-border);
41
+ padding: var(--wzl-space-xs) var(--wzl-space-md);
42
+ background: var(--wzl-surface-raised);
43
+ color: var(--wzl-fg-muted);
44
+ font-size: var(--wzl-font-size-sm);
45
45
  }
@@ -22,38 +22,25 @@ describe('LabShell', () => {
22
22
  expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument();
23
23
  });
24
24
 
25
- test('applies lk-theme-light class when theme="light"', () => {
25
+ test('stamps the requested mode under the interstellar theme', () => {
26
26
  const { container } = render(
27
- <LabShell title="t" theme="light">
27
+ <LabShell title="t" mode="light">
28
28
  x
29
29
  </LabShell>,
30
30
  );
31
31
  const root = container.firstChild as HTMLElement;
32
- expect(root.classList.contains('lk-theme-light')).toBe(true);
33
- expect(root.classList.contains('lk-theme-interstellar')).toBe(false);
32
+ expect(root.getAttribute('data-wzl-theme')).toBe('interstellar');
33
+ expect(root.getAttribute('data-wzl-mode')).toBe('light');
34
34
  });
35
35
 
36
- test('applies lk-theme-interstellar class when theme="interstellar"', () => {
37
- const { container } = render(
38
- <LabShell title="t" theme="interstellar">
39
- x
40
- </LabShell>,
41
- );
42
- const root = container.firstChild as HTMLElement;
43
- expect(root.classList.contains('lk-theme-interstellar')).toBe(true);
44
- expect(root.classList.contains('lk-theme-light')).toBe(false);
45
- });
46
-
47
- test('applies neither theme class when theme="auto" (default)', () => {
36
+ test('resolves mode="auto" to a concrete mode', () => {
48
37
  const { container } = render(<LabShell title="t">x</LabShell>);
49
38
  const root = container.firstChild as HTMLElement;
50
- expect(root.classList.contains('lk-theme-light')).toBe(false);
51
- expect(root.classList.contains('lk-theme-interstellar')).toBe(false);
39
+ expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
52
40
  });
53
41
 
54
42
  test('always applies lk-root class', () => {
55
43
  const { container } = render(<LabShell title="t">x</LabShell>);
56
- const root = container.firstChild as HTMLElement;
57
- expect(root.classList.contains('lk-root')).toBe(true);
44
+ expect(container.querySelector('.lk-root')).not.toBeNull();
58
45
  });
59
46
  });
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
-
3
- export type LabTheme = 'auto' | 'light' | 'interstellar';
2
+ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
3
+ import type { LabMode } from '../state/types';
4
+ import { interstellarTheme } from '../theme/interstellar';
5
+ import { useResolvedMode } from './useSystemMode';
4
6
 
5
7
  export interface LabShellProps {
6
8
  title: string;
@@ -9,19 +11,16 @@ export interface LabShellProps {
9
11
  header?: ReactNode;
10
12
  /** Optional content rendered into the footer. */
11
13
  footer?: ReactNode;
12
- /** Theme override. "auto" (default) follows prefers-color-scheme. */
13
- theme?: LabTheme;
14
+ /** Color mode. "auto" (default) follows prefers-color-scheme. */
15
+ mode?: LabMode;
14
16
  }
15
17
 
16
- export function LabShell({ title, children, header, footer, theme = 'auto' }: LabShellProps) {
17
- const themeClass =
18
- theme === 'light'
19
- ? ' lk-theme-light'
20
- : theme === 'interstellar'
21
- ? ' lk-theme-interstellar'
22
- : '';
23
- return (
24
- <div className={`lk-root lk-shell${themeClass}`}>
18
+ export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
19
+ const resolved = useResolvedMode(mode);
20
+ const outer = useThemeOptional();
21
+
22
+ const shell = (
23
+ <div className="lk-root lk-shell">
25
24
  <header className="lk-shell-header">
26
25
  <h1 className="lk-shell-title">{title}</h1>
27
26
  {header && <div className="lk-shell-header-actions">{header}</div>}
@@ -30,4 +29,14 @@ export function LabShell({ title, children, header, footer, theme = 'auto' }: La
30
29
  {footer && <footer className="lk-shell-footer">{footer}</footer>}
31
30
  </div>
32
31
  );
32
+
33
+ // Inside <Lab> the theme is already applied on `.lk-lab`; wrapping again
34
+ // would only add a div.
35
+ return outer ? (
36
+ shell
37
+ ) : (
38
+ <ThemeProvider theme={interstellarTheme} mode={resolved}>
39
+ {shell}
40
+ </ThemeProvider>
41
+ );
33
42
  }
@@ -2,7 +2,7 @@
2
2
  display: grid;
3
3
  grid-template-columns: repeat(var(--lk-grid-cols, 1), 1fr);
4
4
  grid-template-rows: repeat(var(--lk-grid-rows, 1), 1fr);
5
- gap: var(--lk-spacing-md);
5
+ gap: var(--wzl-space-md);
6
6
  width: 100%;
7
7
  height: 100%;
8
8
  }
@@ -12,10 +12,10 @@ type Story = StoryObj<typeof WorkspaceGrid>;
12
12
  const Tile = ({ children }: { children: React.ReactNode }) => (
13
13
  <div
14
14
  style={{
15
- background: 'var(--lk-bg-elevated)',
16
- border: '1px solid var(--lk-border)',
17
- borderRadius: 'var(--lk-radius)',
18
- padding: 'var(--lk-spacing-md)',
15
+ background: 'var(--wzl-surface-raised)',
16
+ border: '1px solid var(--wzl-border)',
17
+ borderRadius: 'var(--wzl-radius-md)',
18
+ padding: 'var(--wzl-space-md)',
19
19
  minHeight: 120,
20
20
  display: 'grid',
21
21
  placeItems: 'center',
package/src/lab/index.ts CHANGED
@@ -4,7 +4,7 @@ export type { LabProps } from './Lab';
4
4
  export { Lab } from './Lab';
5
5
  export type { LabContextValue } from './LabContext';
6
6
  export { LabContext, useLabContext } from './LabContext';
7
- export type { LabShellProps, LabTheme } from './LabShell';
7
+ export type { LabShellProps } from './LabShell';
8
8
  export { LabShell } from './LabShell';
9
9
  export type { WorkspaceGridProps } from './WorkspaceGrid';
10
10
  export { WorkspaceGrid } from './WorkspaceGrid';
@@ -0,0 +1,30 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { LabMode } from '../state/types';
3
+
4
+ function query(): MediaQueryList | null {
5
+ if (typeof window === 'undefined' || !window.matchMedia) return null;
6
+ return window.matchMedia('(prefers-color-scheme: light)');
7
+ }
8
+
9
+ /**
10
+ * Resolve a `LabMode` to the mode the theme resolver understands.
11
+ *
12
+ * `auto` is not a mode — it means "read the OS", live. An explicit choice
13
+ * opts out of the subscription entirely.
14
+ */
15
+ export function useResolvedMode(mode: LabMode): 'light' | 'dark' {
16
+ const [system, setSystem] = useState<'light' | 'dark'>(() =>
17
+ query()?.matches ? 'light' : 'dark',
18
+ );
19
+
20
+ useEffect(() => {
21
+ if (mode !== 'auto') return;
22
+ const mq = query();
23
+ if (!mq) return;
24
+ const onChange = (e: MediaQueryListEvent) => setSystem(e.matches ? 'light' : 'dark');
25
+ mq.addEventListener('change', onChange);
26
+ return () => mq.removeEventListener('change', onChange);
27
+ }, [mode]);
28
+
29
+ return mode === 'auto' ? system : mode;
30
+ }