@m4l-jweb/surface 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "peerDependencies": {
26
26
  "react": ">=18",
27
- "@m4l-jweb/bridge": "0.4.0"
27
+ "@m4l-jweb/bridge": "0.6.0"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -97,6 +97,61 @@ export type ParamSpec = DialSpec | ToggleSpec | MenuSpec;
97
97
  /** The value type a given parameter carries. `useParam` will be typed by this. */
98
98
  export type ParamValue<P extends ParamSpec> = P extends DialSpec ? number : P extends ToggleSpec ? boolean : P extends MenuSpec<infer O> ? O : never;
99
99
 
100
+ /* ------------------------------------------------------------------ *
101
+ * Windows and State
102
+ * ------------------------------------------------------------------ */
103
+
104
+ /**
105
+ * A floating window: a SECOND page, in a window of its own.
106
+ *
107
+ * The device view in Live is a fixed ~169 px tall and does not scroll, so a UI
108
+ * that needs room (a pattern editor, a waveform) has nowhere to grow inside it.
109
+ * A declared window compiles to a subpatcher holding its own [jweb], and
110
+ * `useWindow()` opens it.
111
+ *
112
+ * `entry` names the component to bundle, from the device's own folder -
113
+ * `entry: "Window"` is `src/app/<device>/Window.tsx`. It is a separate BUNDLE, so
114
+ * it shares no React state with the device view: two pages, two Chromium
115
+ * contexts, talking only through Max.
116
+ */
117
+ export interface WindowSpec {
118
+ kind: "window";
119
+ title: string;
120
+ width: number;
121
+ height: number;
122
+ entry: string;
123
+ }
124
+
125
+ /**
126
+ * A slot of JSON that survives a save - persisted in the LIVE SET, per instance.
127
+ *
128
+ * Not a parameter: Live never looks inside it, it does not automate, and it is not
129
+ * on Push. That is exactly what makes it the right home for the things a parameter
130
+ * cannot hold - a pattern, a preset, a grid of steps.
131
+ */
132
+ export interface StateSpec<T = unknown> {
133
+ kind: "state";
134
+ default: T;
135
+ }
136
+
137
+ /** The value type a state slot carries, so `useStateSync` is typed from the declaration. */
138
+ export type StateValue<S extends StateSpec> = S extends StateSpec<infer T> ? T : never;
139
+
140
+ export const window = (spec: Omit<WindowSpec, "kind">): WindowSpec => ({
141
+ kind: "window",
142
+ ...spec,
143
+ });
144
+
145
+ /**
146
+ * The default is the TYPE. `state({ default: { voices: 4 } })` makes the slot
147
+ * `{ voices: number }` everywhere - in `useStateSync`, and in the setter that
148
+ * writes it back - with no type argument to pass and no `as` to remember.
149
+ */
150
+ export const state = <T>(spec: Omit<StateSpec<T>, "kind">): StateSpec<T> => ({
151
+ kind: "state",
152
+ ...spec,
153
+ });
154
+
100
155
  export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
101
156
  kind: "dial",
102
157
  ...spec,
@@ -129,12 +184,28 @@ export interface Bank<K extends string> {
129
184
  params: readonly K[];
130
185
  }
131
186
 
132
- export interface SurfaceDef<P extends Record<string, ParamSpec>> {
187
+ /**
188
+ * The declaration. `P`, `S` and `W` are inferred from what you write - they exist so
189
+ * that `useParam`, `useStateSync` and `useWindow` are typed against THIS surface: a
190
+ * parameter, slot or window that is not declared here is a build error at the call
191
+ * site, not a control that silently does nothing.
192
+ */
193
+ export interface SurfaceDef<
194
+ P extends Record<string, ParamSpec>,
195
+ S extends Record<string, StateSpec> = Record<string, StateSpec>,
196
+ W extends Record<string, WindowSpec> = Record<string, WindowSpec>,
197
+ > {
133
198
  params: P;
134
199
  banks?: readonly Bank<Extract<keyof P, string>>[];
200
+ windows?: W;
201
+ state?: S;
135
202
  }
136
203
 
137
- export interface Surface<P extends Record<string, ParamSpec> = Record<string, ParamSpec>> extends SurfaceDef<P> {
204
+ export interface Surface<
205
+ P extends Record<string, ParamSpec> = Record<string, ParamSpec>,
206
+ S extends Record<string, StateSpec> = Record<string, StateSpec>,
207
+ W extends Record<string, WindowSpec> = Record<string, WindowSpec>,
208
+ > extends SurfaceDef<P, S, W> {
138
209
  /** Declaration order. This is also the order Push falls back to without banks. */
139
210
  readonly ids: readonly Extract<keyof P, string>[];
140
211
  }
@@ -154,7 +225,11 @@ export const BANK_SIZE = 8;
154
225
  * imports this module to generate the patcher, so a violation fails `pnpm
155
226
  * build` and fails CI. It is only a less pretty error message.
156
227
  */
157
- export function defineSurface<const P extends Record<string, ParamSpec>>(def: SurfaceDef<P>): Surface<P> {
228
+ export function defineSurface<
229
+ const P extends Record<string, ParamSpec>,
230
+ const S extends Record<string, StateSpec>,
231
+ const W extends Record<string, WindowSpec>,
232
+ >(def: SurfaceDef<P, S, W>): Surface<P, S, W> {
158
233
  const ids = Object.keys(def.params) as Extract<keyof P, string>[];
159
234
 
160
235
  for (const id of ids) {
package/src/react.tsx CHANGED
@@ -17,8 +17,9 @@
17
17
  * has no React in it and is tested without a DOM. This file is the hook.
18
18
  */
19
19
  import { useCallback, useMemo, useSyncExternalStore } from "react";
20
- import type { ParamSpec, ParamValue, Surface } from "./index";
21
- import { paramStore } from "./store";
20
+ import { outlet } from "@m4l-jweb/bridge";
21
+ import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
22
+ import { paramStore, stateStore } from "./store";
22
23
 
23
24
  /**
24
25
  * A two-way binding to one Live parameter. `[value, setValue]`, like useState -
@@ -43,3 +44,44 @@ export function useSurface<P extends Record<string, ParamSpec>>(
43
44
  const set = useCallback(<K extends Extract<keyof P, string>>(id: K, value: ParamValue<P[K]>) => store.write(id, value), [store]);
44
45
  return [values as { [K in keyof P]: ParamValue<P[K]> }, set];
45
46
  }
47
+
48
+ /**
49
+ * Open and close a declared floating window.
50
+ *
51
+ * The selectors are DERIVED from the declaration, exactly as the patcher's
52
+ * `[route window_<id>_open ...]` is - so the window id is typed against the
53
+ * surface and a typo is a build error, not a button that does nothing.
54
+ */
55
+ export function useWindow<
56
+ P extends Record<string, ParamSpec>,
57
+ S extends Record<string, StateSpec>,
58
+ W extends Record<string, WindowSpec>,
59
+ K extends Extract<keyof W, string>,
60
+ >(_surface: Surface<P, S, W>, id: K): { open: () => void; close: () => void } {
61
+ return useMemo(
62
+ () => ({
63
+ open: () => outlet(`window_${id}_open`, 1),
64
+ close: () => outlet(`window_${id}_close`, 1),
65
+ }),
66
+ [id],
67
+ );
68
+ }
69
+
70
+ /**
71
+ * A two-way binding to a JSON state slot, persisted in the Live SET.
72
+ *
73
+ * `[value, setValue]`, like useState - except the value survives saving, closing
74
+ * and reopening the set, and each instance of the device keeps its own. The type
75
+ * comes from the declaration's `default`, so there is nothing to cast.
76
+ */
77
+ export function useStateSync<
78
+ P extends Record<string, ParamSpec>,
79
+ S extends Record<string, StateSpec>,
80
+ W extends Record<string, WindowSpec>,
81
+ K extends Extract<keyof S, string>,
82
+ >(surface: Surface<P, S, W>, id: K): [StateValue<S[K]>, (value: StateValue<S[K]>) => void] {
83
+ const store = useMemo(() => stateStore(surface), [surface]);
84
+ const values = useSyncExternalStore(store.subscribe, store.get, store.get);
85
+ const set = useCallback((value: StateValue<S[K]>) => store.write(id, value), [store, id]);
86
+ return [values[id] as StateValue<S[K]>, set];
87
+ }
package/src/store.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  * the user's hand wins, and the next value after the window lands normally.
31
31
  */
32
32
  import { bindInlet, outlet } from "@m4l-jweb/bridge";
33
- import { defaults, type ParamSpec, type Surface } from "./index";
33
+ import { defaults, type ParamSpec, type StateSpec, type Surface } from "./index";
34
34
 
35
35
  /** How long the user's hand beats an inbound value: long enough to cover the gaps between drag events, short enough to be imperceptible. */
36
36
  export const GUARD_MS = 120;
@@ -133,3 +133,89 @@ export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface
133
133
  stores.set(surface, store);
134
134
  return store;
135
135
  }
136
+
137
+ /* ------------------------------------------------------------------ *
138
+ * The state store - the JSON behind useStateSync(), persisted in the Live SET.
139
+ *
140
+ * A parameter is a Live parameter: a number, automatable, on Push. A state slot is
141
+ * none of those things - it is whatever JSON the app wants to survive a save (a
142
+ * pattern, a preset, a grid of steps), and Live never looks inside it.
143
+ *
144
+ * ------------------------------------------------------------------------------
145
+ * THE ID IS AN ARGUMENT, NOT PART OF THE SELECTOR - and getting that wrong is why
146
+ * nothing this store wrote was ever saved.
147
+ *
148
+ * It used to emit `sync_state_<id> <json>`. Max dispatches a message on its FIRST
149
+ * WORD, so `sync_state_config` went looking for a `function sync_state_config()`,
150
+ * found none, and fell into the wrapper's anything() - which exists precisely to
151
+ * swallow messages meant for somebody else, silently, by design. The read path
152
+ * (`get_state <id>`, reply `state_<id> <json>`) had it right, so state loaded and
153
+ * never saved: the failure mode that looks like Live losing your data.
154
+ *
155
+ * OUT: `get_state <id>`, `sync_state <id> <json>` - one handler each, in the wrapper.
156
+ * IN: `state_<id> <json>` - one binding per slot, so the
157
+ * bridge can dispatch it without the app unpacking an id.
158
+ * ------------------------------------------------------------------ */
159
+
160
+ export interface StateStore {
161
+ get(): Values;
162
+ subscribe(fn: () => void): () => void;
163
+ write(id: string, value: unknown): void;
164
+ }
165
+
166
+ const stateStores = new WeakMap<object, StateStore>();
167
+
168
+ /**
169
+ * One store per surface, for the same reason paramStore is: one binding per selector.
170
+ *
171
+ * It asks for the SLOTS, not for a `Surface<P, S>` - a store that named the
172
+ * parameter types would make every caller prove theirs match, and it does not read
173
+ * a single parameter. The hook keeps the types; this keeps the state.
174
+ */
175
+ export function stateStore(surface: { state?: Record<string, StateSpec> }): StateStore {
176
+ const existing = stateStores.get(surface);
177
+ if (existing) return existing;
178
+
179
+ const ids = surface.state ? Object.keys(surface.state) : [];
180
+
181
+ // What the app shows before Live has replied: the declared defaults.
182
+ let values: Values = {};
183
+ for (const id of ids) values[id] = surface.state![id].default;
184
+
185
+ const listeners = new Set<() => void>();
186
+ const notify = () => {
187
+ for (const fn of listeners) fn();
188
+ };
189
+
190
+ for (const id of ids) {
191
+ bindInlet(`state_${id}`, (raw) => {
192
+ try {
193
+ values = { ...values, [id]: JSON.parse(String(raw)) };
194
+ notify();
195
+ } catch {
196
+ // An empty dict stringifies to "{}" and parses fine; anything that does
197
+ // not parse is a slot we have no value for, and the default already in
198
+ // `values` is the honest answer. Keep it rather than blanking the app.
199
+ }
200
+ });
201
+ // The page loads asynchronously and long after the device did, so nothing was
202
+ // listening when Live restored the pattr. Ask for it.
203
+ outlet("get_state", id);
204
+ }
205
+
206
+ const store: StateStore = {
207
+ get: () => values,
208
+ subscribe(fn) {
209
+ listeners.add(fn);
210
+ return () => listeners.delete(fn);
211
+ },
212
+ write(id, value) {
213
+ values = { ...values, [id]: value };
214
+ notify();
215
+ outlet("sync_state", id, JSON.stringify(value));
216
+ },
217
+ };
218
+
219
+ stateStores.set(surface, store);
220
+ return store;
221
+ }