@m4l-jweb/surface 0.7.0 → 0.9.1

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.7.0",
3
+ "version": "0.9.1",
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.7.0"
27
+ "@m4l-jweb/bridge": "0.9.1"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -142,6 +142,20 @@ export interface WindowSpec {
142
142
  width: number;
143
143
  height: number;
144
144
  entry: string;
145
+ /**
146
+ * Keep the window in FRONT of Live, instead of behind it the moment Live is clicked.
147
+ *
148
+ * For a window you read WHILE working in Live - a reference, a cheatsheet - the
149
+ * default behaviour makes it useless: clicking back into the device to type is
150
+ * exactly what sends the window behind the main window. For a window you work IN (an
151
+ * editor), the default is right and this should stay off.
152
+ *
153
+ * It compiles to `[thispatcher]` <- `window flags ... float, window exec`, which is
154
+ * Max's documented route. **The flag list is a REPLACEMENT, not an addition** - that
155
+ * is why the generated message names `grow`, `close` and `title` alongside `float`.
156
+ * Send `float` alone and the window comes up with no close box.
157
+ */
158
+ alwaysOnTop?: boolean;
145
159
  }
146
160
 
147
161
  /**
@@ -375,6 +389,75 @@ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P
375
389
  return out;
376
390
  }
377
391
 
392
+ /* ------------------------------------------------------------------ *
393
+ * defineWatch - declare what to OBSERVE in Live, once, as code.
394
+ *
395
+ * The twin of defineSurface, and it exists to kill hard rule 4 BY CONSTRUCTION.
396
+ * A LiveAPI observer built during `loadbang` is dead forever; the only safe place
397
+ * to create one is `live.thisdevice`'s bang. That is a trap a hand-written
398
+ * observer falls into silently - the object constructs without error and then
399
+ * notifies nothing. So no device writes the observer at all: it DECLARES the LOM
400
+ * path and property, the build injects the list as data, and the packaged wrapper
401
+ * creates every observer from bang() (see setupWatches in @m4l-jweb/wrapper). The
402
+ * one place LiveAPI is safe is the one place the observers are made.
403
+ *
404
+ * Each change reaches the UI as `watch_<key> <value...>`, exactly as a parameter
405
+ * reaches it as `<id> <value>` - one declaration, one name, both sides. `useWatch`
406
+ * binds it; the app never types the selector.
407
+ *
408
+ * This is READ-ONLY: an observed property flows Live -> UI and nowhere back. A
409
+ * value the app can also WRITE is a parameter (`defineSurface`), not a watch.
410
+ * ------------------------------------------------------------------ */
411
+
412
+ /** One observed Live property. `T` is the value the app sees, so `useWatch` is typed from it. */
413
+ export interface WatchSpec<T = unknown> {
414
+ /** The LOM object path, e.g. `"live_set"` or `"live_set view selected_track"`. */
415
+ path: string;
416
+ /** An OBSERVABLE property on that object, e.g. `"tempo"`, `"scale_name"`, `"is_playing"`. */
417
+ property: string;
418
+ /** What the app shows before Live has replied - the same role a parameter's `default` plays. */
419
+ default: T;
420
+ }
421
+
422
+ /** The value type a watch carries. `useWatch` is typed by this. */
423
+ export type WatchValue<S extends WatchSpec> = S extends WatchSpec<infer T> ? T : never;
424
+
425
+ /**
426
+ * Declare one watch. `watch<string>({ path: "live_set", property: "scale_name", default: "C" })`
427
+ * carries the value type through, so `useWatch(w, "scale")` is `string` with nothing to cast.
428
+ */
429
+ export const watch = <T>(spec: WatchSpec<T>): WatchSpec<T> => ({ ...spec });
430
+
431
+ export interface WatchDef<W extends Record<string, WatchSpec>> {
432
+ watches: W;
433
+ }
434
+
435
+ export interface Watch<W extends Record<string, WatchSpec> = Record<string, WatchSpec>> extends WatchDef<W> {
436
+ /** Declaration order - the order the build emits WATCH_SPECS and the wrapper attaches observers. */
437
+ readonly keys: readonly Extract<keyof W, string>[];
438
+ }
439
+
440
+ /**
441
+ * Declare the watch surface.
442
+ *
443
+ * Like defineSurface, the checks run HERE, at call time, and throw - the build
444
+ * imports this module to emit the observer list, so a bad declaration fails
445
+ * `pnpm build` and CI. A key becomes the selector suffix `watch_<key>`, so it may
446
+ * not carry whitespace (Max would split the message on it); a path or property
447
+ * left blank would attach an observer to nothing, silently, which is the exact
448
+ * failure this API exists to prevent.
449
+ */
450
+ export function defineWatch<const W extends Record<string, WatchSpec>>(def: WatchDef<W>): Watch<W> {
451
+ const keys = Object.keys(def.watches) as Extract<keyof W, string>[];
452
+ for (const key of keys) {
453
+ if (/\s/.test(key)) throw new Error(`watch: key "${key}" has whitespace - it becomes the selector watch_${key}, which Max would split`);
454
+ const w = def.watches[key];
455
+ if (!w.path) throw new Error(`watch: "${key}" has no path - an observer with no LOM object attaches to nothing`);
456
+ if (!w.property) throw new Error(`watch: "${key}" has no property - an observer with no property notifies nothing`);
457
+ }
458
+ return { ...def, keys };
459
+ }
460
+
378
461
  /** How a value is displayed - the parameter's own `format`, or a sane default. */
