@m4l-jweb/build 0.2.0 → 0.4.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/build",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "exports": {
19
19
  ".": "./src/index.mjs",
20
20
  "./chains": "./src/chains.mjs",
21
+ "./surface": "./src/surface.mjs",
21
22
  "./amxd": "./src/amxd.mjs",
22
23
  "./init": "./src/init.mjs"
23
24
  },
@@ -30,7 +31,8 @@
30
31
  "dependencies": {
31
32
  "acorn": "^8.17.0",
32
33
  "archiver": "^7.0.1",
34
+ "esbuild": "^0.25.0",
33
35
  "typescript": "^5.7.0",
34
- "@m4l-jweb/wrapper": "0.2.0"
36
+ "@m4l-jweb/wrapper": "0.4.0"
35
37
  }
36
38
  }
package/src/chains.mjs CHANGED
@@ -52,6 +52,50 @@ export function removeLine(lines, srcId, dstId) {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * Splice a `route` into the app's message stream and hand the rest on.
57
+ *
58
+ * More than one thing routes [jweb]'s output - `midiout` claims `midinote` and
59
+ * `flush`, the Surface claims every `set_<id>`, and whatever neither wanted must
60
+ * still reach the wrapper. They cannot hang off [jweb]'s outlet in parallel: each
61
+ * would pass the unmatched messages on to [js], and the wrapper would see
62
+ * `ui_ready` once per route. So they are chained in SERIES, each feeding the next
63
+ * from its unmatched outlet:
64
+ *
65
+ * [jweb] -> [route midinote flush] -> [route set_density] -> [js]
66
+ * unmatched unmatched
67
+ *
68
+ * `ctx.appOut` names the tail of that chain - the outlet currently carrying
69
+ * everything nobody has claimed. Claim from THAT, never from `jwebId` directly,
70
+ * or you steal the messages the chain before you was passing on.
71
+ *
72
+ * Do not go looking for the cord to cut by searching for whatever feeds [js]
73
+ * either: `live.thisdevice` feeds it too, and cutting that one is invisible here
74
+ * and fatal in Live - it is the bang every LiveAPI observer is created from.
75
+ */
76
+ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
77
+ const [srcId, srcOutlet] = ctx.appOut ?? [ctx.jwebId, 0];
78
+
79
+ // Nobody has claimed the stream yet, and yet [jweb] no longer reaches the
80
+ // wrapper: a chain cut that cord by hand (the old `removeLine(jwebId,
81
+ // unmatchedId)` idiom) without saying where it put the messages. We cannot know
82
+ // - and guessing produces a patcher that WORKS while delivering every unrouted
83
+ // message twice, which is not a failure anyone would look for. Say so instead.
84
+ if (!ctx.appOut && !ctx.lines.some((l) => l.patchline.source[0] === ctx.jwebId && l.patchline.destination[0] === ctx.unmatchedId)) {
85
+ throw new Error(
86
+ `a chain on device "${ctx.device?.name}" took [jweb]'s outlet without claimAppMessages(). ` +
87
+ `Routes are chained in series, so each one must hand the next what it did not match. ` +
88
+ `Replace "removeLine(lines, jwebId, unmatchedId); lines.push(line(jwebId, 0, myRoute, 0)); ` +
89
+ `lines.push(line(myRoute, <last>, unmatchedId, 0));" with "claimAppMessages(ctx, myRoute, <last>)".`,
90
+ );
91
+ }
92
+
93
+ removeLine(ctx.lines, srcId, ctx.unmatchedId);
94
+ ctx.lines.push(line(srcId, srcOutlet, routeId, 0));
95
+ ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
96
+ ctx.appOut = [routeId, unmatchedOutlet];
97
+ }
98
+
55
99
  /**
56
100
  * "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
57
101
  *
@@ -77,10 +121,8 @@ function midiInChain({ boxes, lines, jwebId }) {
77
121
  * "midiout" - the app emits `midinote <pitch> <vel> <durMs> <chan> <delayMs>`
78
122
  * and `flush`. Compute WHEN in your app; let Max place the note precisely.
79
123
  */
80
- function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
81
- // This chain consumes jweb's output, so the template's direct jweb -> js cord
82
- // is replaced by the route's unmatched outlet.
83
- removeLine(lines, jwebId, unmatchedId);
124
+ function midiOutChain(ctx) {
125
+ const { boxes, lines } = ctx;
84
126
 
85
127
  boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
86
128
  // Explicit unpack instead of letting pipe spread the list: unpack fires
@@ -108,7 +150,11 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
108
150
  // makenote actually releases hanging notes.
109
151
  boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
110
152
 
111
- lines.push(line(jwebId, 0, "obj-route", 0));
153
+ // Take the app's messages, and pass on what is not a note (ui_ready, set_<id>,
154
+ // ...) from outlet 2 - to the Surface's route if the device has parameters, and
155
+ // to the wrapper in the end.
156
+ claimAppMessages(ctx, "obj-route", 2);
157
+
112
158
  lines.push(line("obj-route", 0, "obj-unpack", 0));
113
159
  lines.push(line("obj-route", 1, "obj-flushmsg", 0));
114
160
  lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
@@ -121,8 +167,6 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
121
167
  lines.push(line("obj-makenote", 1, "obj-packnote", 1));
122
168
  lines.push(line("obj-packnote", 0, "obj-fmt", 0));
123
169
  lines.push(line("obj-fmt", 0, "obj-midiout", 0));
124
- // Unmatched selectors (ui_ready, write_clip, read_notes...) carry on.
125
- lines.push(line("obj-route", 2, unmatchedId, 0));
126
170
  }
127
171
 
