@m4l-jweb/build 0.1.0 → 0.2.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.
@@ -3,102 +3,163 @@
3
3
  * An overgrown layout does not scroll, it silently clips at the bottom.
4
4
  */
5
5
  :root {
6
- --bg: #2f2f2f;
7
- --fg: #d6d6d6;
8
- --muted: #8a8a8a;
9
- --accent: #6fd6a8;
10
- --warn: #e8a33d;
6
+ --bg: #2f2f2f;
7
+ --fg: #d6d6d6;
8
+ --muted: #8a8a8a;
9
+ --accent: #6fd6a8;
10
+ --warn: #e8a33d;
11
11
  }
12
12
 
13
13
  * {
14
- box-sizing: border-box;
15
- margin: 0;
16
- padding: 0;
14
+ box-sizing: border-box;
15
+ margin: 0;
16
+ padding: 0;
17
17
  }
18
18
 
19
19
  body {
20
- background: var(--bg);
21
- color: var(--fg);
22
- font: 11px/1.4 "Segoe UI", system-ui, sans-serif;
23
- height: 169px;
24
- overflow: hidden;
20
+ background: var(--bg);
21
+ color: var(--fg);
22
+ font:
23
+ 11px/1.4 "Segoe UI",
24
+ system-ui,
25
+ sans-serif;
26
+ height: 169px;
27
+ overflow: hidden;
28
+ }
29
+
30
+ /*
31
+ * Dev only (@m4l-jweb/surface/dev). The harness renders beside the device, so
32
+ * the PAGE is no longer the device view - but the DEVICE still is. It keeps its
33
+ * real 169 px box, deliberately: a UI that clips in Live must clip here too, or
34
+ * the harness is lying to you about the one constraint that is cheapest to catch.
35
+ */
36
+ body:has(.dev-layout) {
37
+ height: 100vh;
38
+ }
39
+
40
+ .dev-layout {
41
+ align-items: flex-start;
42
+ background: #0e1013;
43
+ display: flex;
44
+ height: 100vh;
45
+ }
46
+
47
+ .dev-layout .device {
48
+ border: 1px solid #262a31;
49
+ flex: 1;
50
+ height: 169px;
51
+ max-width: 640px;
25
52
  }
26
53
 
27
54
  .device {
28
- display: flex;
29
- flex-direction: column;
30
- gap: 6px;
31
- height: 100%;
32
- padding: 8px 10px;
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: 6px;
58
+ height: 100%;
59
+ padding: 8px 10px;
33
60
  }
34
61
 
35
62
  header {
36
- align-items: center;
37
- border-bottom: 1px solid #444;
38
- display: flex;
39
- gap: 8px;
40
- padding-bottom: 5px;
63
+ align-items: center;
64
+ border-bottom: 1px solid #444;
65
+ display: flex;
66
+ gap: 8px;
67
+ padding-bottom: 5px;
41
68
  }
42
69
 
43
70
  h1 {
44
- font-size: 12px;
45
- letter-spacing: 0.08em;
71
+ font-size: 12px;
72
+ letter-spacing: 0.08em;
46
73
  }
47
74
 
48
75
  .badge {
49
- border-radius: 3px;
50
- font-size: 9px;
51
- padding: 1px 5px;
52
- text-transform: uppercase;
76
+ border-radius: 3px;
77
+ font-size: 9px;
78
+ padding: 1px 5px;
79
+ text-transform: uppercase;
53
80
  }
54
81
  .badge.live {
55
- background: var(--accent);
56
- color: #1a1a1a;
82
+ background: var(--accent);
83
+ color: #1a1a1a;
57
84
  }
58
85
  .badge.dev {
59
- background: #555;
60
- color: var(--fg);
86
+ background: #555;
87
+ color: var(--fg);
61
88
  }
62
89
 
63
90
  dl {
64
- column-gap: 10px;
65
- display: grid;
66
- grid-template-columns: auto 1fr;
67
- row-gap: 3px;
91
+ column-gap: 10px;
92
+ display: grid;
93
+ grid-template-columns: auto 1fr;
94
+ row-gap: 3px;
68
95
  }
69
96
 
