@aaqu/fromcubes-portal-react 0.1.0-alpha.20 → 0.1.0-alpha.22

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/README.md CHANGED
@@ -81,6 +81,29 @@ The opt-out is page-wide — the strictest call wins. If any component on the pa
81
81
 
82
82
  There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
83
83
 
84
+ For shared **non-component** code (helpers, custom hooks, constants), use **fc-portal-utility** — a sibling config node. Unlike component nodes (which export exactly one symbol via an IIFE wrapper), a utility node is injected raw at top level so it can declare any number of `function` / `const` / `let` / `class` symbols. Selective inclusion: a utility node lands in a portal's bundle only when the portal's JSX or any of its referenced library components mentions at least one of the symbols declared in that utility.
85
+
86
+ ```jsx
87
+ // fc-portal-utility, Module name = mathHelpers
88
+ const PI2 = Math.PI * 2;
89
+ function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
90
+ function useDebounce(value, ms = 300) {
91
+ const [v, setV] = React.useState(value);
92
+ React.useEffect(() => {
93
+ const t = setTimeout(() => setV(value), ms);
94
+ return () => clearTimeout(t);
95
+ }, [value, ms]);
96
+ return v;
97
+ }
98
+
99
+ // portal-react, JSX tab
100
+ function App() {
101
+ const { data } = useNodeRed();
102
+ const slow = useDebounce(data?.value);
103
+ return <div>{clamp(slow ?? 0, 0, 100)}</div>;
104
+ }
105
+ ```
106
+
84
107
  ## Editor features
85
108
 
86
109
  - **Monaco** with full JSX support and `useNodeRed()` type declarations
@@ -88,6 +111,8 @@ There is also a config node, **fc-portal-component**, that lets you define reusa
88
111
  - **JSX tag completion** — type tag name, Tab to expand
89
112
  - **Self-close collapse** — type `/` inside empty `<tag></tag>` to convert to `<tag />`
90
113
  - **Component completion** — registry components + any PascalCase word
114
+ - **Utility-symbol completion** — top-level identifiers from any `fc-portal-utility` node, suggested in JS context
115
+ - **Components / Utilities dialogs** — buttons in the JSX tab; Components inserts `<Tag></Tag>`, Utilities expands to the symbols declared in each node and inserts the bare identifier on click
91
116
  - **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts…)
92
117
 
93
118
  ## Multi-user / Multi-tenancy
@@ -177,6 +202,7 @@ Import `001-shared-components-flow.json` first — it provides shared UI compone
177
202
  | `005-threejs-portal-flow.json` | `three` | 3D scene with Three.js |
178
203
  | `006-pixi-portal-flow.json` | `pixi.js`, `@pixi/react` | Clickable bunny sprites with PixiJS |
179
204
  | `007-webgpu-tsl-flow.json` | `three` | WebGPU renderer + TSL animated shaders |
205
+ | `008-utility-debounce-flow.json` | — | `fc-portal-utility` demo: `useDebounce` custom hook + `clamp` helper |
180
206
 
181
207
  ## Troubleshooting
182
208
 
