@aaqu/fromcubes-portal-react 0.1.0-alpha.1 → 0.1.0-alpha.10

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.
@@ -26,7 +26,10 @@
26
26
  // Idempotent — safe to call multiple times, only runs setup once.
27
27
  function ensureJsxSetup() {
28
28
  if (jsxSetupDone) {
29
- console.log(PREFIX, "ensureJsxSetup: already done, re-applying compiler+diag only");
29
+ console.log(
30
+ PREFIX,
31
+ "ensureJsxSetup: already done, re-applying compiler+diag only",
32
+ );
30
33
  // Always re-apply compiler/diag in case Node-RED reset them
31
34
  applyCompilerAndDiag();
32
35
  return;
@@ -36,8 +39,16 @@
36
39
  var jsDef = monaco.typescript.javascriptDefaults;
37
40
 
38
41
  // Log initial state
39
- console.log(PREFIX, "INITIAL compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
40
- console.log(PREFIX, "INITIAL diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
42
+ console.log(
43
+ PREFIX,
44
+ "INITIAL compilerOptions:",
45
+ JSON.stringify(jsDef.getCompilerOptions()),
46
+ );
47
+ console.log(
48
+ PREFIX,
49
+ "INITIAL diagnosticsOptions:",
50
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
51
+ );
41
52
 
42
53
  applyCompilerAndDiag();
43
54
 
@@ -45,7 +56,7 @@
45
56
  var libContent = [
46
57
  "declare var React: any;",
47
58
  "declare var ReactDOM: any;",
48
- "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void };",
59
+ "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null };",
49
60
  ].join("\n");
50
61
  console.log(PREFIX, "addExtraLib globals.d.ts");
51
62
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
@@ -134,7 +145,17 @@
134
145
  });
135
146
  }
136
147
  }
137
- console.log(PREFIX, "TW completion: ctx=" + (isClassName ? "className" : "string") + " word='" + word + "', matched=" + suggestions.length + "/" + classes.length);
148
+ console.log(
149
+ PREFIX,
150
+ "TW completion: ctx=" +
151
+ (isClassName ? "className" : "string") +
152
+ " word='" +
153
+ word +
154
+ "', matched=" +
155
+ suggestions.length +
156
+ "/" +
157
+ classes.length,
158
+ );
138
159
  return { suggestions: suggestions };
139
160
  },
140
161
  });
@@ -142,19 +163,82 @@
142
163
  // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
143
164
  console.log(PREFIX, "registering JSX tag completion provider");
144
165
  var HTML_TAGS = [
145
- "div","span","p","a","button","input","img","form","label",
146
- "h1","h2","h3","h4","h5","h6",
147
- "ul","ol","li","dl","dt","dd",
148
- "table","thead","tbody","tfoot","tr","th","td",
149
- "section","article","aside","header","footer","nav","main",
150
- "strong","em","b","i","u","s","small","mark","code","pre","blockquote",
151
- "br","hr","wbr",
152
- "select","option","optgroup","textarea",
153
- "fieldset","legend","details","summary","dialog",
154
- "canvas","video","audio","source","picture","figure","figcaption",
155
- "svg","path","circle","rect","line","g","defs","use","text",
166
+ "div",
167
+ "span",
168
+ "p",
169
+ "a",
170
+ "button",
171
+ "input",
172
+ "img",
173
+ "form",
174
+ "label",
175
+ "h1",
176
+ "h2",
177
+ "h3",
178
+ "h4",
179
+ "h5",
180
+ "h6",
181
+ "ul",
182
+ "ol",
183
+ "li",
184
+ "dl",
185
+ "dt",
186
+ "dd",
187
+ "table",
188
+ "thead",
189
+ "tbody",
190
+ "tfoot",
191
+ "tr",
192
+ "th",
193
+ "td",
194
+ "section",
195
+ "article",
196
+ "aside",
197
+ "header",
198
+ "footer",
199
+ "nav",
200
+ "main",
201
+ "strong",
202
+ "em",
203
+ "b",
204
+ "i",
205
+ "u",
206
+ "s",
207
+ "small",
208
+ "mark",
209
+ "code",
210
+ "pre",
211
+ "blockquote",
212
+ "br",
213
+ "hr",
214
+ "wbr",
215
+ "select",
216
+ "option",
217
+ "optgroup",
218
+ "textarea",
219
+ "fieldset",
220
+ "legend",
221
+ "details",
222
+ "summary",
223
+ "dialog",
224
+ "canvas",
225
+ "video",
226
+ "audio",
227
+ "source",
228
+ "picture",
229
+ "figure",
230
+ "figcaption",
231
+ "svg",
232
+ "path",
233
+ "circle",
234
+ "rect",
235
+ "line",
236
+ "g",
237
+ "defs",
238
+ "use",
239
+ "text",
156
240
  ];
157
- var VOID_TAGS = new Set(["br","hr","img","input","wbr","source"]);
241
+ var VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
158
242
 
