@weasel-js/labkit 0.1.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 (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,53 @@
1
+ export interface UndoStack {
2
+ past: unknown[];
3
+ future: unknown[];
4
+ }
5
+
6
+ export interface WorkspaceRecord<TS = unknown, TC = unknown> {
7
+ id: string;
8
+ instrumentName: string;
9
+ config: TC;
10
+ state: TS;
11
+ view: { zoom: number; pan: { x: number; y: number } };
12
+ undoStack: UndoStack;
13
+ }
14
+
15
+ export interface SavedSnapshot {
16
+ id: string;
17
+ name: string;
18
+ workspaceId: string;
19
+ instrumentName: string;
20
+ config: unknown;
21
+ state: unknown;
22
+ savedAt: number;
23
+ }
24
+
25
+ export interface LabStoreState {
26
+ workspaces: WorkspaceRecord[];
27
+ savedSnapshots: SavedSnapshot[];
28
+ theme: 'light' | 'interstellar' | 'auto';
29
+ }
30
+
31
+ export interface StorageAdapter {
32
+ read(key: string): string | null;
33
+ write(key: string, value: string): void;
34
+ delete?(key: string): void;
35
+ }
36
+
37
+ export interface ExperimentStateHandle<TS, TC> {
38
+ state: TS;
39
+ setState: (next: TS | ((prev: TS) => TS)) => void;
40
+ config: TC;
41
+ setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
42
+ }
43
+
44
+ export interface CreateLabStoreOptions {
45
+ storageKey: string;
46
+ storage: StorageAdapter;
47
+ initialTheme?: 'light' | 'interstellar' | 'auto';
48
+ }
49
+
50
+ export type InstrumentSerializers = Record<
51
+ string,
52
+ { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
53
+ >;
@@ -0,0 +1,102 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import type { ReactNode } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createMemoryAdapter } from './adapters';
5
+ import { LabStoreProvider, WorkspaceIdProvider } from './context';
6
+ import { createLabStore } from './store';
7
+ import { useExperimentState } from './useExperimentState';
8
+
9
+ type TestState = { count: number };
10
+ type TestConfig = { step: number };
11
+
12
+ function makeWrapper(workspaceId: string) {
13
+ const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
14
+ store.getState().addWorkspace({
15
+ id: workspaceId,
16
+ instrumentName: 'Counter',
17
+ config: { step: 1 } satisfies TestConfig,
18
+ state: { count: 0 } satisfies TestState,
19
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
20
+ });
21
+
22
+ return {
23
+ store,
24
+ wrapper: ({ children }: { children: ReactNode }) => (
25
+ <LabStoreProvider store={store}>
26
+ <WorkspaceIdProvider workspaceId={workspaceId}>{children}</WorkspaceIdProvider>
27
+ </LabStoreProvider>
28
+ ),
29
+ };
30
+ }
31
+
32
+ describe('useExperimentState', () => {
33
+ it('returns the initial state and config', () => {
34
+ const { wrapper } = makeWrapper('w1');
35
+ const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
36
+ expect(result.current.state.count).toBe(0);
37
+ expect(result.current.config.step).toBe(1);
38
+ });
39
+
40
+ it('setState (plain value) updates the store', () => {
41
+ const { wrapper } = makeWrapper('w1');
42
+ const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
43
+ act(() => result.current.setState({ count: 42 }));
44
+ expect(result.current.state.count).toBe(42);
45
+ });
46
+
47
+ it('setState (updater) updates the store', () => {
48
+ const { wrapper } = makeWrapper('w1');
49
+ const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
50
+ act(() => result.current.setState((prev) => ({ count: prev.count + 10 })));
51
+ expect(result.current.state.count).toBe(10);
52
+ });
53
+
54
+ it('setConfig updates a config key', () => {
55
+ const { wrapper } = makeWrapper('w1');
56
+ const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
57
+ act(() => result.current.setConfig('step', 5));
58
+ expect(result.current.config.step).toBe(5);
59
+ });
60
+
61
+ it('sibling workspace does not re-render on unrelated workspace changes', () => {
62
+ const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
63
+ store.getState().addWorkspace({
64
+ id: 'w1',
65
+ instrumentName: 'T',
66
+ config: {},
67
+ state: { n: 0 },
68
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
69
+ });
70
+ store.getState().addWorkspace({
71
+ id: 'w2',
72
+ instrumentName: 'T',
73
+ config: {},
74
+ state: { n: 0 },
75
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
76
+ });
77
+
78
+ let w2RenderCount = 0;
79
+
80
+ const wrapper = ({ children }: { children: ReactNode }) => (
81
+ <LabStoreProvider store={store}>
82
+ <WorkspaceIdProvider workspaceId="w2">{children}</WorkspaceIdProvider>
83
+ </LabStoreProvider>
84
+ );
85
+
86
+ renderHook(
87
+ () => {
88
+ w2RenderCount++;
89
+ return useExperimentState();
90
+ },
91
+ { wrapper },
92
+ );
93
+
94
+ const countBefore = w2RenderCount;
95
+ act(() => store.getState().updateWorkspaceState('w1', { n: 99 }));
96
+ expect(w2RenderCount).toBe(countBefore);
97
+ });
98
+
99
+ it('throws when used outside LabStoreProvider', () => {
100
+ expect(() => renderHook(() => useExperimentState())).toThrow('[labkit]');
101
+ });
102
+ });
@@ -0,0 +1,28 @@
1
+ import { useContext } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { LabStoreContext, WorkspaceIdContext } from './context';
4
+ import type { ExperimentStateHandle } from './types';
5
+
6
+ export function useExperimentState<TS = unknown, TC = unknown>(): ExperimentStateHandle<TS, TC> {
7
+ const ctx = useContext(LabStoreContext);
8
+ if (!ctx) throw new Error('[labkit] useExperimentState must be used inside <LabStoreProvider>');
9
+
10
+ const workspaceId = useContext(WorkspaceIdContext);
11
+ if (!workspaceId)
12
+ throw new Error('[labkit] useExperimentState must be used inside <WorkspaceIdProvider>');
13
+
14
+ const record = useStore(ctx.store, (s) => s.workspaces.find((w) => w.id === workspaceId));
15
+
16
+ if (!record) throw new Error(`[labkit] No workspace found with id "${workspaceId}"`);
17
+
18
+ const updateWorkspaceState = useStore(ctx.store, (s) => s.updateWorkspaceState);
19
+ const updateWorkspaceConfig = useStore(ctx.store, (s) => s.updateWorkspaceConfig);
20
+
21
+ return {
22
+ state: record.state as TS,
23
+ config: record.config as TC,
24
+ setState: (next) =>
25
+ updateWorkspaceState(workspaceId, next as Parameters<typeof updateWorkspaceState>[1]),
26
+ setConfig: (key, value) => updateWorkspaceConfig(workspaceId, key as never, value as never),
27
+ };
28
+ }
@@ -0,0 +1,20 @@
1
+ // Imports compile-time only. Component .less files are added in later steps.
2
+ @import './theme/base.less';
3
+ @import './lab/WorkspaceGrid.less';
4
+ @import './lab/LabShell.less';
5
+ @import './primitives/Toolbar.less';
6
+ @import './primitives/Sidebar.less';
7
+ @import './primitives/StatusBar.less';
8
+ @import './primitives/FpsMeter.less';
9
+ @import './primitives/ScaleIndicator.less';
10
+ @import './lab/Lab.less';
11
+ @import './workspace/Workspace.less';
12
+ @import './controls/ControlPanel.less';
13
+ @import './canvas/CanvasStack.less';
14
+ @import './layers/LayerList.less';
15
+ @import './dragdrop/Palette.less';
16
+ @import './dragdrop/DragGhost.less';
17
+ @import './ui/properties/PropertyPanel.less';
18
+ @import './ui/properties/PropertyGroup.less';
19
+ @import './ui/properties/CurveField.less';
20
+ @import './ui/layers/LayerStack.less';
@@ -0,0 +1,17 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { cleanup } from '@testing-library/react';
3
+ import { afterEach } from 'vitest';
4
+
5
+ // vitest 4's jsdom env no longer hoists Storage APIs to globalThis (its
6
+ // `KEYS` list omits localStorage/sessionStorage). The real Storage objects
7
+ // live on the jsdom instance vitest stashes at `globalThis.jsdom` — pull
8
+ // them onto the global so production code can use `localStorage.foo`.
9
+ const jsdomWin = (globalThis as { jsdom?: { window: Window } }).jsdom?.window;
10
+ if (jsdomWin && typeof globalThis.localStorage === 'undefined') {
11
+ Object.defineProperty(globalThis, 'localStorage', { value: jsdomWin.localStorage });
12
+ Object.defineProperty(globalThis, 'sessionStorage', { value: jsdomWin.sessionStorage });
13
+ }
14
+
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
@@ -0,0 +1,92 @@
1
+ // Storybook-only docs styles for the Interstellar theme page. Not bundled
2
+ // into the library — imported by Interstellar.stories.tsx for vite/storybook.
3
+
4
+ .lk-theme-doc {
5
+ font-family: var(--lk-font);
6
+ color: var(--lk-text);
7
+ }
8
+
9
+ .lk-theme-doc__grid {
10
+ display: grid;
11
+ gap: 32px;
12
+ max-width: 1100px;
13
+ }
14
+
15
+ .lk-theme-doc__heading {
16
+ font: 300 0.72rem/1 var(--lk-font-display);
17
+ color: var(--lk-text-muted);
18
+ text-transform: uppercase;
19
+ letter-spacing: 0.08em;
20
+ margin: 0 0 12px;
21
+ }
22
+
23
+ .lk-theme-doc__swatch-grid {
24
+ display: grid;
25
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
26
+ gap: 12px;
27
+ }
28
+
29
+ .lk-theme-doc__swatch {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 12px;
33
+ background: var(--lk-bg-elevated);
34
+ border: 1px solid var(--lk-border);
35
+ border-radius: var(--lk-radius-lg);
36
+ padding: 10px 12px;
37
+ backdrop-filter: blur(var(--lk-glass-blur, 0));
38
+ -webkit-backdrop-filter: blur(var(--lk-glass-blur, 0));
39
+ }
40
+
41
+ .lk-theme-doc__swatch-fill {
42
+ width: 48px;
43
+ height: 48px;
44
+ border-radius: var(--lk-radius-sm);
45
+ border: 1px solid var(--lk-border);
46
+ flex-shrink: 0;
47
+ }
48
+
49
+ .lk-theme-doc__swatch-meta {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: 2px;
53
+ min-width: 0;
54
+ flex: 1;
55
+ }
56
+
57
+ .lk-theme-doc__swatch-name {
58
+ font: 300 0.95rem/1 var(--lk-font-display);
59
+ color: var(--lk-text);
60
+ }
61
+
62
+ .lk-theme-doc__swatch-var,
63
+ .lk-theme-doc__swatch-value {
64
+ font: 400 0.7rem/1.2 var(--lk-font-mono);
65
+ color: var(--lk-text-muted);
66
+ word-break: break-all;
67
+ }
68
+
69
+ .lk-theme-doc__swatch-value {
70
+ color: var(--lk-text);
71
+ }
72
+
73
+ .lk-theme-doc__nebula {
74
+ width: 100%;
75
+ height: 200px;
76
+ border-radius: var(--lk-radius-lg);
77
+ border: 1px solid var(--lk-border);
78
+ }
79
+
80
+ .lk-theme-doc__note {
81
+ font: 400 0.85rem/1.5 var(--lk-font);
82
+ color: var(--lk-text-muted);
83
+ margin-top: 12px;
84
+ max-width: 640px;
85
+ }
86
+
87
+ .lk-theme-doc__type-stack {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 16px;
91
+ align-items: flex-start;
92
+ }
@@ -0,0 +1,243 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useEffect, useState } from 'react';
3
+ import {
4
+ CheckboxRow,
5
+ ColorRow,
6
+ PropertyList,
7
+ PropertyPanel,
8
+ SliderRow,
9
+ TextRow,
10
+ } from '../ui/properties/PropertyPanel';
11
+ import './Interstellar.stories.less';
12
+
13
+ const meta: Meta = {
14
+ title: 'labkit/Themes/Interstellar',
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ 'The interstellar theme: deep-space cosmic gradient + starscape backdrop, glass surfaces, Oswald display type, purple accent. ' +
20
+ 'Tokens live in `src/theme/tokens.less` (defaults) and `src/theme/interstellar.less` (explicit `.lk-theme-interstellar` overrides).',
21
+ },
22
+ },
23
+ },
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj;
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────────────
30
+
31
+ interface Swatch {
32
+ name: string;
33
+ cssVar: string;
34
+ }
35
+
36
+ const SURFACE_TOKENS: Swatch[] = [
37
+ { name: 'bg', cssVar: '--lk-bg' },
38
+ { name: 'bg-elevated', cssVar: '--lk-bg-elevated' },
39
+ { name: 'bg-canvas', cssVar: '--lk-bg-canvas' },
40
+ { name: 'border', cssVar: '--lk-border' },
41
+ { name: 'divider', cssVar: '--lk-divider' },
42
+ ];
43
+
44
+ const TEXT_TOKENS: Swatch[] = [
45
+ { name: 'text', cssVar: '--lk-text' },
46
+ { name: 'text-muted', cssVar: '--lk-text-muted' },
47
+ { name: 'text-disabled', cssVar: '--lk-text-disabled' },
48
+ ];
49
+
50
+ const ACCENT_TOKENS: Swatch[] = [
51
+ { name: 'accent', cssVar: '--lk-accent' },
52
+ { name: 'accent-hover', cssVar: '--lk-accent-hover' },
53
+ { name: 'focus-ring', cssVar: '--lk-focus-ring' },
54
+ ];
55
+
56
+ const PALETTE_TOKENS: Swatch[] = [
57
+ { name: 'green', cssVar: '--lk-swatch-green' },
58
+ { name: 'pink', cssVar: '--lk-swatch-pink' },
59
+ { name: 'cyan', cssVar: '--lk-swatch-cyan' },
60
+ { name: 'gold', cssVar: '--lk-swatch-gold' },
61
+ { name: 'amber', cssVar: '--lk-swatch-amber' },
62
+ { name: 'violet', cssVar: '--lk-swatch-violet' },
63
+ { name: 'mint', cssVar: '--lk-swatch-mint' },
64
+ { name: 'sky', cssVar: '--lk-swatch-sky' },
65
+ { name: 'orange', cssVar: '--lk-swatch-orange' },
66
+ { name: 'magenta', cssVar: '--lk-swatch-magenta' },
67
+ ];
68
+
69
+ /**
70
+ * Resolves a CSS custom property's computed value off `document.documentElement`
71
+ * so swatch cards can display the actual color alongside the var name.
72
+ * Returns null until the first effect runs.
73
+ */
74
+ function useResolvedVars(vars: readonly string[]): Record<string, string> {
75
+ const key = vars.join('|');
76
+ const [resolved, setResolved] = useState<Record<string, string>>({});
77
+ useEffect(() => {
78
+ const root = document.querySelector('.lk-theme-interstellar') ?? document.documentElement;
79
+ const cs = window.getComputedStyle(root);
80
+ const out: Record<string, string> = {};
81
+ for (const v of key.split('|')) out[v] = cs.getPropertyValue(v).trim();
82
+ setResolved(out);
83
+ }, [key]);
84
+ return resolved;
85
+ }
86
+
87
+ function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: string }) {
88
+ const resolved = useResolvedVars(tokens.map((t) => t.cssVar));
89
+ return (
90
+ <div>
91
+ <h3 className="lk-theme-doc__heading">{title}</h3>
92
+ <div className="lk-theme-doc__swatch-grid">
93
+ {tokens.map((t) => (
94
+ <div key={t.cssVar} className="lk-theme-doc__swatch">
95
+ <div className="lk-theme-doc__swatch-fill" style={{ background: `var(${t.cssVar})` }} />
96
+ <div className="lk-theme-doc__swatch-meta">
97
+ <div className="lk-theme-doc__swatch-name">{t.name}</div>
98
+ <code className="lk-theme-doc__swatch-var">{t.cssVar}</code>
99
+ <code className="lk-theme-doc__swatch-value">{resolved[t.cssVar] ?? '…'}</code>
100
+ </div>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function NebulaPreview() {
109
+ return (
110
+ <div>
111
+ <h3 className="lk-theme-doc__heading">Cosmic backdrop (`--lk-space-nebula`)</h3>
112
+ <div className="lk-theme-doc__nebula" style={{ background: 'var(--lk-space-nebula)' }} />
113
+ <p className="lk-theme-doc__note">
114
+ Default: four radial gradients (purple, pink, blue, then a dark void base). Override per-Lab
115
+ by passing <code>nebula={'{[colors]}'}</code> to the <code>&lt;Lab&gt;</code> component.
116
+ </p>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function TypographySample() {
122
+ return (
123
+ <div>
124
+ <h3 className="lk-theme-doc__heading">Typography</h3>
125
+ <div className="lk-theme-doc__type-stack">
126
+ <h1 style={{ font: '300 2.6rem/1 var(--lk-font-display)', color: 'var(--lk-text)' }}>
127
+ Heading display 300
128
+ </h1>
129
+ <h2 style={{ font: '300 1.4rem/1.2 var(--lk-font-display)', color: 'var(--lk-text)' }}>
130
+ Heading 300
131
+ </h2>
132
+ <div
133
+ style={{
134
+ font: '300 0.78rem/1 var(--lk-font-display)',
135
+ color: 'var(--lk-text-muted)',
136
+ textTransform: 'uppercase',
137
+ letterSpacing: '0.05em',
138
+ }}
139
+ >
140
+ Label uppercase 300
141
+ </div>
142
+ <p style={{ font: '400 0.9rem/1.5 var(--lk-font)', color: 'var(--lk-text)' }}>
143
+ Body 400. Free-form prose at the default font size and line height.
144
+ </p>
145
+ <code
146
+ style={{
147
+ font: '400 0.85rem/1 var(--lk-font-mono)',
148
+ color: 'var(--lk-text)',
149
+ background: 'rgba(0,0,0,0.35)',
150
+ padding: '4px 8px',
151
+ borderRadius: 3,
152
+ }}
153
+ >
154
+ monospace ui-monospace
155
+ </code>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ function LivePanelPreview() {
162
+ return (
163
+ <div>
164
+ <h3 className="lk-theme-doc__heading">Live panel</h3>
165
+ <PropertyPanel title="Sample">
166
+ <PropertyList>
167
+ <SliderRow
168
+ label="Opacity"
169
+ value={0.65}
170
+ min={0}
171
+ max={1}
172
+ step={0.01}
173
+ onChange={() => {}}
174
+ format={(v) => v.toFixed(2)}
175
+ />
176
+ <ColorRow label="Fill" value="#b08adb" onChange={() => {}} />
177
+ <ColorRow label="Stroke" value="#1a1428" onChange={() => {}} />
178
+ <TextRow label="Name" value="Untitled" onChange={() => {}} />
179
+ <CheckboxRow label="Visible" value={true} onChange={() => {}} />
180
+ </PropertyList>
181
+ </PropertyPanel>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ // ── Stories ──────────────────────────────────────────────────────────
187
+
188
+ export const Overview: Story = {
189
+ render: () => (
190
+ <div
191
+ className="lk-theme-interstellar lk-theme-doc"
192
+ style={{
193
+ background: 'var(--lk-space-nebula)',
194
+ padding: 32,
195
+ minHeight: '100vh',
196
+ }}
197
+ >
198
+ <h1 style={{ font: '300 2.6rem/1 var(--lk-font-display)', color: 'var(--lk-text)' }}>
199
+ Interstellar
200
+ </h1>
201
+ <p
202
+ style={{
203
+ font: '300 1.05rem/1.4 var(--lk-font-display)',
204
+ color: 'var(--lk-text-muted)',
205
+ maxWidth: 640,
206
+ margin: '12px 0 32px',
207
+ }}
208
+ >
209
+ Deep-space gradient backdrop, dark glass surfaces, Oswald display type, purple accent.
210
+ Default theme for labs running in lab mode.
211
+ </p>
212
+ <div className="lk-theme-doc__grid">
213
+ <SwatchGrid title="Surface" tokens={SURFACE_TOKENS} />
214
+ <SwatchGrid title="Text" tokens={TEXT_TOKENS} />
215
+ <SwatchGrid title="Accent / interactive" tokens={ACCENT_TOKENS} />
216
+ <SwatchGrid title="Palette" tokens={PALETTE_TOKENS} />
217
+ <NebulaPreview />
218
+ <TypographySample />
219
+ <LivePanelPreview />
220
+ </div>
221
+ </div>
222
+ ),
223
+ };
224
+
225
+ export const Palette: Story = {
226
+ render: () => (
227
+ <div
228
+ className="lk-theme-interstellar lk-theme-doc"
229
+ style={{
230
+ background: 'var(--lk-space-nebula)',
231
+ padding: 32,
232
+ minHeight: '100vh',
233
+ }}
234
+ >
235
+ <div className="lk-theme-doc__grid">
236
+ <SwatchGrid title="Surface" tokens={SURFACE_TOKENS} />
237
+ <SwatchGrid title="Text" tokens={TEXT_TOKENS} />
238
+ <SwatchGrid title="Accent / interactive" tokens={ACCENT_TOKENS} />
239
+ <SwatchGrid title="Palette" tokens={PALETTE_TOKENS} />
240
+ </div>
241
+ </div>
242
+ ),
243
+ };
@@ -0,0 +1,122 @@
1
+ @import './tokens.less';
2
+
3
+ // Self-hosted Oswald (variable font, weights 100–900) for the headline typeface.
4
+ @font-face {
5
+ font-family: 'Oswald';
6
+ font-style: normal;
7
+ font-weight: 100 900;
8
+ font-display: swap;
9
+ // URL is resolved relative to the COMPILED stylesheet at dist/styles.css,
10
+ // so this points to dist/fonts/. Source-tree usage (less compilation
11
+ // happens before the file is moved) doesn't need to resolve the URL.
12
+ src: url('./fonts/oswald-latin-variable.woff2') format('woff2');
13
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
14
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
15
+ U+2212, U+2215, U+FEFF, U+FFFD;
16
+ }
17
+
18
+ // Resets and global element defaults.
19
+ .lk-root {
20
+ font-family: var(--lk-font);
21
+ font-size: var(--lk-font-size);
22
+ font-weight: var(--lk-font-weight-light, 300);
23
+ color: var(--lk-text);
24
+ background: var(--lk-bg);
25
+ line-height: 1.4;
26
+
27
+ *, *::before, *::after {
28
+ box-sizing: border-box;
29
+ }
30
+
31
+ button {
32
+ font: inherit;
33
+ color: inherit;
34
+ background: var(--lk-bg-elevated);
35
+ border: 1px solid var(--lk-border);
36
+ border-radius: var(--lk-radius);
37
+ height: var(--lk-control-height);
38
+ padding: 0 var(--lk-spacing-md);
39
+ cursor: pointer;
40
+ backdrop-filter: blur(var(--lk-glass-blur, 0));
41
+ -webkit-backdrop-filter: blur(var(--lk-glass-blur, 0));
42
+
43
+ &:hover {
44
+ border-color: var(--lk-accent);
45
+ color: var(--lk-accent);
46
+ }
47
+
48
+ &:focus-visible {
49
+ outline: 2px solid var(--lk-focus-ring);
50
+ outline-offset: 1px;
51
+ }
52
+
53
+ &:disabled {
54
+ color: var(--lk-text-disabled);
55
+ cursor: not-allowed;
56
+ }
57
+ }
58
+
59
+ input[type="range"] {
60
+ accent-color: var(--lk-accent);
61
+ }
62
+ }
63
+
64
+ // Cosmic background applied to the Lab root. The starscape sits as a fixed
65
+ // pseudo-element so panning the workspace doesn't shift the field of stars.
66
+ .lk-lab {
67
+ position: relative;
68
+ isolation: isolate;
69
+ background: var(--lk-space-nebula, var(--lk-bg));
70
+ background-attachment: fixed;
71
+ }
72
+
73
+ .lk-lab::before {
74
+ content: '';
75
+ position: absolute;
76
+ inset: 0;
77
+ pointer-events: none;
78
+ z-index: 0;
79
+ background-image:
80
+ radial-gradient(1.2px 1.2px at 4% 18%, #fff, transparent 60%),
81
+ radial-gradient(1px 1px at 9% 64%, #fff, transparent 60%),
82
+ radial-gradient(1.5px 1.5px at 14% 36%, #fff, transparent 60%),
83
+ radial-gradient(1px 1px at 19% 82%, #cce, transparent 60%),
84
+ radial-gradient(0.8px 0.8px at 23% 28%, #fff, transparent 60%),
85
+ radial-gradient(1.4px 1.4px at 28% 55%, #fff, transparent 60%),
86
+ radial-gradient(1px 1px at 33% 12%, #ffe, transparent 60%),
87
+ radial-gradient(1.2px 1.2px at 37% 70%, #fff, transparent 60%),
88
+ radial-gradient(0.7px 0.7px at 42% 40%, #fff, transparent 60%),
89
+ radial-gradient(1.5px 1.5px at 47% 88%, #fff, transparent 60%),
90
+ radial-gradient(1px 1px at 52% 22%, #cef, transparent 60%),
91
+ radial-gradient(1.3px 1.3px at 57% 60%, #fff, transparent 60%),
92
+ radial-gradient(0.9px 0.9px at 62% 35%, #fff, transparent 60%),
93
+ radial-gradient(1.1px 1.1px at 66% 75%, #fff, transparent 60%),
94
+ radial-gradient(1.4px 1.4px at 71% 14%, #fff, transparent 60%),
95
+ radial-gradient(1px 1px at 76% 50%, #fff, transparent 60%),
96
+ radial-gradient(0.8px 0.8px at 81% 80%, #ffe, transparent 60%),
97
+ radial-gradient(1.3px 1.3px at 85% 25%, #fff, transparent 60%),
98
+ radial-gradient(1px 1px at 89% 62%, #cce, transparent 60%),
99
+ radial-gradient(1.5px 1.5px at 93% 38%, #fff, transparent 60%),
100
+ radial-gradient(0.9px 0.9px at 97% 78%, #fff, transparent 60%);
101
+ background-repeat: no-repeat;
102
+ }
103
+
104
+ // Light theme opts out of the cosmic backdrop & starscape.
105
+ .lk-theme-light.lk-lab {
106
+ background: var(--lk-bg);
107
+ background-attachment: scroll;
108
+ }
109
+
110
+ .lk-theme-light.lk-lab::before {
111
+ display: none;
112
+ }
113
+
114
+ // Headline / display text uses Oswald light at tighter line-height.
115
+ .lk-lab h1,
116
+ .lk-lab h2,
117
+ .lk-lab h3 {
118
+ font-family: var(--lk-font-display);
119
+ font-weight: var(--lk-font-weight-light, 300);
120
+ letter-spacing: 0.01em;
121
+ line-height: 1.2;
122
+ }