@@ -0,0 +1,72 @@
1
+ [
2
+ {
3
+ "id": "fc-util-flow",
4
+ "type": "tab",
5
+ "label": "Utility: useDebounce",
6
+ "disabled": false
7
+ },
8
+ {
9
+ "id": "fc-util-tick",
10
+ "type": "inject",
11
+ "z": "fc-util-flow",
12
+ "name": "Fast tick (10Hz)",
13
+ "props": [{ "p": "payload" }],
14
+ "repeat": "0.1",
15
+ "payload": "",
16
+ "payloadType": "date",
17
+ "x": 160,
18
+ "y": 160,
19
+ "wires": [["fc-util-noisy"]]
20
+ },
21
+ {
22
+ "id": "fc-util-noisy",
23
+ "type": "function",
24
+ "z": "fc-util-flow",
25
+ "name": "Noisy signal",
26
+ "func": "// Random walk between 0..120 with bursts of noise — exactly the sort of\n// fast-changing input where a debounced display is much more readable\n// than the raw value.\nconst prev = context.get('v') ?? 50;\nconst drift = (Math.random() - 0.5) * 8;\nconst noise = Math.random() < 0.15 ? (Math.random() - 0.5) * 30 : 0;\nconst v = Math.max(0, Math.min(120, prev + drift + noise));\ncontext.set('v', v);\nmsg.payload = { value: +v.toFixed(2), ts: Date.now() };\nreturn msg;",
27
+ "outputs": 1,
28
+ "x": 360,
29
+ "y": 160,
30
+ "wires": [["fc-util-portal"]]
31
+ },
32
+ {
33
+ "id": "fc-util-helpers",
34
+ "type": "fc-portal-utility",
35
+ "z": "fc-util-flow",
36
+ "name": "math helpers + useDebounce",
37
+ "utilName": "mathHelpers",
38
+ "utilCode": "// Constants — bundled once, shared across portals that reference them.\nconst CLAMP_MIN = 0;\nconst CLAMP_MAX = 100;\n\n// Pure helper.\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\n// Custom React hook — name MUST start with `use` for React's rules of hooks.\n// This node groups a constant + a helper + a hook in one block; portals that\n// reference any of `clamp`, `useDebounce`, `CLAMP_MIN`, `CLAMP_MAX` pull in\n// the whole node.\nfunction useDebounce(value, ms = 300) {\n const [v, setV] = React.useState(value);\n React.useEffect(() => {\n const t = setTimeout(() => setV(value), ms);\n return () => clearTimeout(t);\n }, [value, ms]);\n return v;\n}",
39
+ "x": 160,
40
+ "y": 280,
41
+ "wires": []
42
+ },
43
+ {
44
+ "id": "fc-util-bar",
45
+ "type": "fc-portal-component",
46
+ "z": "fc-util-flow",
47
+ "name": "Bar",
48
+ "compName": "Bar",
49
+ "compCode": "function Bar({ value = 0, label = '' }) {\n // Component uses `clamp` from the utility node — selective inclusion picks\n // up the dependency through the components' code, not just the user JSX.\n const pct = clamp(value, CLAMP_MIN, CLAMP_MAX);\n const color = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500';\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"w-24 text-xs text-zinc-400 uppercase tracking-wider\">{label}</div>\n <div className=\"flex-1 h-3 bg-zinc-800 rounded-full overflow-hidden\">\n <div className={`h-full ${color} transition-all duration-150`} style={{ width: pct + '%' }} />\n </div>\n <div className=\"w-14 text-right font-mono text-sm tabular-nums text-zinc-200\">\n {value.toFixed(1)}\n </div>\n </div>\n );\n}",
50
+ "compInputs": "value,label",
51
+ "compOutputs": "",
52
+ "x": 360,
53
+ "y": 280,
54
+ "wires": []
55
+ },
56
+ {
57
+ "id": "fc-util-portal",
58
+ "type": "portal-react",
59
+ "z": "fc-util-flow",
60
+ "name": "Debounce demo",
61
+ "subPath": "debounce",
62
+ "pageTitle": "useDebounce demo",
63
+ "customHead": "",
64
+ "componentCode": "function App() {\n const { data } = useNodeRed();\n const raw = data?.value ?? 0;\n\n // Live raw signal vs debounced views at three different windows.\n // `useDebounce` is declared in the fc-portal-utility node — no import.\n const slow100 = useDebounce(raw, 100);\n const slow400 = useDebounce(raw, 400);\n const slow1500 = useDebounce(raw, 1500);\n\n return (\n <div className=\"min-h-screen bg-zinc-950 text-zinc-100 p-8 font-sans\">\n <h1 className=\"text-2xl font-light text-amber-400 mb-2\">useDebounce</h1>\n <p className=\"text-sm text-zinc-500 mb-8\">\n Custom hook from <code className=\"text-amber-300\">fc-portal-utility</code>. Same noisy 10 Hz input fed through different debounce windows.\n </p>\n <div className=\"max-w-xl space-y-4\">\n <Bar value={raw} label=\"raw\" />\n <Bar value={slow100} label=\"100 ms\" />\n <Bar value={slow400} label=\"400 ms\" />\n <Bar value={slow1500} label=\"1500 ms\" />\n </div>\n </div>\n );\n}",
65
+ "libs": [],
66
+ "portalAuth": false,
67
+ "showWsStatus": false,
68
+ "x": 580,
69
+ "y": 160,
70
+ "wires": [[]]
71
+ }
72
+ ]
@@ -121,12 +121,42 @@ function removeRoute(router, path) {
121
121
  );