70
97
  dt {
71
- color: var(--muted);
72
- text-transform: uppercase;
73
- font-size: 9px;
74
- align-self: center;
98
+ color: var(--muted);
99
+ text-transform: uppercase;
100
+ font-size: 9px;
101
+ align-self: center;
75
102
  }
76
103
 
77
104
  dd {
78
- font-variant-numeric: tabular-nums;
105
+ font-variant-numeric: tabular-nums;
79
106
  }
80
107
 
81
108
  .dot {
82
- background: #666;
83
- border-radius: 50%;
84
- display: inline-block;
85
- height: 6px;
86
- width: 6px;
109
+ background: #666;
110
+ border-radius: 50%;
111
+ display: inline-block;
112
+ height: 6px;
113
+ width: 6px;
87
114
  }
88
115
  .dot.on {
89
- background: var(--accent);
116
+ background: var(--accent);
117
+ }
118
+
119
+ .slider {
120
+ align-items: center;
121
+ cursor: pointer;
122
+ display: flex;
123
+ gap: 8px;
124
+ }
125
+
126
+ .slider input {
127
+ accent-color: var(--accent);
128
+ cursor: pointer;
129
+ margin: 0;
130
+ width: 120px;
131
+ }
132
+
133
+ .slider strong {
134
+ color: var(--accent);
135
+ min-width: 26px;
136
+ }
137
+
138
+ /* The transport belongs to Live, and nothing in the device starts it. */
139
+ .row {
140
+ align-items: baseline;
141
+ display: flex;
142
+ gap: 8px;
143
+ justify-content: space-between;
144
+ }
145
+
146
+ .hint {
147
+ color: var(--warn);
148
+ font-size: 9px;
149
+ font-style: normal;
150
+ white-space: nowrap;
90
151
  }
91
152
 
92
153
  footer {
93
- border-top: 1px solid #444;
94
- color: var(--muted);
95
- display: flex;
96
- font-size: 9px;
97
- gap: 10px;
98
- margin-top: auto;
99
- padding-top: 5px;
154
+ border-top: 1px solid #444;
155
+ color: var(--muted);
156
+ display: flex;
157
+ font-size: 9px;
158
+ gap: 10px;
159
+ margin-top: auto;
160
+ padding-top: 5px;
100
161
  }
101
162
 
102
163
  .warn {
103
- color: var(--warn);
164
+ color: var(--warn);
104
165
  }
@@ -1,10 +1,41 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
- import App from "./app/App";
4
3
  import "./index.css";
5
4
 
5
+ /**
6
+ * ONE DEVICE PER BUNDLE.
7
+ *
8
+ * `@device` is an alias resolved in vite.config.ts to `src/app/<device>/`, where
9
+ * <device> comes from the dev/build script (see scripts/). So this entry point is
10
+ * shared but contains no branching, and each bundle holds exactly one device: a
11
+ * MIDI device's bundle carries no audio code, and vice versa.
12
+ *
13
+ * That separation is not tidiness. Every .amxd embeds its own UI bundle, so a
14
+ * device ships what it is, not what its siblings are.
15
+ */
16
+ import App from "@device/App";
17
+
18
+ /**
19
+ * The mocked-Live dev harness renders BESIDE the app in dev, and must never reach
20
+ * a device.
21
+ *
22
+ * `import.meta.env.DEV` is replaced by the literal `false` in a production build,
23
+ * so this branch - and with it the only reference to @m4l-jweb/surface/dev -
24
+ * becomes dead code that rollup drops before the bundle is inlined into the
25
+ * .amxd. tests/bundle.test.mjs asserts it actually did: a dev panel shipped
26
+ * inside someone's device throws no error, it just sits there, in Live.
27
+ */
28
+ const DevHarness = import.meta.env.DEV ? (await import("@m4l-jweb/surface/dev")).DevHarness : null;
29
+
6
30
  createRoot(document.getElementById("root")!).render(
7
- <StrictMode>
8
- <App />
9
- </StrictMode>,
31
+ <StrictMode>
32
+ {DevHarness ? (
33
+ <div className="dev-layout">
34
+ <DevHarness />
35
+ <App />
36
+ </div>
37
+ ) : (
38
+ <App />
39
+ )}
40
+ </StrictMode>,
10
41
  );