159
243
  monaco.languages.registerCompletionItemProvider("javascript", {
160
244
  triggerCharacters: ["<"],
@@ -165,12 +249,15 @@
165
249
  // After "<" — suggest tags
166
250
  var afterBracket = before.match(/<([a-zA-Z]*)$/);
167
251
  // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
168
- var bareWord = !afterBracket && before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
252
+ var bareWord =
253
+ !afterBracket &&
254
+ before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
169
255
 
170
256
  if (!afterBracket && !bareWord) return { suggestions: [] };
171
257
 
172
258
  var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
173
- var replaceStart = position.column - typed.length - (afterBracket ? 1 : 0);
259
+ var replaceStart =
260
+ position.column - typed.length - (afterBracket ? 1 : 0);
174
261
 
175
262
  var range = {
176
263
  startLineNumber: position.lineNumber,
@@ -192,7 +279,8 @@
192
279
  label: tag,
193
280
  kind: monaco.languages.CompletionItemKind.Property,
194
281
  insertText: "<" + tag + " $0/>",
195
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
282
+ insertTextRules:
283
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
196
284
  range: range,
197
285
  detail: "<" + tag + " />",
198
286
  sortText: "0" + tag,
@@ -202,7 +290,8 @@
202
290
  label: tag,
203
291
  kind: monaco.languages.CompletionItemKind.Property,
204
292
  insertText: "<" + tag + ">$0</" + tag + ">",
205
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
293
+ insertTextRules:
294
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
206
295
  range: range,
207
296
  detail: "<" + tag + ">…</" + tag + ">",
208
297
  sortText: "0" + tag + "a",
@@ -211,7 +300,8 @@
211
300
  label: tag,
212
301
  kind: monaco.languages.CompletionItemKind.Property,
213
302
  insertText: "<" + tag + " $0/>",
214
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
303
+ insertTextRules:
304
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
215
305
  range: range,
216
306
  detail: "<" + tag + " />",
217
307
  sortText: "0" + tag + "b",
@@ -220,9 +310,15 @@
220
310
  }
221
311
 
222
312
  // Custom components from registry (PascalCase)
223
- var upperMatch = afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
224
- var bareUpper = !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
225
- var compTyped = upperMatch ? upperMatch[1] : (bareUpper ? bareUpper[1] : "");
313
+ var upperMatch =
314
+ afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
315
+ var bareUpper =
316
+ !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
317
+ var compTyped = upperMatch
318
+ ? upperMatch[1]
319
+ : bareUpper
320
+ ? bareUpper[1]
321
+ : "";
226
322
 
227
323
  if (compTyped || (!typed && afterBracket)) {
228
324
  // Fetch component names from registry (cached on window)
@@ -231,13 +327,19 @@
231
327
  var name = reg[c];
232
328
  if (seen.has(name)) continue;
233
329
  if (compTyped && name.indexOf(compTyped) !== 0) continue;
234
- if (!compTyped && typed && name.toLowerCase().indexOf(typed) !== 0) continue;
330
+ if (
331
+ !compTyped &&
332
+ typed &&
333
+ name.toLowerCase().indexOf(typed) !== 0
334
+ )
335
+ continue;
235
336
  seen.add(name);
236
337
  suggestions.push({
237
338
  label: name,
238
339
  kind: monaco.languages.CompletionItemKind.Class,
239
340
  insertText: "<" + name + ">$0</" + name + ">",
240
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
341
+ insertTextRules:
342
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
241
343
  range: range,
242
344
  detail: "<" + name + ">…</" + name + ">",
243
345
  sortText: "0" + name + "a",
@@ -246,7 +348,8 @@
246
348
  label: name,
247
349
  kind: monaco.languages.CompletionItemKind.Class,
248
350
  insertText: "<" + name + " $0/>",
249
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
351
+ insertTextRules:
352
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
250
353
  range: range,
251
354
  detail: "<" + name + " />",
252
355
  sortText: "0" + name + "b",
@@ -258,7 +361,8 @@
258
361
  label: compTyped,
259
362
  kind: monaco.languages.CompletionItemKind.Class,
260
363
  insertText: "<" + compTyped + ">$0</" + compTyped + ">",
261
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
364
+ insertTextRules:
365
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
262
366
  range: range,
263
367
  detail: "<" + compTyped + ">…</" + compTyped + ">",
264
368
  sortText: "0" + compTyped + "a",
@@ -267,7 +371,8 @@
267
371
  label: compTyped,
268
372
  kind: monaco.languages.CompletionItemKind.Class,
269
373
  insertText: "<" + compTyped + " $0/>",
270
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
374
+ insertTextRules:
375
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
271
376
  range: range,
272
377
  detail: "<" + compTyped + " />",
273
378
  sortText: "0" + compTyped + "b",
@@ -275,7 +380,13 @@
275
380
  }
276
381
  }
277
382
 
278
- console.log(PREFIX, "JSX tag completion: typed='" + typed + "', suggestions=" + suggestions.length);
383
+ console.log(
384
+ PREFIX,
385
+ "JSX tag completion: typed='" +
386
+ typed +
387
+ "', suggestions=" +
388
+ suggestions.length,
389
+ );
279
390
  return { suggestions: suggestions };
280
391
  },
281
392
  });
@@ -296,12 +407,14 @@
296
407
  var m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
297
408
  var matchStr, tag, prefix;
298
409
  if (m) {
299
- prefix = m[1]; tag = m[2];
410
+ prefix = m[1];
411
+ tag = m[2];
300
412
  matchStr = "<" + tag + ">/</" + tag + ">";
301
413
  } else {
302
414
  m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
303
415
  if (!m) return;
304
- prefix = m[1]; tag = m[2];
416
+ prefix = m[1];
417
+ tag = m[2];
305
418
  matchStr = "<" + tag + "/></" + tag + ">";
306
419
  }
