@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.
- package/README.md +72 -0
- package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
- package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
- package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
- package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
- package/dist/canvas/index.d.ts +34 -0
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -0
- package/dist/chunk-2QNYYL3V.js +86 -0
- package/dist/chunk-2QNYYL3V.js.map +1 -0
- package/dist/chunk-54IQ2DX7.js +222 -0
- package/dist/chunk-54IQ2DX7.js.map +1 -0
- package/dist/chunk-6FO7G5BB.js +6095 -0
- package/dist/chunk-6FO7G5BB.js.map +1 -0
- package/dist/chunk-7BKDG73Z.js +54 -0
- package/dist/chunk-7BKDG73Z.js.map +1 -0
- package/dist/chunk-B5ZUYRKN.js +23330 -0
- package/dist/chunk-B5ZUYRKN.js.map +1 -0
- package/dist/chunk-HXZHVU4G.js +126 -0
- package/dist/chunk-HXZHVU4G.js.map +1 -0
- package/dist/chunk-PWC7AQZM.js +8 -0
- package/dist/chunk-PWC7AQZM.js.map +1 -0
- package/dist/chunk-SD3HDXR7.js +161 -0
- package/dist/chunk-SD3HDXR7.js.map +1 -0
- package/dist/chunk-SSMHPMDV.js +350 -0
- package/dist/chunk-SSMHPMDV.js.map +1 -0
- package/dist/chunk-VLAHRJOC.js +201 -0
- package/dist/chunk-VLAHRJOC.js.map +1 -0
- package/dist/controls/index.d.ts +13 -0
- package/dist/controls/index.js +3 -0
- package/dist/controls/index.js.map +1 -0
- package/dist/dragdrop/index.d.ts +3 -0
- package/dist/dragdrop/index.js +3 -0
- package/dist/dragdrop/index.js.map +1 -0
- package/dist/fonts/oswald-latin-variable.woff2 +0 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +1425 -0
- package/dist/index.js.map +1 -0
- package/dist/layers/index.d.ts +17 -0
- package/dist/layers/index.js +3 -0
- package/dist/layers/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +258 -0
- package/dist/passthrough/weasel-canvas.js +3 -0
- package/dist/passthrough/weasel-canvas.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +2826 -0
- package/dist/passthrough/weasel-ui.js +4 -0
- package/dist/passthrough/weasel-ui.js.map +1 -0
- package/dist/primitives/index.d.ts +57 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/state/index.d.ts +33 -0
- package/dist/state/index.js +34 -0
- package/dist/state/index.js.map +1 -0
- package/dist/styles.css +1224 -0
- package/dist/theme-interstellar.css +25 -0
- package/dist/theme-light.css +13 -0
- package/dist/ui/layers/index.d.ts +42 -0
- package/dist/ui/layers/index.js +5 -0
- package/dist/ui/layers/index.js.map +1 -0
- package/dist/undo/index.d.ts +30 -0
- package/dist/undo/index.js +3 -0
- package/dist/undo/index.js.map +1 -0
- package/package.json +99 -0
- package/src/canvas/AGENTS.md +72 -0
- package/src/canvas/CanvasStack.less +22 -0
- package/src/canvas/CanvasStack.stories.tsx +63 -0
- package/src/canvas/CanvasStack.test.tsx +82 -0
- package/src/canvas/CanvasStack.tsx +108 -0
- package/src/canvas/CanvasStackContext.ts +8 -0
- package/src/canvas/canvasCoords.test.ts +30 -0
- package/src/canvas/canvasCoords.ts +15 -0
- package/src/canvas/index.ts +6 -0
- package/src/canvas/useLayerScheduler.ts +72 -0
- package/src/canvas/usePanZoom.ts +98 -0
- package/src/controls/ControlPanel.less +61 -0
- package/src/controls/ControlPanel.stories.tsx +77 -0
- package/src/controls/ControlPanel.test.tsx +149 -0
- package/src/controls/ControlPanel.tsx +249 -0
- package/src/controls/index.ts +12 -0
- package/src/controls/types.ts +61 -0
- package/src/dragdrop/DragDropRuntime.tsx +135 -0
- package/src/dragdrop/DragGhost.less +17 -0
- package/src/dragdrop/DragGhost.tsx +24 -0
- package/src/dragdrop/Palette.less +34 -0
- package/src/dragdrop/Palette.tsx +27 -0
- package/src/dragdrop/dragDrop.test.tsx +145 -0
- package/src/dragdrop/index.ts +1 -0
- package/src/fonts/oswald-latin-variable.woff2 +0 -0
- package/src/index.ts +70 -0
- package/src/instrument/SineWave.smoke.test.tsx +94 -0
- package/src/instrument/capabilityDetector.test.ts +64 -0
- package/src/instrument/capabilityDetector.ts +17 -0
- package/src/instrument/defineInstrument.test.ts +27 -0
- package/src/instrument/defineInstrument.ts +5 -0
- package/src/instrument/index.ts +19 -0
- package/src/instrument/types.ts +65 -0
- package/src/instrument/validateConfigSchema.test.ts +125 -0
- package/src/instrument/validateConfigSchema.ts +65 -0
- package/src/lab/Lab.less +3 -0
- package/src/lab/Lab.stories.tsx +51 -0
- package/src/lab/Lab.test.tsx +126 -0
- package/src/lab/Lab.tsx +196 -0
- package/src/lab/LabContext.test.ts +11 -0
- package/src/lab/LabContext.ts +28 -0
- package/src/lab/LabShell.less +45 -0
- package/src/lab/LabShell.stories.tsx +39 -0
- package/src/lab/LabShell.test.tsx +59 -0
- package/src/lab/LabShell.tsx +33 -0
- package/src/lab/WorkspaceGrid.less +8 -0
- package/src/lab/WorkspaceGrid.stories.tsx +61 -0
- package/src/lab/WorkspaceGrid.test.tsx +40 -0
- package/src/lab/WorkspaceGrid.tsx +20 -0
- package/src/lab/gridDims.test.ts +35 -0
- package/src/lab/gridDims.ts +11 -0
- package/src/lab/index.ts +10 -0
- package/src/layers/AGENTS.md +65 -0
- package/src/layers/LayerList.less +53 -0
- package/src/layers/LayerList.test.tsx +49 -0
- package/src/layers/LayerList.tsx +105 -0
- package/src/layers/index.ts +3 -0
- package/src/passthrough/weasel-canvas.test.ts +30 -0
- package/src/passthrough/weasel-canvas.ts +23 -0
- package/src/passthrough/weasel-ui.test.ts +79 -0
- package/src/passthrough/weasel-ui.ts +150 -0
- package/src/primitives/FpsMeter.less +14 -0
- package/src/primitives/FpsMeter.stories.tsx +14 -0
- package/src/primitives/FpsMeter.test.tsx +38 -0
- package/src/primitives/FpsMeter.tsx +36 -0
- package/src/primitives/ScaleIndicator.less +25 -0
- package/src/primitives/ScaleIndicator.stories.tsx +16 -0
- package/src/primitives/ScaleIndicator.test.tsx +46 -0
- package/src/primitives/ScaleIndicator.tsx +48 -0
- package/src/primitives/Sidebar.less +57 -0
- package/src/primitives/Sidebar.stories.tsx +38 -0
- package/src/primitives/Sidebar.test.tsx +74 -0
- package/src/primitives/Sidebar.tsx +32 -0
- package/src/primitives/StatusBar.less +21 -0
- package/src/primitives/StatusBar.stories.tsx +24 -0
- package/src/primitives/StatusBar.test.tsx +25 -0
- package/src/primitives/StatusBar.tsx +18 -0
- package/src/primitives/Toolbar.less +43 -0
- package/src/primitives/Toolbar.stories.tsx +36 -0
- package/src/primitives/Toolbar.test.tsx +65 -0
- package/src/primitives/Toolbar.tsx +44 -0
- package/src/primitives/fpsAverage.test.ts +21 -0
- package/src/primitives/fpsAverage.ts +5 -0
- package/src/primitives/index.ts +9 -0
- package/src/state/SingletonExperiment.test.tsx +101 -0
- package/src/state/SingletonExperiment.tsx +52 -0
- package/src/state/adapters.test.ts +94 -0
- package/src/state/adapters.ts +102 -0
- package/src/state/context.tsx +42 -0
- package/src/state/helpers.test.ts +89 -0
- package/src/state/helpers.ts +61 -0
- package/src/state/index.ts +40 -0
- package/src/state/store.test.ts +243 -0
- package/src/state/store.ts +225 -0
- package/src/state/types.ts +53 -0
- package/src/state/useExperimentState.test.tsx +102 -0
- package/src/state/useExperimentState.ts +28 -0
- package/src/styles.less +20 -0
- package/src/test-setup.ts +17 -0
- package/src/theme/Interstellar.stories.less +92 -0
- package/src/theme/Interstellar.stories.tsx +243 -0
- package/src/theme/base.less +122 -0
- package/src/theme/interstellar.less +34 -0
- package/src/theme/light.less +14 -0
- package/src/theme/tokens.less +85 -0
- package/src/ui/format.ts +19 -0
- package/src/ui/layers/LayerStack.less +143 -0
- package/src/ui/layers/LayerStack.stories.tsx +45 -0
- package/src/ui/layers/LayerStack.test.tsx +170 -0
- package/src/ui/layers/LayerStack.tsx +215 -0
- package/src/ui/layers/index.ts +1 -0
- package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
- package/src/ui/properties/ColorRow.stories.tsx +106 -0
- package/src/ui/properties/CurveField.less +48 -0
- package/src/ui/properties/CurveField.stories.tsx +20 -0
- package/src/ui/properties/CurveField.test.tsx +127 -0
- package/src/ui/properties/CurveField.tsx +181 -0
- package/src/ui/properties/EffectCard.tsx +312 -0
- package/src/ui/properties/Gallery.stories.tsx +178 -0
- package/src/ui/properties/NumberRow.stories.tsx +65 -0
- package/src/ui/properties/PropertyGroup.less +50 -0
- package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
- package/src/ui/properties/PropertyGroup.test.tsx +33 -0
- package/src/ui/properties/PropertyGroup.tsx +44 -0
- package/src/ui/properties/PropertyList.stories.tsx +83 -0
- package/src/ui/properties/PropertyPanel.less +592 -0
- package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
- package/src/ui/properties/PropertyPanel.test.tsx +193 -0
- package/src/ui/properties/PropertyPanel.tsx +441 -0
- package/src/ui/properties/PropertyRow.stories.tsx +53 -0
- package/src/ui/properties/SelectRow.stories.tsx +58 -0
- package/src/ui/properties/SliderRow.stories.tsx +74 -0
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
- package/src/ui/properties/TextRow.stories.tsx +62 -0
- package/src/ui/properties/ToggleRow.stories.tsx +57 -0
- package/src/ui/properties/index.ts +39 -0
- package/src/ui/properties/storyLayouts.tsx +36 -0
- package/src/undo/eventBus.test.ts +49 -0
- package/src/undo/eventBus.ts +32 -0
- package/src/undo/index.ts +5 -0
- package/src/undo/undoStack.test.ts +70 -0
- package/src/undo/undoStack.ts +38 -0
- package/src/workspace/DefaultSidebar.tsx +26 -0
- package/src/workspace/DefaultStatusBar.tsx +15 -0
- package/src/workspace/DefaultToolbar.tsx +88 -0
- package/src/workspace/Workspace.less +58 -0
- package/src/workspace/Workspace.stories.tsx +78 -0
- package/src/workspace/Workspace.test.tsx +186 -0
- package/src/workspace/Workspace.tsx +255 -0
- package/src/workspace/WorkspaceChrome.tsx +150 -0
- package/src/workspace/index.ts +24 -0
- package/src/workspace/slotTypes.ts +44 -0
- package/src/workspace/workspaceOps.test.ts +142 -0
- package/src/workspace/workspaceOps.ts +78 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ConfigField, ConfigFieldType } from '../controls/types';
|
|
2
|
+
|
|
3
|
+
export interface ValidationResult {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
errors: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
|
|
9
|
+
|
|
10
|
+
export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
11
|
+
const errors: string[] = [];
|
|
12
|
+
const seenKeys = new Set<string>();
|
|
13
|
+
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
if (field.key === '') {
|
|
16
|
+
errors.push('Field has empty key');
|
|
17
|
+
} else if (seenKeys.has(field.key)) {
|
|
18
|
+
errors.push(`Duplicate config key: "${field.key}"`);
|
|
19
|
+
} else {
|
|
20
|
+
seenKeys.add(field.key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (field.label === '') {
|
|
24
|
+
errors.push(`Field "${field.key}" has empty label`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!KNOWN_TYPES.includes(field.type)) {
|
|
28
|
+
errors.push(`Unknown field type: "${field.type}" on key "${field.key}"`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (field.type === 'slider') {
|
|
33
|
+
if (!Number.isFinite(field.default)) {
|
|
34
|
+
errors.push(`Field "${field.key}": default must be a finite number`);
|
|
35
|
+
}
|
|
36
|
+
if (field.min >= field.max) {
|
|
37
|
+
errors.push(`Slider "${field.key}": min must be < max`);
|
|
38
|
+
} else if (field.default < field.min || field.default > field.max) {
|
|
39
|
+
errors.push(`Slider "${field.key}": default ${field.default} is outside [min, max]`);
|
|
40
|
+
}
|
|
41
|
+
} else if (field.type === 'number') {
|
|
42
|
+
if (!Number.isFinite(field.default)) {
|
|
43
|
+
errors.push(`Field "${field.key}": default must be a finite number`);
|
|
44
|
+
}
|
|
45
|
+
} else if (field.type === 'select') {
|
|
46
|
+
if (field.options.length === 0) {
|
|
47
|
+
errors.push(`Select "${field.key}": options array must not be empty`);
|
|
48
|
+
} else {
|
|
49
|
+
const seenValues = new Set<string>();
|
|
50
|
+
for (const opt of field.options) {
|
|
51
|
+
if (seenValues.has(opt.value)) {
|
|
52
|
+
errors.push(`Select "${field.key}": duplicate option value "${opt.value}"`);
|
|
53
|
+
} else {
|
|
54
|
+
seenValues.add(opt.value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!seenValues.has(field.default)) {
|
|
58
|
+
errors.push(`Select "${field.key}": default "${field.default}" is not among options`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { valid: errors.length === 0, errors };
|
|
65
|
+
}
|
package/src/lab/Lab.less
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import { Lab } from './Lab';
|
|
5
|
+
import { useLabContext } from './LabContext';
|
|
6
|
+
|
|
7
|
+
const StubInstrument: Instrument = {
|
|
8
|
+
name: 'Stub',
|
|
9
|
+
defaultConfig: () => ({ value: 50 }),
|
|
10
|
+
initialState: (config) => ({ doubled: (config as { value: number }).value * 2 }),
|
|
11
|
+
render: ({ state }) => (
|
|
12
|
+
<div className="lk-stub-display">doubled: {(state as { doubled: number }).doubled}</div>
|
|
13
|
+
),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const meta: Meta<typeof Lab> = {
|
|
17
|
+
title: 'labkit/Lab/Lab',
|
|
18
|
+
component: Lab,
|
|
19
|
+
parameters: { layout: 'fullscreen' },
|
|
20
|
+
};
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof Lab>;
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
instruments: [StubInstrument],
|
|
28
|
+
defaultInstrument: 'Stub',
|
|
29
|
+
title: 'Default Lab',
|
|
30
|
+
storage: null,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function AddSecondWorkspace() {
|
|
35
|
+
const ctx = useLabContext();
|
|
36
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (ctx.workspaces.length < 2) ctx.addWorkspace('Stub');
|
|
39
|
+
}, []);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const TwoWorkspaces: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
instruments: [StubInstrument],
|
|
46
|
+
defaultInstrument: 'Stub',
|
|
47
|
+
title: 'Two Workspaces',
|
|
48
|
+
storage: null,
|
|
49
|
+
children: <AddSecondWorkspace />,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { Lab } from './Lab';
|
|
6
|
+
import { LabContext, type LabContextValue } from './LabContext';
|
|
7
|
+
|
|
8
|
+
const stub: Instrument = {
|
|
9
|
+
name: 'Stub',
|
|
10
|
+
defaultConfig: () => ({ count: 0 }),
|
|
11
|
+
initialState: (config) => ({ value: (config as { count: number }).count }),
|
|
12
|
+
render: () => <div data-testid="stub-content">stub</div>,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const stubB: Instrument = {
|
|
16
|
+
...stub,
|
|
17
|
+
name: 'StubB',
|
|
18
|
+
render: () => <div data-testid="stub-b-content">b</div>,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let labRef: LabContextValue | null = null;
|
|
22
|
+
|
|
23
|
+
function CaptureLab({ children }: { children?: ReactNode }) {
|
|
24
|
+
return (
|
|
25
|
+
<LabContext.Consumer>
|
|
26
|
+
{(value) => {
|
|
27
|
+
if (value) labRef = value;
|
|
28
|
+
return children ?? null;
|
|
29
|
+
}}
|
|
30
|
+
</LabContext.Consumer>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
|
|
35
|
+
labRef = null;
|
|
36
|
+
return render(
|
|
37
|
+
<Lab instruments={[stub, stubB]} defaultInstrument="Stub" {...props}>
|
|
38
|
+
<CaptureLab />
|
|
39
|
+
</Lab>,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('<Lab>', () => {
|
|
44
|
+
it('renders one workspace by default', () => {
|
|
45
|
+
mountLab();
|
|
46
|
+
expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('addWorkspace adds a second workspace', () => {
|
|
50
|
+
mountLab();
|
|
51
|
+
act(() => labRef?.addWorkspace('Stub'));
|
|
52
|
+
expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('closeWorkspace removes one when more than one exists', () => {
|
|
56
|
+
mountLab();
|
|
57
|
+
act(() => labRef?.addWorkspace('Stub'));
|
|
58
|
+
const first = labRef?.workspaces[0];
|
|
59
|
+
act(() => labRef?.closeWorkspace(first?.id ?? ''));
|
|
60
|
+
expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('closeWorkspace is a no-op on the last workspace', () => {
|
|
64
|
+
mountLab();
|
|
65
|
+
const only = labRef?.workspaces[0];
|
|
66
|
+
act(() => labRef?.closeWorkspace(only?.id ?? ''));
|
|
67
|
+
expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('cloneWorkspace inserts immediately after source', () => {
|
|
71
|
+
mountLab();
|
|
72
|
+
act(() => labRef?.addWorkspace('StubB'));
|
|
73
|
+
const ws0 = labRef?.workspaces[0];
|
|
74
|
+
const ws1 = labRef?.workspaces[1];
|
|
75
|
+
act(() => labRef?.cloneWorkspace(ws0?.id ?? ''));
|
|
76
|
+
const ids = labRef?.workspaces.map((w) => w.id) ?? [];
|
|
77
|
+
expect(ids).toHaveLength(3);
|
|
78
|
+
expect(ids[0]).toBe(ws0?.id);
|
|
79
|
+
expect(ids[2]).toBe(ws1?.id);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resetWorkspace restores defaults', () => {
|
|
83
|
+
mountLab();
|
|
84
|
+
const ws = labRef?.workspaces[0];
|
|
85
|
+
if (!ws) throw new Error('no workspace');
|
|
86
|
+
act(() => labRef?.resetWorkspace(ws.id));
|
|
87
|
+
const reset = labRef?.workspaces[0];
|
|
88
|
+
expect(reset?.config).toEqual({ count: 0 });
|
|
89
|
+
expect(reset?.state).toEqual({ value: 0 });
|
|
90
|
+
});
|
|
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/);
|
|
95
|
+
});
|
|
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/);
|
|
100
|
+
});
|
|
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/);
|
|
107
|
+
});
|
|
108
|
+
|
|
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/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws when instruments is empty', () => {
|
|
116
|
+
expect(() => render(<Lab instruments={[]} defaultInstrument="Stub" />)).toThrow(
|
|
117
|
+
/requires a non-empty `instruments` array/,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws when defaultInstrument does not match any instrument', () => {
|
|
122
|
+
expect(() => render(<Lab instruments={[stub]} defaultInstrument="Missing" />)).toThrow(
|
|
123
|
+
/Unknown instrument/,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/lab/Lab.tsx
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { useStore } from 'zustand/react';
|
|
3
|
+
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import { noneAdapter } from '../state/adapters';
|
|
5
|
+
import { LabStoreContext } from '../state/context';
|
|
6
|
+
import { createLabStore, type LabStore } from '../state/store';
|
|
7
|
+
import type { StorageAdapter, WorkspaceRecord } from '../state/types';
|
|
8
|
+
import { Workspace } from '../workspace/Workspace';
|
|
9
|
+
import {
|
|
10
|
+
addWorkspace as addWorkspaceOp,
|
|
11
|
+
cloneWorkspace as cloneWorkspaceOp,
|
|
12
|
+
closeWorkspace as closeWorkspaceOp,
|
|
13
|
+
resetWorkspace as resetWorkspaceOp,
|
|
14
|
+
} from '../workspace/workspaceOps';
|
|
15
|
+
import { LabContext, type LabContextValue } from './LabContext';
|
|
16
|
+
import { LabShell } from './LabShell';
|
|
17
|
+
import { WorkspaceGrid } from './WorkspaceGrid';
|
|
18
|
+
|
|
19
|
+
export interface LabProps {
|
|
20
|
+
instruments: Instrument[];
|
|
21
|
+
defaultInstrument: string;
|
|
22
|
+
storage?: StorageAdapter | null;
|
|
23
|
+
storageKey?: string;
|
|
24
|
+
theme?: 'auto' | 'light' | 'interstellar';
|
|
25
|
+
/**
|
|
26
|
+
* Optional list of CSS colors used to compose the interstellar theme's
|
|
27
|
+
* cosmic backdrop. Each color becomes one radial-gradient blob on the
|
|
28
|
+
* dark void base. Order maps to a fixed spread of positions; extras wrap
|
|
29
|
+
* around. Ignored unless `theme="interstellar"` is active.
|
|
30
|
+
*/
|
|
31
|
+
nebula?: readonly string[];
|
|
32
|
+
title?: string;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildStore(
|
|
37
|
+
instruments: Instrument[],
|
|
38
|
+
defaultInstrument: string,
|
|
39
|
+
storage: StorageAdapter,
|
|
40
|
+
storageKey: string,
|
|
41
|
+
initialTheme: 'auto' | 'light' | 'interstellar',
|
|
42
|
+
): LabStore {
|
|
43
|
+
const store = createLabStore({ storageKey, storage, initialTheme });
|
|
44
|
+
if (store.getState().workspaces.length === 0) {
|
|
45
|
+
const seeded = addWorkspaceOp([], instruments, defaultInstrument);
|
|
46
|
+
const record = seeded[0];
|
|
47
|
+
if (record) {
|
|
48
|
+
const { undoStack: _undoStack, ...rest } = record;
|
|
49
|
+
store.getState().addWorkspace(rest);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return store;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fixed spread of nebula blob positions / sizes / fall-off stops. Colors
|
|
56
|
+
// supplied via the `nebula` prop are slotted into these slots in order;
|
|
57
|
+
// callers passing more than 5 wrap around (intentional — keeps the look
|
|
58
|
+
// readable and bounded).
|
|
59
|
+
const NEBULA_SLOTS = [
|
|
60
|
+
{ cx: '18%', cy: '30%', sx: '60%', sy: '80%', stop: '70%' },
|
|
61
|
+
{ cx: '78%', cy: '70%', sx: '55%', sy: '90%', stop: '65%' },
|
|
62
|
+
{ cx: '50%', cy: '50%', sx: '50%', sy: '70%', stop: '75%' },
|
|
63
|
+
{ cx: '12%', cy: '82%', sx: '50%', sy: '70%', stop: '70%' },
|
|
64
|
+
{ cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
function buildNebula(colors: readonly string[]): string {
|
|
68
|
+
const blobs = colors.map((c, i) => {
|
|
69
|
+
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length]!;
|
|
70
|
+
return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
|
|
71
|
+
});
|
|
72
|
+
blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
|
|
73
|
+
return blobs.join(', ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function Lab({
|
|
77
|
+
instruments,
|
|
78
|
+
defaultInstrument,
|
|
79
|
+
storage,
|
|
80
|
+
storageKey,
|
|
81
|
+
theme,
|
|
82
|
+
nebula,
|
|
83
|
+
title,
|
|
84
|
+
children,
|
|
85
|
+
}: LabProps) {
|
|
86
|
+
if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
|
|
87
|
+
throw new Error('[labkit] <Lab> requires a non-empty `instruments` array');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const storeRef = useRef<LabStore | null>(null);
|
|
91
|
+
if (storeRef.current === null) {
|
|
92
|
+
storeRef.current = buildStore(
|
|
93
|
+
instruments,
|
|
94
|
+
defaultInstrument,
|
|
95
|
+
storage ?? noneAdapter,
|
|
96
|
+
storageKey ?? 'labkit',
|
|
97
|
+
theme ?? 'auto',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const store = storeRef.current;
|
|
101
|
+
|
|
102
|
+
const workspaces = useStore(store, (s) => s.workspaces);
|
|
103
|
+
const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
|
|
104
|
+
const themeValue = useStore(store, (s) => s.theme);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (theme && theme !== store.getState().theme) {
|
|
108
|
+
store.getState().setTheme(theme);
|
|
109
|
+
}
|
|
110
|
+
}, [theme, store]);
|
|
111
|
+
|
|
112
|
+
const contextValue = useMemo<LabContextValue>(() => {
|
|
113
|
+
const replaceWorkspaces = (next: WorkspaceRecord[]): void => {
|
|
114
|
+
const currentById = new Map(store.getState().workspaces.map((w) => [w.id, w]));
|
|
115
|
+
const merged = next.map((w) => currentById.get(w.id) ?? w);
|
|
116
|
+
store.setState({ workspaces: merged });
|
|
117
|
+
// Trigger persistence flush via a tracked action.
|
|
118
|
+
store.getState().setTheme(store.getState().theme);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
instruments,
|
|
123
|
+
workspaces,
|
|
124
|
+
addWorkspace: (instrumentName) => {
|
|
125
|
+
const next = addWorkspaceOp(store.getState().workspaces, instruments, instrumentName);
|
|
126
|
+
replaceWorkspaces(next);
|
|
127
|
+
},
|
|
128
|
+
cloneWorkspace: (id) => {
|
|
129
|
+
const next = cloneWorkspaceOp(store.getState().workspaces, id);
|
|
130
|
+
replaceWorkspaces(next);
|
|
131
|
+
},
|
|
132
|
+
closeWorkspace: (id) => {
|
|
133
|
+
const next = closeWorkspaceOp(store.getState().workspaces, id);
|
|
134
|
+
replaceWorkspaces(next);
|
|
135
|
+
},
|
|
136
|
+
resetWorkspace: (id) => {
|
|
137
|
+
const next = resetWorkspaceOp(store.getState().workspaces, id, instruments);
|
|
138
|
+
const record = next.find((w) => w.id === id);
|
|
139
|
+
if (!record) return;
|
|
140
|
+
store.setState((s) => ({
|
|
141
|
+
workspaces: s.workspaces.map((w) =>
|
|
142
|
+
w.id === id
|
|
143
|
+
? { ...w, config: record.config, state: record.state, view: record.view }
|
|
144
|
+
: w,
|
|
145
|
+
),
|
|
146
|
+
}));
|
|
147
|
+
store.getState().updateWorkspaceView(id, record.view);
|
|
148
|
+
},
|
|
149
|
+
savedSnapshots,
|
|
150
|
+
saveSnapshot: (workspaceId, name) => {
|
|
151
|
+
store.getState().saveSnapshot(workspaceId, name ?? `Save ${new Date().toLocaleString()}`);
|
|
152
|
+
},
|
|
153
|
+
loadSnapshot: (workspaceId, snapshotId) => {
|
|
154
|
+
store.getState().loadSnapshot(snapshotId, workspaceId);
|
|
155
|
+
},
|
|
156
|
+
deleteSnapshot: (snapshotId) => {
|
|
157
|
+
store.getState().deleteSnapshot(snapshotId);
|
|
158
|
+
},
|
|
159
|
+
theme: themeValue,
|
|
160
|
+
setTheme: (t) => {
|
|
161
|
+
store.getState().setTheme(t);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}, [instruments, workspaces, savedSnapshots, themeValue, store]);
|
|
165
|
+
|
|
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)
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<LabStoreContext.Provider value={{ store }}>
|
|
183
|
+
<LabContext.Provider value={contextValue}>
|
|
184
|
+
<div className={`lk-lab ${themeClass}`.trim()} style={nebulaStyle}>
|
|
185
|
+
<LabShell title={title ?? 'Labkit'} theme={themeValue} header={children}>
|
|
186
|
+
<WorkspaceGrid>
|
|
187
|
+
{workspaces.map((w) => (
|
|
188
|
+
<Workspace key={w.id} id={w.id} />
|
|
189
|
+
))}
|
|
190
|
+
</WorkspaceGrid>
|
|
191
|
+
</LabShell>
|
|
192
|
+
</div>
|
|
193
|
+
</LabContext.Provider>
|
|
194
|
+
</LabStoreContext.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { useLabContext } from './LabContext';
|
|
4
|
+
|
|
5
|
+
describe('useLabContext', () => {
|
|
6
|
+
it('throws when used outside <Lab>', () => {
|
|
7
|
+
expect(() => renderHook(() => useLabContext())).toThrow(
|
|
8
|
+
/useLabContext must be used inside <Lab>/,
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { Instrument } from '../instrument/types';
|
|
3
|
+
import type { SavedSnapshot, WorkspaceRecord } from '../state/types';
|
|
4
|
+
|
|
5
|
+
export interface LabContextValue {
|
|
6
|
+
instruments: Instrument[];
|
|
7
|
+
workspaces: WorkspaceRecord[];
|
|
8
|
+
addWorkspace: (instrumentName: string) => void;
|
|
9
|
+
cloneWorkspace: (id: string) => void;
|
|
10
|
+
closeWorkspace: (id: string) => void;
|
|
11
|
+
resetWorkspace: (id: string) => void;
|
|
12
|
+
savedSnapshots: SavedSnapshot[];
|
|
13
|
+
saveSnapshot: (workspaceId: string, name?: string) => void;
|
|
14
|
+
loadSnapshot: (workspaceId: string, snapshotId: string) => void;
|
|
15
|
+
deleteSnapshot: (snapshotId: string) => void;
|
|
16
|
+
theme: 'auto' | 'light' | 'interstellar';
|
|
17
|
+
setTheme: (t: 'auto' | 'light' | 'interstellar') => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const LabContext = createContext<LabContextValue | null>(null);
|
|
21
|
+
|
|
22
|
+
export function useLabContext(): LabContextValue {
|
|
23
|
+
const ctx = useContext(LabContext);
|
|
24
|
+
if (ctx === null) {
|
|
25
|
+
throw new Error('useLabContext must be used inside <Lab>');
|
|
26
|
+
}
|
|
27
|
+
return ctx;
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.lk-shell {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100vh;
|
|
6
|
+
background: var(--lk-bg);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.lk-shell-header {
|
|
10
|
+
display: flex;
|
|
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);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.lk-shell-title {
|
|
20
|
+
margin: 0;
|
|
21
|
+
font-size: 16px;
|
|
22
|
+
font-weight: 600;
|
|
23
|
+
color: var(--lk-text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.lk-shell-header-actions {
|
|
27
|
+
margin-left: auto;
|
|
28
|
+
display: flex;
|
|
29
|
+
gap: var(--lk-spacing-sm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.lk-shell-body {
|
|
33
|
+
flex: 1;
|
|
34
|
+
min-height: 0;
|
|
35
|
+
overflow: auto;
|
|
36
|
+
padding: var(--lk-spacing-md);
|
|
37
|
+
}
|
|
38
|
+
|
|
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);
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { LabShell } from './LabShell';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof LabShell> = {
|
|
5
|
+
title: 'labkit/Lab/LabShell',
|
|
6
|
+
component: LabShell,
|
|
7
|
+
parameters: { layout: 'fullscreen' },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof LabShell>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
title: 'My Lab',
|
|
16
|
+
children: <p>Body content goes here.</p>,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const WithHeaderActions: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
title: 'My Lab',
|
|
23
|
+
header: (
|
|
24
|
+
<>
|
|
25
|
+
<button type="button">+ Add</button>
|
|
26
|
+
<button type="button">Reset</button>
|
|
27
|
+
</>
|
|
28
|
+
),
|
|
29
|
+
children: <p>Body content with header actions.</p>,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const WithFooter: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
title: 'My Lab',
|
|
36
|
+
children: <p>Body content with footer.</p>,
|
|
37
|
+
footer: <span>Status: ready</span>,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { LabShell } from './LabShell';
|
|
4
|
+
|
|
5
|
+
describe('LabShell', () => {
|
|
6
|
+
test('renders title in header', () => {
|
|
7
|
+
render(<LabShell title="My Lab">body</LabShell>);
|
|
8
|
+
expect(screen.getByRole('heading', { name: 'My Lab' })).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('renders children in body', () => {
|
|
12
|
+
render(<LabShell title="t">body content</LabShell>);
|
|
13
|
+
expect(screen.getByText('body content')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('renders header slot when provided', () => {
|
|
17
|
+
render(
|
|
18
|
+
<LabShell title="t" header={<button type="button">action</button>}>
|
|
19
|
+
body
|
|
20
|
+
</LabShell>,
|
|
21
|
+
);
|
|
22
|
+
expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('applies lk-theme-light class when theme="light"', () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<LabShell title="t" theme="light">
|
|
28
|
+
x
|
|
29
|
+
</LabShell>,
|
|
30
|
+
);
|
|
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);
|
|
34
|
+
});
|
|
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)', () => {
|
|
48
|
+
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
49
|
+
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);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('always applies lk-root class', () => {
|
|
55
|
+
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
56
|
+
const root = container.firstChild as HTMLElement;
|
|
57
|
+
expect(root.classList.contains('lk-root')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type LabTheme = 'auto' | 'light' | 'interstellar';
|
|
4
|
+
|
|
5
|
+
export interface LabShellProps {
|
|
6
|
+
title: string;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Optional content rendered into the header (e.g., action buttons). */
|
|
9
|
+
header?: ReactNode;
|
|
10
|
+
/** Optional content rendered into the footer. */
|
|
11
|
+
footer?: ReactNode;
|
|
12
|
+
/** Theme override. "auto" (default) follows prefers-color-scheme. */
|
|
13
|
+
theme?: LabTheme;
|
|
14
|
+
}
|
|
15
|
+
|
|
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}`}>
|
|
25
|
+
<header className="lk-shell-header">
|
|
26
|
+
<h1 className="lk-shell-title">{title}</h1>
|
|
27
|
+
{header && <div className="lk-shell-header-actions">{header}</div>}
|
|
28
|
+
</header>
|
|
29
|
+
<main className="lk-shell-body">{children}</main>
|
|
30
|
+
{footer && <footer className="lk-shell-footer">{footer}</footer>}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|