@m4l-jweb/surface 0.6.5 → 0.9.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.6.5",
3
+ "version": "0.9.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.6.5"
27
+ "@m4l-jweb/bridge": "0.9.0"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -92,10 +92,32 @@ export interface MenuSpec<O extends string = string> {
92
92
  short: string;
93
93
  }
94
94
 
95
- export type ParamSpec = DialSpec | ToggleSpec | MenuSpec;
95
+ /**
96
+ * A LABELLED toggle button - a `live.text` in toggle mode, which a bare
97
+ * `live.toggle` cannot be: it carries visible text. The on/off value is the same as
98
+ * a toggle; `label` is what the button reads. Handy as a native view switch (a
99
+ * "Back" button) where the plain orange square of a toggle says nothing.
100
+ */
101
+ export interface ButtonSpec {
102
+ kind: "button";
103
+ default: boolean;
104
+ /** The text on the button. */
105
+ label: string;
106
+ short: string;
107
+ }
108
+
109
+ export type ParamSpec = DialSpec | ToggleSpec | MenuSpec | ButtonSpec;
96
110
 
97
111
  /** The value type a given parameter carries. `useParam` will be typed by this. */
98
- export type ParamValue<P extends ParamSpec> = P extends DialSpec ? number : P extends ToggleSpec ? boolean : P extends MenuSpec<infer O> ? O : never;
112
+ export type ParamValue<P extends ParamSpec> = P extends DialSpec
113
+ ? number
114
+ : P extends ToggleSpec
115
+ ? boolean
116
+ : P extends ButtonSpec
117
+ ? boolean
118
+ : P extends MenuSpec<infer O>
119
+ ? O
120
+ : never;
99
121
 
100
122
  /* ------------------------------------------------------------------ *
101
123
  * Windows and State
@@ -120,6 +142,20 @@ export interface WindowSpec {
120
142
  width: number;
121
143
  height: number;
122
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;
123
159
  }
124
160
 
125
161
  /**
@@ -160,6 +196,11 @@ export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
160
196
  kind: "toggle",
161
197
  ...spec,
162
198
  });
199
+ /** A labelled toggle button (`live.text`). Same on/off value as a toggle, with visible text. */
200
+ export const button = (spec: Omit<ButtonSpec, "kind">): ButtonSpec => ({
201
+ kind: "button",
202
+ ...spec,
203
+ });
163
204
  /**
164
205
  * The options are spelled out rather than written as `Omit<MenuSpec<O>, "kind">`,
165
206
  * and that is not a style choice: TypeScript cannot infer `O` THROUGH an `Omit`,
@@ -184,6 +225,47 @@ export interface Bank<K extends string> {
184
225
  params: readonly K[];
185
226
  }
186
227
 
228
+ /**
229
+ * Which parameters render as NATIVE `live.*` objects in the device view, and how
230
+ * they are laid out.
231
+ *
232
+ * The compiler ALREADY generates a `live.dial` / `live.toggle` / `live.menu` for
233
+ * every declared parameter; they are invisible today only because they carry no
234
+ * `presentation` attribute, and Live shows the presentation view. Naming a
235
+ * parameter here makes the SAME object visible - a presentation overlay on codegen
236
+ * that already exists, with no wiring change: it is the same parameter, the same
237
+ * fan-out graph, `useParam()` still reads it, now drawn by Max instead of React.
238
+ */
239
+ export interface NativeLayout<K extends string = string> {
240
+ /**
241
+ * In display order: fills rows top-to-bottom, then overflows into the next
242
+ * column (column-major, so adding a parameter does not reshuffle the rest).
243
+ */
244
+ params: readonly K[];
245
+ /**
246
+ * Max rows per column. Default 3 - the device view is a fixed ~169 px tall and a
247
+ * `live.dial` needs a 56 px pitch, so only 3 fit vertically.
248
+ */
249
+ rows?: number;
250
+ /**
251
+ * LAYERED "two screens" instead of side-by-side. When true, `[jweb]` is built
252
+ * full-width and the dials OVERLAP its left, and the app flips between them with
253
+ * `useNativePanel` (hide one layer, show the other) - because runtime reposition
254
+ * of native objects does not work in a frozen M4L device, only hide/show does.
255
+ * When false (the default), the dials sit BESIDE a right-shifted `[jweb]`, both
256
+ * visible at once.
257
+ */
258
+ panel?: boolean;
259
+ /**
260
+ * A parameter that is the VIEW SWITCH, not a grid dial: pinned to the top-right
261
+ * (over the web UI's own switch button, so the control stays in one place across
262
+ * both views), kept out of the `params` grid, and shown in both modes. Meant for a
263
+ * toggle in a `panel` layout - the way back from the native panel, since the web
264
+ * UI is hidden there.
265
+ */
266
+ switch?: K;
267
+ }
268
+
187
269
  /**
188
270
  * The declaration. `P`, `S` and `W` are inferred from what you write - they exist so
189
271
  * that `useParam`, `useStateSync` and `useWindow` are typed against THIS surface: a
@@ -199,6 +281,8 @@ export interface SurfaceDef<
199
281
  banks?: readonly Bank<Extract<keyof P, string>>[];
200
282
  windows?: W;
201
283
  state?: S;
284
+ /** Which parameters render as native Max objects in the device view. */
285
+ layout?: { native?: NativeLayout<Extract<keyof P, string>> };
202
286
  }