307
420
  var startCol = prefix.length + 1;
@@ -309,15 +422,17 @@
309
422
  var replacement = "<" + tag + " />";
310
423
  var cursorCol = startCol + replacement.length - 2;
311
424
  setTimeout(function () {
312
- editor.executeEdits("self-close-collapse", [{
313
- range: {
314
- startLineNumber: lineNum,
315
- endLineNumber: lineNum,
316
- startColumn: startCol,
317
- endColumn: endCol,
425
+ editor.executeEdits("self-close-collapse", [
426
+ {
427
+ range: {
428
+ startLineNumber: lineNum,
429
+ endLineNumber: lineNum,
430
+ startColumn: startCol,
431
+ endColumn: endCol,
432
+ },
433
+ text: replacement,
318
434
  },
319
- text: replacement,
320
- }]);
435
+ ]);
321
436
  editor.setPosition({ lineNumber: lineNum, column: cursorCol });
322
437
  }, 0);
323
438
  });
@@ -328,7 +443,11 @@
328
443
  function refreshComponentNames() {
329
444
  $.getJSON("portal-react/registry", function (reg) {
330
445
  window.__fcComponentNames = Object.keys(reg);
331
- console.log(PREFIX, "component names loaded:", window.__fcComponentNames);
446
+ console.log(
447
+ PREFIX,
448
+ "component names loaded:",
449
+ window.__fcComponentNames,
450
+ );
332
451
  }).fail(function () {
333
452
  window.__fcComponentNames = [];
334
453
  });
@@ -343,11 +462,19 @@
343
462
  console.group(PREFIX + " MARKERS on " + uri.toString());
344
463
  markers.forEach(function (m) {
345
464
  console.log(
346
- " code=" + (m.code || "?") +
347
- " severity=" + m.severity +
348
- " source=" + (m.source || "?") +
349
- " msg=" + m.message +
350
- " [L" + m.startLineNumber + ":" + m.startColumn + "]"
465
+ " code=" +
466
+ (m.code || "?") +
467
+ " severity=" +
468
+ m.severity +
469
+ " source=" +
470
+ (m.source || "?") +
471
+ " msg=" +
472
+ m.message +
473
+ " [L" +
474
+ m.startLineNumber +
475
+ ":" +
476
+ m.startColumn +
477
+ "]",
351
478
  );
352
479
  });
353
480
  console.groupEnd();
@@ -379,8 +506,16 @@
379
506
  console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
380
507
  jsDef.setDiagnosticsOptions(diagOpts);
381
508
 
382
- console.log(PREFIX, "readback compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
383
- console.log(PREFIX, "readback diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
509
+ console.log(
510
+ PREFIX,
511
+ "readback compilerOptions:",
512
+ JSON.stringify(jsDef.getCompilerOptions()),
513
+ );
514
+ console.log(
515
+ PREFIX,
516
+ "readback diagnosticsOptions:",
517
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
518
+ );
384
519
  }
385
520
 
386
521
  // Exported for oneditprepare to call before model creation
@@ -391,7 +526,10 @@
391
526
  window.__fcLoadMonaco = function (cb) {
392
527
  console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
393
528
  if (window.monaco) {
394
- console.log(PREFIX, "Monaco already loaded (by Node-RED?), running setup inline");
529
+ console.log(
530
+ PREFIX,
531
+ "Monaco already loaded (by Node-RED?), running setup inline",
532
+ );
395
533
  ensureJsxSetup();
396
534
  cb();
397
535
  return;
@@ -497,7 +635,7 @@
497
635
  inputs: 0,
498
636
  outputs: 0,
499
637
  icon: "font-awesome/fa-cube",
500
- paletteLabel: "component",
638
+ paletteLabel: "fromcubes component",
501
639
  label: function () {
502
640
  return this.name || this.compName || "component";
503
641
  },
@@ -510,22 +648,32 @@
510
648
  if (!window.__fcTwClasses) {
511
649
  console.log("[FC-Monaco] loading tw-classes...");
512
650
  $.getJSON("portal-react/tw-classes", function (classes) {
513
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
651
+ console.log(
652
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
653
+ );
514
654
  window.__fcTwClasses = classes;
515
655
  }).fail(function (xhr) {
516
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
656
+ console.error(
657
+ "[FC-Monaco] tw-classes FAILED:",
658
+ xhr.status,
659
+ xhr.statusText,
660
+ );
517
661
  });
518
662
  }
519
663
 
520
664
  window.__fcLoadMonaco(function (failed) {
521
665
  if (failed) {
522
- console.error("[FC-Monaco] COMP: Monaco load failed, using fallback textarea");
666
+ console.error(
667
+ "[FC-Monaco] COMP: Monaco load failed, using fallback textarea",
668
+ );
523
669
  $("#fcc-monaco").hide();
524
670
  $("#fcc-fallback").show().val(code);
525
671
  return;
526
672
  }
527
673
 
528
- console.log("[FC-Monaco] COMP: applying JSX defaults before model creation");
674
+ console.log(
675
+ "[FC-Monaco] COMP: applying JSX defaults before model creation",
676
+ );
529
677
  window.__fcApplyJsxDefaults();
530
678
 
531
679
  var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
@@ -535,8 +683,15 @@
535
683
  console.log("[FC-Monaco] COMP: disposing existing model");
536
684
  existingModel.dispose();
537
685
  }
538
- var compModel = monaco.editor.createModel(code, "javascript", compUri);
539
- console.log("[FC-Monaco] COMP: model created, language=" + compModel.getLanguageId());
686
+ var compModel = monaco.editor.createModel(
687
+ code,
688
+ "javascript",
689
+ compUri,
690
+ );
691
+ console.log(
692
+ "[FC-Monaco] COMP: model created, language=" +
693
+ compModel.getLanguageId(),
694
+ );
540
695
 
541
696
  compEditorInstance = monaco.editor.create(
542
697
  document.getElementById("fcc-monaco"),
@@ -547,13 +702,27 @@
547
702
  // Log markers after a short delay (diagnostics are async)
548
703
  setTimeout(function () {
549
704
  var markers = monaco.editor.getModelMarkers({ resource: compUri });
550
- console.log("[FC-Monaco] COMP: markers after 500ms, count=" + markers.length);
705
+ console.log(
706
+ "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
707
+ );
551
708
  markers.forEach(function (m) {
552
- console.log("[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message);
709
+ console.log(
710
+ "[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message,
711
+ );
553
712
  });
554
713
  // Also log current compiler options state
555
- console.log("[FC-Monaco] COMP: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
556
- console.log("[FC-Monaco] COMP: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
714
+ console.log(
715
+ "[FC-Monaco] COMP: current compilerOptions:",
716
+ JSON.stringify(
717
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
718
+ ),
719
+ );
720
+ console.log(
721
+ "[FC-Monaco] COMP: current diagnosticsOptions:",
722
+ JSON.stringify(
723
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
724
+ ),
725
+ );
557
726
  }, 500);
558
727
  });
559
728
  },
@@ -631,6 +800,34 @@
631
800
  <ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
632
801
  </div>
633
802
  <div id="fc-tabs-content">
803
+ <!-- ── Tab: JSX ── -->
804
+ <div id="fc-tab-jsx" class="fc-tab-pane">
805
+ <div class="form-row node-text-editor-row">
806
+ <div
807
+ id="fc-editor-wrap"
808
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
809
+ >
810
+ <div id="fc-monaco" style="width:100%;height:100%;"></div>
811
+ <textarea
812
+ id="fc-fallback"
813
+ style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
814
+ ></textarea>
815
+ </div>
816
+ </div>
817
+ <div class="form-row" style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap;">
818
+ <button type="button" class="red-ui-button" id="fc-btn-starter">
819
+ <i class="fa fa-magic"></i> Default
820
+ </button>
821
+ <button type="button" class="red-ui-button" id="fc-btn-components">
822
+ <i class="fa fa-cube"></i> Components
823
+ </button>
824
+ <span style="flex:1"></span>
825
+ <button type="button" class="red-ui-button" id="fc-btn-preview">
826
+ <i class="fa fa-eye"></i> Preview
827
+ </button>
828
+ </div>
829
+ </div>
830
+
634
831
  <!-- ── Tab: Properties ── -->
635
832
  <div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
636
833
  <div class="form-row">
@@ -641,7 +838,7 @@
641
838
  <label for="node-input-endpoint"
642
839
  ><i class="fa fa-globe"></i> Endpoint</label
643
840
  >
644
- <input type="text" id="node-input-endpoint" placeholder="/portal" />
841
+ <input type="text" id="node-input-endpoint" placeholder="/fromcubes" />
645
842
  <div
646
843
  style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
647
844
  id="fc-url-hint"
@@ -653,41 +850,41 @@
653
850
  >
654
851
  <input type="text" id="node-input-pageTitle" placeholder="Portal" />
655
852
  </div>
656
- </div>
657
-
658
- <!-- ── Tab: JSX ── -->
659
- <div id="fc-tab-jsx" class="fc-tab-pane">
660
- <div class="form-row" style="margin-bottom:0;">
661
- <div style="font-size:11px;opacity:.6;margin-bottom:4px;">
662
- <code>useNodeRed()</code> &rarr;
663
- <code>{ data, send }</code> &nbsp;|&nbsp; Components from
664
- <code>fc-portal-component</code> nodes auto-imported &nbsp;|&nbsp;
665
- Must export <code>&lt;App /&gt;</code> &nbsp;|&nbsp;
666
- <strong>Transpiled server-side at deploy</strong>
667
- </div>
668
- </div>
669
- <div class="form-row node-text-editor-row">
853
+ <div class="form-row node-input-libs-container-row">
854
+ <label style="width:auto;"><i class="fa fa-archive"></i> Modules</label>
855
+ <ol id="node-input-libs-container"></ol>
670
856
  <div
671
- id="fc-editor-wrap"
672
- style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
857
+ style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;"
673
858
  >
674
- <div id="fc-monaco" style="width:100%;height:100%;"></div>
675
- <textarea
676
- id="fc-fallback"
677
- style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
678
- ></textarea>
859
+ npm packages to bundle. Node-RED auto-installs them at deploy time.
679
860
  </div>
680
861
  </div>
681
- <div class="form-row" style="display:flex;gap:8px;margin-top:4px;">
682
- <button type="button" class="red-ui-button" id="fc-btn-preview">
683
- <i class="fa fa-eye"></i> Preview
684
- </button>
685
- <button type="button" class="red-ui-button" id="fc-btn-components">
686
- <i class="fa fa-cube"></i> Components
687
- </button>
688
- <button type="button" class="red-ui-button" id="fc-btn-starter">
689
- <i class="fa fa-magic"></i> Starter
690
- </button>
862
+ <div class="form-row" style="margin-top:12px;">
863
+ <label style="width:auto;">
864
+ <input
865
+ type="checkbox"
866
+ id="node-input-showWsStatus"
867
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
868
+ />
869
+ Show WebSocket status indicator
870
+ </label>
871
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
872
+ Displays a small <em>fromcubes</em> badge in the bottom-right corner
873
+ showing connection state.
874
+ </div>
875
+ </div>
876
+ <div class="form-row" style="margin-top:12px;">
877
+ <label style="width:auto;">
878
+ <input
879
+ type="checkbox"
880
+ id="node-input-portalAuth"
881
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
882
+ />
883
+ Enable Portal Auth headers
884
+ </label>
885
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
886
+ Read <code>X-Portal-*</code> headers from reverse proxy.
887
+ </div>
691
888
  </div>
692
889
  </div>
693
890
 
@@ -734,7 +931,7 @@
734
931
  "",
735
932
  " return (",
736
933
  ' <div className="min-h-screen bg-zinc-950 p-8">',
737
- ' <h1 className="text-2xl font-light text-cyan-400 mb-6">Portal</h1>',
934
+ ' <h1 className="text-2xl font-light text-cyan-400 mb-6">fromcubes</h1>',
738
935
  ' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
739
936
  ' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
740
937
  " </div>",
@@ -754,15 +951,18 @@
754
951
  color: "#61dafb",
755
952
  defaults: {
756
953
  name: { value: "" },
757
- endpoint: { value: "/portal", required: true },
758
- pageTitle: { value: "Portal" },
954
+ endpoint: { value: "/fromcubes", required: true },
955
+ pageTitle: { value: "fromcubes" },
759
956
  componentCode: { value: STARTER },
760
957
  customHead: { value: "" },
958
+ portalAuth: { value: false },
959
+ showWsStatus: { value: false },
960
+ libs: { value: [] },
761
961
  },
762
962
  inputs: 1,
763
963
  outputs: 1,
764
964
  icon: "font-awesome/fa-desktop",
765
- paletteLabel: "portal react",
965
+ paletteLabel: "fromcubes portal",
766
966
  label: function () {
767
967
  return this.name || this.endpoint || "portal react";
768
968
  },
@@ -776,10 +976,16 @@
776
976
  if (!window.__fcTwClasses) {
777
977
  console.log("[FC-Monaco] loading tw-classes...");
778
978
  $.getJSON("portal-react/tw-classes", function (classes) {
779
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
979
+ console.log(
980
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
981
+ );
780
982
  window.__fcTwClasses = classes;
781
983
  }).fail(function (xhr) {
782
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
984
+ console.error(
985
+ "[FC-Monaco] tw-classes FAILED:",
986
+ xhr.status,
987
+ xhr.statusText,
988
+ );
783
989
  });
784
990
  }
785
991
 
@@ -802,16 +1008,39 @@
802
1008
 
803
1009
  // URL hint
804
1010
  function updateHint() {
805
- var ep = $("#node-input-endpoint").val() || "/portal";
806
- $("#fc-url-hint").text("Page served at: http://<host>:1880" + ep);
1011
+ var ep = $("#node-input-endpoint").val() || "/fromcubes";
1012
+ var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1013
+ $("#fc-url-hint").text("Page served at: http://<host>:1880" + root + ep);
807
1014
  }
808
1015
  $("#node-input-endpoint").on("input", updateHint);
809
1016
  updateHint();
810
1017
 
1018
+ // Modules editableList (like function node's libs)
1019
+ var libsList = node.libs || [];
1020
+ $("#node-input-libs-container").css("min-height","68px").editableList({
1021
+ addItem: function(container, i, opt) {
1022
+ var lib = opt || {};
1023
+ var row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1024
+ var modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1025
+ var varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1026
+ modInput.val(lib.module || "");
1027
+ varInput.val(lib.var || "");
1028
+ container.data("mod", modInput);
1029
+ container.data("var", varInput);
1030
+ },
1031
+ removable: true,
1032
+ sortable: true
1033
+ });
1034
+ libsList.forEach(function(lib) {
1035
+ $("#node-input-libs-container").editableList("addItem", lib);
1036
+ });
1037
+
811
1038
  // Monaco
812
1039
  window.__fcLoadMonaco(function (failed) {
813
1040
  if (failed) {
814
- console.error("[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea");
1041
+ console.error(
1042
+ "[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea",
1043
+ );
815
1044
  $("#fc-monaco").hide();
816
1045
  $("#fc-fallback").show().val(code);
817
1046
  $("#fc-head-monaco").hide();
@@ -819,11 +1048,15 @@
819
1048
  return;
820
1049
  }
821
1050
 
822
- console.log("[FC-Monaco] PORTAL: applying JSX defaults before model creation");
1051
+ console.log(
1052
+ "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1053
+ );
823
1054
  window.__fcApplyJsxDefaults();
824
1055
  var opts = window.__fcEditorOpts;
825
1056
 
826
- var jsxUri = monaco.Uri.parse("file:///fc-portal-" + node.id + ".jsx");
1057
+ var jsxUri = monaco.Uri.parse(
1058
+ "file:///fc-portal-" + node.id + ".jsx",
1059
+ );
827
1060
  console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
828
1061
  var existingJsx = monaco.editor.getModel(jsxUri);
829
1062
  if (existingJsx) {
@@ -831,15 +1064,21 @@
831
1064
  existingJsx.dispose();
832
1065
  }
833
1066
  var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
834
- console.log("[FC-Monaco] PORTAL: JSX model created, language=" + jsxModel.getLanguageId());
1067
+ console.log(
1068
+ "[FC-Monaco] PORTAL: JSX model created, language=" +
1069
+ jsxModel.getLanguageId(),
1070
+ );
835
1071
  editorInstance = monaco.editor.create(
836
1072
  document.getElementById("fc-monaco"),
837
1073
  Object.assign({ model: jsxModel }, opts),
838
1074
  );
839
1075
  console.log("[FC-Monaco] PORTAL: JSX editor created");
840
- if (window.__fcAttachSelfClose) window.__fcAttachSelfClose(editorInstance);
1076
+ if (window.__fcAttachSelfClose)
1077
+ window.__fcAttachSelfClose(editorInstance);
841
1078
 
842
- var headUri = monaco.Uri.parse("file:///fc-head-" + node.id + ".html");
1079
+ var headUri = monaco.Uri.parse(
1080
+ "file:///fc-head-" + node.id + ".html",
1081
+ );
843
1082
  var existingHead = monaco.editor.getModel(headUri);
844
1083
  if (existingHead) existingHead.dispose();
845
1084
  var headModel = monaco.editor.createModel(headCode, "html", headUri);
@@ -852,77 +1091,173 @@
852
1091
  // Log markers after a short delay (diagnostics are async)
853
1092
  setTimeout(function () {
854
1093
  var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
855
- console.log("[FC-Monaco] PORTAL: markers after 500ms, count=" + markers.length);
1094
+ console.log(
1095
+ "[FC-Monaco] PORTAL: markers after 500ms, count=" +
1096
+ markers.length,
1097
+ );
856
1098
  markers.forEach(function (m) {
857
- console.log("[FC-Monaco] PORTAL marker: code=" + m.code + " severity=" + m.severity + " msg=" + m.message);
1099
+ console.log(
1100
+ "[FC-Monaco] PORTAL marker: code=" +
1101
+ m.code +
1102
+ " severity=" +
1103
+ m.severity +
1104
+ " msg=" +
1105
+ m.message,
1106
+ );
858
1107
  });
859
- console.log("[FC-Monaco] PORTAL: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
860
- console.log("[FC-Monaco] PORTAL: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
1108
+ console.log(
1109
+ "[FC-Monaco] PORTAL: current compilerOptions:",
1110
+ JSON.stringify(
1111
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
1112
+ ),
1113
+ );
1114
+ console.log(
1115
+ "[FC-Monaco] PORTAL: current diagnosticsOptions:",
1116
+ JSON.stringify(
1117
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
1118
+ ),
1119
+ );
861
1120
  }, 500);
862
1121
  });
863
1122
 
864
1123
  // Buttons
865
1124
  $("#fc-btn-starter").on("click", function () {
866
- if (editorInstance) editorInstance.setValue(STARTER);
867
- else $("#fc-fallback").val(STARTER);
1125
+ $("<div>Replace current JSX with default starter code?</div>").dialog({
1126
+ title: "Load Default",
1127
+ modal: true,
1128
+ width: 360,
1129
+ buttons: [
1130
+ { text: "Cancel", click: function () { $(this).dialog("close"); } },
1131
+ { text: "Replace", class: "primary", click: function () {
1132
+ if (editorInstance) editorInstance.setValue(STARTER);
1133
+ else $("#fc-fallback").val(STARTER);
1134
+ $(this).dialog("close");
1135
+ }},
1136
+ ],
1137
+ close: function () { $(this).remove(); },
1138
+ });
868
1139
  });
869
1140
 
870
1141
  $("#fc-btn-preview").on("click", function () {
871
1142
  var ep = $("#node-input-endpoint").val();
872
- if (ep) window.open(ep, "_blank");
1143
+ var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1144
+ if (ep) window.open(root + ep, "_blank");
873
1145
  });
874
1146
 
875
1147
  $("#fc-btn-components").on("click", function () {
876
1148
  $.getJSON("portal-react/registry", function (reg) {
877
- var names = Object.keys(reg);
1149
+ var names = Object.keys(reg).sort();
878
1150
  if (!names.length) {
879
1151
  RED.notify("No component nodes on canvas.", "warning");
880
1152
  return;
881
1153
  }
882
- var html = "<p>Click to insert:</p>";
1154
+
1155
+ function extractProps(code) {
1156
+ if (!code) return [];
1157
+ var m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1158
+ if (!m) return [];
1159
+ return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
1160
+ }
1161
+
1162
+ var html =
1163
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1164
+ '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
1165
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1166
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1167
+ '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
1168
+
883
1169
  names.forEach(function (n) {
884
1170
  var c = reg[n];
1171
+ var props = extractProps(c.code);
1172
+ var detailParts = [];
1173
+ if (props.length) {
1174
+ detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
1175
+ props.map(function (p) {
1176
+ return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
1177
+ }).join("") + '</div>');
1178
+ }
1179
+ var io = [];
1180
+ if ((c.inputs || []).length) io.push("in: " + c.inputs.join(", "));
1181
+ if ((c.outputs || []).length) io.push("out: " + c.outputs.join(", "));
1182
+ if (io.length) {
1183
+ detailParts.push('<div style="font-size:10px;opacity:.4;margin-top:2px;">' + io.join(" &bull; ") + '</div>');
1184
+ }
1185
+ var hasDetail = detailParts.length > 0;
1186
+
885
1187
  html +=
886
- '<div style="padding:4px 0;border-bottom:1px solid rgba(128,128,128,.2)">' +
887
- '<button class="red-ui-button fc-lib-ins" data-name="' +
888
- n +
889
- '">' +
890
- n +
891
- "</button>" +
892
- ' <span style="opacity:.5;font-size:11px">in:[' +
893
- (c.inputs || []).join(",") +
894
- "] out:[" +
895
- (c.outputs || []).join(",") +
896
- "]</span></div>";
1188
+ '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
1189
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1190
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-comp-row">' +
1191
+ (hasDetail ? '<i class="fa fa-caret-right fc-comp-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' : '<span style="width:14px;"></span>') +
1192
+ '<span class="fc-comp-name" data-name="' + n + '" style="font-weight:600;font-size:12px;flex:1;">' + n + '</span>' +
1193
+ '</div>' +
1194
+ (hasDetail ? '<div class="fc-comp-detail" style="display:none;padding:0 6px 4px 20px;">' + detailParts.join("") + '</div>' : '') +
1195
+ '</div>';
897
1196
  });
898
- $("<div></div>")
899
- .html(html)
900
- .dialog({
901
- title: "Available Components",
902
- modal: true,
903
- width: 400,
904
- buttons: [
905
- {
906
- text: "Close",
907
- click: function () {
908
- $(this).dialog("close");
909
- },
910
- },
911
- ],
912
- close: function () {
913
- $(this).remove();
914
- },
1197
+ html += '</div></div>';
1198
+
1199
+ var $dlg = $("<div></div>").html(html).dialog({
1200
+ title: "Components",
1201
+ modal: true,
1202
+ width: 380,
1203
+ buttons: [
1204
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1205
+ ],
1206
+ close: function () { $(this).remove(); },
1207
+ });
1208
+
1209
+ // Search
1210
+ $dlg.find("#fc-comp-search").on("input", function () {
1211
+ var q = $(this).val().toLowerCase();
1212
+ $dlg.find(".fc-comp-item").each(function () {
1213
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
915
1214
  });
916
- $(document).on("click", ".fc-lib-ins", function () {
917
- var tag = "<" + $(this).data("name") + " />";
1215
+ }).focus();
1216
+
1217
+ // Hover
1218
+ $dlg.on("mouseenter", ".fc-comp-row", function () {
1219
+ $(this).css("background", "rgba(128,128,128,.1)");
1220
+ }).on("mouseleave", ".fc-comp-row", function () {
1221
+ $(this).css("background", "");
1222
+ });
1223
+
1224
+ // Arrow toggle detail
1225
+ $dlg.on("click", ".fc-comp-arrow", function (e) {
1226
+ e.stopPropagation();
1227
+ var $item = $(this).closest(".fc-comp-item");
1228
+ var $detail = $item.find(".fc-comp-detail");
1229
+ var open = $detail.is(":visible");
1230
+ $detail.slideToggle(100);
1231
+ $(this).css("transform", open ? "" : "rotate(90deg)");
1232
+ });
1233
+
1234
+ // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
1235
+ $dlg.on("click", ".fc-comp-name", function () {
1236
+ var name = $(this).data("name");
1237
+ var openTag = "<" + name + ">";
1238
+ var closeTag = "</" + name + ">";
1239
+ var text = openTag + closeTag;
1240
+ $dlg.dialog("close");
918
1241
  if (editorInstance) {
919
- editorInstance.trigger("keyboard", "type", { text: tag });
920
- editorInstance.focus();
1242
+ var sel = editorInstance.getSelection();
1243
+ var startLine = sel.startLineNumber;
1244
+ var startCol = sel.startColumn;
1245
+ editorInstance.executeEdits("fc-components", [{
1246
+ range: sel,
1247
+ text: text,
1248
+ }]);
1249
+ setTimeout(function () {
1250
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + openTag.length });
1251
+ editorInstance.focus();
1252
+ }, 50);
921
1253
  } else {
922
1254
  var ta = $("#fc-fallback")[0];
923
- var p = ta.selectionStart,
924
- v = ta.value;
925
- ta.value = v.slice(0, p) + tag + v.slice(p);
1255
+ var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1256
+ ta.value = v.slice(0, s) + text + v.slice(e);
1257
+ setTimeout(function () {
1258
+ ta.selectionStart = ta.selectionEnd = s + openTag.length;
1259
+ ta.focus();
1260
+ }, 50);
926
1261
  }
927
1262
  });
928
1263
  });
@@ -931,6 +1266,19 @@
931
1266
 
932
1267
  oneditsave: function () {
933
1268
  console.log("[FC-Monaco] PORTAL oneditsave");
1269
+
1270
+ // Collect libs from editableList
1271
+ var libs = [];
1272
+ var items = $("#node-input-libs-container").editableList("items");
1273
+ items.each(function() {
1274
+ var mod = $(this).data("mod").val().trim();
1275
+ var v = $(this).data("var").val().trim();
1276
+ if (mod) {
1277
+ libs.push({ module: mod, var: v });
1278
+ }
1279
+ });
1280
+ this.libs = libs;
1281
+
934
1282
  var code = editorInstance
935
1283
  ? editorInstance.getValue()
936
1284
  : $("#fc-fallback").val();
@@ -997,15 +1345,20 @@
997
1345
  Help
998
1346
  ============================================================ -->
999
1347
  <script type="text/html" data-help-name="portal-react">
1348
+ <h3>Quick Reference</h3>
1349
+ <ul>
1350
+ <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user }</code></li>
1351
+ <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
1352
+ <li>Must export <code>&lt;App /&gt;</code></li>
1353
+ <li>JSX is transpiled server-side at deploy</li>
1354
+ </ul>
1355
+
1000
1356
  <p>
1001
- Renders a React application on a configurable HTTP endpoint with live
1002
- WebSocket data binding.
1003
- </p>
1004
- <h3>Key difference from Dashboard 2.0</h3>
1005
- <p>
1006
- JSX is transpiled <strong>server-side at deploy time</strong> using Sucrase.
1007
- The browser receives plain JS — no Babel, no runtime compilation. Page
1008
- weight: ~45 KB (React production).
1357
+ Renders a React 19 application on a configurable HTTP endpoint with live
1358
+ WebSocket data binding. JSX is transpiled <strong>server-side at deploy
1359
+ time</strong> using esbuild — browsers receive pre-compiled JS with zero
1360
+ runtime compilation. Tailwind CSS 4 utility classes are generated
1361
+ server-side and served as static CSS.
1009
1362
  </p>
1010
1363
 
1011
1364
  <h3>Inputs</h3>
@@ -1013,21 +1366,36 @@
1013
1366
  <code>msg.payload</code> is pushed to all connected clients via WebSocket.
1014
1367
  </p>
1015
1368
  <pre>
1016
- const { data, send } = useNodeRed();
1017
- // data = last msg.payload</pre
1369
+ const { data, send, user } = useNodeRed();
1370
+ // data = last msg.payload (reactive)
1371
+ // send(payload, topic?) — emit msg on output wire
1372
+ // user = portal auth data or null</pre
1018
1373
  >
1019
1374
 
1020
1375
  <h3>Outputs</h3>
1021
1376
  <p>
1022
1377
  <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
1023
- output wire.
1378
+ output wire via WebSocket.
1379
+ </p>
1380
+
1381
+ <h3>npm Packages</h3>
1382
+ <p>
1383
+ Add npm packages in the <strong>Modules</strong> list (e.g.
1384
+ <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
1385
+ Node-RED auto-installs them at deploy time. All packages are bundled with
1386
+ React into a single vendor IIFE via esbuild, cached by hash of installed
1387
+ versions. Use them in JSX via standard imports.
1024
1388
  </p>
1025
1389
 
1026
1390
  <h3>Deploy behavior</h3>
1027
1391
  <ul>
1028
1392
  <li>
1029
- Each deploy re-transpiles JSX (cached by content hash unchanged code is
1030
- instant)
1393
+ Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
1394
+ (both cached by content hash — unchanged code is instant)
1395
+ </li>
1396
+ <li>
1397
+ Vendor bundle (React + npm packages) rebuilt only when package versions
1398
+ change
1031
1399
  </li>
1032
1400
  <li>
1033
1401
  Active WebSocket clients receive close code 1001 and auto-reconnect
@@ -1045,15 +1413,34 @@ const { data, send } = useNodeRed();
1045
1413
  <p>
1046
1414
  Components defined in <strong>fc-portal-component</strong> nodes on the
1047
1415
  canvas are auto-injected into every portal-react page. Use them as JSX tags
1048
- by their component name.
1416
+ by their component name — no imports needed.
1049
1417
  </p>
1050
1418
 
1419
+ <h3>Portal Auth</h3>
1420
+ <p>
1421
+ When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
1422
+ a reverse proxy (e.g. Nginx) and exposes user data:
1423
+ </p>
1424
+ <ul>
1425
+ <li><code>useNodeRed()</code> returns <code>{ data, send, user }</code></li>
1426
+ <li>
1427
+ Messages from WebSocket include <code>msg._client</code> with user info
1428
+ </li>
1429
+ </ul>
1430
+
1051
1431
  <h3>Custom Head HTML</h3>
1052
1432
  <p>
1053
1433
  Inject CDN links, fonts, or extra stylesheets into
1054
1434
  <code>&lt;head&gt;</code>. Example:
1055
1435
  </p>
1056
1436
  <pre>
1057
- &lt;link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"&gt;</pre
1437
+ &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
1058
1438
  >
1439
+
1440
+ <h3>WebSocket Status</h3>
1441
+ <p>
1442
+ Optional <em>fromcubes</em> badge in the bottom-right corner showing
1443
+ connection state. Enable via the <strong>Show WebSocket status
1444
+ indicator</strong> checkbox (off by default).
1445
+ </p>
1059
1446
  </script>