@aaqu/fromcubes-react 0.2.1 → 0.2.2

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.
@@ -14,7 +14,7 @@
14
14
  link.rel = "stylesheet";
15
15
  link.href = base + "editor/workspace.css";
16
16
  document.head.appendChild(link);
17
- ["file-tree.js", "workspace.js", "format.js", "provider-settings.js"].forEach(function (file) {
17
+ ["file-tree.js", "class-completion.js", "workspace.js", "format.js", "provider-settings.js"].forEach(function (file) {
18
18
  const s = document.createElement("script");
19
19
  s.src = base + "editor/" + file;
20
20
  s.async = false;
@@ -44,12 +44,13 @@
44
44
  quickSuggestions: { strings: true },
45
45
  };
46
46
 
47
- // className="…" completion list, from the CSS engine in force (Settings →
48
- // Fromcubes). Refetched every time a dialog or the React Editor opens and
47
+ // className="…" completion data, from the CSS engine in force (Settings →
48
+ // Fromcubes): classes with their swatch colours and modifiers, and the
49
+ // variants. Refetched every time a dialog or the React Editor opens and
49
50
  // after the engine changes — the engine can change while the editor is
50
51
  // open, so "load once" would keep offering the old engine's classes.
51
52
  window.__fcCssClasses = null;
52
- window.__fcCssClassSet = null;
53
+ window.__fcCssData = null;
53
54
  window.__fcCssEngine = null;
54
55
  let cssClassesPending = false;
55
56
  window.__fcRefreshCssClasses = function () {
@@ -57,9 +58,26 @@
57
58
  cssClassesPending = true;
58
59
  $.getJSON("fromcubes-react/css-classes", function (data) {
59
60
  if (!data || !Array.isArray(data.classes)) return;
61
+ const prev = window.__fcCssData;
60
62
  window.__fcCssEngine = data.engine;
61
63
  window.__fcCssClasses = data.classes;
62
- window.__fcCssClassSet = null;
64
+ window.__fcCssData = {
65
+ engine: data.engine,
66
+ classes: data.classes,
67
+ colors: data.colors || [],
68
+ modifiers: data.modifiers || [],
69
+ classModifiers: data.classModifiers || [],
70
+ variants: data.variants || [],
71
+ describe: data.describe === true,
72
+ canonical: data.canonical === true,
73
+ };
74
+ // What the engine said about classes stays true while it is the same
75
+ // engine; a refetch on every dialog open must not ask again.
76
+ if (prev && prev.engine === data.engine && prev._described) {
77
+ Object.defineProperty(window.__fcCssData, "_described", { value: prev._described });
78
+ Object.defineProperty(window.__fcCssData, "_canonical", { value: prev._canonical });
79
+ }
80
+ if (window.__fcRevalidateCss) window.__fcRevalidateCss();
63
81
  })
64
82
  .fail(function (xhr) {
65
83
  console.error("[FC-Monaco] css-classes FAILED:", xhr.status, xhr.statusText);
@@ -117,102 +135,398 @@
117
135
  console.log(PREFIX, "addExtraLib globals.d.ts");
118
136
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
119
137
 
120
- // className completion provider — classes from the CSS engine in force
121
- // Triggers in: className="...", className={`...`}, and any string literal
122
- // containing at least one known TW class (e.g. "bg-red-500 text-white")
123
- console.log(PREFIX, "registering Tailwind completion provider");
124
- monaco.languages.registerCompletionItemProvider("javascript", {
125
- triggerCharacters: ['"', "'", "`", " "],
126
- provideCompletionItems: function (model, position) {
127
- const line = model.getLineContent(position.lineNumber);
128
- const before = line.substring(0, position.column - 1);
138
+ // className completion, hover and CSS preview — Tailwind CSS
139
+ // IntelliSense's logic (editor/class-completion.js) over the data of the
140
+ // CSS engine in force. Inside className="…" / className={…} it offers
141
+ // what IntelliSense offers: variants first (`hover:` reopens the list),
142
+ // then every class in the engine's order with colour swatches, and after
143
+ // `/` the class's modifiers. Strings elsewhere complete too once they
144
+ // hold a known class — this editor's addition.
145
+ console.log(PREFIX, "registering CSS class completion provider");
146
+ const cssKinds = {
147
+ variant: monaco.languages.CompletionItemKind.Module,
148
+ color: monaco.languages.CompletionItemKind.Color,
149
+ constant: monaco.languages.CompletionItemKind.Constant,
150
+ };
129
151
 
130
- // 1) className="..." or className={`...`}
131
- const isClassName =
132
- before.match(/className\s*=\s*["'][^"']*$/) ||
133
- before.match(/className\s*=\s*\{`[^`]*$/);
152
+ function cssData() {
153
+ const cc = window.__fcClassCompletion;
154
+ const data = window.__fcCssData;
155
+ if (!cc || !data || !data.classes.length) return null;
156
+ return cc.prepare(data);
157
+ }
134
158
 
135
- // 2) Any open string literal: "..., '..., or `...
136
- const inString = !isClassName && before.match(/["'`][^"'`]*$/);
159
+ // What the engine says about each candidate (CSS, summary, colour,
160
+ // conflict rules), or null — one request per batch, answers kept for
161
+ // the data's lifetime. Fast: hovers and the detail pane wait for it.
162
+ function describeCss(data, candidates) {
163
+ return askCssEngine(data, data.describe, "css-describe", data._described, candidates, 5000);
164
+ }
137
165
 
138
- if (!isClassName && !inString) return { suggestions: [] };
166
+ // Canonical spellings, for the diagnostic only: the engine may take its
167
+ // time (the Tailwind add-on's worker builds an index on its first
168
+ // answer), so nothing else waits for these.
169
+ function canonicalCss(data, candidates) {
170
+ return askCssEngine(data, data.canonical, "css-canonical", data._canonical, candidates, 30000);
171
+ }
139
172
 
140
- // Extract text inside the string
141
- const raw = (isClassName || inString)[0];
142
- let inside;
143
- if (isClassName) {
144
- inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
145
- } else {
146
- inside = raw.substring(1); // skip opening quote
173
+ const cssInflight = new WeakMap(); // answer cache → candidate → pending request
174
+
175
+ function askCssEngine(data, supported, route, cache, candidates, timeout) {
176
+ const cc = window.__fcClassCompletion;
177
+ const nulls = candidates.map(function () {
178
+ return null;
179
+ });
180
+ if (!supported) return Promise.resolve(nulls);
181
+ // Classes already asked about and not answered yet are waited for,
182
+ // not asked again — typing while the first answer is slow would
183
+ // otherwise queue the same classes over and over.
184
+ let inflight = cssInflight.get(cache);
185
+ if (!inflight) cssInflight.set(cache, (inflight = new Map()));
186
+ const waiting = [];
187
+ const missing = candidates.filter(function (c, i) {
188
+ if (cache.has(c) || candidates.indexOf(c) !== i) return false;
189
+ if (inflight.has(c)) {
190
+ waiting.push(inflight.get(c));
191
+ return false;
147
192
  }
148
- inside = inside.replace(/\$\{[^}]*\}/g, " ");
149
- const parts = inside.split(/\s+/);
150
- const word = parts[parts.length - 1] || "";
151
-
152
- // For non-className strings, only activate if at least one existing
153
- // token in the string looks like a TW class (avoid noise in random strings)
154
- if (!isClassName && parts.length > 0) {
155
- const classes = window.__fcCssClasses || [];
156
- let twSet = window.__fcCssClassSet;
157
- if (!twSet && classes.length) {
158
- twSet = new Set(classes);
159
- window.__fcCssClassSet = twSet;
160
- console.log(PREFIX, "CSS: built class lookup Set, size=" + twSet.size);
161
- }
162
- if (twSet) {
163
- let hasTwToken = false;
164
- for (let p = 0; p < parts.length; p++) {
165
- if (parts[p] && twSet.has(parts[p])) {
166
- hasTwToken = true;
167
- break;
168
- }
169
- }
170
- // If typing the very first word, accept if it looks like a prefix
171
- if (!hasTwToken && parts.length === 1 && word.length >= 2) {
172
- for (let j = 0; j < classes.length; j++) {
173
- if (classes[j].indexOf(word) === 0) {
174
- hasTwToken = true;
175
- break;
176
- }
193
+ return true;
194
+ });
195
+ const batches = [];
196
+ for (let i = 0; i < missing.length; i += cc.MAX_DESCRIBE) {
197
+ batches.push(missing.slice(i, i + cc.MAX_DESCRIBE));
198
+ }
199
+ const asked = batches.map(function (batch) {
200
+ const request = new Promise(function (resolve) {
201
+ $.ajax({
202
+ url: "fromcubes-react/" + route,
203
+ data: { c: batch.join("\n") },
204
+ dataType: "json",
205
+ timeout: timeout,
206
+ })
207
+ .done(function (res) {
208
+ if (res && Array.isArray(res.results) && res.engine === data.engine) {
209
+ batch.forEach(function (c, i) {
210
+ cache.set(c, res.results[i] || null);
211
+ });
177
212
  }
178
- }
179
- if (!hasTwToken) return { suggestions: [] };
180
- }
181
- }
213
+ })
214
+ .fail(function (xhr) {
215
+ console.error(PREFIX, route + " FAILED:", xhr.status, xhr.statusText);
216
+ })
217
+ .always(function () {
218
+ batch.forEach(function (c) {
219
+ inflight.delete(c);
220
+ });
221
+ resolve();
222
+ });
223
+ });
224
+ batch.forEach(function (c) {
225
+ inflight.set(c, request);
226
+ });
227
+ return request;
228
+ });
229
+ return Promise.all(asked.concat(waiting)).then(function () {
230
+ return candidates.map(function (c) {
231
+ return cache.has(c) ? cache.get(c) : null;
232
+ });
233
+ });
234
+ }
182
235
 
183
- const range = {
184
- startLineNumber: position.lineNumber,
236
+ // A variant the data does not list by name (`group-hover/item`,
237
+ // `data-[open]`) is valid when the engine compiles it — IntelliSense's
238
+ // test, `<part>:[color:red]`.
239
+ function isValidVariant(data, part) {
240
+ if (!window.__fcClassCompletion.mayBeVariant(data, part)) return false;
241
+ return describeCss(data, [part + ":[color:red]"]).then(function (r) {
242
+ return !!r[0];
243
+ });
244
+ }
245
+
246
+ // Text the class-list scan needs: up to SEARCH_RANGE before the cursor,
247
+ // and the cursor's line split at the cursor.
248
+ function cssScanText(model, position) {
249
+ const cc = window.__fcClassCompletion;
250
+ const offset = model.getOffsetAt(position);
251
+ const from = model.getPositionAt(Math.max(0, offset - cc.SEARCH_RANGE));
252
+ const line = model.getLineContent(position.lineNumber);
253
+ return {
254
+ before: model.getValueInRange({
255
+ startLineNumber: from.lineNumber,
256
+ startColumn: from.column,
185
257
  endLineNumber: position.lineNumber,
186
- startColumn: position.column - word.length,
187
258
  endColumn: position.column,
188
- };
259
+ }),
260
+ lineBefore: line.substring(0, position.column - 1),
261
+ lineAfter: line.substring(position.column - 1),
262
+ };
263
+ }
189
264
 
190
- const classes = window.__fcCssClasses || [];
191
- const suggestions = [];
192
- for (let i = 0; i < classes.length; i++) {
193
- const cls = classes[i];
194
- if (!word.length || cls.indexOf(word) === 0) {
195
- suggestions.push({
196
- label: cls,
197
- kind: monaco.languages.CompletionItemKind.Value,
198
- insertText: cls,
199
- range: range,
200
- sortText: cls.padStart(60, "0"),
265
+ function cssFence(css) {
266
+ return { value: "```css\n" + css + "\n```" };
267
+ }
268
+
269
+ monaco.languages.registerCompletionItemProvider("javascript", {
270
+ triggerCharacters: window.__fcClassCompletion
271
+ ? window.__fcClassCompletion.TRIGGER_CHARACTERS
272
+ : ['"', "'", "`", " ", ".", "(", "[", "]", "!", "/", "-", ":"],
273
+ provideCompletionItems: function (model, position, context) {
274
+ const data = cssData();
275
+ if (!data) return { suggestions: [] };
276
+ const cc = window.__fcClassCompletion;
277
+ const text = cssScanText(model, position);
278
+ const at = cc.classListAt(data, text.before, text.lineBefore);
279
+ if (!at) return { suggestions: [] };
280
+ const kinds = monaco.languages.CompletionTriggerKind;
281
+ const ctx = {
282
+ triggerKind:
283
+ context.triggerKind === kinds.Invoke
284
+ ? "invoke"
285
+ : context.triggerKind === kinds.TriggerCharacter
286
+ ? "character"
287
+ : "incomplete",
288
+ triggerCharacter: context.triggerCharacter,
289
+ };
290
+ return cc
291
+ .completionsFromClassList(data, at.classList, ctx, function (part) {
292
+ return isValidVariant(data, part);
293
+ })
294
+ .then(function (list) {
295
+ if (!list.modifiers) return list;
296
+ return describeCss(
297
+ data,
298
+ list.items.map(function (item) {
299
+ return item.label;
300
+ }),
301
+ ).then(function (described) {
302
+ cc.applyModifierColors(list.items, described);
303
+ return list;
304
+ });
305
+ })
306
+ .then(function (list) {
307
+ const range = {
308
+ startLineNumber: position.lineNumber,
309
+ endLineNumber: position.lineNumber,
310
+ startColumn: position.column + list.range.start,
311
+ endColumn: position.column + list.range.end,
312
+ };
313
+ const owner = { variants: list.variants, important: list.important };
314
+ const suggestions = list.items.map(function (item) {
315
+ const s = {
316
+ label: item.label,
317
+ kind: cssKinds[item.kind],
318
+ insertText: item.insertText || item.label,
319
+ sortText: item.sortText,
320
+ range: range,
321
+ };
322
+ if (item.filterText) s.filterText = item.filterText;
323
+ if (item.detail) s.detail = item.detail;
324
+ if (item.documentation) s.documentation = item.documentation;
325
+ if (item.snippet) {
326
+ s.insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
327
+ }
328
+ if (item.retrigger) s.command = { id: "editor.action.triggerSuggest", title: "" };
329
+ if (item.kind !== "variant") s.fcClassOwner = owner;
330
+ return s;
201
331
  });
332
+ console.log(
333
+ PREFIX,
334
+ "CSS class completion: ctx=" +
335
+ (at.attribute ? "attribute" : "string") +
336
+ " items=" +
337
+ suggestions.length,
338
+ );
339
+ return { suggestions: suggestions };
340
+ });
341
+ },
342
+ // The detail pane: the declarations on one line, and the CSS the
343
+ // class generates under the variants typed before it.
344
+ resolveCompletionItem: function (item) {
345
+ const data = cssData();
346
+ if (!data || !item.fcClassOwner || (item.detail && item.documentation)) return item;
347
+ const className = item.fcClassOwner.important ? "!" + item.label : item.label;
348
+ const full = item.fcClassOwner.variants.concat([className]).join(":");
349
+ return describeCss(data, [full, className]).then(function (r) {
350
+ const whole = r[0];
351
+ const base = r[1];
352
+ if (!whole) return item;
353
+ if (!item.detail) {
354
+ item.detail = /^\d+ rules$/.test(whole.detail || "") ? whole.detail : (base || whole).detail || undefined;
202
355
  }
203
- }
204
- console.log(
205
- PREFIX,
206
- "CSS class completion: ctx=" +
207
- (isClassName ? "className" : "string") +
208
- " word='" +
209
- word +
210
- "', matched=" +
211
- suggestions.length +
212
- "/" +
213
- classes.length,
214
- );
215
- return { suggestions: suggestions };
356
+ if (!item.documentation && whole.documentation) item.documentation = cssFence(whole.documentation);
357
+ return item;
358
+ });
359
+ },
360
+ });
361
+
362
+ monaco.languages.registerHoverProvider("javascript", {
363
+ provideHover: function (model, position) {
364
+ const data = cssData();
365
+ if (!data || !data.describe) return null;
366
+ const text = cssScanText(model, position);
367
+ const hit = window.__fcClassCompletion.classNameAt(data, text.before, text.lineBefore, text.lineAfter);
368
+ if (!hit) return null;
369
+ return describeCss(data, [hit.className]).then(function (r) {
370
+ if (!r[0] || !r[0].hover) return null;
371
+ return {
372
+ range: {
373
+ startLineNumber: position.lineNumber,
374
+ endLineNumber: position.lineNumber,
375
+ startColumn: position.column + hit.start,
376
+ endColumn: position.column + hit.end,
377
+ },
378
+ contents: [cssFence(r[0].hover)],
379
+ };
380
+ });
381
+ },
382
+ });
383
+
384
+ // Diagnostics, quick fixes and colour squares — IntelliSense's
385
+ // cssConflict and suggestCanonicalClasses warnings and its document
386
+ // colours, over every class attribute of every javascript model (node
387
+ // dialogs and the React Editor alike), rechecked as the code changes.
388
+ function cssRange(model, start, end) {
389
+ const a = model.getPositionAt(start);
390
+ const b = model.getPositionAt(end);
391
+ return { startLineNumber: a.lineNumber, startColumn: a.column, endLineNumber: b.lineNumber, endColumn: b.column };
392
+ }
393
+
394
+ function cssClassLists(model) {
395
+ const cc = window.__fcClassCompletion;
396
+ return cc.findClassListsInText(cc.jsWithoutComments(model.getValue()));
397
+ }
398
+
399
+ const CSS_MARKER_OWNER = "tailwindcss";
400
+ const cssDiagnostics = new WeakMap(); // model → diagnostics, for their quick fixes
401
+ const cssTimers = new WeakMap();
402
+
403
+ function validateCss(model) {
404
+ if (model.isDisposed() || model.getLanguageId() !== "javascript") return;
405
+ const data = cssData();
406
+ if (!data || !data.describe) {
407
+ cssDiagnostics.delete(model);
408
+ monaco.editor.setModelMarkers(model, CSS_MARKER_OWNER, []);
409
+ return;
410
+ }
411
+ const cc = window.__fcClassCompletion;
412
+ const version = model.getVersionId();
413
+ const lists = cssClassLists(model);
414
+ const names = cc.uniqueClassNames(lists);
415
+ // Conflicts as soon as the classes are described; canonical
416
+ // suggestions join when the engine has them — a slow first answer
417
+ // never holds back the rest.
418
+ describeCss(data, names).then(function () {
419
+ publishCssMarkers(model, version, data, lists);
420
+ canonicalCss(data, names).then(function () {
421
+ publishCssMarkers(model, version, data, lists);
422
+ });
423
+ });
424
+ }
425
+
426
+ function publishCssMarkers(model, version, data, lists) {
427
+ if (model.isDisposed() || model.getVersionId() !== version || cssData() !== data) return;
428
+ const diagnostics = window.__fcClassCompletion.diagnose(lists, data._described, data._canonical);
429
+ cssDiagnostics.set(model, diagnostics);
430
+ monaco.editor.setModelMarkers(
431
+ model,
432
+ CSS_MARKER_OWNER,
433
+ diagnostics.map(function (d) {
434
+ return Object.assign(cssRange(model, d.start, d.end), {
435
+ severity: d.severity === "error" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
436
+ message: d.message,
437
+ source: "tailwindcss",
438
+ code: d.code,
439
+ relatedInformation: d.related.map(function (r) {
440
+ return Object.assign(cssRange(model, r.start, r.end), { resource: model.uri, message: r.message });
441
+ }),
442
+ });
443
+ }),
444
+ );
445
+ }
446
+
447
+ function scheduleCssValidation(model) {
448
+ clearTimeout(cssTimers.get(model));
449
+ cssTimers.set(
450
+ model,
451
+ setTimeout(function () {
452
+ validateCss(model);
453
+ }, 400),
454
+ );
455
+ }
456
+
457
+ function watchCssModel(model) {
458
+ const changed = model.onDidChangeContent(function () {
459
+ scheduleCssValidation(model);
460
+ });
461
+ const language = model.onDidChangeLanguage(function () {
462
+ scheduleCssValidation(model);
463
+ });
464
+ model.onWillDispose(function () {
465
+ changed.dispose();
466
+ language.dispose();
467
+ clearTimeout(cssTimers.get(model));
468
+ });
469
+ scheduleCssValidation(model);
470
+ }
471
+
472
+ monaco.editor.getModels().forEach(watchCssModel);
473
+ monaco.editor.onDidCreateModel(watchCssModel);
474
+ // New engine data (a dialog opened, the engine switched): check again.
475
+ window.__fcRevalidateCss = function () {
476
+ monaco.editor.getModels().forEach(scheduleCssValidation);
477
+ };
478
+
479
+ monaco.languages.registerCodeActionProvider("javascript", {
480
+ provideCodeActions: function (model, _range, context) {
481
+ const diagnostics = cssDiagnostics.get(model) || [];
482
+ const actions = [];
483
+ context.markers.forEach(function (marker) {
484
+ if (marker.source !== "tailwindcss") return;
485
+ const start = model.getOffsetAt({ lineNumber: marker.startLineNumber, column: marker.startColumn });
486
+ const end = model.getOffsetAt({ lineNumber: marker.endLineNumber, column: marker.endColumn });
487
+ diagnostics.forEach(function (d) {
488
+ if (d.start !== start || d.end !== end || d.code !== marker.code || d.message !== marker.message) return;
489
+ d.fixes.forEach(function (fix) {
490
+ actions.push({
491
+ title: fix.title,
492
+ kind: "quickfix",
493
+ diagnostics: [marker],
494
+ edit: {
495
+ edits: [
496
+ {
497
+ resource: model.uri,
498
+ versionId: model.getVersionId(),
499
+ textEdit: { range: cssRange(model, fix.start, fix.end), text: fix.text },
500
+ },
501
+ ],
502
+ },
503
+ });
504
+ });
505
+ });
506
+ });
507
+ return { actions: actions, dispose: function () {} };
508
+ },
509
+ });
510
+
511
+ monaco.languages.registerColorProvider("javascript", {
512
+ provideDocumentColors: function (model) {
513
+ const data = cssData();
514
+ if (!data) return [];
515
+ const cc = window.__fcClassCompletion;
516
+ const lists = cssClassLists(model);
517
+ return describeCss(data, cc.classesNeedingColor(data, lists)).then(function () {
518
+ if (model.isDisposed()) return [];
519
+ return cc.documentColors(data, lists, data._described).map(function (c) {
520
+ return { color: c.color, range: cssRange(model, c.start, c.end) };
521
+ });
522
+ });
523
+ },
524
+ // Only an arbitrary colour (`bg-[#f00]`) takes one back from the picker.
525
+ provideColorPresentations: function (model, info) {
526
+ const className = model.getValueInRange(info.range);
527
+ return window.__fcClassCompletion.colorPresentations(className, info.color).map(function (label) {
528
+ return { label: label, textEdit: { range: info.range, text: label } };
529
+ });
216
530
  },
217
531
  });
218
532
 
@@ -1328,32 +1642,26 @@
1328
1642
  </script>
1329
1643
 
1330
1644
  <script type="text/html" data-help-name="fc-portal-component">
1645
+ <p>A React component every portal can use as a tag.</p>
1646
+
1647
+ <h3>Details</h3>
1331
1648
  <p>
1332
- Defines a shared JSX component available to all
1333
- <strong>portal-react</strong> nodes.
1649
+ Write the component in <b>JSX Code</b> and give its name in
1650
+ <b>JSX Tag</b>. After deploy, any <b>React Portal</b> uses it as
1651
+ <code>&lt;Name /&gt;</code>, without imports. Components can use each
1652
+ other and <code>useNodeRed()</code>.
1334
1653
  </p>
1654
+ <pre>function Badge({ label }) {
1655
+ return &lt;span className="badge"&gt;{label}&lt;/span&gt;;
1656
+ }</pre>
1657
+
1335
1658
  <h3>Properties</h3>
1336
1659
  <dl>
1337
1660
  <dt>JSX Tag</dt>
1338
- <dd>
1339
- The name used as a JSX tag in portal-react pages (e.g.
1340
- <code>&lt;MyComponent /&gt;</code>).
1341
- </dd>
1342
- <dt>Input fields</dt>
1343
- <dd>Comma-separated list of expected input props (documentation only).</dd>
1344
- <dt>Output fields</dt>
1345
- <dd>Comma-separated list of output props (documentation only).</dd>
1661
+ <dd>The component's name, starting with a capital letter (<code>Badge</code>).</dd>
1346
1662
  <dt>JSX Code</dt>
1347
- <dd>
1348
- The component function. Use <code>useNodeRed()</code> for data binding.
1349
- </dd>
1663
+ <dd>The component function.</dd>
1350
1664
  </dl>
1351
- <h3>Usage</h3>
1352
- <p>
1353
- Place this node on the canvas, set the JSX tag name and code, then deploy.
1354
- All <strong>portal-react</strong> nodes will automatically have access to
1355
- this component.
1356
- </p>
1357
1665
  </script>
1358
1666
 
1359
1667
  <!-- ============================================================
@@ -1574,57 +1882,28 @@
1574
1882
  </script>
1575
1883
 
1576
1884
  <script type="text/html" data-help-name="fc-portal-utility">
1885
+ <p>Shared JavaScript — helpers, hooks, constants — for every portal.</p>
1886
+
1887
+ <h3>Details</h3>
1577
1888
  <p>
1578
- Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
1579
- <strong>constants</strong> available to all <strong>portal-react</strong>
1580
- nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
1581
- top level (no IIFE wrapper) — a single utility node may declare many symbols.
1889
+ Declare any number of functions, constants or classes in <b>Code</b>.
1890
+ Portals and components use them by name, without imports. A portal only
1891
+ includes this node if it uses something from it.
1582
1892
  </p>
1893
+ <pre>function clamp(n, min, max) {
1894
+ return Math.max(min, Math.min(max, n));
1895
+ }</pre>
1896
+
1583
1897
  <h3>Properties</h3>
1584
1898
  <dl>
1585
1899
  <dt>Module name</dt>
1586
1900
  <dd>
1587
- Identifier for this utility node. Shares a namespace with
1588
- <code>fc-portal-component</code> names — must be unique across all
1589
- components and utilities.
1901
+ A name for this node; it must not repeat a component's or another
1902
+ utility's name.
1590
1903
  </dd>
1591
1904
  <dt>Code</dt>
1592
- <dd>
1593
- Top-level JavaScript: <code>const</code>, <code>let</code>,
1594
- <code>function</code>, <code>class</code> declarations. <code>import</code>
1595
- statements at the top are auto-hoisted into the bundle.
1596
- </dd>
1905
+ <dd>Top-level declarations; <code>import</code> lines are allowed.</dd>
1597
1906
  </dl>
1598
- <h3>Selective inclusion</h3>
1599
- <p>
1600
- A utility node is bundled into a portal only when the portal's JSX (or any
1601
- of its referenced library components) mentions at least one of the symbols
1602
- declared in the utility. Unused utility nodes are skipped.
1603
- </p>
1604
- <p>
1605
- Group related symbols in one node — referencing any one symbol pulls in the
1606
- whole node's code. Split unrelated helpers across multiple nodes for finer
1607
- granularity.
1608
- </p>
1609
- <h3>Example</h3>
1610
- <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1611
-
1612
- function useDebounce(value, ms = 300) {
1613
- const [v, setV] = React.useState(value);
1614
- React.useEffect(() => {
1615
- const t = setTimeout(() => setV(value), ms);
1616
- return () => clearTimeout(t);
1617
- }, [value, ms]);
1618
- return v;
1619
- }</pre>
1620
- <p>
1621
- Then in any <strong>portal-react</strong> node:
1622
- </p>
1623
- <pre>function App() {
1624
- const { data } = useNodeRed();
1625
- const slow = useDebounce(data?.value);
1626
- return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1627
- }</pre>
1628
1907
  </script>
1629
1908
 
1630
1909
  <!-- ============================================================
@@ -2431,125 +2710,86 @@ function useDebounce(value, ms = 300) {
2431
2710
  Help
2432
2711
  ============================================================ -->
2433
2712
  <script type="text/html" data-help-name="portal-react">
2434
- <h3>Quick Reference</h3>
2435
- <ul>
2436
- <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user, portalClient }</code></li>
2437
- <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
2438
- <li>Must export <code>&lt;App /&gt;</code></li>
2439
- <li>JSX is transpiled server-side at deploy</li>
2440
- </ul>
2441
-
2442
2713
  <p>
2443
- Renders a React 19 application on a configurable HTTP endpoint with live
2444
- WebSocket data binding. JSX is transpiled <strong>server-side at deploy
2445
- time</strong> using esbuild — browsers receive pre-compiled JS with zero
2446
- runtime compilation. Tailwind CSS 4 utility classes are generated
2447
- server-side and served as static CSS.
2714
+ A React page served at <code>/fromcubes/&lt;sub-path&gt;</code>, kept live
2715
+ with data from the flow.
2448
2716
  </p>
2449
2717
 
2450
2718
  <h3>Inputs</h3>
2451
- <p>
2452
- <code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
2453
- to target specific clients, or omit it to broadcast to all.
2454
- </p>
2455
- <pre>
2456
- const { data, send, user, portalClient } = useNodeRed();
2457
- // data = last msg.payload (reactive)
2458
- // send(payload, topic?) — emit msg on output wire
2459
- // user = portal auth data or null
2460
- // portalClient = unique session/tab ID (assigned by server)</pre
2461
- >
2719
+ <dl class="message-properties">
2720
+ <dt>payload <span class="property-type">any</span></dt>
2721
+ <dd>Sent to the open pages; read it with <code>useNodeRed().data</code>.</dd>
2722
+ <dt class="optional">_client <span class="property-type">object</span></dt>
2723
+ <dd>
2724
+ Who gets it: <code>{ portalClient }</code> one browser tab,
2725
+ <code>{ userId }</code> every tab of one user,
2726
+ <code>{ authenticated: true }</code> every logged-in visitor.
2727
+ Without it, every open page.
2728
+ </dd>
2729
+ </dl>
2462
2730
 
2463
2731
  <h3>Outputs</h3>
2464
- <p>
2465
- <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
2466
- output wire via WebSocket.
2467
- </p>
2468
-
2469
- <h3>npm Packages</h3>
2470
- <p>
2471
- Add npm packages in the <strong>Modules</strong> list (e.g.
2472
- <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
2473
- Node-RED auto-installs them at deploy time. All packages are bundled with
2474
- React into a single vendor IIFE via esbuild, cached by hash of installed
2475
- versions. Use them in JSX via standard imports.
2476
- </p>
2732
+ <dl class="message-properties">
2733
+ <dt>payload <span class="property-type">any</span></dt>
2734
+ <dd>What the page passed to <code>send(payload, topic)</code>.</dd>
2735
+ <dt>_client <span class="property-type">object</span></dt>
2736
+ <dd>The tab and user it came from — send a reply back with it.</dd>
2737
+ </dl>
2477
2738
 
2478
- <h3>Deploy behavior</h3>
2479
- <ul>
2480
- <li>
2481
- Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
2482
- (both cached by content hash — unchanged code is instant)
2483
- </li>
2484
- <li>
2485
- Vendor bundle (React + npm packages) rebuilt only when package versions
2486
- change
2487
- </li>
2488
- <li>
2489
- Active WebSocket clients receive close code 1001 and auto-reconnect
2490
- (exponential backoff)
2491
- </li>
2492
- <li>
2493
- Stale HTTP routes and WS handlers are cleaned up before new ones register
2494
- </li>
2495
- <li>
2496
- Transpile errors show in node status and as an error page on the endpoint
2497
- </li>
2498
- </ul>
2499
-
2500
- <h3>Shared Components</h3>
2739
+ <h3>Details</h3>
2501
2740
  <p>
2502
- Components defined in <strong>fc-portal-component</strong> nodes on the
2503
- canvas are auto-injected into every portal-react page. Use them as JSX tags
2504
- by their component name — no imports needed.
2741
+ Write a <code>function App()</code> in the <b>JSX</b> tab. It is compiled
2742
+ when you deploy, so the browser gets ready-made JavaScript.
2505
2743
  </p>
2506
-
2507
- <h3>Portal Auth</h3>
2744
+ <pre>function App() {
2745
+ const { data, send } = useNodeRed();
2746
+ return &lt;button onClick={() =&gt; send("hi")}&gt;{data}&lt;/button&gt;;
2747
+ }</pre>
2508
2748
  <p>
2509
- When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
2510
- a reverse proxy (e.g. Nginx) and exposes user data:
2749
+ <code>useNodeRed()</code> gives <code>data</code> (the last payload,
2750
+ <code>null</code> until one arrives), <code>send</code>, <code>user</code>
2751
+ (the visitor, or <code>null</code>) and <code>portalClient</code> (this
2752
+ tab's id).
2511
2753
  </p>
2512
- <ul>
2513
- <li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
2514
- <li>
2515
- All WebSocket messages include <code>msg._client</code> with
2516
- <code>{ portalClient, ...userFields }</code>
2517
- </li>
2518
- <li>
2519
- To target a specific tab: keep <code>msg._client</code> (or set
2520
- <code>msg._client = { portalClient: "..." }</code>)
2521
- </li>
2522
- <li>
2523
- To target all tabs of a user: set
2524
- <code>msg._client = { userId: "..." }</code> (no portalClient)
2525
- </li>
2526
- <li>
2527
- To target every authenticated session (skips anonymous):
2528
- <code>msg._client = { authenticated: true }</code>
2529
- </li>
2530
- <li>
2531
- To broadcast to all: remove <code>msg._client</code>. With
2532
- <b>Authenticated-only delivery</b> enabled this defaults to the
2533
- authenticated sessions instead; force a true broadcast with
2534
- <code>msg._client = { authenticated: false }</code>
2535
- </li>
2536
- </ul>
2537
-
2538
- <h3>Custom Head HTML</h3>
2539
2754
  <p>
2540
- Inject CDN links, fonts, or extra stylesheets into
2541
- <code>&lt;head&gt;</code>. This is raw trusted-author HTML; scripts and
2542
- event handlers run in the public portal page. Example:
2755
+ <b>React Component</b> and <b>React Utility</b> nodes can be used here by
2756
+ name, without imports.
2543
2757
  </p>
2544
- <pre>
2545
- &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
2546
- >
2547
2758
 
2548
- <h3>WebSocket Status</h3>
2759
+ <h3>Properties</h3>
2760
+ <dl>
2761
+ <dt>Sub-path</dt>
2762
+ <dd>Where the page lives: <code>sensors</code> → <code>/fromcubes/sensors</code>.</dd>
2763
+ <dt>Title</dt>
2764
+ <dd>The browser tab's title.</dd>
2765
+ <dt>Modules</dt>
2766
+ <dd>
2767
+ npm packages to use in the code (<code>chart.js/auto</code>,
2768
+ <code>d3</code>, <code>three</code>); installed on deploy, then
2769
+ <code>import</code> them.
2770
+ </dd>
2771
+ <dt>Enable Portal Auth headers</dt>
2772
+ <dd>
2773
+ Identify visitors with the identity provider chosen in
2774
+ <b>Settings → Fromcubes</b>. Off: everyone is anonymous.
2775
+ </dd>
2776
+ <dt>Authenticated-only delivery</dt>
2777
+ <dd>
2778
+ Messages without <code>_client</code> go to logged-in visitors only;
2779
+ <code>_client = { authenticated: false }</code> still reaches everyone.
2780
+ </dd>
2781
+ <dt>Show WebSocket status indicator</dt>
2782
+ <dd>A small connected/disconnected badge in the page's corner.</dd>
2783
+ <dt>Head HTML</dt>
2784
+ <dd>Extra tags for the page's <code>&lt;head&gt;</code>, such as fonts or stylesheets.</dd>
2785
+ </dl>
2786
+
2787
+ <h3>Styling</h3>
2549
2788
  <p>
2550
- Optional <em>fromcubes</em> badge in the bottom-right corner showing
2551
- connection state. Enable via the <strong>Show WebSocket status
2552
- indicator</strong> checkbox (off by default).
2789
+ The page's stylesheet comes from the CSS engine chosen in
2790
+ <b>Settings → Fromcubes</b>. Install <code>@aaqu/fromcubes-tailwind</code>
2791
+ to style with Tailwind CSS classes; without a CSS engine the page has no
2792
+ generated stylesheet.
2553
2793
  </p>
2554
2794
  </script>
2555
2795