@weasel-js/labkit 1.0.2 → 1.0.3
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/dist/_dts/{DrawCommand-CitWPxMY.d.ts → DrawCommand-uKHt4Vul.d.ts} +56 -0
- package/dist/_dts/{index-C6Yze7sQ.d.ts → index-CFlDPeZh.d.ts} +30 -1
- package/dist/_dts/{types-Si4Fw-1F.d.ts → types-x92Kfeme.d.ts} +14 -0
- package/dist/_dts/{useExperimentState-CJn2hHzd.d.ts → useExperimentState-vrttakTt.d.ts} +43 -0
- package/dist/canvas/index.d.ts +13 -2
- package/dist/canvas/index.js +2 -2
- package/dist/{chunk-3P56ZCCJ.js → chunk-3WPOGKUP.js} +93 -62
- package/dist/chunk-3WPOGKUP.js.map +1 -0
- package/dist/{chunk-54IQ2DX7.js → chunk-574LJAV4.js} +3 -3
- package/dist/chunk-574LJAV4.js.map +1 -0
- package/dist/{chunk-VLAHRJOC.js → chunk-5R2ATYPJ.js} +2 -2
- package/dist/{chunk-VLAHRJOC.js.map → chunk-5R2ATYPJ.js.map} +1 -1
- package/dist/{chunk-2ZRE7WGQ.js → chunk-73PXCRCR.js} +3 -3
- package/dist/chunk-73PXCRCR.js.map +1 -0
- package/dist/{chunk-PWC7AQZM.js → chunk-CPUJ3QXL.js} +2 -2
- package/dist/chunk-CPUJ3QXL.js.map +1 -0
- package/dist/{chunk-R4TACNW7.js → chunk-N5KTQKQA.js} +11 -11
- package/dist/chunk-N5KTQKQA.js.map +1 -0
- package/dist/{chunk-HXZHVU4G.js → chunk-PMAU3SEE.js} +3 -3
- package/dist/chunk-PMAU3SEE.js.map +1 -0
- package/dist/{chunk-7BKDG73Z.js → chunk-RL2LOLNI.js} +2 -2
- package/dist/chunk-RL2LOLNI.js.map +1 -0
- package/dist/{chunk-2QNYYL3V.js → chunk-SFL7NFKN.js} +2 -2
- package/dist/chunk-SFL7NFKN.js.map +1 -0
- package/dist/{chunk-53XSBIUK.js → chunk-T7OKNJTY.js} +2 -2
- package/dist/chunk-T7OKNJTY.js.map +1 -0
- package/dist/controls/index.d.ts +4 -2
- package/dist/controls/index.js +1 -1
- package/dist/dragdrop/index.d.ts +2 -2
- package/dist/index.d.ts +122 -6
- package/dist/index.js +18 -18
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +5 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +444 -7
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +13 -0
- package/dist/primitives/index.js +2 -2
- package/dist/state/index.d.ts +15 -2
- package/dist/state/index.js +2 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/ui/layers/index.d.ts +5 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +17 -2
- package/dist/undo/index.js +1 -1
- package/package.json +1 -1
- package/src/canvas/CanvasStack.tsx +4 -0
- package/src/canvas/CanvasStackContext.ts +3 -0
- package/src/canvas/canvasCoords.ts +2 -0
- package/src/canvas/useLayerScheduler.ts +2 -0
- package/src/controls/ControlPanel.tsx +2 -0
- package/src/controls/types.ts +14 -0
- package/src/instrument/capabilityDetector.ts +3 -0
- package/src/instrument/defineInstrument.ts +2 -0
- package/src/instrument/types.ts +29 -0
- package/src/instrument/validateConfigSchema.ts +5 -0
- package/src/lab/Lab.tsx +3 -0
- package/src/lab/LabContext.ts +5 -0
- package/src/lab/LabShell.tsx +4 -0
- package/src/lab/WorkspaceGrid.tsx +2 -0
- package/src/lab/gridDims.ts +2 -0
- package/src/layers/LayerList.tsx +3 -0
- package/src/primitives/FpsMeter.tsx +1 -0
- package/src/primitives/ScaleIndicator.tsx +4 -0
- package/src/primitives/Sidebar.tsx +3 -0
- package/src/primitives/StatusBar.tsx +3 -0
- package/src/primitives/Toolbar.tsx +6 -0
- package/src/state/SingletonExperiment.tsx +1 -0
- package/src/state/adapters.ts +8 -0
- package/src/state/context.tsx +9 -0
- package/src/state/helpers.ts +12 -0
- package/src/state/store.ts +6 -0
- package/src/state/types.ts +17 -0
- package/src/state/useExperimentState.ts +3 -0
- package/src/ui/layers/LayerStack.tsx +5 -0
- package/src/ui/properties/CurveField.tsx +4 -1
- package/src/ui/properties/EffectCard.tsx +12 -0
- package/src/ui/properties/PropertyGroup.tsx +1 -0
- package/src/ui/properties/PropertyPanel.tsx +28 -0
- package/src/undo/eventBus.ts +4 -0
- package/src/undo/undoStack.ts +11 -0
- package/src/workspace/DefaultSidebar.tsx +3 -0
- package/src/workspace/DefaultStatusBar.tsx +3 -0
- package/src/workspace/DefaultToolbar.tsx +3 -0
- package/src/workspace/Workspace.tsx +3 -0
- package/src/workspace/WorkspaceChrome.tsx +4 -0
- package/src/workspace/slotTypes.ts +9 -0
- package/src/workspace/workspaceOps.ts +8 -0
- package/dist/chunk-2QNYYL3V.js.map +0 -1
- package/dist/chunk-2ZRE7WGQ.js.map +0 -1
- package/dist/chunk-3P56ZCCJ.js.map +0 -1
- package/dist/chunk-53XSBIUK.js.map +0 -1
- package/dist/chunk-54IQ2DX7.js.map +0 -1
- package/dist/chunk-7BKDG73Z.js.map +0 -1
- package/dist/chunk-HXZHVU4G.js.map +0 -1
- package/dist/chunk-PWC7AQZM.js.map +0 -1
- package/dist/chunk-R4TACNW7.js.map +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Instrument } from './types';
|
|
2
2
|
|
|
3
|
+
/** Which optional capabilities an instrument declared — what the workspace
|
|
4
|
+
* consults to decide which chrome to show. */
|
|
3
5
|
export interface CapabilityFlags {
|
|
4
6
|
hasCanvas: boolean;
|
|
5
7
|
hasLayers: boolean;
|
|
@@ -7,6 +9,7 @@ export interface CapabilityFlags {
|
|
|
7
9
|
hasUndo: boolean;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/** Read an instrument's declared capabilities off its definition. */
|
|
10
13
|
export function detectCapabilities(instrument: Instrument<unknown, unknown>): CapabilityFlags {
|
|
11
14
|
return {
|
|
12
15
|
hasCanvas: instrument.canvas != null,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Instrument } from './types';
|
|
2
2
|
|
|
3
|
+
/** Identity at runtime; exists so an instrument's state and config types are
|
|
4
|
+
* inferred from the spec rather than having to be written out. */
|
|
3
5
|
export function defineInstrument<TS, TC>(spec: Instrument<TS, TC>): Instrument<TS, TC> {
|
|
4
6
|
return spec;
|
|
5
7
|
}
|
package/src/instrument/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { ConfigField } from '../controls/types';
|
|
3
3
|
|
|
4
|
+
/** What an instrument's `render` is handed: its state and config, the setters
|
|
5
|
+
* for both, the workspace it is mounted in, and a way to emit named events. */
|
|
4
6
|
export interface RenderContext<TS = unknown, TC = unknown> {
|
|
5
7
|
state: TS;
|
|
6
8
|
config: TC;
|
|
@@ -14,20 +16,28 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
14
16
|
emit: (event: string) => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/** One 2D canvas layer of an instrument, drawn in declaration order. */
|
|
17
20
|
export interface CanvasLayer<TS = unknown, TC = unknown> {
|
|
18
21
|
id: string;
|
|
19
22
|
draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** Declares that an instrument draws to a canvas: its layers, and where the
|
|
26
|
+
* view starts. */
|
|
22
27
|
export interface CanvasCapability<TS = unknown, TC = unknown> {
|
|
23
28
|
layers: CanvasLayer<TS, TC>[];
|
|
24
29
|
initialView?: { zoom: number; pan: { x: number; y: number } };
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/** Declares which of an instrument's layers the workspace should offer
|
|
33
|
+
* show/hide controls for. */
|
|
27
34
|
export interface LayerCapability {
|
|
28
35
|
ids: string[];
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/** Declares that an instrument accepts items dragged from a palette: what the
|
|
39
|
+
* palette offers, what a drop does to the state, and — optionally — live
|
|
40
|
+
* feedback during the drag and the ability to drag existing items back out. */
|
|
31
41
|
export interface DragDropCapability<TS = unknown, TC = unknown> {
|
|
32
42
|
palette: PaletteItem[] | ((state: TS, config: TC) => PaletteItem[]);
|
|
33
43
|
onDrop: (worldPos: Point, item: PaletteItem, state: TS, config: TC) => TS;
|
|
@@ -35,20 +45,39 @@ export interface DragDropCapability<TS = unknown, TC = unknown> {
|
|
|
35
45
|
pickUp?: (hit: HitResult, state: TS, config: TC) => { item: PaletteItem; state: TS } | null;
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
/** Declares that an instrument's state is undoable: which emitted events
|
|
49
|
+
* snapshot it, and how many snapshots to keep. */
|
|
38
50
|
export interface UndoCapability {
|
|
39
51
|
snapshotOn?: string[];
|
|
40
52
|
maxDepth?: number;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/** The name of an event an instrument emits through `RenderContext.emit`. */
|
|
43
56
|
export type SystemEvent = string;
|
|
44
57
|
|
|
58
|
+
/** A point in world coordinates. */
|
|
45
59
|
export type Point = { x: number; y: number };
|
|
60
|
+
/** What a hit-test found, and where. */
|
|
46
61
|
export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
|
|
62
|
+
/** A workspace's camera. */
|
|
47
63
|
export type ViewTransform = { zoom: number; pan: Point };
|
|
64
|
+
/** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
|
|
48
65
|
export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
|
|
66
|
+
/** One draggable entry in an instrument's palette. */
|
|
49
67
|
export type PaletteItem = { id: string; label: string; data?: unknown };
|
|
68
|
+
/** Whether a drop would be accepted at the current position, and why not if
|
|
69
|
+
* it would not. */
|
|
50
70
|
export type DragFeedback = { ok: boolean; reason?: string };
|
|
51
71
|
|
|
72
|
+
/**
|
|
73
|
+
* An instrument: one self-contained interactive experiment a lab can host.
|
|
74
|
+
*
|
|
75
|
+
* It owns two pieces of data — `config`, the settings the control panel edits,
|
|
76
|
+
* and `state`, what the experiment is currently doing — and renders from both.
|
|
77
|
+
* The optional capability fields declare what else it wants from the runtime:
|
|
78
|
+
* a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
|
|
79
|
+
* is what makes the workspace provide the corresponding chrome.
|
|
80
|
+
*/
|
|
52
81
|
export interface Instrument<TS = unknown, TC = unknown> {
|
|
53
82
|
name: string;
|
|
54
83
|
defaultConfig: () => TC;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ConfigField, ConfigFieldType } from '../controls/types';
|
|
2
2
|
|
|
3
|
+
/** Whether a config schema is usable, and every problem found rather than
|
|
4
|
+
* just the first. */
|
|
3
5
|
export interface ValidationResult {
|
|
4
6
|
valid: boolean;
|
|
5
7
|
errors: string[];
|
|
@@ -7,6 +9,9 @@ export interface ValidationResult {
|
|
|
7
9
|
|
|
8
10
|
const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
|
|
9
11
|
|
|
12
|
+
/** Check a config schema for the mistakes that would otherwise surface as a
|
|
13
|
+
* silently broken control: empty or duplicate keys, unknown field types, and
|
|
14
|
+
* fields whose own constraints do not hold. */
|
|
10
15
|
export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
11
16
|
const errors: string[] = [];
|
|
12
17
|
const seenKeys = new Set<string>();
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { LabShell } from './LabShell';
|
|
|
19
19
|
import { useResolvedMode } from './useSystemMode';
|
|
20
20
|
import { WorkspaceGrid } from './WorkspaceGrid';
|
|
21
21
|
|
|
22
|
+
/** Props for `<Lab>`. */
|
|
22
23
|
export interface LabProps {
|
|
23
24
|
instruments: Instrument[];
|
|
24
25
|
defaultInstrument: string;
|
|
@@ -76,6 +77,8 @@ function buildNebula(colors: readonly string[]): string {
|
|
|
76
77
|
return blobs.join(', ');
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** The lab runtime: creates the store, provides it, and renders one workspace
|
|
81
|
+
* per record in a grid. Each workspace runs one of `instruments`. */
|
|
79
82
|
export function Lab({
|
|
80
83
|
instruments,
|
|
81
84
|
defaultInstrument,
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { createContext, useContext } from 'react';
|
|
|
2
2
|
import type { Instrument } from '../instrument/types';
|
|
3
3
|
import type { LabMode, SavedSnapshot, WorkspaceRecord } from '../state/types';
|
|
4
4
|
|
|
5
|
+
/** Lab-wide state and commands: the available instruments, the open
|
|
6
|
+
* workspaces and the operations over them, saved snapshots, and the color
|
|
7
|
+
* mode. */
|
|
5
8
|
export interface LabContextValue {
|
|
6
9
|
instruments: Instrument[];
|
|
7
10
|
workspaces: WorkspaceRecord[];
|
|
@@ -17,8 +20,10 @@ export interface LabContextValue {
|
|
|
17
20
|
setMode: (m: LabMode) => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/** Context carrying the surrounding lab. Prefer `useLabContext`. */
|
|
20
24
|
export const LabContext = createContext<LabContextValue | null>(null);
|
|
21
25
|
|
|
26
|
+
/** The surrounding lab. Throws outside a `<Lab>`. */
|
|
22
27
|
export function useLabContext(): LabContextValue {
|
|
23
28
|
const ctx = useContext(LabContext);
|
|
24
29
|
if (ctx === null) {
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { LabMode } from '../state/types';
|
|
|
4
4
|
import { interstellarTheme } from '../theme/interstellar';
|
|
5
5
|
import { useResolvedMode } from './useSystemMode';
|
|
6
6
|
|
|
7
|
+
/** Props for `<LabShell>`. */
|
|
7
8
|
export interface LabShellProps {
|
|
8
9
|
title: string;
|
|
9
10
|
children: ReactNode;
|
|
@@ -15,6 +16,9 @@ export interface LabShellProps {
|
|
|
15
16
|
mode?: LabMode;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
20
|
+
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
21
|
+
* the workspace runtime is wanted too. */
|
|
18
22
|
export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
|
|
19
23
|
const resolved = useResolvedMode(mode);
|
|
20
24
|
const outer = useThemeOptional();
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Children, type CSSProperties, type ReactNode } from 'react';
|
|
2
2
|
import { gridDims } from './gridDims';
|
|
3
3
|
|
|
4
|
+
/** Props for `<WorkspaceGrid>`. */
|
|
4
5
|
export interface WorkspaceGridProps {
|
|
5
6
|
children: ReactNode;
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/** Lays its children out in the most nearly square grid that fits them. */
|
|
8
10
|
export function WorkspaceGrid({ children }: WorkspaceGridProps) {
|
|
9
11
|
const count = Children.count(children);
|
|
10
12
|
const { cols, rows } = gridDims(count);
|
package/src/lab/gridDims.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
/** A grid's column and row counts. */
|
|
1
2
|
export interface GridDims {
|
|
2
3
|
cols: number;
|
|
3
4
|
rows: number;
|
|
4
5
|
}
|
|
5
6
|
|
|
7
|
+
/** The most nearly square grid that fits `count` items. */
|
|
6
8
|
export function gridDims(count: number): GridDims {
|
|
7
9
|
if (count <= 1) return { cols: 1, rows: 1 };
|
|
8
10
|
const cols = Math.ceil(Math.sqrt(count));
|
package/src/layers/LayerList.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PointerEvent, useRef, useState } from 'react';
|
|
2
2
|
import type { LayerDescriptor } from '../instrument/types';
|
|
3
3
|
|
|
4
|
+
/** Props for `<LayerList>`. */
|
|
4
5
|
export interface LayerListProps {
|
|
5
6
|
layers: LayerDescriptor[];
|
|
6
7
|
visibility: Record<string, boolean>;
|
|
@@ -15,6 +16,8 @@ interface DragState {
|
|
|
15
16
|
startY: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** A reorderable list of layers with per-layer visibility toggles. Layers
|
|
20
|
+
* marked `alwaysOn` are pinned and cannot be reordered or hidden. */
|
|
18
21
|
export function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {
|
|
19
22
|
const reorderable = layers.filter((l) => !l.alwaysOn);
|
|
20
23
|
const pinned = layers.filter((l) => l.alwaysOn);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useContext } from 'react';
|
|
2
2
|
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
3
3
|
|
|
4
|
+
/** Props for `<ScaleIndicator>`. */
|
|
4
5
|
export interface ScaleIndicatorProps {
|
|
5
6
|
/** Current view zoom factor. If omitted, reads from CanvasStackContext. Defaults to 1. */
|
|
6
7
|
zoom?: number;
|
|
@@ -25,6 +26,9 @@ function niceNumber(n: number): number {
|
|
|
25
26
|
return nice * 10 ** exp;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** A scale bar: a labeled rule showing how far a round number of world units
|
|
30
|
+
* is at the current zoom. Reads the zoom from the surrounding canvas stack
|
|
31
|
+
* unless one is passed. */
|
|
28
32
|
export function ScaleIndicator({
|
|
29
33
|
zoom,
|
|
30
34
|
pixelsPerUnit = 1,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<Sidebar>`. */
|
|
3
4
|
export interface SidebarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
title?: string;
|
|
@@ -7,6 +8,8 @@ export interface SidebarProps {
|
|
|
7
8
|
onToggle?: () => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
/** A collapsible side panel with an optional title. Collapse state is the
|
|
12
|
+
* caller's to hold. */
|
|
10
13
|
export function Sidebar({ children, title, collapsed = false, onToggle }: SidebarProps) {
|
|
11
14
|
const className = `lk-sidebar${collapsed ? ' lk-sidebar--collapsed' : ''}`;
|
|
12
15
|
return (
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<StatusBar>`. */
|
|
3
4
|
export interface StatusBarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** A footer strip for readouts. Fill it with `<StatusBar.Section>`. */
|
|
7
9
|
export function StatusBar({ children }: StatusBarProps) {
|
|
8
10
|
return <div className="lk-status-bar">{children}</div>;
|
|
9
11
|
}
|
|
@@ -15,4 +17,5 @@ function Section({ children }: SectionProps) {
|
|
|
15
17
|
return <span className="lk-status-bar-section">{children}</span>;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** One readout within a status bar. */
|
|
18
21
|
StatusBar.Section = Section;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { MouseEventHandler, ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<Toolbar>`. */
|
|
3
4
|
export interface ToolbarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
|
|
9
|
+
* `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
7
10
|
export function Toolbar({ children }: ToolbarProps) {
|
|
8
11
|
return <div className="lk-toolbar">{children}</div>;
|
|
9
12
|
}
|
|
@@ -39,6 +42,9 @@ function Spacer() {
|
|
|
39
42
|
return <span className="lk-toolbar-spacer" aria-hidden="true" />;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/** A label within a toolbar. */
|
|
42
46
|
Toolbar.Title = Title;
|
|
47
|
+
/** A button within a toolbar. */
|
|
43
48
|
Toolbar.Button = Button;
|
|
49
|
+
/** Flexible space that pushes what follows to the far end of the toolbar. */
|
|
44
50
|
Toolbar.Spacer = Spacer;
|
|
@@ -5,6 +5,7 @@ import type { StorageAdapter } from './types';
|
|
|
5
5
|
|
|
6
6
|
const SINGLETON_INSTRUMENT = '__singleton__';
|
|
7
7
|
|
|
8
|
+
/** Props for `<SingletonExperimentProvider>`. */
|
|
8
9
|
export interface SingletonExperimentProviderProps<TS, TC> {
|
|
9
10
|
/** Stable id for the synthetic workspace; also doubles as the
|
|
10
11
|
* WorkspaceIdContext value. */
|
package/src/state/adapters.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { decodeUrlHash, encodeUrlHash } from './helpers';
|
|
2
2
|
import type { StorageAdapter } from './types';
|
|
3
3
|
|
|
4
|
+
/** Persist to `localStorage` — state survives a reload and a new tab. Falls
|
|
5
|
+
* back to no-ops when storage is unavailable. */
|
|
4
6
|
export const localStorageAdapter: StorageAdapter = {
|
|
5
7
|
read: (key) => {
|
|
6
8
|
try {
|
|
@@ -25,6 +27,7 @@ export const localStorageAdapter: StorageAdapter = {
|
|
|
25
27
|
},
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
/** Persist to `sessionStorage` — state survives a reload but not a new tab. */
|
|
28
31
|
export const sessionStorageAdapter: StorageAdapter = {
|
|
29
32
|
read: (key) => {
|
|
30
33
|
try {
|
|
@@ -68,6 +71,8 @@ function writeHashMap(map: Record<string, string>): void {
|
|
|
68
71
|
window.history.replaceState(null, '', `#${encoded}`);
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/** Persist into the URL fragment, so the page's link carries its state and
|
|
75
|
+
* can be shared or bookmarked. */
|
|
71
76
|
export const urlHashAdapter: StorageAdapter = {
|
|
72
77
|
read: (key) => readHashMap()[key] ?? null,
|
|
73
78
|
write: (key, value) => {
|
|
@@ -82,6 +87,8 @@ export const urlHashAdapter: StorageAdapter = {
|
|
|
82
87
|
},
|
|
83
88
|
};
|
|
84
89
|
|
|
90
|
+
/** An in-memory store, discarded on reload. For tests, and for labs that
|
|
91
|
+
* should start fresh every time. */
|
|
85
92
|
export function createMemoryAdapter(): StorageAdapter {
|
|
86
93
|
const store = new Map<string, string>();
|
|
87
94
|
return {
|
|
@@ -95,6 +102,7 @@ export function createMemoryAdapter(): StorageAdapter {
|
|
|
95
102
|
};
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/** Persists nothing and reads back nothing. */
|
|
98
106
|
export const noneAdapter: StorageAdapter = {
|
|
99
107
|
read: () => null,
|
|
100
108
|
write: () => {},
|
package/src/state/context.tsx
CHANGED
|
@@ -5,8 +5,11 @@ import type { LabStoreState } from './types';
|
|
|
5
5
|
|
|
6
6
|
type LabStoreCtx = { store: LabStore } | null;
|
|
7
7
|
|
|
8
|
+
/** Context carrying the lab store. Prefer `useLabStore`; this is exported for
|
|
9
|
+
* code that needs to read the context without subscribing. */
|
|
8
10
|
export const LabStoreContext = createContext<LabStoreCtx>(null);
|
|
9
11
|
|
|
12
|
+
/** Provides a lab store to its subtree. */
|
|
10
13
|
export function LabStoreProvider({
|
|
11
14
|
store,
|
|
12
15
|
children,
|
|
@@ -17,14 +20,18 @@ export function LabStoreProvider({
|
|
|
17
20
|
return <LabStoreContext.Provider value={{ store }}>{children}</LabStoreContext.Provider>;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/** Subscribe to the whole lab store. Throws outside a `<LabStoreProvider>`. */
|
|
20
24
|
export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']> {
|
|
21
25
|
const ctx = useContext(LabStoreContext);
|
|
22
26
|
if (!ctx) throw new Error('[labkit] useLabStore must be used inside <LabStoreProvider>');
|
|
23
27
|
return useStore(ctx.store);
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** Context carrying which workspace the subtree belongs to. */
|
|
26
31
|
export const WorkspaceIdContext = createContext<string | null>(null);
|
|
27
32
|
|
|
33
|
+
/** Names the workspace its subtree belongs to, so an instrument's hooks can
|
|
34
|
+
* find their own record in the store without being passed an id. */
|
|
28
35
|
export function WorkspaceIdProvider({
|
|
29
36
|
workspaceId,
|
|
30
37
|
children,
|
|
@@ -35,6 +42,8 @@ export function WorkspaceIdProvider({
|
|
|
35
42
|
return <WorkspaceIdContext.Provider value={workspaceId}>{children}</WorkspaceIdContext.Provider>;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/** The id of the workspace this component is inside. Throws outside a
|
|
46
|
+
* `<WorkspaceIdProvider>`. */
|
|
38
47
|
export function useWorkspaceId(): string {
|
|
39
48
|
const id = useContext(WorkspaceIdContext);
|
|
40
49
|
if (!id) throw new Error('[labkit] useWorkspaceId must be used inside <WorkspaceIdProvider>');
|
package/src/state/helpers.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { InstrumentSerializers, UndoStack, WorkspaceRecord } from './types';
|
|
2
2
|
|
|
3
|
+
/** The storage key a lab writes one of its three buckets under. Namespaced by
|
|
4
|
+
* `storageKey` so two labs sharing an origin do not collide. */
|
|
3
5
|
export function labStorageKey(
|
|
4
6
|
storageKey: string,
|
|
5
7
|
bucket: 'workspaces' | 'saves' | 'theme',
|
|
@@ -7,10 +9,13 @@ export function labStorageKey(
|
|
|
7
9
|
return `lk:${storageKey}:${bucket}`;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/** Encode a string for the URL fragment. */
|
|
10
13
|
export function encodeUrlHash(value: string): string {
|
|
11
14
|
return btoa(encodeURIComponent(value));
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/** Decode a URL fragment written by `encodeUrlHash`, or `null` if it is
|
|
18
|
+
* malformed. */
|
|
14
19
|
export function decodeUrlHash(hash: string): string | null {
|
|
15
20
|
if (!hash) return null;
|
|
16
21
|
try {
|
|
@@ -20,12 +25,16 @@ export function decodeUrlHash(hash: string): string | null {
|
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
/** A fresh, empty undo history. */
|
|
23
29
|
export function emptyUndoStack(): UndoStack {
|
|
24
30
|
return { past: [], future: [] };
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
type SerializedRecord = Omit<WorkspaceRecord, 'undoStack'>;
|
|
28
34
|
|
|
35
|
+
/** Serialize workspaces for storage, running each instrument's own serializer
|
|
36
|
+
* over its state. Undo history is deliberately dropped — it does not survive
|
|
37
|
+
* a reload. */
|
|
29
38
|
export function serializeWorkspaces(
|
|
30
39
|
workspaces: WorkspaceRecord[],
|
|
31
40
|
serializers: InstrumentSerializers,
|
|
@@ -40,6 +49,9 @@ export function serializeWorkspaces(
|
|
|
40
49
|
return JSON.stringify(records);
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** Rebuild workspaces from storage, running each instrument's deserializer
|
|
53
|
+
* over its state and starting each with an empty undo history. Returns an
|
|
54
|
+
* empty list rather than throwing on malformed input. */
|
|
43
55
|
export function deserializeWorkspaces(
|
|
44
56
|
raw: string,
|
|
45
57
|
deserializers: InstrumentSerializers,
|
package/src/state/store.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
WorkspaceRecord,
|
|
15
15
|
} from './types';
|
|
16
16
|
|
|
17
|
+
/** Every mutation a lab store supports: managing workspaces, saving and
|
|
18
|
+
* restoring snapshots, and setting the color mode. */
|
|
17
19
|
export interface LabStoreActions {
|
|
18
20
|
addWorkspace: (record: Omit<WorkspaceRecord, 'undoStack'>) => void;
|
|
19
21
|
removeWorkspace: (id: string) => void;
|
|
@@ -34,10 +36,14 @@ export interface LabStoreActions {
|
|
|
34
36
|
setMode: (mode: LabMode) => void;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** A lab's store: its state and actions, plus the hook instruments use to
|
|
40
|
+
* register how their state is serialized. */
|
|
37
41
|
export type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
|
|
38
42
|
registerSerializers: (s: InstrumentSerializers) => void;
|
|
39
43
|
};
|
|
40
44
|
|
|
45
|
+
/** Build a lab store, hydrating from storage if anything was saved under the
|
|
46
|
+
* same key. Writes back are debounced. */
|
|
41
47
|
export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
42
48
|
let serializers: InstrumentSerializers = {};
|
|
43
49
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
package/src/state/types.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
/** A workspace's undo history, as snapshots of its state either side of the
|
|
2
|
+
* present. */
|
|
1
3
|
export interface UndoStack {
|
|
2
4
|
past: unknown[];
|
|
3
5
|
future: unknown[];
|
|
4
6
|
}
|
|
5
7
|
|
|
8
|
+
/** One workspace as the store holds it: which instrument it runs, that
|
|
9
|
+
* instrument's config and state, the camera, and the undo history. */
|
|
6
10
|
export interface WorkspaceRecord<TS = unknown, TC = unknown> {
|
|
7
11
|
id: string;
|
|
8
12
|
instrumentName: string;
|
|
@@ -12,6 +16,8 @@ export interface WorkspaceRecord<TS = unknown, TC = unknown> {
|
|
|
12
16
|
undoStack: UndoStack;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/** A named, saved copy of a workspace's config and state, restorable into any
|
|
20
|
+
* workspace running the same instrument. */
|
|
15
21
|
export interface SavedSnapshot {
|
|
16
22
|
id: string;
|
|
17
23
|
name: string;
|
|
@@ -25,18 +31,25 @@ export interface SavedSnapshot {
|
|
|
25
31
|
/** `auto` follows the OS; the other two are an explicit choice. */
|
|
26
32
|
export type LabMode = 'auto' | 'light' | 'dark';
|
|
27
33
|
|
|
34
|
+
/** Everything a lab persists: its workspaces, its saved snapshots, and the
|
|
35
|
+
* chosen color mode. */
|
|
28
36
|
export interface LabStoreState {
|
|
29
37
|
workspaces: WorkspaceRecord[];
|
|
30
38
|
savedSnapshots: SavedSnapshot[];
|
|
31
39
|
mode: LabMode;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** Where a lab persists itself. Implementations are keyed string storage and
|
|
43
|
+
* nothing more, so the same store works against localStorage, the URL hash,
|
|
44
|
+
* or memory. */
|
|
34
45
|
export interface StorageAdapter {
|
|
35
46
|
read(key: string): string | null;
|
|
36
47
|
write(key: string, value: string): void;
|
|
37
48
|
delete?(key: string): void;
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/** What `useExperimentState` hands an instrument: its state and config, with
|
|
52
|
+
* a setter for each. */
|
|
40
53
|
export interface ExperimentStateHandle<TS, TC> {
|
|
41
54
|
state: TS;
|
|
42
55
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
@@ -44,12 +57,16 @@ export interface ExperimentStateHandle<TS, TC> {
|
|
|
44
57
|
setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
/** Options for `createLabStore`. `storageKey` namespaces the keys written, so
|
|
61
|
+
* two labs on one origin do not collide. */
|
|
47
62
|
export interface CreateLabStoreOptions {
|
|
48
63
|
storageKey: string;
|
|
49
64
|
storage: StorageAdapter;
|
|
50
65
|
initialMode?: LabMode;
|
|
51
66
|
}
|
|
52
67
|
|
|
68
|
+
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
69
|
+
* instrument whose state is already JSON-safe needs no entry. */
|
|
53
70
|
export type InstrumentSerializers = Record<
|
|
54
71
|
string,
|
|
55
72
|
{ serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
|
|
@@ -3,6 +3,9 @@ import { useStore } from 'zustand/react';
|
|
|
3
3
|
import { LabStoreContext, WorkspaceIdContext } from './context';
|
|
4
4
|
import type { ExperimentStateHandle } from './types';
|
|
5
5
|
|
|
6
|
+
/** An instrument's own state and config, plus setters. Reads the surrounding
|
|
7
|
+
* workspace id, so an instrument never has to know which workspace it is
|
|
8
|
+
* running in. Throws outside a lab store and workspace. */
|
|
6
9
|
export function useExperimentState<TS = unknown, TC = unknown>(): ExperimentStateHandle<TS, TC> {
|
|
7
10
|
const ctx = useContext(LabStoreContext);
|
|
8
11
|
if (!ctx) throw new Error('[labkit] useExperimentState must be used inside <LabStoreProvider>');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, type RefCallback, useEffect, useState } from 'react';
|
|
2
2
|
import { dlog, useReorderDragList } from '../../passthrough/weasel-ui';
|
|
3
3
|
|
|
4
|
+
/** One card in a layer stack: its identity, the label shown when collapsed,
|
|
5
|
+
* and the optional select hoisted into its header. */
|
|
4
6
|
export interface LayerStackItem {
|
|
5
7
|
/** Stable id used for keys, onRemove, onReorder. Numeric to match
|
|
6
8
|
* common id-from-nextId conventions; string ids also work. */
|
|
@@ -22,6 +24,7 @@ export interface LayerStackItem {
|
|
|
22
24
|
defaultExpanded?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Props for `<LayerStack>`. */
|
|
25
28
|
export interface LayerStackProps {
|
|
26
29
|
title: string;
|
|
27
30
|
items: LayerStackItem[];
|
|
@@ -38,6 +41,8 @@ export interface LayerStackProps {
|
|
|
38
41
|
hideHead?: boolean;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** A drag-reorderable stack of expandable cards, with a palette in the header
|
|
45
|
+
* for adding more. The body of each card is the caller's to render. */
|
|
41
46
|
export function LayerStack({
|
|
42
47
|
title,
|
|
43
48
|
items,
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { useCallback, useMemo } from 'react';
|
|
2
2
|
import { type ControlPoint, CurveEditor, dlog } from '../../passthrough/weasel-ui';
|
|
3
3
|
|
|
4
|
+
/** An annotation drawn over a curve plot: a shaded band across a range of x,
|
|
5
|
+
* or a vertical line at one x. */
|
|
4
6
|
export type CurveMark =
|
|
5
7
|
| { kind: 'band'; x: [number, number]; color?: string }
|
|
6
8
|
| { kind: 'line'; x: number; color?: string };
|
|
7
9
|
|
|
10
|
+
/** Props for `<CurveField>`. */
|
|
8
11
|
export interface CurveFieldProps {
|
|
9
12
|
/** Flat [x0, y0, x1, y1, …] — matches how curve-as-array configs
|
|
10
13
|
* serialize in JSON snapshots. */
|
|
@@ -91,7 +94,7 @@ export function CurveField({
|
|
|
91
94
|
<div className="lk-curve-field__plot">
|
|
92
95
|
<CurveEditor
|
|
93
96
|
value={points}
|
|
94
|
-
|
|
97
|
+
onInput={handleChange}
|
|
95
98
|
domain="1d"
|
|
96
99
|
constrain="function"
|
|
97
100
|
xRange={[0, 1]}
|
|
@@ -5,12 +5,15 @@ import { type CSSProperties, type ReactNode, useState } from 'react';
|
|
|
5
5
|
// label flanked by a horizontal rule — no background, padding, or border;
|
|
6
6
|
// purely a typographic divider. Ported from speech-balloons styles.css:277-313.
|
|
7
7
|
|
|
8
|
+
/** Props for `<Subpanel>`. */
|
|
8
9
|
export interface SubpanelProps {
|
|
9
10
|
title: ReactNode;
|
|
10
11
|
children: ReactNode;
|
|
11
12
|
className?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** A typographic divider inside a property list: a small title flanked by a
|
|
16
|
+
* rule. Purely a heading — use `<PropertyGroup>` for a bordered section. */
|
|
14
17
|
export function Subpanel({ title, children, className }: SubpanelProps) {
|
|
15
18
|
const cls = className ? `lk-subpanel ${className}` : 'lk-subpanel';
|
|
16
19
|
return (
|
|
@@ -29,6 +32,7 @@ export function Subpanel({ title, children, className }: SubpanelProps) {
|
|
|
29
32
|
// per-instance recolors the title bar, border-left, and (via re-binding --wzl-accent
|
|
30
33
|
// inside the card) every descendant control that reads from the accent token.
|
|
31
34
|
|
|
35
|
+
/** Props for `<EffectCard>`. */
|
|
32
36
|
export interface EffectCardProps {
|
|
33
37
|
/** Card title rendered in the title bar (e.g. effect kind label or a select). */
|
|
34
38
|
title: ReactNode;
|
|
@@ -59,6 +63,9 @@ export interface EffectCardProps {
|
|
|
59
63
|
className?: string;
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/** An accented, collapsible card for one item in a list of like things —
|
|
67
|
+
* effects, layers, tails. The accent color rebinds the theme accent token
|
|
68
|
+
* within the card, so its controls pick it up too. */
|
|
62
69
|
export function EffectCard({
|
|
63
70
|
title,
|
|
64
71
|
primary,
|
|
@@ -182,10 +189,12 @@ function DragHandleIcon() {
|
|
|
182
189
|
// onReorder fires with (sourceId, targetId, position) on drop. Mirrors the
|
|
183
190
|
// LayerStack pattern from speech-balloons Lab.tsx:630-707.
|
|
184
191
|
|
|
192
|
+
/** The minimum an item must carry to appear in an `<EffectCardList>`. */
|
|
185
193
|
export interface EffectCardListItem {
|
|
186
194
|
id: string | number;
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
/** Props for `<EffectCardList>`. */
|
|
189
198
|
export interface EffectCardListProps<T extends EffectCardListItem> {
|
|
190
199
|
items: ReadonlyArray<T>;
|
|
191
200
|
renderItem: (
|
|
@@ -217,6 +226,9 @@ export interface EffectCardListProps<T extends EffectCardListItem> {
|
|
|
217
226
|
defaultExpandedIds?: ReadonlyArray<T['id']>;
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
/** A drag-reorderable list of `<EffectCard>`s. Owns the expanded and dragging
|
|
230
|
+
* state and hands it back to `renderItem`, so the caller only supplies each
|
|
231
|
+
* card's contents. */
|
|
220
232
|
export function EffectCardList<T extends EffectCardListItem>({
|
|
221
233
|
items,
|
|
222
234
|
renderItem,
|