@@ -4,6 +4,6 @@
4
4
  declare const __APP_VERSION__: string;
5
5
 
6
6
  declare module "*?worker&inline" {
7
- const workerConstructor: new () => Worker;
8
- export default workerConstructor;
7
+ const workerConstructor: new () => Worker;
8
+ export default workerConstructor;
9
9
  }
@@ -1,24 +1,28 @@
1
1
  {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "useDefineForClassFields": true,
5
- "lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
6
- "module": "ESNext",
7
- "skipLibCheck": true,
8
- "moduleResolution": "bundler",
9
- "allowImportingTsExtensions": true,
10
- "resolveJsonModule": true,
11
- "paths": {
12
- "@/*": ["./src/*"]
13
- },
14
- "isolatedModules": true,
15
- "moduleDetection": "force",
16
- "noEmit": true,
17
- "jsx": "react-jsx",
18
- "strict": true,
19
- "noUnusedLocals": true,
20
- "noUnusedParameters": true,
21
- "noFallthroughCasesInSwitch": true
22
- },
23
- "include": ["src"]
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "paths": {
12
+ "@/*": ["./src/*"],
13
+ // vite rebinds this per build (see vite.config.ts). tsc cannot, so it
14
+ // typechecks src/main.tsx against ONE device. Every device's app lives
15
+ // under src/ and is typechecked on its own regardless.
16
+ "@device/*": ["./src/app/{{name}}/*"]
17
+ },
18
+ "isolatedModules": true,
19
+ "moduleDetection": "force",
20
+ "noEmit": true,
21
+ "jsx": "react-jsx",
22
+ "strict": true,
23
+ "noUnusedLocals": true,
24
+ "noUnusedParameters": true,
25
+ "noFallthroughCasesInSwitch": true
26
+ },
27
+ "include": ["src"]
24
28
  }
@@ -11,5 +11,5 @@
11
11
  "noEmit": true,
12
12
  "strict": true
13
13
  },
14
- "include": ["vite.config.ts", "patcher/**/*.mjs"]
14
+ "include": ["vite.config.ts", "scripts/**/*.mjs", "patcher/**/*.mjs"]
15
15
  }
@@ -1,34 +1,67 @@
1
- import { defineConfig } from "vite";
1
+ import { defineConfig, type UserConfig } from "vite";
2
2
  import { fileURLToPath, URL } from "node:url";
3
3
  import react from "@vitejs/plugin-react";
4
4
  import { viteSingleFile } from "vite-plugin-singlefile";
5
5
  import pkg from "./package.json";
6
+ import { devices, uiDir } from "./scripts/devices.mjs";
6
7
 