203
287
 
204
288
  export interface Surface<
@@ -265,9 +349,39 @@ export function defineSurface<
265
349
  }
266
350
  }
267
351
 
352
+ // A native layout may only name parameters that exist, and may not ask for more
353
+ // rows than the device view holds. Both throw here, at build time, for the same
354
+ // reason the bank checks do: a typo would otherwise generate a cord from a box
355
+ // that never gets a presentation rect, or overflow a 169 px view silently.
356
+ const native = def.layout?.native;
357
+ if (native) {
358
+ for (const id of native.params) {
359
+ if (!def.params[id]) throw new Error(`surface: layout.native names "${id}", which is not a declared parameter`);
360
+ }
361
+ const rows = native.rows ?? 3;
362
+ if (rows < 1 || rows > 3) throw new Error(`surface: layout.native.rows must be 1..3 - the device view is 169 px tall`);
363
+ if (native.switch !== undefined && !def.params[native.switch]) {
364
+ throw new Error(`surface: layout.native.switch names "${native.switch}", which is not a declared parameter`);
365
+ }
366
+ }
367
+
268
368
  return { ...def, ids };
269
369
  }
270
370
 
371
+ /**
372
+ * Does this parameter render as a native Max object? App code uses it to stop
373
+ * drawing an HTML control the device view now owns. Cheap and honest: a parameter
374
+ * that is not in `layout.native` is still an HTML control, so `useParam()` stays
375
+ * the source of truth either way.
376
+ */
377
+ export const isNative = (surface: Surface, id: string): boolean => !!surface.layout?.native?.params.includes(id as never);
378
+
379
+ /**
380
+ * The scripting name the build gives `[jweb]` when a surface declares native
381
+ * layout, so the app can hide/show it at runtime (see `useNativePanel`).
382
+ */
383
+ export const JWEB_VARNAME = "obj-jweb";
384
+
271
385
  /** The default value of every parameter. The app's initial state, before Live replies. */
272
386
  export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P>): { [K in keyof P]: ParamValue<P[K]> } {
273
387
  const out = {} as { [K in keyof P]: ParamValue<P[K]> };
@@ -275,9 +389,78 @@ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P
275
389
  return out;
276
390
  }
277
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
+
278
461
  /** How a value is displayed - the parameter's own `format`, or a sane default. */