122
122
  }
123
123
 
124
+ function formatEsbuildError(e) {
125
+ return e.errors?.length
126
+ ? e.errors
127
+ .map(
128
+ (err) =>
129
+ `${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
130
+ )
131
+ .join("\n")
132
+ : e.message;
133
+ }
134
+
135
+ // Fast JSX syntax validation (no bundling). Returns null on OK, error string on fail.
136
+ function quickCheckSyntax(jsx) {
137
+ if (!jsx || !jsx.trim()) return null;
138
+ try {
139
+ esbuild.transformSync(jsx, {
140
+ loader: "jsx",
141
+ jsx: "transform",
142
+ jsxFactory: "React.createElement",
143
+ jsxFragment: "React.Fragment",
144
+ logLevel: "silent",
145
+ });
146
+ return null;
147
+ } catch (e) {
148
+ return formatEsbuildError(e);
149
+ }
150
+ }
151
+
124
152
  module.exports = function (RED) {
125
153
  return createHelpers(RED);
126
154
  };
127
155
 
128
156
  module.exports.validateSubPath = validateSubPath;
129
157
  module.exports.isSafeName = isSafeName;
158
+ module.exports.quickCheckSyntax = quickCheckSyntax;
159
+ module.exports.formatEsbuildError = formatEsbuildError;
130
160
 
131
161
  function createHelpers(RED) {
132
162
  // Package root — where react/react-dom live (this package's own node_modules)
@@ -220,25 +250,8 @@ function createHelpers(RED) {
220
250
 
221
251
  function transpile(jsx) {
222
252
  // Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
223
- try {
224
- esbuild.transformSync(jsx, {
225
- loader: "jsx",
226
- jsx: "transform",
227
- jsxFactory: "React.createElement",
228
- jsxFragment: "React.Fragment",
229
- logLevel: "silent",
230
- });
231
- } catch (e) {
232
- const msg = e.errors?.length
233
- ? e.errors
234
- .map(
235
- (err) =>
236
- `${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
237
- )
238
- .join("\n")
239
- : e.message;
240
- return { js: null, error: msg };
241
- }
253
+ const syntaxErr = quickCheckSyntax(jsx);
254
+ if (syntaxErr) return { js: null, error: syntaxErr };
242
255
  // Syntax OK — bundle with full resolution
243
256
  try {
244
257
  const buildResult = esbuild.buildSync({
@@ -275,21 +288,14 @@ function createHelpers(RED) {
275
288
  error: null,
276
289
  };
277
290
  } catch (e) {
278
- const msg = e.errors?.length
279
- ? e.errors
280
- .map(
281
- (err) =>
282
- `${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
283
- )
284
- .join("\n")
285
- : e.message;
286
- return { js: null, error: msg };
291
+ return { js: null, error: formatEsbuildError(e) };
287
292
  }
288
293
  }
289
294
 
290
295
  return {
291
296
  hash,
292
297
  transpile,
298
+ quickCheckSyntax,
293
299
  generateCSS,
294
300
  extractPortalUser,
295
301
  removeRoute,
@@ -14,6 +14,63 @@ function escScript(s) {
14
14
  return String(s).replace(/<\/(script)/gi, "<\\/$1");
15
15
  }
16
16
 
17
+ const ERROR_OVERLAY_CSS = `
18
+ #__error_overlay {
19
+ position: fixed; inset: 0; z-index: 99999;
20
+ background: #1a0000; color: #f87171;
21
+ font-family: monospace; padding: 40px;
22
+ overflow: auto;
23
+ display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
24
+ }
25
+ #__error_overlay h1 { color: #ff4444; margin: 0 0 16px; font-size: 24px }
26
+ #__error_overlay p.__hint { color: #888; margin: 0 0 16px }
27
+ #__error_overlay pre {
28
+ background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px;
29
+ padding: 20px; color: #fca5a5;
30
+ max-width: 90vw; max-height: 60vh; overflow: auto;
31
+ white-space: pre-wrap; margin: 0;
32
+ }
33
+ #__error_overlay p.__status { color: #4ade80; font-size: 12px; margin: 24px 0 0 }
34
+ #__error_overlay p.__status.__off { color: #888 }
35
+ #__error_banner {
36
+ position: fixed; top: 8px; right: 8px; z-index: 99998;
37
+ max-width: 360px; padding: 8px 12px;
38
+ background: #1a0000; color: #fca5a5;
39
+ border: 1px solid #ff4444; border-radius: 6px;
40
+ font-family: monospace; font-size: 12px;
41
+ box-shadow: 0 4px 12px rgba(0,0,0,.4);
42
+ cursor: pointer; user-select: none;
43
+ }
44
+ #__error_banner b { color: #ff4444; display: block; margin-bottom: 2px }
45
+ #__error_banner.__expanded {
46
+ max-width: 70vw; max-height: 60vh; overflow: auto;
47
+ cursor: default;
48
+ }
49
+ #__error_banner pre {
50
+ margin: 8px 0 0; padding: 8px; background: #0a0a0a;
51
+ border-radius: 4px; white-space: pre-wrap; display: none;
52
+ }
53
+ #__error_banner.__expanded pre { display: block }
54
+ #__error_banner .__close {
55
+ float: right; padding: 0 4px; color: #888; cursor: pointer;
56
+ }
57
+ `;
58
+
59
+ // Shared error overlay markup (HTML inside #__error_overlay).
60
+ // Used by buildPage (WS error frame, runtime try/catch) and buildErrorPage.
61
+ function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
62
+ return (
63
+ `<h1>${esc(title)}</h1>` +
64
+ (hint ? `<p class="__hint">${esc(hint)}</p>` : "") +
65
+ `<pre>${esc(message)}</pre>` +
66
+ (statusLine
67
+ ? `<p class="__status${statusOk ? "" : " __off"}" id="__err_status">${esc(statusLine)}</p>`
68
+ : "")
69
+ );
70
+ }
71
+
72
+ const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
73
+
17
74
  function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
18
75
  return `<!DOCTYPE html>
