vibespot 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +103 -33
  2. package/README.md +55 -6
  3. package/assets/blog-rules.md +251 -0
  4. package/assets/email-rules.md +390 -0
  5. package/assets/humanify-guide.md +300 -101
  6. package/assets/plan-templates/agency-services.md +42 -0
  7. package/assets/plan-templates/blog-content-hub.md +50 -0
  8. package/assets/plan-templates/ecommerce-product.md +42 -0
  9. package/assets/plan-templates/email-announcement.md +41 -0
  10. package/assets/plan-templates/email-event-invite.md +43 -0
  11. package/assets/plan-templates/email-newsletter.md +41 -0
  12. package/assets/plan-templates/email-re-engagement.md +42 -0
  13. package/assets/plan-templates/email-welcome.md +41 -0
  14. package/assets/plan-templates/event-registration.md +42 -0
  15. package/assets/plan-templates/portfolio.md +41 -0
  16. package/assets/plan-templates/restaurant.md +42 -0
  17. package/assets/plan-templates/saas-landing.md +42 -0
  18. package/dist/index.js +1485 -397
  19. package/dist/index.js.map +1 -1
  20. package/package.json +11 -7
  21. package/starters/01-saas-landing.json +43 -0
  22. package/starters/02-portfolio.json +39 -0
  23. package/starters/03-restaurant.json +39 -0
  24. package/starters/04-event.json +39 -0
  25. package/starters/05-coming-soon.json +32 -0
  26. package/starters/06-blog-content-hub.json +75 -0
  27. package/starters/06-email-welcome.json +60 -0
  28. package/starters/07-email-announcement.json +60 -0
  29. package/starters/08-email-newsletter.json +52 -0
  30. package/ui/chat.js +1604 -155
  31. package/ui/code-editor.js +49 -7
  32. package/ui/dashboard.js +551 -83
  33. package/ui/docs/docs.css +29 -0
  34. package/ui/docs/index.html +274 -117
  35. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  36. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  37. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  38. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  39. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  40. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  41. package/ui/docs/screenshots/split-pane-view.png +0 -0
  42. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  43. package/ui/docs/screenshots/workspace-tabs.png +0 -0
  44. package/ui/email-preview.js +109 -0
  45. package/ui/field-editor.js +71 -0
  46. package/ui/icons.js +120 -0
  47. package/ui/index.html +882 -515
  48. package/ui/inline-edit.js +710 -0
  49. package/ui/marketplace.js +218 -0
  50. package/ui/plan.js +0 -0
  51. package/ui/preview.js +219 -1
  52. package/ui/section-controls.js +628 -0
  53. package/ui/settings.js +84 -28
  54. package/ui/setup.js +1016 -118
  55. package/ui/styles.css +6119 -2456
  56. package/ui/upload-panel.js +47 -20
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Marketplace publication panel — runs the Marketplace check against the
3
+ * active theme, lets users browse findings, fix the auto-fixable ones, and
4
+ * edit the marketplace.json listing sidecar.
5
+ */
6
+
7
+ async function openMarketplacePanel() {
8
+ const overlay = document.createElement("div");
9
+ overlay.className = "confirm-overlay marketplace-overlay";
10
+ overlay.innerHTML = `
11
+ <div class="confirm-dialog marketplace-dialog">
12
+ <div class="confirm-dialog__title">HubSpot Marketplace check</div>
13
+ <div class="marketplace-body" id="marketplace-body">
14
+ <p class="confirm-dialog__detail">Running check…</p>
15
+ </div>
16
+ <div class="confirm-dialog__actions">
17
+ <button class="btn btn--ghost" data-action="close">Close</button>
18
+ <button class="btn btn--ghost" data-action="edit">Edit listing</button>
19
+ <button class="btn btn--ghost" data-action="fix" disabled>Apply fixes</button>
20
+ <button class="btn btn--primary" data-action="recheck">Re-check</button>
21
+ </div>
22
+ </div>
23
+ `;
24
+ document.body.appendChild(overlay);
25
+
26
+ const body = overlay.querySelector("#marketplace-body");
27
+ const fixBtn = overlay.querySelector('[data-action="fix"]');
28
+ const editBtn = overlay.querySelector('[data-action="edit"]');
29
+ const recheckBtn = overlay.querySelector('[data-action="recheck"]');
30
+ const closeBtn = overlay.querySelector('[data-action="close"]');
31
+
32
+ let lastCategories = [];
33
+
34
+ async function refresh() {
35
+ body.innerHTML = `<p class="confirm-dialog__detail">Running check…</p>`;
36
+ try {
37
+ const res = await fetch("/api/marketplace/check");
38
+ const data = await res.json();
39
+ if (!res.ok) throw new Error(data.error || "Check failed");
40
+ lastCategories = data.categories || [];
41
+ renderReport(body, data.report);
42
+ const autoFixable = (data.report.findings || []).some((f) => f.autoFixable);
43
+ fixBtn.disabled = !autoFixable;
44
+ } catch (err) {
45
+ body.innerHTML = `<p class="confirm-dialog__detail">${esc(err.message)}</p>`;
46
+ }
47
+ }
48
+
49
+ recheckBtn.addEventListener("click", refresh);
50
+ closeBtn.addEventListener("click", () => overlay.remove());
51
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
52
+
53
+ fixBtn.addEventListener("click", async () => {
54
+ fixBtn.disabled = true;
55
+ try {
56
+ const res = await fetch("/api/marketplace/fix", { method: "POST" });
57
+ const data = await res.json();
58
+ if (!res.ok) throw new Error(data.error || "Fix failed");
59
+ renderReport(body, data.report, data.fix);
60
+ const stillFixable = (data.report.findings || []).some((f) => f.autoFixable);
61
+ fixBtn.disabled = !stillFixable;
62
+ } catch (err) {
63
+ await vibeAlert(err.message, "Fix failed");
64
+ }
65
+ });
66
+
67
+ editBtn.addEventListener("click", async () => {
68
+ try {
69
+ const res = await fetch("/api/marketplace/listing");
70
+ const data = await res.json();
71
+ if (!res.ok) throw new Error(data.error || "Could not load listing");
72
+ lastCategories = data.categories || lastCategories;
73
+ const next = await openListingEditor(data.metadata, lastCategories);
74
+ if (!next) return;
75
+ const save = await fetch("/api/marketplace/listing", {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify(next),
79
+ });
80
+ const result = await save.json();
81
+ if (!save.ok) throw new Error(result.error || "Save failed");
82
+ await refresh();
83
+ } catch (err) {
84
+ await vibeAlert(err.message, "Listing");
85
+ }
86
+ });
87
+
88
+ refresh();
89
+ }
90
+
91
+ function renderReport(container, report, fix) {
92
+ if (!report) {
93
+ container.innerHTML = `<p class="confirm-dialog__detail">No report available.</p>`;
94
+ return;
95
+ }
96
+
97
+ const errors = report.findings.filter((f) => f.severity === "error");
98
+ const warnings = report.findings.filter((f) => f.severity === "warning");
99
+ const info = report.findings.filter((f) => f.severity === "info");
100
+
101
+ const summaryClass = report.passed ? "marketplace-summary--ok" : "marketplace-summary--fail";
102
+ const summaryText = report.passed
103
+ ? `Theme passes Marketplace checks.`
104
+ : `Theme is not yet ready.`;
105
+
106
+ let html = `
107
+ <div class="marketplace-summary ${summaryClass}">
108
+ <strong>${esc(summaryText)}</strong>
109
+ <div class="marketplace-summary__counts">
110
+ <span class="marketplace-pill marketplace-pill--error">${errors.length} errors</span>
111
+ <span class="marketplace-pill marketplace-pill--warn">${warnings.length} warnings</span>
112
+ <span class="marketplace-pill marketplace-pill--info">${info.length} notes</span>
113
+ </div>
114
+ </div>
115
+ `;
116
+
117
+ if (fix && (fix.applied?.length || fix.skipped?.length)) {
118
+ html += `<div class="marketplace-fix-result">`;
119
+ if (fix.applied?.length) {
120
+ html += `<strong>Applied:</strong><ul>${fix.applied.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>`;
121
+ }
122
+ if (fix.skipped?.length) {
123
+ html += `<strong>Skipped:</strong><ul>${fix.skipped.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>`;
124
+ }
125
+ html += `</div>`;
126
+ }
127
+
128
+ html += renderFindingGroup("Errors", errors);
129
+ html += renderFindingGroup("Warnings", warnings);
130
+ html += renderFindingGroup("Notes", info);
131
+
132
+ if (report.findings.length === 0) {
133
+ html += `<p class="confirm-dialog__detail">Nothing to flag — submit when ready.</p>`;
134
+ }
135
+
136
+ container.innerHTML = html;
137
+ }
138
+
139
+ function renderFindingGroup(title, findings) {
140
+ if (findings.length === 0) return "";
141
+ const items = findings.map((f) => `
142
+ <li class="marketplace-finding marketplace-finding--${f.severity}">
143
+ ${f.file ? `<code class="marketplace-finding__file">${esc(f.file)}</code>` : ""}
144
+ <span class="marketplace-finding__msg">${esc(f.message)}</span>
145
+ ${f.fix ? `<div class="marketplace-finding__fix">${esc(f.fix)}</div>` : ""}
146
+ </li>
147
+ `).join("");
148
+ return `<section class="marketplace-section"><h4>${esc(title)}</h4><ul class="marketplace-findings">${items}</ul></section>`;
149
+ }
150
+
151
+ function openListingEditor(existing, categories) {
152
+ const meta = existing || {};
153
+ return new Promise((resolve) => {
154
+ const overlay = document.createElement("div");
155
+ overlay.className = "confirm-overlay";
156
+ const catOptions = (categories || []).map(
157
+ (c) => `<option value="${esc(c)}" ${c === meta.category ? "selected" : ""}>${esc(c)}</option>`
158
+ ).join("");
159
+ overlay.innerHTML = `
160
+ <div class="confirm-dialog marketplace-dialog">
161
+ <div class="confirm-dialog__title">Marketplace listing</div>
162
+ <form id="marketplace-listing-form" class="marketplace-listing-form">
163
+ <label>Category
164
+ <select name="category"><option value="">Select…</option>${catOptions}</select>
165
+ </label>
166
+ <label>Description
167
+ <textarea name="description" rows="3" placeholder="A 1–2 sentence summary for the Marketplace listing.">${esc(meta.description || "")}</textarea>
168
+ </label>
169
+ <label>Features (comma-separated, 2–5 items)
170
+ <input name="features" value="${esc((meta.features || []).join(", "))}" placeholder="Hero, Pricing, Testimonials, Footer" />
171
+ </label>
172
+ <label>Support URL
173
+ <input type="url" name="supportUrl" value="${esc(meta.supportUrl || "")}" placeholder="https://example.com/support" />
174
+ </label>
175
+ <label>Documentation URL (optional)
176
+ <input type="url" name="documentationUrl" value="${esc(meta.documentationUrl || "")}" placeholder="https://example.com/docs" />
177
+ </label>
178
+ <label>Pricing tier
179
+ <select name="pricingTier">
180
+ <option value="free" ${meta.pricingTier === "free" ? "selected" : ""}>Free</option>
181
+ <option value="paid" ${meta.pricingTier === "paid" ? "selected" : ""}>Paid</option>
182
+ </select>
183
+ </label>
184
+ </form>
185
+ <div class="confirm-dialog__actions">
186
+ <button class="btn btn--ghost" data-action="cancel">Cancel</button>
187
+ <button class="btn btn--primary" data-action="save">Save</button>
188
+ </div>
189
+ </div>
190
+ `;
191
+ document.body.appendChild(overlay);
192
+ const form = overlay.querySelector("form");
193
+
194
+ const cleanup = () => overlay.remove();
195
+ overlay.querySelector('[data-action="cancel"]').addEventListener("click", () => { cleanup(); resolve(null); });
196
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) { cleanup(); resolve(null); } });
197
+ overlay.querySelector('[data-action="save"]').addEventListener("click", () => {
198
+ const fd = new FormData(form);
199
+ const features = String(fd.get("features") || "")
200
+ .split(",").map((s) => s.trim()).filter(Boolean);
201
+ const result = {
202
+ category: String(fd.get("category") || "") || undefined,
203
+ description: String(fd.get("description") || "") || undefined,
204
+ features: features.length ? features : undefined,
205
+ supportUrl: String(fd.get("supportUrl") || "") || undefined,
206
+ documentationUrl: String(fd.get("documentationUrl") || "") || undefined,
207
+ pricingTier: String(fd.get("pricingTier") || "") || undefined,
208
+ };
209
+ cleanup();
210
+ resolve(result);
211
+ });
212
+ });
213
+ }
214
+
215
+ document.addEventListener("DOMContentLoaded", () => {
216
+ const btn = document.getElementById("btn-marketplace");
217
+ if (btn) btn.addEventListener("click", openMarketplacePanel);
218
+ });
package/ui/plan.js CHANGED
Binary file
package/ui/preview.js CHANGED
@@ -3,11 +3,71 @@
3
3
  */