7
- // The device UI is bundled into ONE self-contained index.html (every script,
8
- // style and asset inlined) so it can be embedded in the .amxd as a base64
9
- // payload and extracted to a real file:// path that jweb (Chromium) reads.
10
- export default defineConfig({
11
- base: "./",
12
- plugins: [react(), viteSingleFile()],
13
- resolve: {
14
- alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
15
- },
16
- define: {
17
- __APP_VERSION__: JSON.stringify(pkg.version),
18
- },
19
- // A compute worker (src/app/worker.ts) is inlined into the single chunk:
20
- // a ?worker&inline blob URL cannot resolve relative chunk imports at
21
- // runtime, so bundle dynamic imports in too.
22
- worker: {
23
- format: "es",
24
- rollupOptions: {
25
- output: {
26
- inlineDynamicImports: true,
27
- },
28
- },
29
- },
30
- build: {
31
- outDir: "dist",
32
- emptyOutDir: true,
33
- },
8
+ /**
9
+ * ONE BUILD PER DEVICE.
10
+ *
11
+ * A repo can hold several devices, each .amxd embeds its OWN UI bundle, and a
12
+ * device should ship what it is - not its siblings' code. So the app to bundle is
13
+ * chosen here, by DEVICE, and src/main.tsx imports it through the `@device`
14
+ * alias. There is no `if (mode === ...)` anywhere in the app.
15
+ *
16
+ * DEVICE is set by scripts/dev.mjs and scripts/build-ui.mjs (which read the
17
+ * device list from patcher/devices.mjs). It is an env var rather than vite's
18
+ * `--mode` deliberately: `--mode` also flips `import.meta.env.DEV`, and a build
19
+ * with DEV=true would ship the dev harness inside the device.
20
+ */
21
+ //
22
+ // NOTE this is a FACTORY, not a plain object. scripts/build-ui.mjs sets DEVICE
23
+ // and calls vite's build() once per device in the same process; a top-level
24
+ // `const DEVICE = process.env.DEVICE` would be evaluated once, when the module
25
+ // was first loaded, and every device after the first would be built from the
26
+ // first one's sources. Vite invokes the factory on each config load, so reading
27
+ // the env var HERE is what makes the loop work.
28
+ export default defineConfig(() => {
29
+ // Falls back to the first device in the manifest, so a bare `vite` still runs
30
+ // something and this config carries no device name of its own.
31
+ const DEVICE = process.env.DEVICE ?? uiDir(devices[0]);
32
+
33
+ // The device UI is bundled into ONE self-contained index.html (every script,
34
+ // style and asset inlined) so it can be embedded in the .amxd as a base64
35
+ // payload and extracted to a real file:// path that jweb (Chromium) reads.
36
+ const config: UserConfig = {
37
+ base: "./",
38
+ plugins: [react(), viteSingleFile()],
39
+ resolve: {
40
+ alias: [
41
+ { find: "@device", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}`, import.meta.url)) },
42
+ { find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) },
43
+ ],
44
+ },
45
+ define: {
46
+ __APP_VERSION__: JSON.stringify(pkg.version),
47
+ __DEVICE__: JSON.stringify(DEVICE),
48
+ },
49
+ // A compute worker (src/app/shared/worker.ts) is inlined into the single chunk:
50
+ // a ?worker&inline blob URL cannot resolve relative chunk imports at runtime,
51
+ // so bundle dynamic imports in too.
52
+ worker: {
53
+ format: "es",
54
+ rollupOptions: {
55
+ output: {
56
+ inlineDynamicImports: true,
57
+ },
58
+ },
59
+ },
60
+ build: {
61
+ // dist/ui/<device>/index.html - one per device, picked up by `m4l-jweb build`.
62
+ outDir: `dist/ui/${DEVICE}`,
63
+ emptyOutDir: true,
64
+ },
65
+ };
66
+ return config;
34
67
  });
@@ -6,7 +6,7 @@ export default defineConfig({
6
6
  alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
7
7
  },
8
8
  test: {
9
- include: ["src/**/*.test.{ts,tsx}"],
9
+ include: ["tests/**/*.test.{ts,mjs}", "packages/*/tests/**/*.test.{ts,mjs}", "src/**/*.test.{ts,tsx}"],
10
10
  environment: "node",
11
11
  },
12
12
  });
@@ -1,87 +0,0 @@
1
- /**
2
- * App.tsx - the jweb UI. A hello-world React page wired to the Max bridge.
3
- *
4
- * It demonstrates the whole device loop end to end:
5
- * - announce `ui_ready` on mount and show the state the wrapper replies with
6
- * (mode, build stamp, tempo, transport ticks);
7
- * - forward each transport tick into an optional Web Worker and render the
8
- * count the worker sends back;
9
- * - flag a stale install when the wrapper's build stamp does not match the
10
- * one baked into this page.
11
- *
12
- * Replace the body with your device UI. The bridge surface never changes.
13
- */
14
- import { useEffect, useRef, useState } from "react";
15
- import { bindInlet, inJweb, uiReady } from "@m4l-jweb/bridge";
16
- import { IN } from "./protocol";
17
- import DemoWorker from "./worker.ts?worker&inline";
18
-
19
- declare const __APP_VERSION__: string;
20
-
21
- export default function App() {
22
- const [mode, setMode] = useState("dev");
23
- const [wrapperBuild, setWrapperBuild] = useState<string | null>(null);
24
- const [tempo, setTempo] = useState<number | null>(null);
25
- const [playing, setPlaying] = useState(false);
26
- const [beats, setBeats] = useState(0);
27
- const [workerTicks, setWorkerTicks] = useState(0);
28
- const workerRef = useRef<Worker | null>(null);
29
-
30
- useEffect(() => {
31
- const worker = new DemoWorker();
32
- worker.onmessage = (e: MessageEvent) => {
33
- const [type, value] = e.data as [string, number];
34
- if (type === "ticks") setWorkerTicks(value);
35
- };
36
- workerRef.current = worker;
37
-
38
- bindInlet(IN.mode, (m) => setMode(String(m)));
39
- bindInlet(IN.build, (b) => setWrapperBuild(String(b)));
40
- bindInlet(IN.tempo, (bpm) => setTempo(Number(bpm)));
41
- bindInlet(IN.tick, (isPlaying, position) => {
42
- setPlaying(Number(isPlaying) === 1);
43
- setBeats(Number(position));
44
- worker.postMessage(["tick"]);
45
- });
46
-
47
- // Handshake: the page loads asynchronously, so never assume the wrapper
48
- // already sent state - announce readiness and let it reply.
49
- uiReady();
50
-
51
- return () => worker.terminate();
52
- }, []);
53
-
54
- // The wrapper stamp is "<version> <iso date>"; the UI only bakes in the
55
- // version. A mismatch means a mixed install: Live embeds a copy of the
56
- // device in the set, so a reinstalled .amxd does NOT update instances
57
- // already on tracks.
58
- const stale = wrapperBuild !== null && wrapperBuild.split(" ")[0] !== __APP_VERSION__;
59
-
60
- return (
61
- <main className="device">
62
- <header>
63
- <h1>{{name}}</h1>
64
- <span className={`badge ${inJweb ? "live" : "dev"}`}>{inJweb ? "in Max" : "browser dev"}</span>
65
- </header>
66
-
67
- <dl>
68
- <dt>mode</dt>
69
- <dd>{mode}</dd>
70
- <dt>tempo</dt>
71
- <dd>{tempo === null ? "-" : `${tempo.toFixed(1)} BPM`}</dd>
72
- <dt>transport</dt>
73
- <dd>
74
- <span className={playing ? "dot on" : "dot"} /> {playing ? "playing" : "stopped"} @ {beats.toFixed(2)} beats
75
- </dd>
76
- <dt>worker ticks</dt>
77
- <dd>{workerTicks}</dd>
78
- </dl>
79
-
80
- <footer>
81
- <span>ui {__APP_VERSION__}</span>
82
- <span>wrapper {wrapperBuild ?? "-"}</span>
83
- {stale && <span className="warn">stale install - delete and re-drag the device</span>}
84
- </footer>
85
- </main>
86
- );
87
- }
@@ -1,32 +0,0 @@
1
- /**
2
- * protocol.ts - the typed list of selectors crossing the UI <-> device bridge.
3
- *
4
- * This is the single source of truth for BOTH sides of the bridge:
5
- * - the web app binds/emits these selectors (via @m4l-jweb/bridge)
6
- * - the [js] wrapper and the generated patcher route these selectors
7
- *
8
- * A Max message is a selector word followed by arguments
9
- * (e.g. `tick 1 12.5`). Keep selectors here so a CI lint can assert every one
10
- * appears in a route or handler on the patcher/wrapper side.
11
- */
12
-
13
- /** Selectors the DEVICE sends INTO the UI (bindInlet these). */
14
- export const IN = {
15
- /** wrapper -> UI: current run mode (midi | audio | instrument). */
16
- mode: "mode",
17
- /** wrapper -> UI: build stamp, for the stale-install check. */
18
- build: "build",
19
- /** wrapper -> UI: transport state. args: `<playing 0|1> <beats>`. */
20
- tick: "tick",
21
- /** wrapper -> UI: Live tempo in BPM. args: `<bpm>`. */
22
- tempo: "tempo",
23
- } as const;
24
-
25
- /** Selectors the UI sends OUT to the device (outlet these). */
26
- export const OUT = {
27
- /** UI -> wrapper: page finished loading; reply with current state. */
28
- ui_ready: "ui_ready",
29
- } as const;
30
-
31
- export type InSelector = (typeof IN)[keyof typeof IN];
32
- export type OutSelector = (typeof OUT)[keyof typeof OUT];