279
462
  export function formatValue(spec: ParamSpec, value: unknown): string {
280
- if (spec.kind === "toggle") return value ? "on" : "off";
463
+ if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
281
464
  if (spec.kind === "menu") return String(value);
282
465
  if (spec.format) return spec.format(Number(value));
283
466
  const n = Number(value);
package/src/react.tsx CHANGED
@@ -18,8 +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";
22
- import { paramStore, stateStore } from "./store";
21
+ import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
22
+ import { JWEB_VARNAME } from "./index";
23
+ import { paramStore, stateStore, watchStore } from "./store";
23
24
 
24
25
  /**
25
26
  * A two-way binding to one Live parameter. `[value, setValue]`, like useState -
@@ -67,6 +68,78 @@ export function useWindow<
67
68
  );
68
69
  }
69
70
 
71
+ /**
72
+ * Show or hide a NATIVE dial in the device view at runtime.
73
+ *
74
+ * `layout.native` makes a parameter a native `live.*` object, but its presentation
75
+ * is STATIC - the dial is always visible. This is the runtime override: the app says
76
+ * which native params should be shown, and a `[thispatcher]` runs `script show`/
77
+ * `script hide` on the object by its scripting name (`param-<id>`, see
78
+ * `applyNativeControl` in @m4l-jweb/build). The parameter itself is untouched - a
79
+ * hidden dial still automates, MIDI-maps and reaches Push; only visibility changes.
80
+ *
81
+ * Returns a stable `(id, visible) => void`. Drive it from an effect that mirrors the
82
+ * app's own "which stages are active" state, e.g. `useEffect` over the shown set.
83
+ */
84
+ export function useNativeVisibility<P extends Record<string, ParamSpec>>(
85
+ _surface: Surface<P>,
86
+ ): (id: Extract<keyof P, string>, visible: boolean) => void {
87
+ return useCallback((id: Extract<keyof P, string>, visible: boolean) => {
88
+ // The varname applySurface() gave the object is `param-<id>`. Keep this prefix
89
+ // in step with that codegen - it is the one string both sides must agree on.
90
+ outlet(visible ? "native_show" : "native_hide", `param-${id}`);
91
+ }, []);
92
+ }
93
+
94
+ /**
95
+ * Flip the device view between the WEB UI and a NATIVE control panel - the "two
96
+ * screens" model. Runtime reposition/resize of presentation objects does NOT work in
97
+ * a frozen M4L device (measured: `presentation_rect` writes are stored but never
98
+ * redrawn), but `hidden` DOES. So instead of reflowing dials, we layer them:
99
+ *
100
+ * "web" - show [jweb] (full width), hide every native dial and the switch. The
101
+ * web UI paints its own switch button; the native one would only fight
102
+ * it for the same top-right spot.
103
+ * "native" - hide [jweb], show every native dial AND the switch - which is the way
104
+ * back, since the web UI is hidden here.
105
+ *
106
+ * Only `hidden` is used, so this actually works where reflow could not, and no layer
107
+ * is ever visible at the same time as another - so z-order never matters.
108
+ */
109
+ export function useNativePanel<P extends Record<string, ParamSpec>>(surface: Surface<P>): (mode: "web" | "native") => void {
110
+ return useCallback(
111
+ (mode: "web" | "native") => {
112
+ const native = surface.layout?.native;
113
+ if (!native) return;
114
+ const web = mode === "web";
115
+ const toggle = (varname: string) => outlet(web ? "native_hide" : "native_show", varname);
116
+ outlet(web ? "native_show" : "native_hide", JWEB_VARNAME);
117
+ for (const id of native.params as readonly string[]) toggle(`param-${id}`);
118
+ if (native.switch) toggle(`param-${native.switch}`);
119
+ },
120
+ [surface],
121
+ );
122
+ }
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
+
70
143
  /**
71
144
  * A two-way binding to a JSON state slot, persisted in the Live SET.
72
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;
@@ -40,7 +40,7 @@ const EPSILON = 1e-6;
40
40
 
41
41
  /** Max stores every parameter as a NUMBER. A menu is an index into its options; a toggle is 0/1. */
42
42
  export function toWire(spec: ParamSpec, value: unknown): number {
43
- if (spec.kind === "toggle") return value ? 1 : 0;
43
+ if (spec.kind === "toggle" || spec.kind === "button") return value ? 1 : 0;
44
44
  if (spec.kind === "menu") {
45
45
  const i = spec.options.indexOf(String(value));
46
46
  return i < 0 ? 0 : i;
@@ -50,7 +50,7 @@ export function toWire(spec: ParamSpec, value: unknown): number {
50
50
 
51
51
  /** ...and back. An out-of-range menu index falls back to the default rather than `undefined`. */
52
52
  export function fromWire(spec: ParamSpec, wire: number): unknown {
53
- if (spec.kind === "toggle") return wire >= 0.5;
53
+ if (spec.kind === "toggle" || spec.kind === "button") return wire >= 0.5;
54
54
  if (spec.kind === "menu") return spec.options[Math.round(wire)] ?? spec.default;
55
55
  return wire;
56
56
  }
@@ -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
+ }