19
76
  <html lang="en">
@@ -23,6 +80,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
23
80
  <title>${esc(title)}</title>
24
81
  ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
25
82
  ${escScript(customHead)}
83
+ <style>${ERROR_OVERLAY_CSS}</style>
26
84
  ${showWsStatus ? `<style>
27
85
  #__cs {
28
86
  position: fixed; bottom: 6px; right: 6px;
@@ -39,6 +97,51 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
39
97
  <div id="root"></div>
40
98
  ${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
41
99
  <script>
100
+ function __safe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
101
+ function __renderErrorOverlay(title, message, hint) {
102
+ const root = document.getElementById('root');
103
+ if (root) root.style.display = 'none';
104
+ const bo = document.getElementById('__building_overlay');
105
+ if (bo) bo.remove();
106
+ const bn = document.getElementById('__error_banner');
107
+ if (bn) bn.remove();
108
+ let ov = document.getElementById('__error_overlay');
109
+ if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
110
+ ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
111
+ + (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
112
+ + '<pre>' + __safe(message) + '</pre>'
113
+ + '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
114
+ }
115
+ function __renderErrorBanner(message) {
116
+ const ov = document.getElementById('__error_overlay');
117
+ if (ov) ov.remove();
118
+ const bo = document.getElementById('__building_overlay');
119
+ if (bo) bo.remove();
120
+ const root = document.getElementById('root');
121
+ if (root) root.style.display = '';
122
+ let bn = document.getElementById('__error_banner');
123
+ if (!bn) {
124
+ bn = document.createElement('div');
125
+ bn.id = '__error_banner';
126
+ document.body.appendChild(bn);
127
+ bn.addEventListener('click', function(e) {
128
+ if (e.target && e.target.className === '__close') { bn.remove(); return; }
129
+ bn.classList.toggle('__expanded');
130
+ });
131
+ }
132
+ bn.innerHTML = '<span class="__close" title="Dismiss">\\u00d7</span>'
133
+ + '<b>\\u26a0 Latest deploy failed</b>'
134
+ + '<span>Running previous version. Click for details.</span>'
135
+ + '<pre>' + __safe(message) + '</pre>';
136
+ }
137
+ function __clearErrorOverlay() {
138
+ const ov = document.getElementById('__error_overlay');
139
+ if (ov) ov.remove();
140
+ const bn = document.getElementById('__error_banner');
141
+ if (bn) bn.remove();
142
+ const root = document.getElementById('root');
143
+ if (root) root.style.display = '';
144
+ }
42
145
  window.__NR = {
43
146
  _ws: null,
44
147
  _listeners: new Set(),
@@ -48,6 +151,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
48
151
  _wasConnected: false,
49
152
  _version: null,
50
153
  _portalClient: null,
154
+ // Set on building/error WS frames. Next version with real hash reloads,
155
+ // independent of hash diff (build may produce same hash again).
156
+ _buildErrorActive: false,
157
+ // Pending runtime error message captured before WS opened.
158
+ // Flushed in onopen so node status can go red even when the
159
+ // exception fires synchronously during initial bundle execution.
160
+ _pendingRuntimeError: null,
51
161
  _user: ${user ? escScript(JSON.stringify(user)) : "null"},
52
162
 
53
163
  connect() {
@@ -57,9 +167,15 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
57
167
  const s = document.getElementById('__cs');
58
168
 
59
169
  ws.onopen = () => {
60
- if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
170
+ if (s) { s.textContent = 'fromcubes connected'; s.className = 'ok'; }
61
171
  this._retries = 0;
62
172
  this._wasConnected = true;
173
+ const es = document.getElementById('__err_status');
174
+ if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
175
+ if (this._pendingRuntimeError) {
176
+ try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
177
+ this._pendingRuntimeError = null;
178
+ }
63
179
  };
64
180
 
65
181
  ws.onmessage = (e) => {
@@ -69,16 +185,26 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
69
185
  this._portalClient = m.portalClient;
70
186
  }
71
187
  if (m.type === 'version') {
72
- var hasOverlay = document.getElementById('__building_overlay') || document.getElementById('__error_overlay');
73
- if (hasOverlay || (this._version && this._version !== m.hash)) { location.reload(); return; }
188
+ // Empty hash = server still in building/error state, ignore (overlay stays).
189
+ if (!m.hash) return;
190
+ // Reload when server was in build-error/building (recover regardless of
191
+ // hash) OR when hash differs from prior known hash (deploy detected).
192
+ // Do NOT reload merely because an overlay is in the DOM: a runtime
193
+ // exception caught locally renders the same overlay node and would
194
+ // otherwise loop reload to runtime-error to reload forever.
195
+ if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
196
+ location.reload();
197
+ return;
198
+ }
74
199
  this._version = m.hash;
75
200
  }
76
201
  if (m.type === 'building') {
202
+ this._buildErrorActive = true;
77
203
  document.getElementById('root').style.display = 'none';
78
- var eo = document.getElementById('__error_overlay');
204
+ const eo = document.getElementById('__error_overlay');
79
205
  if (eo) eo.remove();
80
206
  if (!document.getElementById('__building_overlay')) {
81
- var ov = document.createElement('div');
207
+ const ov = document.createElement('div');
82
208
  ov.id = '__building_overlay';
83
209
  ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#111;color:#888;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace';
84
210
  ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
@@ -88,22 +214,14 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
88
214
  }
89
215
  }
90
216
  if (m.type === 'error') {
91
- document.getElementById('root').style.display = 'none';
92
- var bo = document.getElementById('__building_overlay');
93
- if (bo) bo.remove();
94
- var ov = document.getElementById('__error_overlay');
95
- if (!ov) {
96
- ov = document.createElement('div');
97
- ov.id = '__error_overlay';
98
- ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#1a0000;color:#f87171;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace;padding:40px';
99
- document.body.appendChild(ov);
217
+ this._buildErrorActive = true;
218
+ if (m.degraded) {
219
+ __renderErrorBanner(m.message);
220
+ } else {
221
+ __renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
222
+ const es2 = document.getElementById('__err_status');
223
+ if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
100
224
  }
101
- ov.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px;font-size:24px">JSX Transpile Error</h1>'
102
- + '<p style="color:#888;margin-bottom:16px">Fix the component code in Node-RED and deploy again.</p>'
103
- + '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;max-width:90vw;max-height:60vh;overflow:auto;white-space:pre-wrap">'
104
- + m.message.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
105
- + '</pre>'
106
- + '<p style="color:#4ade80;font-size:12px;margin-top:24px">Connected \\u2014 will reload on redeploy</p>';
107
225
  }
108
226
  if (m.type === 'data') {
109
227
  this._lastData = m.payload;
@@ -121,8 +239,10 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
121
239
  };
122
240
 
123
241
  ws.onclose = () => {
124
- if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
242
+ if (s) { s.textContent = 'fromcubes disconnected'; s.className = 'err'; }
125
243
  this._ws = null;
244
+ const es = document.getElementById('__err_status');
245
+ if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
126
246
  const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
127
247
  this._retries++;
128
248
  setTimeout(() => this.connect(), delay);
@@ -147,12 +267,17 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
147
267
  <script>
148
268
  try { ${escScript(transpiledJs)}
149
269
  } catch(__e) {
150
- var __r = document.getElementById('root');
151
- __r.style.cssText = 'font-family:monospace;background:#1a0000;color:#f87171;padding:40px;min-height:100vh;margin:0';
152
- __r.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px">Runtime Error</h1>'
153
- + '<p style="color:#888">Fix the component code in Node-RED and deploy again.</p>'
154
- + '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;white-space:pre-wrap">'
155
- + (__e.message || __e).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
270
+ const __m = (__e && (__e.stack || __e.message)) || String(__e);
271
+ __renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
272
+ // Report back to server so node status goes red. WS may not be open
273
+ // yet (sync throw during initial bundle); queue until onopen.
274
+ try {
275
+ if (window.__NR && window.__NR._ws && window.__NR._ws.readyState === 1) {
276
+ window.__NR._ws.send(JSON.stringify({ type: 'runtime_error', message: __m }));
277
+ } else if (window.__NR) {
278
+ window.__NR._pendingRuntimeError = __m;
279
+ }
280
+ } catch(_) {}
156
281
  }
157
282
  <\/script>
158
283
  </body>
@@ -165,40 +290,43 @@ function buildErrorPage(title, error, wsPath) {
165
290
  <head>
166
291
  <meta charset="UTF-8">
167
292
  <title>${esc(title)} — Error</title>
168
- <style>
169
- body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
170
- h1 { color: #ff4444; margin-bottom: 16px }
171
- pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
172
- .status { color: #888; font-size: 12px; margin-top: 24px }
173
- .status.ok { color: #4ade80 }
174
- </style>
293
+ <style>${ERROR_OVERLAY_CSS}</style>
175
294
  </head>
176
295
  <body>
177
- <h1>JSX Transpile Error</h1>
178
- <p>Fix the component code in Node-RED and deploy again.</p>
179
- <pre>${esc(error)}</pre>
180
- <p class="status" id="st">Waiting for redeploy…</p>
296
+ <div id="__error_overlay">${errorOverlayInnerHtml({
297
+ title: "Build Error",
298
+ hint: DEFAULT_HINT,
299
+ message: error,
300
+ statusLine: "Waiting for redeploy…",
301
+ statusOk: false,
302
+ })}</div>
181
303
  <script>
182
304
  (function() {
183
- var st = document.getElementById('st');
184
- var retries = 0;
305
+ const st = document.getElementById('__err_status');
306
+ const pre = document.querySelector('#__error_overlay pre');
307
+ let retries = 0;
308
+ function setStatus(text, ok) {
309
+ if (!st) return;
310
+ st.textContent = text;
311
+ st.className = '__status' + (ok ? '' : ' __off');
312
+ }
185
313
  function connect() {
186
- var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
187
- var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
314
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
315
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
188
316
  ws.onopen = function() {
189
317
  retries = 0;
190
- if (st) { st.textContent = 'Connected \\u2014 will reload on redeploy'; st.className = 'status ok'; }
318
+ setStatus('Connected \\u2014 will reload on redeploy', true);
191
319
  };
192
320
  ws.onmessage = function(e) {
193
321
  try {
194
- var m = JSON.parse(e.data);
322
+ const m = JSON.parse(e.data);
195
323
  if (m.type === 'version' && m.hash) location.reload();
196
- if (m.type === 'error') { var pre = document.querySelector('pre'); if (pre) pre.textContent = m.message; }
324
+ if (m.type === 'error' && pre) pre.textContent = m.message;
197
325
  } catch(_) {}
198
326
  };
199
327
  ws.onclose = function() {
200
- if (st) { st.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; st.className = 'status'; }
201
- var delay = Math.min(500 * Math.pow(2, retries), 8000);
328
+ setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
329
+ const delay = Math.min(500 * Math.pow(2, retries), 8000);
202
330
  retries++;
203
331
  setTimeout(connect, delay);
204
332
  };