379
462
  export function formatValue(spec: ParamSpec, value: unknown): string {
380
463
  if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
package/src/react.tsx CHANGED
@@ -18,9 +18,9 @@
18
18
  */
19
19
  import { useCallback, useMemo, useSyncExternalStore } from "react";
20
20
  import { outlet } from "@m4l-jweb/bridge";
21
- import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
21
+ import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
22
22
  import { JWEB_VARNAME } from "./index";
23
- import { paramStore, stateStore } from "./store";
23
+ import { paramStore, stateStore, watchStore } from "./store";
24
24
 
25
25
  /**
26
26
  * A two-way binding to one Live parameter. `[value, setValue]`, like useState -
@@ -106,9 +106,7 @@ export function useNativeVisibility<P extends Record<string, ParamSpec>>(
106
106
  * Only `hidden` is used, so this actually works where reflow could not, and no layer
107
107
  * is ever visible at the same time as another - so z-order never matters.
108
108
  */
109
- export function useNativePanel<P extends Record<string, ParamSpec>>(
110
- surface: Surface<P>,
111
- ): (mode: "web" | "native") => void {
109
+ export function useNativePanel<P extends Record<string, ParamSpec>>(surface: Surface<P>): (mode: "web" | "native") => void {
112
110
  return useCallback(
113
111
  (mode: "web" | "native") => {
114
112
  const native = surface.layout?.native;
@@ -123,6 +121,25 @@ export function useNativePanel<P extends Record<string, ParamSpec>>(
123
121
  );
124
122
  }
125
123
 
124
+ /**
125
+ * A one-way binding to an observed Live property, declared with `defineWatch()`.
126
+ *
127
+ * Read-only, so it returns the value alone - no setter. The mirror of `useParam`:
128
+ * a parameter the app both reads and writes; a watch it only reads. Turning the
129
+ * tempo, changing the scale, selecting a track in Live moves this React state, and
130
+ * the wrapper attaches every observer from `bang()` - the one place LiveAPI is
131
+ * safe - so the trap of a dead loadbang observer is not one a device can fall into.
132
+ *
133
+ * The selector is DERIVED (`watch_<key>`), exactly as `useParam`'s is, so a key
134
+ * that is not declared is a build error at the call site, not a value that never
135
+ * arrives.
136
+ */
137
+ export function useWatch<W extends Record<string, WatchSpec>, K extends Extract<keyof W, string>>(watch: Watch<W>, key: K): WatchValue<W[K]> {
138
+ const store = useMemo(() => watchStore(watch), [watch]);
139
+ const values = useSyncExternalStore(store.subscribe, store.get, store.get);
140
+ return values[key] as WatchValue<W[K]>;
141
+ }
142
+
126
143
  /**
127
144
  * A two-way binding to a JSON state slot, persisted in the Live SET.
128
145
  *
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 StateSpec, type Surface } from "./index";
33
+ import { defaults, type ParamSpec, type StateSpec, type Surface, type WatchSpec } 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;
@@ -155,8 +155,70 @@ export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface
155
155
  * OUT: `get_state <id>`, `sync_state <id> <json>` - one handler each, in the wrapper.
156
156
  * IN: `state_<id> <json>` - one binding per slot, so the
157
157
  * bridge can dispatch it without the app unpacking an id.
158
+ *
159
+ * ------------------------------------------------------------------------------
160
+ * EVERY VALUE TRAVELS INSIDE AN ENVELOPE, because a Max [dict] IS a key/value map
161
+ * and cannot hold anything else.
162
+ *
163
+ * The wrapper stores a slot by handing the JSON to `Dict.parse()`. A dict has KEYS;
164
+ * that is what a dict is. So an OBJECT round-tripped fine and nothing else did:
165
+ * `state<string>` sent `"c1 e1"` and `state<FxParam[]>` sent `["cutoff"]`, and
166
+ * `parse()` had nowhere to put either. The dict stayed empty, `stringify()` gave back
167
+ * `{}`, and the app read its own default forever.
168
+ *
169
+ * That one bug wore two disguises, and cost real debugging as both: a drum map (an
170
+ * object) persisted while the pattern text (a string) silently did not - which looks
171
+ * exactly like Live losing your work; and the fx device's `named` slot (an array) came
172
+ * back `{}` on every load, which was written off as the state-DEFAULT seeding gap. That
173
+ * gap is real, but it is not this: `named` had never persisted at all.
174
+ *
175
+ * So the wire format is `{"__value": <whatever>}`, always. The dict gets its key, and
176
+ * `state<T>` means what it says for every T.
158
177
  * ------------------------------------------------------------------ */
159
178
 
179
+ /** The one key a [dict] carries, so that a scalar has somewhere to live. */
180
+ const ENVELOPE = "__value";
181
+
182
+ /**
183
+ * The envelope, as JSON, with every literal space escaped - so it crosses as ONE atom.
184
+ *
185
+ * [jweb] hands each argument to Max, and Max SPLITS A SYMBOL ON WHITESPACE. The
186
+ * wrapper has always papered over this by rejoining the pieces with a single space,
187
+ * which works for exactly as long as the payload never contains meaningful whitespace.
188
+ * `JSON.stringify` of an object is compact, so a drum map arrived in one piece and the
189
+ * seam held.
190
+ *
191
+ * A PATTERN IS NOTHING BUT WHITESPACE. `"c1 e1"` would come back `"c1 e1"` - the run
192
+ * of spaces rejoined as one, the user's text quietly reformatted - and a multi-line
193
+ * pattern (which is the whole point of the Studio window) is worse.
194
+ *
195
+ * ` ` is JSON's own escape for a space, so the payload contains no literal spaces
196
+ * at all: Max cannot split what is not there, the rejoin becomes a no-op, and the dict's
197
+ * JSON parser turns the escapes back into the spaces the user typed. Newlines and tabs
198
+ * need no help - `JSON.stringify` already escapes those as `\n` and `\t`.
199
+ */
200
+ function stateToWire(value: unknown): string {
201
+ return JSON.stringify({ [ENVELOPE]: value }).replace(/ /g, "\\u0020");
202
+ }
203
+
204
+ /**
205
+ * Unwrap what came out of the dict.
206
+ *
207
+ * A missing envelope is not an error, and there are two innocent ways to get one: a
208
+ * slot Live has never saved (a fresh, empty `{}`), or a value written by a build from
209
+ * before the envelope - which could only ever have been an object, since nothing else
210
+ * could be stored. Both are answered without blanking the app, so opening an old set
211
+ * keeps its drum map.
212
+ */
213
+ function unwrap(parsed: unknown, fallback: unknown): unknown {
214
+ if (parsed && typeof parsed === "object" && ENVELOPE in (parsed as Record<string, unknown>)) {
215
+ return (parsed as Record<string, unknown>)[ENVELOPE];
216
+ }
217
+ // An empty dict means "nothing saved yet" - the declared default is the honest answer.
218
+ if (parsed && typeof parsed === "object" && Object.keys(parsed as object).length === 0) return fallback;
219
+ return parsed ?? fallback;
220
+ }
221
+
160
222
  export interface StateStore {
161
223
  get(): Values;
162
224
  subscribe(fn: () => void): () => void;
@@ -190,7 +252,10 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
190
252
  for (const id of ids) {
191
253
  bindInlet(`state_${id}`, (raw) => {
192
254
  try {
193
- values = { ...values, [id]: JSON.parse(String(raw)) };
255
+ // The dict hands back `{"__value": ...}` - see the envelope note above. An
256
+ // empty dict and a pre-envelope value both resolve to something sane rather
257
+ // than to nothing.
258
+ values = { ...values, [id]: unwrap(JSON.parse(String(raw)), surface.state![id].default) };
194
259
  notify();
195
260
  } catch {
196
261
  // An empty dict stringifies to "{}" and parses fine; anything that does
@@ -212,10 +277,73 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
212
277
  write(id, value) {
213
278
  values = { ...values, [id]: value };
214
279
  notify();
215
- outlet("sync_state", id, JSON.stringify(value));
280
+ // Enveloped, ALWAYS - a bare string or array has nowhere to live in a [dict] -
281
+ // and space-escaped, so Max cannot split the payload on the way.
282
+ outlet("sync_state", id, stateToWire(value));
216
283
  },
217
284
  };
218
285
 
219
286
  stateStores.set(surface, store);
220
287
  return store;
221
288
  }
289
+
290
+ /* ------------------------------------------------------------------ *
291
+ * The watch store - the read-only Live values behind useWatch().
292
+ *
293
+ * A watch is the mirror image of a parameter: it flows Live -> UI only, so this
294
+ * store has a `get`/`subscribe` and NO `write`. Each declared watch binds
295
+ * `watch_<key>` exactly once - same one-binding-per-selector reason paramStore
296
+ * exists - and fans out to subscribers. The wrapper attaches the observer (from
297
+ * bang(), the only safe place) and resends the current value on ui_ready, so a
298
+ * page that loaded after the last change still gets it; the app just calls
299
+ * uiReady() as it already does for tempo.
300
+ * ------------------------------------------------------------------ */
301
+
302
+ export interface WatchStore {
303
+ get(): Values;
304
+ subscribe(fn: () => void): () => void;
305
+ }
306
+
307
+ const watchStores = new WeakMap<object, WatchStore>();
308
+
309
+ /**
310
+ * One store per watch declaration, created once and never torn down - the bridge's
311
+ * bindings are process-wide, so tearing one down would unbind a watch another
312
+ * component still reads.
313
+ */
314
+ export function watchStore(watch: { watches?: Record<string, WatchSpec>; keys?: readonly string[] }): WatchStore {
315
+ const existing = watchStores.get(watch);
316
+ if (existing) return existing;
317
+
318
+ const keys = watch.keys ?? (watch.watches ? Object.keys(watch.watches) : []);
319
+
320
+ // What the app shows before Live has replied: the declared defaults.
321
+ let values: Values = {};
322
+ for (const key of keys) values[key] = watch.watches?.[key].default;
323
+
324
+ const listeners = new Set<() => void>();
325
+ const notify = () => {
326
+ for (const fn of listeners) fn();
327
+ };
328
+
329
+ for (const key of keys) {
330
+ // `watch_<key> <value...>` - out of the wrapper's observeProperty, forwarded on
331
+ // change and once on ui_ready. A scalar arrives as one atom; a property that
332
+ // yields several (a colour, a pair) arrives spread, so keep the list intact.
333
+ bindInlet(`watch_${key}`, (...args) => {
334
+ values = { ...values, [key]: args.length <= 1 ? args[0] : args };
335
+ notify();
336
+ });
337
+ }
338
+
339
+ const store: WatchStore = {
340
+ get: () => values,
341
+ subscribe(fn) {
342
+ listeners.add(fn);
343
+ return () => listeners.delete(fn);
344
+ },
345
+ };
346
+
347
+ watchStores.set(watch, store);
348
+ return store;
349
+ }