128
172
  /**
@@ -154,22 +198,20 @@ function passthroughChain({ boxes, lines }) {
154
198
  * SIGNAL domain, not just the app.
155
199
  *
156
200
  * Note what does NOT happen here: the value does not travel through [jweb] and
157
- * back. The dial is wired straight into the `*~` right inlet, in the patcher, so
158
- * the audio path does not depend on the browser being alive or keeping up. The
159
- * app gets its own copy of the value (via addParameters) purely to DISPLAY it.
160
- * Audio is Max's job; the UI is a view of it.
201
+ * back. The parameter is wired straight into the `*~` right inlet, in the patcher,
202
+ * so the audio path does not depend on the browser being alive or keeping up. The
203
+ * app gets its own copy of the value purely to DISPLAY it. Audio is Max's job; the
204
+ * UI is a view of it.
161
205
  *
162
- * Requires a parameter named `gain` (or pass `device.gainParam`).
206
+ * Requires a parameter named `gain` in the device's surface.ts (or pass
207
+ * `device.gainParam`).
163
208
  */
164
- function gainChain({ boxes, lines, device }) {
209
+ function gainChain(ctx) {
210
+ const { boxes, lines, device } = ctx;
165
211
  removeBox(boxes, lines, "obj-midiin");
166
212
  removeBox(boxes, lines, "obj-midiout");
167
213
 
168
- const paramId = device?.gainParam ?? "gain";
169
- const declared = (device?.parameters ?? []).some((p) => p.id === paramId);
170
- if (!declared) {
171
- throw new Error(`chain "gain" on device "${device?.name}" needs a parameter with id "${paramId}" (or set gainParam)`);
172
- }
214
+ const paramId = requireParam(ctx, "gain", device?.gainParam ?? "gain", "gainParam");
173
215
 
174
216
  boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
175
217
  boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
@@ -183,83 +225,43 @@ function gainChain({ boxes, lines, device }) {
183
225
  boxes.push(box(id, "*~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
184
226
  lines.push(line("obj-plugin", i, id, 0));
185
227
  lines.push(line(id, 0, "obj-plugout", i));
186
- // The live.dial box is created LATER, by addParameters(). A patchline may name
187
- // a box that appears further down the boxes array - the patcher is a graph, not
188
- // a script - so this cord is valid as long as the parameter is declared, which
189
- // is what the check above guarantees.
190
- lines.push(line(`obj-param-${paramId}`, 0, id, 1));
228
+ fanParamInto(ctx, paramId, id, 1);
191
229
  }
192
230
  }
193
231
 
194
232
  /**
195
- * Let the APP write to a Live parameter: `set_<id> <value>` from [jweb] lands on
196
- * the live.* object's inlet, and from there in the signal path and in Live's
197
- * automation, exactly as if the user had turned the dial.
198
- *
199
- * This is the missing half of the parameter story. Reading one has always worked
200
- * (addParameters wires the object out to the app). Writing one did not exist, so
201
- * a control in the web UI could only ever be a readout of a knob you had to turn
202
- * somewhere else - useless.
203
- *
204
- * TWO TRAPS, and the whole design of this helper is about them:
205
- *
206
- * 1. FEEDBACK. Sending a bare value into a live.dial's inlet SETS IT AND MAKES
207
- * IT OUTPUT, which sends it straight back to the app - which could set it
208
- * again. `set <value>` is the documented message that updates the value
209
- * WITHOUT producing outlet output, so the loop never starts. (Spike 1.1 in
210
- * doc/SPIKES.md confirms the behaviour properly; the field evidence is
211
- * below.)
212
- *
213
- * 2. `set` SUPPRESSING THE OUTPUT IS NOT FREE. It silences the dial for
214
- * EVERYONE, not just for the app - including whatever the dial drives inside
215
- * the patcher. The first version of the lowpass chain fed its filter from the
216
- * dial's OUTLET, so writing the parameter with `set` moved the dial and told
217
- * the filter nothing: the slider appeared dead and the cutoff never budged.
233
+ * Wire a parameter into the thing it controls, from BOTH of its sources:
218
234
  *
219
- * So the value is FANNED OUT, not chained. The route outlet is the source of
220
- * truth for the app's write, and the caller taps it directly (see
221
- * `valueOutlet` below) to drive whatever the parameter controls. The dial is
222
- * updated in parallel, so Live's automation, MIDI mapping and Push all stay
223
- * correct - but nothing downstream *depends* on the dial re-emitting.
235
+ * the OBJECT's outlet - a knob turn, an automation lane, a Push encoder.
236
+ * the ROUTE's outlet - the value the app wrote.
224
237
  *
225
- * The dial's own outlet still feeds the same destination, because that is the
226
- * path a real knob-turn, an automation lane or a Push encoder travels.
238
+ * The second is not redundant, and leaving it out is the bug this helper exists to
239
+ * make unrepeatable. The app's write reaches the object as `set <value>`, which
240
+ * updates it WITHOUT producing output - so the object never passes the app's value
241
+ * on, and whatever it drives sits where it was while the UI's slider appears dead.
242
+ * It did exactly that. See packages/build/src/surface.mjs.
227
243
  *
228
- * Returns `{ valueOutlet(id) }`: the [route] outlet carrying the raw value the
229
- * app wrote, for the chain to wire wherever the parameter actually acts.
230
- *
231
- * This is a hand-rolled sliver of what the Surface will generate for every
232
- * parameter at once (Stage 2 of doc/TODO.md). It is here because "a slider in
233
- * the device window that actually does something" should not have to wait.
244
+ * The boxes named here are created LATER, by applySurface(). A patchline may name a
245
+ * box further down the array: a patcher is a graph, not a script.
234
246
  */
235
- function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
236
- removeLine(lines, jwebId, unmatchedId);
237
-
238
- const selectors = ids.map((id) => `set_${id}`);
239
- boxes.push(
240
- box("obj-setparam-route", `route ${selectors.join(" ")}`, {
241
- numoutlets: ids.length + 1,
242
- outlettype: ids.map(() => "").concat(""),
243
- }),
244
- );
245
- lines.push(line(jwebId, 0, "obj-setparam-route", 0));
246
-
247
- ids.forEach((id, i) => {
248
- // `route` STRIPS the selector, so what emerges is the bare value. Re-wrap it
249
- // as `set <value>` - the set-without-output message - and feed the object, so
250
- // the dial, the automation lane and Push all follow the app's slider.
251
- boxes.push(box(`obj-set-${id}`, "prepend set"));
252
- lines.push(line("obj-setparam-route", i, `obj-set-${id}`, 0));
253
- lines.push(line(`obj-set-${id}`, 0, `obj-param-${id}`, 0));
254
- });
255
-
256
- // Unmatched (ui_ready, and anything the wrapper handles) carries on.
257
- lines.push(line("obj-setparam-route", ids.length, unmatchedId, 0));
247
+ function fanParamInto(ctx, paramId, dstId, dstInlet) {
248
+ const [objId, objOut] = ctx.paramObject(paramId);
249
+ const [routeId, routeOut] = ctx.paramValue(paramId);
250
+ ctx.lines.push(line(objId, objOut, dstId, dstInlet));
251
+ ctx.lines.push(line(routeId, routeOut, dstId, dstInlet));
252
+ }
258
253
 
259
- return {
260
- /** The outlet carrying the value the APP wrote. Wire it where the parameter acts. */
261
- valueOutlet: (id) => ["obj-setparam-route", ids.indexOf(id)],
262
- };
254
+ /** A chain that drives DSP from a parameter is broken without it - say so loudly. */
255
+ function requireParam(ctx, chainName, paramId, overrideField) {
256
+ if (!ctx.surface?.params?.[paramId]) {
257
+ const declared = ctx.surface ? ctx.surface.ids.join(", ") || "none" : "no surface.ts at all";
258
+ throw new Error(
259
+ `chain "${chainName}" on device "${ctx.device?.name}" needs a parameter "${paramId}" in ` +
260
+ `src/app/${ctx.device?.ui ?? ctx.device?.name}/surface.ts (declared: ${declared}). ` +
261
+ `Rename it, or point the chain at another one with \`${overrideField}\`.`,
262
+ );
263
+ }
264
+ return paramId;
263
265
  }
264
266
 
265
267
  /**
@@ -279,53 +281,26 @@ function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
279
281
  * effect is unmistakable when you sweep it, and there is no way to configure it
280
282
  * into silence or into a scream. Swap in `svf~` when you want a real filter.
281
283
  *
282
- * THE CUTOFF MAPPING is the interesting part. The dial is 0-1 (a Live parameter
283
- * wants a bounded, automatable range), but pitch is logarithmic: a linear sweep
284
- * from 20 Hz to 18 kHz spends almost all its travel in the top octave, where you
285
- * can hear nothing happening, and races through the bottom, where everything
286
- * happens. So the value goes through [expr] first:
284
+ * THE CUTOFF IS IN HERTZ, and no arithmetic happens here. The chain used to take a
285
+ * 0-1 parameter and map it through `[expr 40. * pow(450., $f1)]`, because pitch is
286
+ * logarithmic and a linear knob is useless on a filter. That mapping now lives on
287
+ * the PARAMETER (`range: [40, 18000]`, `unit: "Hz"`, `exponent`), which is where
288
+ * Live wants it: the automation lane reads Hz, Push reads "7.3 kHz", the app reads
289
+ * Hz, and the value drops straight into onepole~. A normalised parameter with the
290
+ * curve hidden in a chain lies to every one of those readouts.
287
291
  *
288
- * 40 * 450^x x = 0 -> 40 Hz, x = 0.5 -> ~850 Hz, x = 1 -> 18 kHz
289
- *
290
- * which spreads the audible action evenly across the knob. This is exactly the
291
- * curve a filter knob on real hardware has.
292
- *
293
- * Requires a parameter named `cutoff` (or pass `device.cutoffParam`).
292
+ * Requires a parameter named `cutoff` (or pass `device.cutoffParam`), in Hz.
294
293
  */
295
294
  function lowpassChain(ctx) {
296
295
  const { boxes, lines, device } = ctx;
297
296
  removeBox(boxes, lines, "obj-midiin");
298
297
  removeBox(boxes, lines, "obj-midiout");
299
298
 
300
- const paramId = device?.cutoffParam ?? "cutoff";
301
- const declared = (device?.parameters ?? []).some((p) => p.id === paramId);
302
- if (!declared) {
303
- throw new Error(`chain "lowpass" on device "${device?.name}" needs a parameter with id "${paramId}" (or set cutoffParam)`);
304
- }
305
-
306
- // The slider in the device window writes the parameter: `set_cutoff <0-1>`.
307
- const { valueOutlet } = writableParams(ctx, [paramId]);
308
- const [routeId, routeOut] = valueOutlet(paramId);
299
+ const paramId = requireParam(ctx, "lowpass", device?.cutoffParam ?? "cutoff", "cutoffParam");
309
300
 
310
301
  boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
311
302
  boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
312
303
 
313
- // 0-1 -> 40..18000 Hz, logarithmically. Floats, not ints: `40.` and `450.` keep
314
- // expr in float mode, and an int cutoff would quantise the sweep into steps.
315
- boxes.push(box("obj-cutoff-hz", "expr 40. * pow(450., $f1)", { numinlets: 1, numoutlets: 1, outlettype: [""] }));
316
-
317
- // TWO sources feed the filter, and it needs both:
318
- //
319
- // the DIAL's outlet - a knob turn, an automation lane, a Push encoder.
320
- // the ROUTE's outlet - the app's slider.
321
- //
322
- // The second is not redundant. The app writes the dial with `set`, which
323
- // updates it WITHOUT producing output - so the dial would never pass the app's
324
- // value on, and the filter would sit wherever it was while the slider appeared
325
- // to do nothing. (It did exactly that.) Tap the value where it enters.
326
- lines.push(line(`obj-param-${paramId}`, 0, "obj-cutoff-hz", 0));
327
- lines.push(line(routeId, routeOut, "obj-cutoff-hz", 0));
328
-
329
304
  // One filter per channel: a signal object handles ONE signal, and plugin~ hands
330
305
  // us a stereo pair. Both take the same cutoff, so the image does not shift.
331
306
  for (const [i, id] of [
@@ -335,10 +310,10 @@ function lowpassChain(ctx) {
335
310
  boxes.push(box(id, "onepole~ 18000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
336
311
  lines.push(line("obj-plugin", i, id, 0));
337
312
  lines.push(line(id, 0, "obj-plugout", i));
338
- // Cutoff into the RIGHT inlet. The dial box itself is created LATER, by
339
- // addParameters() - a patchline may name a box further down the array,
340
- // because a patcher is a graph, not a script.
341
- lines.push(line("obj-cutoff-hz", 0, id, 1));
313
+ // The cutoff, in Hz, into the RIGHT inlet - from both of its sources: the dial
314
+ // (a knob turn, an automation lane, a Push encoder) and the route (the app's
315
+ // write, which the dial will not re-emit because it arrives as `set`).
316
+ fanParamInto(ctx, paramId, id, 1);
342
317
  }
343
318
  }
344
319
 
@@ -356,48 +331,12 @@ export function registerChain(name, fn) {
356
331
  }
357
332
 
358
333
  /**
359
- * Real Live parameters: automatable, MIDI-mappable, and the ONLY thing Push can
360
- * display. Each becomes a live.* object wired into the UI as `<id> <value>`, so
361
- * a parameter change is just another inlet message to your app.
334
+ * Parameters used to be declared in the manifest and generated here, by
335
+ * `addParameters()`, in ONE direction: object -> app. Writing one back was a
336
+ * per-chain hand-roll (`writableParams()`).
362
337
  *
363
- * `default` is not optional in practice, whatever the type says. A live.* object
364
- * with no initial value loads at the BOTTOM of its range, and for a great many
365
- * parameters the bottom of the range is a broken device: a filter cutoff of 0
366
- * loads as a device that eats the signal, and it looks exactly like a bug in your
367
- * DSP. Declare `default` and Max stores it as the object's initial value, which
368
- * Live restores on load and on "reset to default".
338
+ * Both are gone. A device's parameters are declared in `src/app/<ui>/surface.ts`
339
+ * and compiled by `applySurface()` in surface.mjs - objects, both directions, and
340
+ * the fan-out that the `set` behaviour forces. A chain reaches them through
341
+ * `fanParamInto()` above.
369
342
  */
370
- export function addParameters(boxes, lines, params, dstId) {
371
- let x = 480;
372
- for (const p of params) {
373
- const objId = `obj-param-${p.id}`;
374
- const prependId = `obj-prepend-${p.id}`;
375
- boxes.push({
376
- box: {
377
- id: objId,
378
- maxclass: p.object, // live.dial | live.toggle | live.menu
379
- numinlets: 1,
380
- numoutlets: 1,
381
- outlettype: [""],
382
- parameter_enable: 1,
383
- patching_rect: [x, 300, 44, 48],
384
- saved_attribute_attributes: {
385
- valueof: {
386
- parameter_longname: p.id,
387
- parameter_shortname: p.id.slice(0, 8), // Push shows short names
388
- parameter_type: p.object === "live.toggle" ? 2 : 0, // 2 = enum, 0 = float
389
- ...(p.range ? { parameter_range: p.range } : {}),
390
- // parameter_initial is a LIST, and it is inert without
391
- // parameter_initial_enable - setting one without the other silently
392
- // does nothing, which is the worst way for this to fail.
393
- ...(p.default !== undefined ? { parameter_initial_enable: 1, parameter_initial: [p.default] } : {}),
394
- },
395
- },
396
- },
397
- });
398
- boxes.push(box(prependId, `prepend ${p.id}`));
399
- lines.push(line(objId, 0, prependId, 0));
400
- lines.push(line(prependId, 0, dstId, 0));
401
- x += 56;
402
- }
403
- }
package/src/index.mjs CHANGED
@@ -16,7 +16,8 @@ import path from "node:path";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
18
  import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
- import { CHAINS, addParameters, resetLayout } from "./chains.mjs";
19
+ import { CHAINS, resetLayout } from "./chains.mjs";
20
+ import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
20
21
 
21
22
  const require = createRequire(import.meta.url);
22
23
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -164,17 +165,46 @@ export async function generatePatchers(root) {
164
165
 
165
166
  const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
166
167
 
168
+ /**
169
+ * The device's parameters, declared once in src/app/<ui>/surface.ts. A chain
170
+ * that drives DSP from a parameter needs two things from it, and needs BOTH:
171
+ *
172
+ * paramObject(id) the live.* object's outlet - a knob turn, an automation
173
+ * lane, a Push encoder.
174
+ * paramValue(id) the route outlet carrying what the APP wrote. Not
175
+ * redundant: the app's write reaches the object as `set`,
176
+ * which updates it WITHOUT output, so the object would
177
+ * never pass that value on. See surface.mjs.
178
+ */
179
+ // The manifest carried `parameters` until 0.4.0. It is now declared in
180
+ // src/app/<ui>/surface.ts and generated from there - so a leftover field is not
181
+ // a harmless extra key, it is a device whose parameters have SILENTLY
182
+ // disappeared. Fail the build and say where they went.
183
+ if (d.parameters) {
184
+ throw new Error(
185
+ `device "${d.name}" still declares \`parameters\` in patcher/devices.mjs. ` +
186
+ `That field is gone: declare them in src/app/${d.ui ?? d.name}/surface.ts with defineSurface(), ` +
187
+ `which generates the live.* objects, both wiring directions and the protocol selectors. ` +
188
+ `See doc/SURFACE.md.`,
189
+ );
190
+ }
191
+
192
+ const surface = await loadSurface(root, d.ui ?? d.name);
193
+ const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
194
+
167
195
  for (const name of d.chains ?? []) {
168
196
  const chain = CHAINS[name];
169
197
  if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
170
- chain({ boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d });
198
+ chain(ctx);
171
199
  }
172
200
 
173
- // Parameters feed the UI: a knob move arrives as just another inlet message.
174
- addParameters(boxes, lines, d.parameters ?? [], "obj-jweb");
201
+ // AFTER the chains: the Surface routes every `set_<id>` off the app's message
202
+ // stream, and passes on what nobody claimed (ui_ready, ...) to the wrapper.
203
+ applySurface(ctx);
175
204
 
176
205
  writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
177
- console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"})`);
206
+ const params = surface ? surface.ids.join(", ") : "none";
207
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
178
208
  }
179
209
  return devices;
180
210
  }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * surface.mjs - the Surface compiler.
3
+ *
4
+ * One declaration (`src/app/<device>/surface.ts`) becomes the whole Max side of a
5
+ * parameter: the `live.*` object, its wiring in BOTH directions, and the protocol
6
+ * selectors the lint then checks for free. It replaces `addParameters()` (which
7
+ * only ever did the read direction) and `writableParams()` (which did the write
8
+ * direction, by hand, for one parameter at a time).
9
+ *
10
+ * ------------------------------------------------------------------------------
11
+ * THE TRAP THIS FILE EXISTS TO NOT REPRODUCE
12
+ *
13
+ * The app writes a parameter by sending `set_<id> <value>`, and the patcher feeds
14
+ * the object a `set <value>` message. `set` updates the object WITHOUT making it
15
+ * output - which is what stops the app feeding itself back in a loop.
16
+ *
17
+ * But `set` does not suppress the outlet for the app only. It suppresses it for
18
+ * EVERYONE, including whatever that object drives inside the patcher. The first
19
+ * `lowpass` chain fed its filter from the dial's outlet, and the app wrote the
20
+ * dial with `set`: the dial moved, and the filter never heard a thing. The slider
21
+ * looked dead.
22
+ *
23
+ * So a parameter's value is FANNED OUT, never chained:
24
+ *
25
+ * [jweb] --set_cutoff--> [route] --+--> [prepend set] --> [live.dial] --+
26
+ * | |
27
+ * +-------------> the DSP <------------+
28
+ * (or whatever it drives)
29
+ *
30
+ * The object is updated in parallel, so automation, MIDI mapping and Push all stay
31
+ * correct - but nothing downstream DEPENDS on it re-emitting. The object's own
32
+ * outlet still reaches the same destination, because that is the path a knob turn,
33
+ * an automation lane or a Push encoder travels.
34
+ *
35
+ * `paramValue()` below is the route outlet a chain taps for the app's write;
36
+ * `paramObject()` is the object's own outlet. A chain that drives DSP from a
37
+ * parameter must wire BOTH. `tests/surface.test.mjs` asserts it.
38
+ * ------------------------------------------------------------------------------
39
+ */
40
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
41
+ import { tmpdir } from "node:os";
42
+ import path from "node:path";
43
+ import { pathToFileURL } from "node:url";
44
+
45
+ import { box, claimAppMessages, line } from "./chains.mjs";
46
+
47
+ /** The one route that dispatches every `set_<id>` the app sends. */
48
+ export const SURFACE_ROUTE = "obj-surface-route";
49
+
50
+ /** The `live.*` object for a parameter. Its outlet is a knob turn / automation. */
51
+ export const paramObject = (id) => `obj-param-${id}`;
52
+
53
+ /**
54
+ * The route outlet carrying the value the APP wrote - the fan-out tap.
55
+ *
56
+ * Deterministic from the declaration, so a chain can wire it before the route box
57
+ * exists: a patcher is a graph, not a script, and a cord may name a box that
58
+ * appears later in the array.
59
+ */
60
+ export const paramValue = (surface, id) => [SURFACE_ROUTE, surface.ids.indexOf(id)];
61
+
62
+ /**
63
+ * What a chain is handed to reach the parameters: `surface` (to check a parameter
64
+ * it needs exists) and the two outlets it must fan a value out of. Spread into the
65
+ * chain context by the build - and by the codegen test, so the test drives the
66
+ * chains through the same seam the build does.
67
+ */
68
+ export function surfaceContext(surface) {
69
+ return {
70
+ surface,
71
+ paramObject: (id) => [paramObject(id), 0],
72
+ paramValue: (id) => paramValue(surface, id),
73
+ };
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ *
77
+ * Reading the declaration
78
+ * ------------------------------------------------------------------ */
79
+
80
+ /**
81
+ * Evaluate `src/app/<ui>/surface.ts` in Node.
82
+ *
83
+ * It is TypeScript, and it imports @m4l-jweb/surface, whose entry point is also
84
+ * TypeScript - so it has to be bundled before it can be imported. esbuild does
85
+ * that in milliseconds. `defineSurface()` returns plain serializable data, so
86
+ * nothing exotic crosses the boundary.
87
+ *
88
+ * `format` is the exception: it is a FUNCTION, and functions do not serialize
89
+ * into a patcher. It survives the import (this is a real module, not JSON) and is
90
+ * used app-side only - by the dev harness and the Push preview. Do not try to
91
+ * ship it into [js].
92
+ */
93
+ export async function loadSurface(root, uiDir) {
94
+ const src = path.join(root, "src", "app", uiDir, "surface.ts");
95
+ if (!existsSync(src)) return null;
96
+
97
+ const { build } = await import("esbuild");
98
+ const tmp = mkdtempSync(path.join(tmpdir(), "m4l-surface-"));
99
+ const out = path.join(tmp, "surface.mjs");
100
+ try {
101
+ await build({
102
+ entryPoints: [src],
103
+ outfile: out,
104
+ bundle: true,
105
+ format: "esm",
106
+ platform: "node",
107
+ logLevel: "silent",
108
+ // React is not imported by a surface declaration, and bundling it here would
109
+ // be both slow and pointless.
110
+ external: ["react", "react-dom"],
111
+ });
112
+ const mod = await import(pathToFileURL(out).href);
113
+ const surface = mod.default;
114
+ if (!surface?.ids) {
115
+ throw new Error(`${src} must \`export default defineSurface({...})\``);
116
+ }
117
+ return surface;
118
+ } finally {
119
+ rmSync(tmp, { recursive: true, force: true });
120
+ }
121
+ }
122
+
123
+ /* ------------------------------------------------------------------ *
124
+ * Generating the objects
125
+ * ------------------------------------------------------------------ */
126
+
127
+ const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" };
128
+
129
+ /**
130
+ * Max's `parameter_type`: 0 = float, 1 = int, 2 = enum.
131
+ *
132
+ * A dial with `step: 1` is an INTEGER parameter - which matters to Live, not just
133
+ * to us: an int parameter quantises automation and shows whole numbers on Push,
134
+ * where a float one would read "2.4 of [off 1/4 1/8 ...]".
135
+ */
136
+ function parameterType(spec) {
137
+ if (spec.kind === "menu" || spec.kind === "toggle") return 2;
138
+ return spec.step === 1 ? 1 : 0;
139
+ }
140
+
141
+ /**
142
+ * `parameter_unitstyle` - HOW LIVE PRINTS THE VALUE, and the reason a perfectly
143
+ * good float parameter can read "0" and "1" on a Push.
144
+ *
145
+ * The unit style is a display concern with no effect on the value, which is
146
+ * exactly what makes it easy to get wrong and hard to notice: the dial sweeps
147
+ * continuously, the DSP hears every intermediate value, and Push rounds the
148
+ * readout to an integer because THAT is what unit style 0 means. Declare the unit
149
+ * and the same knob reads "7.3 kHz".
150
+ *
151
+ * The order below is the order the unit styles are listed in Max's own reference
152
+ * (docs/refpages/m4l-ref/parameters.maxref.xml), and 3 = Hertz is confirmed
153
+ * against the factory devices that ship with Live: every parameter named
154
+ * "Frequency" / "Master Freq" carries `parameter_unitstyle: 3`.
155
+ */
156
+ const UNITSTYLE = {
157
+ int: 0,
158
+ float: 1,
159
+ ms: 2,
160
+ Hz: 3,
161
+ dB: 4,
162
+ "%": 5,
163
+ pan: 6,
164
+ st: 7,
165
+ midi: 8,
166
+ // 9 = Custom (takes parameter_units), 10 = Native.
167
+ };
168
+ const UNITSTYLE_CUSTOM = 9;
169
+
170
+ /**
171
+ * A dial's unit. No `unit` means "just a number": integer if the parameter is an
172
+ * integer, float otherwise - because the default, 0, prints a float as a rounded
173
+ * integer.
174
+ */
175
+ function unitAttrs(spec) {
176
+ if (!spec.unit) return { parameter_unitstyle: spec.step === 1 ? UNITSTYLE.int : UNITSTYLE.float };
177
+ const known = UNITSTYLE[spec.unit];
178
+ if (known !== undefined) return { parameter_unitstyle: known };
179
+ // Anything else is a custom unit: Live prints the number and appends the string
180
+ // (or honours a sprintf pattern, e.g. "%0.2f Bogons").
181
+ return { parameter_unitstyle: UNITSTYLE_CUSTOM, parameter_units: spec.unit };
182
+ }
183
+
184
+ /** The parameter's value as MAX stores it: numbers, always. */
185
+ function initialValue(spec) {
186
+ if (spec.kind === "toggle") return spec.default ? 1 : 0;
187
+ if (spec.kind === "menu") return spec.options.indexOf(spec.default);
188
+ return spec.default;
189
+ }
190
+
191
+ /**
192
+ * The `saved_attribute_attributes.valueof` block, in the shape Max itself writes.
193
+ *
194
+ * THE RANGE IS `parameter_mmin` / `parameter_mmax`, NOT `parameter_range`. This
195
+ * cost a device: we emitted `parameter_range: [0, 1]` for a long time, and it is
196
+ * not a key Max uses for a continuous parameter - so the range was whatever the
197
+ * object defaulted to, silently. `parameter_range` appears in exactly zero of the
198
+ * patchers Ableton ships. An enum's options are `parameter_enum`, with
199
+ * `parameter_mmax` holding the highest index.
200
+ */
201
+ function parameterAttrs(id, spec) {
202
+ const attrs = {
203
+ parameter_longname: id,
204
+ parameter_shortname: spec.short,
205
+ parameter_type: parameterType(spec),
206
+ // `parameter_initial` is a LIST, and it is INERT without
207
+ // parameter_initial_enable - setting one without the other silently does
208
+ // nothing, which is the worst way for this to fail. A live.* object with no
209
+ // initial value loads at the BOTTOM of its range, and for a filter cutoff the
210
+ // bottom of the range is a device that eats the signal on load.
211
+ parameter_initial_enable: 1,
212
+ parameter_initial: [initialValue(spec)],
213
+ };
214
+
215
+ if (spec.kind === "dial") {
216
+ const [min, max] = spec.range;
217
+ attrs.parameter_mmin = min;
218
+ attrs.parameter_mmax = max;
219
+ Object.assign(attrs, unitAttrs(spec));
220
+ // `parameter_exponent` bends the knob's travel: > 1 gives the bottom of the
221
+ // range more of the sweep, which is what a frequency or a time wants, because
222
+ // hearing is logarithmic and a linear sweep spends its travel where nothing
223
+ // happens. The VALUE is unaffected - only how the dial's rotation maps onto it.
224
+ if (spec.exponent !== undefined && spec.exponent !== 1) attrs.parameter_exponent = spec.exponent;
225
+ // `parameter_steps` quantises a continuous range into N settings.
226
+ if (spec.steps !== undefined) attrs.parameter_steps = spec.steps;
227
+ }
228
+
229
+ if (spec.kind === "toggle") {
230
+ attrs.parameter_mmax = 1;
231
+ attrs.parameter_enum = ["off", "on"];
232
+ }
233
+
234
+ if (spec.kind === "menu") {
235
+ attrs.parameter_enum = [...spec.options];
236
+ attrs.parameter_mmax = spec.options.length - 1;
237
+ }
238
+
239
+ return attrs;
240
+ }
241
+
242
+ /**
243
+ * Compile the Surface into the patcher.
244
+ *
245
+ * Runs AFTER the chains, and claims what they did not want: the app's `set_<id>`
246
+ * messages are picked off the stream, and everything else carries on to the
247
+ * wrapper. Doing it last means no chain has to know the Surface exists.
248
+ */
249
+ export function applySurface(ctx) {
250
+ const { boxes, lines, surface, jwebId } = ctx;
251
+ if (!surface || surface.ids.length === 0) return;
252
+
253
+ let x = 480;
254
+ for (const id of surface.ids) {
255
+ const spec = surface.params[id];
256
+ boxes.push({
257
+ box: {
258
+ id: paramObject(id),
259
+ maxclass: MAXCLASS[spec.kind],
260
+ numinlets: 1,
261
+ numoutlets: 1,
262
+ outlettype: [""],
263
+ parameter_enable: 1,
264
+ patching_rect: [x, 300, 44, 48],
265
+ saved_attribute_attributes: { valueof: parameterAttrs(id, spec) },
266
+ },
267
+ });
268
+ // Read direction: a knob turn reaches the app as `<id> <value>`. A parameter
269
+ // is just another inlet message.
270
+ boxes.push(box(`obj-prepend-${id}`, `prepend ${id}`));
271
+ lines.push(line(paramObject(id), 0, `obj-prepend-${id}`, 0));
272
+ lines.push(line(`obj-prepend-${id}`, 0, jwebId, 0));
273
+ x += 56;
274
+ }
275
+
276
+ // Write direction: one route for every `set_<id>` the app can send. It goes at
277
+ // the END of the chain of routes (see claimAppMessages), so a chain that already
278
+ // took [jweb]'s outlet keeps it and hands us what it did not match.
279
+ const selectors = surface.ids.map((id) => `set_${id}`);
280
+ boxes.push(
281
+ box(SURFACE_ROUTE, `route ${selectors.join(" ")}`, {
282
+ numoutlets: surface.ids.length + 1,
283
+ outlettype: surface.ids.map(() => "").concat(""),
284
+ }),
285
+ );
286
+ claimAppMessages(ctx, SURFACE_ROUTE, surface.ids.length);
287
+
288
+ surface.ids.forEach((id, i) => {
289
+ // `route` STRIPS the selector, so what emerges is the bare value. Re-wrap it as
290
+ // `set <value>` - the set-WITHOUT-output message - so the object, the automation
291
+ // lane and Push all follow the app's control without echoing back at it.
292
+ boxes.push(box(`obj-set-${id}`, "prepend set"));
293
+ lines.push(line(SURFACE_ROUTE, i, `obj-set-${id}`, 0));
294
+ lines.push(line(`obj-set-${id}`, 0, paramObject(id), 0));
295
+ });
296
+ }
@@ -19,7 +19,7 @@ track.
19
19
  | `src/app/{{name}}/App.tsx` | The UI, and the device's logic. A React app. |
20
20
  | `src/app/{{name}}/protocol.ts` | Every selector crossing the bridge. Both sides read it. |
21
21
  | `src/app/{{name}}/surface.ts` | The Live parameters (automatable, MIDI-mappable, visible to Push). |
22
- | `patcher/devices.mjs` | The manifest: name, type, chains, parameters. The patcher is generated from it. |
22
+ | `patcher/devices.mjs` | The manifest: name, type, chains. The patcher is generated from it. |
23
23
 
24
24
  `src/app/shared/` and `scripts/` are infrastructure. You should rarely need to
25
25
  touch them.
@@ -17,13 +17,13 @@
17
17
  "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"scripts/*.mjs\" \"patcher/*.mjs\""
18
18
  },
19
19
  "dependencies": {
20
- "@m4l-jweb/bridge": "^0.2.0",
21
- "@m4l-jweb/surface": "^0.2.0",
20
+ "@m4l-jweb/bridge": "^0.4.0",
21
+ "@m4l-jweb/surface": "^0.4.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^0.2.0",
26
+ "@m4l-jweb/build": "^0.4.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",
@@ -19,15 +19,14 @@
19
19
  * parameter. An audio effect you can hear.
20
20
  * "gain" plugin~ -> *~ -> plugout~, with a `gain` parameter.
21
21
  * "passthrough" plugin~ -> plugout~. Does nothing to the audio.
22
- * parameters real Live parameters: automatable, MIDI-mappable, and what Push
23
- * reads. Each becomes a live.* object, and reaches the app as
24
- * `<id> <value>`.
25
- *
26
- * Set `default`. Without it the object loads at the BOTTOM of its
27
- * range, which for many parameters is a broken device.
28
22
  * unmatchedTo where messages the chains did not consume go. "js" sends them to
29
23
  * the wrapper (ui_ready, ...).
30
24
  *
25
+ * Parameters are NOT here: they are declared in src/app/<ui>/surface.ts, and the
26
+ * build generates the live.* objects and their wiring from that one declaration.
27
+ * A chain that names a parameter (`lowpass` wants `cutoff`) fails the build if the
28
+ * surface does not declare it.
29
+ *
31
30
  * Add a second device by adding an entry here and a folder at src/app/<name>/.
32
31
  */
33
32
  export default [
@@ -35,7 +34,6 @@ export default [
35
34
  name: "{{name}}",
36
35
  type: "midi",
37
36
  chains: ["midiin", "midiout"],
38
- parameters: [{ id: "density", object: "live.dial", range: [0, 1], default: 0.5 }],
39
37
  unmatchedTo: "js",
40
38
  },
41
39
  ];
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * shared/Frame.tsx - the chrome every device wears: a title, a where-am-I badge,
3
- * and a footer carrying the two build stamps.
3
+ * and the two build stamps.
4
4
  *
5
- * The footer is not decoration. Live embeds a COPY of a device into the set, so
5
+ * The stamps are not decoration. Live embeds a COPY of a device into the set, so
6
6
  * reinstalling does not update instances already on tracks - and a stale device
7
7
  * behaves like a bug in code you have already fixed. The stamps make that
8
8
  * visible instead of mysterious.
9
+ *
10
+ * They live in the HEADER, top right, and that placement is load-bearing: the
11
+ * device view is a fixed ~169 px and overgrown UI clips silently at the BOTTOM.
12
+ * A stamp in a footer is a staleness check that disappears exactly when the
13
+ * device has grown enough to be worth checking. Anchored to the header, it
14
+ * survives whatever the device does below it.
9
15
  */
10
16
  import type { ReactNode } from "react";
11
17
  import { inJweb } from "@m4l-jweb/bridge";
@@ -19,15 +25,19 @@ export function Frame({ title, device, children }: { title: string; device: Devi
19
25
  <header>
20
26
  <h1>{title}</h1>
21
27
  <span className={`badge ${inJweb ? "live" : "dev"}`}>{inJweb ? "in Max" : "browser dev"}</span>
28
+ <span className="stamp" title={`ui ${__APP_VERSION__} / wrapper ${device.build ?? "-"}`}>
29
+ {device.stale ? (
30
+ <span className="warn">stale - delete and re-drag the device</span>
31
+ ) : (
32
+ <>
33
+ <span>ui {__APP_VERSION__}</span>
34
+ <span>wrapper {device.build ?? "-"}</span>
35
+ </>
36
+ )}
37
+ </span>
22
38
  </header>
23
39
 
24
40
  <dl>{children}</dl>
25
-
26
- <footer>
27
- <span>ui {__APP_VERSION__}</span>
28
- <span>wrapper {device.build ?? "-"}</span>
29
- {device.stale && <span className="warn">stale install - delete and re-drag the device</span>}
30
- </footer>
31
41
  </main>
32
42
  );
33
43
  }
@@ -4,10 +4,10 @@
4
4
  * Push reads Live parameters, not your UI, so anything musically meaningful has
5
5
  * to exist here as well as in the app.
6
6
  *
7
- * Not wired up yet: this declaration is typechecked and validated, but the
8
- * codegen that turns it into live.* objects is not built. For now the parameters
9
- * Live sees come from `parameters` in patcher/devices.mjs, and the two have to
10
- * be kept in step by hand.
7
+ * This is the ONLY place they are declared. The build imports this file and
8
+ * generates the live.* objects from it, wired in both directions: a knob turn
9
+ * reaches the app as `<id> <value>`, and the app writes the parameter back with
10
+ * `set_<id> <value>` - which moves the dial, the automation lane and Push.
11
11
  */
12
12
  import { defineSurface, dial } from "@m4l-jweb/surface";
13
13
 
@@ -87,6 +87,23 @@ h1 {
87
87
  color: var(--fg);
88
88
  }
89
89
 
90
+ /* The build stamps, top right. Deliberately NOT in a footer: the device view is
91
+ a fixed ~169 px and clips at the bottom, so a footer stamp vanishes exactly
92
+ when the UI has grown enough for staleness to be worth checking. The stamps
93
+ are long (version + ISO timestamp), so they ellipsize - the full text is in
94
+ the title attribute, and the hover works in Max's jweb. */
95
+ .stamp {
96
+ color: var(--muted);
97
+ display: flex;
98
+ font-size: 9px;
99
+ gap: 8px;
100
+ margin-left: auto;
101
+ max-width: 45%;
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ white-space: nowrap;
105
+ }
106
+
90
107
  dl {
91
108
  column-gap: 10px;
92
109
  display: grid;
@@ -27,11 +27,32 @@ import App from "@device/App";
27
27
  */
28
28
  const DevHarness = import.meta.env.DEV ? (await import("@m4l-jweb/surface/dev")).DevHarness : null;
29
29
 
30
+ /**
31
+ * The device's parameter surface, for the harness to render - the same
32
+ * declaration the Max objects are generated from, so the panel and the Push
33
+ * preview cannot drift from what Live will show.
34
+ *
35
+ * A GLOB rather than `import "@device/surface"`, because surface.ts is OPTIONAL:
36
+ * a device with no parameters has no such file, and a static import of a missing
37
+ * module is a build error, not an undefined.
38
+ *
39
+ * The glob sits INSIDE the `import.meta.env.DEV` branch, and that placement is
40
+ * load-bearing. A glob resolves to every match, so hoisting it to a `const` would
41
+ * put EVERY device's declaration in EVERY bundle - one device shipping its
42
+ * siblings' parameters - and the single-file build inlines dynamic chunks, so
43
+ * being lazy is not enough on its own. Written here, the whole expression is dead
44
+ * code once DEV is replaced by `false`, and rollup drops all of it.
45
+ * `tests/bundle.test.mjs` asserts a device carries no sibling's parameters.
46
+ */
47
+ const surface = import.meta.env.DEV
48
+ ? (((await import.meta.glob("./app/*/surface.ts", { import: "default" })[`./app/${__DEVICE__}/surface.ts`]?.()) as never) ?? null)
49
+ : null;
50
+
30
51
  createRoot(document.getElementById("root")!).render(
31
52
  <StrictMode>
32
53
  {DevHarness ? (
33
54
  <div className="dev-layout">
34
- <DevHarness />
55
+ <DevHarness surface={surface} />
35
56
  <App />
36
57
  </div>
37
58
  ) : (
@@ -3,6 +3,11 @@
3
3
  // Injected by vite's `define` (see vite.config.ts) - the UI's own build stamp.
4
4
  declare const __APP_VERSION__: string;
5
5
 
6
+ // Injected by vite's `define` - which device this bundle IS. Used to pick the
7
+ // device's surface.ts out of a glob, since a static import cannot name a file
8
+ // that some devices do not have.
9
+ declare const __DEVICE__: string;
10
+
6
11
  declare module "*?worker&inline" {
7
12
  const workerConstructor: new () => Worker;
8
13
  export default workerConstructor;