4
4
 
5
5
  const previewFrame = document.getElementById("preview-frame");
6
+ const previewEmptyState = document.getElementById("preview-empty-state");
7
+
8
+ // Highlights to apply once the iframe finishes loading after the next refresh.
9
+ let pendingChangedModules = null;
10
+ let pendingNewModules = null;
11
+
12
+ /**
13
+ * Show or hide the preview empty state. Called when generation starts or when
14
+ * the iframe finishes loading and we can detect whether any modules rendered.
15
+ */
16
+ function setPreviewEmptyState(show) {
17
+ if (!previewEmptyState) return;
18
+ previewEmptyState.setAttribute("aria-hidden", show ? "false" : "true");
19
+ }
20
+
21
+ /**
22
+ * Inspect iframe contents post-load and toggle the empty state accordingly.
23
+ * Empty state stays visible if the rendered preview has no module content.
24
+ */
25
+ function syncEmptyStateFromFrame() {
26
+ if (!previewEmptyState) return;
27
+ try {
28
+ const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
29
+ if (!doc || !doc.body) {
30
+ setPreviewEmptyState(true);
31
+ return;
32
+ }
33
+ const hasModules = doc.querySelector("[data-module]") !== null;
34
+ setPreviewEmptyState(!hasModules);
35
+ } catch {
36
+ // cross-origin — assume content is present
37
+ setPreviewEmptyState(false);
38
+ }
39
+ }
40
+
41
+ previewFrame.addEventListener("load", () => {
42
+ syncEmptyStateFromFrame();
43
+ if (!pendingChangedModules && !pendingNewModules) return;
44
+ const changed = pendingChangedModules;
45
+ const fresh = pendingNewModules;
46
+ pendingChangedModules = null;
47
+ pendingNewModules = null;
48
+ try {
49
+ const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
50
+ if (!doc || !doc.body) return;
51
+ ensureChangeHighlightStyles(doc);
52
+ if (fresh && fresh.length) animateNewModules(doc, fresh);
53
+ if (changed && changed.length) highlightChangedModules(doc, changed, fresh || []);
54
+ } catch {
55
+ // cross-origin — skip
56
+ }
57
+ });
6
58
 
