@m4l-jweb/surface 1.2.0 → 1.2.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": "1.2.0",
3
+ "version": "1.2.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": "1.2.0"
27
+ "@m4l-jweb/bridge": "1.2.1"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -265,13 +265,39 @@ export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
265
265
  * currently IS (name, unit, range - see describeParam in @m4l-jweb/bridge), and
266
266
  * keeps the scaling straight.
267
267
  */
268
- export const knobPool = <N extends number>(count: N, prefix = "s"): Record<string, DialSpec> => {
268
+ /** 0 .. N-1 as a union. The recursion is bounded by the count the caller wrote. */
269
+ type UpTo<N extends number, Acc extends number[] = []> = Acc["length"] extends N ? Acc[number] : UpTo<N, [...Acc, Acc["length"]]>;
270
+
271
+ /**
272
+ * The dials are numbered from 1, and TypeScript cannot add. A lookup tuple is the
273
+ * whole trick: index 0 holds 1, index 7 holds 8. It stops at 32, which is four Push
274
+ * banks - past that `knobPool` still WORKS, its keys just widen back to `string` and
275
+ * `useParam(surface, "s33")` stops being checked.
276
+ */
277
+ type OneBased = [
278
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
279
+ 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
280
+ ];
281
+
282
+ /** `PoolIds<8>` is `"s1" | ... | "s8"`. */
283
+ export type PoolIds<N extends number, P extends string = "s"> = `${P}${OneBased[UpTo<N>] & number}`;
284
+
285
+ /**
286
+ * The KEYS are the point, not just the values. `params: { ...knobPool(8) }` has to
287
+ * leave `keyof P` carrying `s1`..`s8`, or the spread widens the surface to an index
288
+ * signature and every downstream `useParam(surface, "s3")` and `layout.native.params`
289
+ * entry stops type-checking against the declaration it came from.
290
+ */
291
+ export const knobPool = <N extends number, P extends string = "s">(
292
+ count: N,
293
+ prefix: P = "s" as P,
294
+ ): Record<PoolIds<N, P>, DialSpec> => {
269
295
  const out: Record<string, DialSpec> = {};
270
296
  for (let i = 1; i <= count; i++) {
271
297
  // The short name is what Push prints when nothing has borrowed the slot yet.
272
298
  out[`${prefix}${i}`] = dial({ range: [0, 1], default: 0, short: `${prefix.toUpperCase()}${i}` });
273
299
  }
274
- return out;
300
+ return out as Record<PoolIds<N, P>, DialSpec>;
275
301
  };
276
302
  export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
277
303
  kind: "toggle",
package/src/react.tsx CHANGED
@@ -212,6 +212,21 @@ export interface PooledControl extends BorrowedControl {
212
212
  * minimum - the bug that got an earlier attempt at this reverted. The wrapper
213
213
  * answers whether each range took, and this hook scales exactly once either way.
214
214
  *
215
+ * `widenRange` IS OFF BY DEFAULT, and that is a measured decision rather than
216
+ * caution. A dial whose range was widened at runtime stops following its automation
217
+ * lane and any Rack macro mapped to it: both keep driving the parameter in the
218
+ * BUILD-TIME domain the frozen device gave them, so the value lands at the bottom of
219
+ * the new range and the dial never moves, silently. Left alone, the dials stay 0..1,
220
+ * this hook does the scaling, and automation, macros and Push all keep working - at
221
+ * the cost of the dial's own readout showing 0.44 rather than 600 Hz. Turn it on
222
+ * only where that readout is worth more than the automation.
223
+ *
224
+ * `describe: false` borrows and scales as usual but says NOTHING to Live. A device
225
+ * with two pages - a device view and a window - has two React trees against one
226
+ * pool, and both calling this means the last render wins: the dial ends up named
227
+ * after whichever page rendered most recently, flickering between them. Exactly one
228
+ * page should describe. The other still gets its `PooledControl[]` to draw.
229
+ *
215
230
  * KNOWN LIMIT, measured in Live: the name reaches the DEVICE PANEL, not Live's
216
231
  * parameter registry or a Rack macro picker, which keep the pool's own `S1..S8`.
217
232
  * A frozen device cannot rename a parameter there. So render the name in your own
@@ -221,6 +236,7 @@ export function useControls<P extends Record<string, ParamSpec>>(
221
236
  surface: Surface<P>,
222
237
  controls: readonly BorrowedControl[],
223
238
  poolIds: readonly Extract<keyof P, string>[],
239
+ options: { widenRange?: boolean; describe?: boolean } = {},
224
240
  ): PooledControl[] {
225
241
  /* eslint-disable react-hooks/rules-of-hooks */
226
242
  // A pool is dials, so its values are numbers - but `P` is the whole surface and
@@ -229,6 +245,8 @@ export function useControls<P extends Record<string, ParamSpec>>(
229
245
  const params = poolIds.map((id) => useParam(surface, id)) as unknown as [number, (v: number) => void][];
230
246
  /* eslint-enable react-hooks/rules-of-hooks */
231
247
 
248
+ const { widenRange = false, describe = true } = options;
249
+
232
250
  /** Which dials Live actually widened. Until it says so, the dial is 0..1. */
233
251
  const [real, setReal] = useState<boolean[]>([]);
234
252
  useEffect(() => {
@@ -250,6 +268,7 @@ export function useControls<P extends Record<string, ParamSpec>>(
250
268
  // parameter attributes, and a device re-rendering is not news.
251
269
  const described = useRef<string[]>([]);
252
270
  useEffect(() => {
271
+ if (!describe) return;
253
272
  for (let i = 0; i < poolIds.length; i++) {
254
273
  const c = controls[i];
255
274
  const key = c ? `${c.name} ${c.min} ${c.max} ${c.unit ?? ""}` : "";
@@ -257,10 +276,10 @@ export function useControls<P extends Record<string, ParamSpec>>(
257
276
  described.current[i] = key;
258
277
  // A slot nobody is borrowing goes back to its declared identity, or it would
259
278
  // keep the name of a control that is no longer there.
260
- if (c) describeParam(poolIds[i], { name: c.name, unit: c.unit, range: [c.min, c.max] });
261
- else describeParam(poolIds[i], { name: String(poolIds[i]).toUpperCase(), range: [0, 1] });
279
+ if (c) describeParam(poolIds[i], { name: c.name, unit: c.unit, range: [c.min, c.max], widenRange });
280
+ else describeParam(poolIds[i], { name: String(poolIds[i]).toUpperCase(), range: [0, 1], widenRange });
262
281
  }
263
- }, [controls, poolIds]);
282
+ }, [controls, poolIds, widenRange, describe]);
264
283
 
265
284
  // Seed a slot the first time a given control takes it, so an untouched dial reads
266
285
  // what the control says rather than 0.