7
59
  /**
8
60
  * Refresh the preview iframe by reloading from /preview endpoint.
61
+ *
62
+ * @param {Object} [opts]
63
+ * @param {string[]} [opts.changedModules] Module names that were just regenerated.
64
+ * @param {string[]} [opts.newModules] Subset of changedModules that are first-time additions.
9
65
  */
10
- function refreshPreview() {
66
+ function refreshPreview(opts) {
67
+ if (opts && (opts.changedModules || opts.newModules)) {
68
+ pendingChangedModules = opts.changedModules || null;
69
+ pendingNewModules = opts.newModules || null;
70
+ }
11
71
  // Use srcdoc approach: fetch preview HTML and set as srcdoc
12
72
  // This avoids cache issues and allows the iframe to update smoothly
13
73
  fetch("/preview")
@@ -20,6 +80,79 @@ function refreshPreview() {
20
80
  });
21
81
  }
22
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // Change highlighting — outline glow on regenerated modules and slide-in for
85
+ // brand-new modules. Styles are injected into the sandboxed preview iframe.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function ensureChangeHighlightStyles(doc) {
89
+ if (doc.getElementById("vibespot-change-highlight-css")) return;
90
+ const style = doc.createElement("style");
91
+ style.id = "vibespot-change-highlight-css";
92
+ style.textContent = `
93
+ @keyframes vibespot-change-glow {
94
+ 0% { outline-color: rgba(232, 97, 58, 0.85); box-shadow: 0 0 0 6px rgba(232, 97, 58, 0.18); }
95
+ 70% { outline-color: rgba(232, 97, 58, 0.55); box-shadow: 0 0 0 4px rgba(232, 97, 58, 0.10); }
96
+ 100% { outline-color: rgba(232, 97, 58, 0); box-shadow: 0 0 0 0 rgba(232, 97, 58, 0); }
97
+ }
98
+ .vibespot-module--changed {
99
+ outline: 2px solid rgba(232, 97, 58, 0.85);
100
+ outline-offset: 4px;
101
+ border-radius: 2px;
102
+ animation: vibespot-change-glow 2s ease-out forwards;
103
+ }
104
+ @keyframes vibespot-module-slide-in {
105
+ 0% { opacity: 0; transform: translateY(24px); }
106
+ 100% { opacity: 1; transform: translateY(0); }
107
+ }
108
+ .vibespot-module--new {
109
+ animation: vibespot-module-slide-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) both;
110
+ }
111
+ @media (prefers-reduced-motion: reduce) {
112
+ .vibespot-module--changed,
113
+ .vibespot-module--new { animation: none; }
114
+ .vibespot-module--changed { outline-color: transparent; }
115
+ }
116
+ `;
117
+ doc.head.appendChild(style);
118
+ }
119
+
120
+ function highlightChangedModules(doc, moduleNames, newModuleNames) {
121
+ // Skip modules that are already getting the slide-in animation — the slide-in
122
+ // is a stronger signal on its own.
123
+ const newSet = new Set(newModuleNames);
124
+ for (const name of moduleNames) {
125
+ if (newSet.has(name)) continue;
126
+ const el = doc.querySelector(`[data-module="${cssEscape(name)}"]`);
127
+ if (!el) continue;
128
+ el.classList.remove("vibespot-module--changed");
129
+ // Force a reflow so the animation restarts if the class was just removed.
130
+ void el.offsetWidth;
131
+ el.classList.add("vibespot-module--changed");
132
+ setTimeout(() => {
133
+ el.classList.remove("vibespot-module--changed");
134
+ }, 2200);
135
+ }
136
+ }
137
+
138
+ function animateNewModules(doc, moduleNames) {
139
+ for (const name of moduleNames) {
140
+ const el = doc.querySelector(`[data-module="${cssEscape(name)}"]`);
141
+ if (!el) continue;
142
+ el.classList.add("vibespot-module--new");
143
+ setTimeout(() => {
144
+ el.classList.remove("vibespot-module--new");
145
+ }, 700);
146
+ }
147
+ }
148
+
149
+ function cssEscape(value) {
150
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
151
+ return CSS.escape(value);
152
+ }
153
+ return String(value).replace(/["\\]/g, "\\$&");
154
+ }
155
+
23
156
  /**
24
157
  * Scroll the preview iframe to a specific module by name.
25
158
  */
@@ -49,6 +182,7 @@ function scrollPreviewToModule(moduleName) {
49
182
  * Called when AI generation starts to entertain the user while waiting.
50
183
  */
51
184
  function showGeneratingPreview() {
185
+ setPreviewEmptyState(false);
52
186
  const html = `<!DOCTYPE html>
53
187
  <html lang="en">
54
188
  <head>
@@ -278,3 +412,87 @@ function clearAllModulesWorking() {
278
412
 
279
413
  // Preview refresh is triggered by setup.js after a session is created.
280
414
  // Do NOT auto-refresh here.
415
+
416
+ // Select/edit mode has been unified into interact mode (inline-edit.js)
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // HubL validity badge — aggregates per-module checks reported by chat.js into
420
+ // a single status pill in the preview toolbar.
421
+ // ---------------------------------------------------------------------------
422
+
423
+ const hublBadgeEl = document.getElementById("hubl-badge");
424
+ const hublBadgeLabelEl = hublBadgeEl ? hublBadgeEl.querySelector(".hubl-badge__label") : null;
425
+ const hublBadgeCountEl = document.getElementById("hubl-badge-count");
426
+ const hublModuleIssues = new Map();
427
+ let hublBadgeReveal = null;
428
+
429
+ function applyHublBadgeState() {
430
+ if (!hublBadgeEl) return;
431
+ let totalIssues = 0;
432
+ for (const issues of hublModuleIssues.values()) totalIssues += issues.length;
433
+ const failedModules = Array.from(hublModuleIssues.values()).filter((arr) => arr.length > 0).length;
434
+
435
+ const state = totalIssues === 0 ? "valid" : "issues";
436
+ hublBadgeEl.dataset.state = state;
437
+ hublBadgeEl.classList.toggle("hubl-badge--valid", state === "valid");
438
+ hublBadgeEl.classList.toggle("hubl-badge--issues", state === "issues");
439
+
440
+ if (hublBadgeLabelEl) {
441
+ hublBadgeLabelEl.textContent = state === "valid" ? "Valid HubL" : "HubL issues";
442
+ }
443
+ if (hublBadgeCountEl) {
444
+ if (state === "valid") {
445
+ hublBadgeCountEl.classList.add("hidden");
446
+ hublBadgeCountEl.textContent = "";
447
+ } else {
448
+ hublBadgeCountEl.classList.remove("hidden");
449
+ hublBadgeCountEl.textContent = String(totalIssues);
450
+ }
451
+ }
452
+
453
+ if (state === "valid") {
454
+ const checked = hublModuleIssues.size;
455
+ hublBadgeEl.title = checked === 0
456
+ ? "HubL syntax check — no modules generated yet."
457
+ : `HubL syntax check — all ${checked} module${checked === 1 ? "" : "s"} parse cleanly.`;
458
+ } else {
459
+ hublBadgeEl.title = `${totalIssues} HubL issue${totalIssues === 1 ? "" : "s"} across ${failedModules} module${failedModules === 1 ? "" : "s"}. Click to review.`;
460
+ }
461
+ }
462
+
463
+ function flashHublBadge() {
464
+ if (!hublBadgeEl) return;
465
+ hublBadgeEl.classList.remove("hubl-badge--flash");
466
+ // Force reflow so the animation restarts.
467
+ void hublBadgeEl.offsetWidth;
468
+ hublBadgeEl.classList.add("hubl-badge--flash");
469
+ }
470
+
471
+ window.resetHublCheck = function () {
472
+ hublModuleIssues.clear();
473
+ applyHublBadgeState();
474
+ };
475
+
476
+ window.reportHublCheck = function (moduleName, issues) {
477
+ if (!moduleName) return;
478
+ hublModuleIssues.set(moduleName, Array.isArray(issues) ? issues : []);
479
+ applyHublBadgeState();
480
+ flashHublBadge();
481
+ };
482
+
483
+ if (hublBadgeEl) {
484
+ applyHublBadgeState();
485
+ hublBadgeEl.addEventListener("click", () => {
486
+ if (hublBadgeEl.dataset.state !== "issues") return;
487
+ const lines = [];
488
+ for (const [name, issues] of hublModuleIssues) {
489
+ if (!issues.length) continue;
490
+ lines.push(`• ${name}: ${issues.map((i) => i.message || i.kind).join(", ")}`);
491
+ }
492
+ if (typeof appendSystemMessage === "function") {
493
+ appendSystemMessage(`HubL issues in current run:\n${lines.join("\n")}`);
494
+ } else {
495
+ console.warn("HubL issues:\n" + lines.join("\n"));
496
+ }
497
+ });
498
+ }