fraim-hub 2.0.285 → 2.0.286

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.
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runMandatoryDesktopUpdateCheck = runMandatoryDesktopUpdateCheck;
4
+ let updateCheckInFlight = null;
5
+ function runMandatoryDesktopUpdateCheck(options) {
6
+ if (!options.isPackaged)
7
+ return Promise.resolve({ action: 'skipped-unpackaged' });
8
+ if (updateCheckInFlight)
9
+ return updateCheckInFlight;
10
+ updateCheckInFlight = runMandatoryDesktopUpdateCheckOnce(options)
11
+ .finally(() => {
12
+ updateCheckInFlight = null;
13
+ });
14
+ return updateCheckInFlight;
15
+ }
16
+ async function runMandatoryDesktopUpdateCheckOnce(options) {
17
+ const { updater, prompt, currentVersion, logger = console } = options;
18
+ updater.autoDownload = false;
19
+ updater.autoInstallOnAppQuit = false;
20
+ const checkResult = await checkForDesktopUpdate(updater, prompt, currentVersion, logger);
21
+ if (checkResult.action === 'check-failed')
22
+ return checkResult;
23
+ if (!checkResult.update?.isUpdateAvailable) {
24
+ logger.info('[fraim] desktop update check found no newer version');
25
+ return { action: 'current', availableVersion: checkResult.update?.updateInfo?.version };
26
+ }
27
+ const availableVersion = checkResult.update.updateInfo?.version;
28
+ try {
29
+ logger.info(`[fraim] desktop update ${availableVersion ?? 'unknown'} available; downloading`);
30
+ if (checkResult.update.downloadPromise) {
31
+ await checkResult.update.downloadPromise;
32
+ }
33
+ else {
34
+ await updater.downloadUpdate();
35
+ }
36
+ }
37
+ catch (error) {
38
+ const message = errorMessage(error);
39
+ logger.error(`[fraim] desktop update download failed: ${message}`);
40
+ await promptUpdateFailure(prompt, 'FRAIM Hub update download failed', 'FRAIM Hub found an update but could not download it. Please restart FRAIM Hub or reinstall from the latest installer.', message);
41
+ return { action: 'download-failed', availableVersion, error: message };
42
+ }
43
+ try {
44
+ logger.info(`[fraim] desktop update ${availableVersion ?? 'unknown'} downloaded; installing`);
45
+ updater.quitAndInstall(false, true);
46
+ return { action: 'installing', availableVersion };
47
+ }
48
+ catch (error) {
49
+ const message = errorMessage(error);
50
+ logger.error(`[fraim] desktop update install failed: ${message}`);
51
+ await promptUpdateFailure(prompt, 'FRAIM Hub update install failed', 'FRAIM Hub downloaded an update but could not start the installer. Please restart FRAIM Hub or reinstall from the latest installer.', message);
52
+ return { action: 'install-failed', availableVersion, error: message };
53
+ }
54
+ }
55
+ async function checkForDesktopUpdate(updater, prompt, currentVersion, logger) {
56
+ try {
57
+ logger.info(`[fraim] checking for desktop update from ${currentVersion}`);
58
+ return { action: 'checked', update: await updater.checkForUpdates() };
59
+ }
60
+ catch (error) {
61
+ const message = errorMessage(error);
62
+ logger.warn(`[fraim] desktop update check failed: ${message}`);
63
+ await promptUpdateFailure(prompt, 'FRAIM Hub update check failed', 'FRAIM Hub could not check for updates. It will continue starting, but this installed app may be stale.', message);
64
+ return { action: 'check-failed', error: message };
65
+ }
66
+ }
67
+ function promptUpdateFailure(prompt, title, message, detail) {
68
+ return prompt.showErrorBox(title, message, detail);
69
+ }
70
+ function errorMessage(error) {
71
+ if (error instanceof Error)
72
+ return error.message;
73
+ return String(error);
74
+ }
@@ -19,6 +19,7 @@ const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
19
19
  const server_2 = require("../first-run/server");
20
20
  const session_service_1 = require("../first-run/session-service");
21
21
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
22
+ const desktop_auto_updater_1 = require("./desktop-auto-updater");
22
23
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
23
24
  // stable identity declared in packages/fraim-hub/package.json.
24
25
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -110,18 +111,33 @@ function ensureLoginItem() {
110
111
  fs_1.default.mkdirSync(path_1.default.dirname(flagPath), { recursive: true });
111
112
  fs_1.default.writeFileSync(flagPath, '1');
112
113
  }
113
- function configureAutoUpdater() {
114
+ async function configureAutoUpdater() {
114
115
  if (!electron_1.app.isPackaged)
115
- return;
116
+ return false;
116
117
  // #1110: electron-updater compiles ~114 files (js-yaml, builder-util-runtime, ...) that a
117
118
  // non-packaged `npx fraim-hub` launch never uses, and this whole function returns early
118
119
  // there. Requiring it lazily keeps those file reads off the cold-start path, which is what
119
120
  // dominates time-to-ready on a freshly unpacked install.
120
121
  // eslint-disable-next-line @typescript-eslint/no-require-imports
121
122
  const { autoUpdater } = require('electron-updater');
122
- autoUpdater.autoDownload = true;
123
- autoUpdater.checkForUpdatesAndNotify().catch((err) => {
124
- console.warn('[fraim] auto-update check failed:', err);
123
+ const result = await (0, desktop_auto_updater_1.runMandatoryDesktopUpdateCheck)({
124
+ isPackaged: electron_1.app.isPackaged,
125
+ updater: autoUpdater,
126
+ currentVersion: electron_1.app.getVersion(),
127
+ logger: console,
128
+ prompt: {
129
+ showErrorBox: (title, message, detail) => {
130
+ electron_1.dialog.showErrorBox(title, detail ? `${message}\n\n${detail}` : message);
131
+ },
132
+ },
133
+ });
134
+ return result.action === 'installing';
135
+ }
136
+ function checkForUpdateAfterSecondInstance() {
137
+ if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
138
+ return;
139
+ void configureAutoUpdater().catch((err) => {
140
+ console.warn('[fraim] second-instance update check failed:', err);
125
141
  });
126
142
  }
127
143
  // ---------------------------------------------------------------------------
@@ -467,6 +483,7 @@ async function bootstrap() {
467
483
  return;
468
484
  }
469
485
  electron_1.app.on('second-instance', () => {
486
+ void electron_1.app.whenReady().then(checkForUpdateAfterSecondInstance);
470
487
  if (mainWindow) {
471
488
  mainWindow.show();
472
489
  mainWindow.focus();
@@ -486,7 +503,9 @@ async function bootstrap() {
486
503
  // First-launch housekeeping (idempotent, fast on subsequent runs)
487
504
  if (process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1') {
488
505
  ensureLoginItem();
489
- configureAutoUpdater();
506
+ const installingUpdate = await configureAutoUpdater();
507
+ if (installingUpdate)
508
+ return;
490
509
  }
491
510
  electron_1.app.on('activate', () => {
492
511
  // macOS: clicking dock icon re-shows the window
@@ -2071,6 +2071,29 @@ class CliHostRuntime {
2071
2071
  return null;
2072
2072
  return active.pending.length + 1;
2073
2073
  }
2074
+ stopActiveSession(hostId, sessionId) {
2075
+ const key = `${hostId}::${sessionId}`;
2076
+ const active = this.activeContinueRuns.get(key);
2077
+ if (!active)
2078
+ return false;
2079
+ active.pending.splice(0);
2080
+ this.activeContinueRuns.delete(key);
2081
+ if (active.child.pid == null)
2082
+ return false;
2083
+ try {
2084
+ this.killTree(active.child.pid, 'SIGTERM');
2085
+ return true;
2086
+ }
2087
+ catch (error) {
2088
+ console.warn('[ai-hub] failed to stop active host session process tree:', {
2089
+ hostId,
2090
+ sessionId,
2091
+ pid: active.child.pid,
2092
+ error: error instanceof Error ? error.message : String(error),
2093
+ });
2094
+ return false;
2095
+ }
2096
+ }
2074
2097
  guardedContinue(hostId, sessionId, entry) {
2075
2098
  const key = `${hostId}::${sessionId}`;
2076
2099
  const active = this.activeContinueRuns.get(key);
@@ -417,7 +417,12 @@ class AiHubRunRegistry {
417
417
  (0, tree_kill_1.default)(child.pid, 'SIGTERM');
418
418
  return true;
419
419
  }
420
- catch {
420
+ catch (error) {
421
+ console.warn('[ai-hub] failed to stop run process tree:', {
422
+ runId,
423
+ pid: child.pid,
424
+ error: error instanceof Error ? error.message : String(error),
425
+ });
421
426
  return false;
422
427
  }
423
428
  }
@@ -5945,7 +5950,11 @@ class AiHubServer {
5945
5950
  return res.json(this.enrichRunForResponse(run));
5946
5951
  }
5947
5952
  this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
5948
- const killed = this.runRegistry.stop(run.id);
5953
+ const killedRunChild = this.runRegistry.stop(run.id);
5954
+ const killedHostSession = run.sessionId
5955
+ ? this.hostRuntime.stopActiveSession?.(run.hostId, run.sessionId) === true
5956
+ : false;
5957
+ const killed = killedRunChild || killedHostSession;
5949
5958
  // Park it immediately (don't wait for onExit, which may lag or not fire on a
5950
5959
  // host that already detached). onExit, if it fires, keeps this same state.
5951
5960
  this.runRegistry.update(run.id, (current) => {
@@ -7309,7 +7318,7 @@ class AiHubServer {
7309
7318
  const delay = recoveryBackoffMs(attempt);
7310
7319
  const tid = setTimeout(() => {
7311
7320
  const current = this.runRegistry.get(runId);
7312
- if (!current || current.status !== 'running')
7321
+ if (!current || current.status !== 'running' || current.stoppedByUser)
7313
7322
  return;
7314
7323
  const message = classification.recoveryKind === 'compaction'
7315
7324
  ? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
@@ -31,6 +31,7 @@ const GENERALIST_PROFILE = {
31
31
  };
32
32
  /** Role key -> the human manager best suited to manage that AI employee. Mirror of registry/scripts/ai-manager-hiring.ts. */
33
33
  exports.HUMAN_MANAGER_PROFILES = {
34
+ aida: { humanTitle: 'Head of AI Engineering', keywords: ['"Head of AI Engineering"', '"Director of AI"', '"AI Engineering Manager"', '"VP Engineering"'] },
34
35
  maestro: { humanTitle: 'Co-Founder / General Manager', keywords: ['"Co-Founder"', '"General Manager"', '"Chief of Staff"', '"Founder"'] },
35
36
  beza: { humanTitle: 'Head of Strategy', keywords: ['"Head of Strategy"', '"Strategy Director"', '"Chief of Staff"'] },
36
37
  pam: { humanTitle: 'Head of Product', keywords: ['"Head of Product"', '"Group Product Manager"', '"Director of Product"'] },
@@ -61,6 +61,7 @@ exports.JOB_DOMAIN_MAP = {
61
61
  'security': 'engineering',
62
62
  'delivery-ops': 'engineering',
63
63
  'salesforce': 'engineering',
64
+ 'ai-engineering': 'engineering',
64
65
  // product
65
66
  'product-management': 'product',
66
67
  'customer-development': 'product',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.285",
3
+ "version": "2.0.286",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -210,7 +210,7 @@
210
210
  "electron-updater": "^6.8.9",
211
211
  "express": "^5.2.1",
212
212
  "extract-zip": "^2.0.1",
213
- "fraim": "2.0.285",
213
+ "fraim": "2.0.286",
214
214
  "mongodb": "^7.0.0",
215
215
  "node-cron": "4.2.1",
216
216
  "node-edge-tts": "^1.2.10",
@@ -91,6 +91,11 @@ const state = {
91
91
  cpPersonaOverride: null, // issue #945: custom:* key to stamp on conv.personaKey
92
92
  lastRun: null, // {job, instructions, employeeId} for Cmd+Shift+R
93
93
  configuredAgentCheckResults: {},
94
+ // #1389: an active Company Brand draft must survive poll-driven tfRenderCompany()
95
+ // calls, just as #1351 preserves an open Manager-tab configured-agent form.
96
+ brandEditorDraftActive: false,
97
+ brandEditorStatus: '',
98
+ brandEditorStatusWarn: false,
94
99
  // Issue #540 R10: pending run params captured when hire strip is shown.
95
100
  _hireStripPending: null,
96
101
  };
@@ -13071,7 +13076,11 @@ function tfRenderCompany() {
13071
13076
  // + organizational-learning-synthesis) — not in any project's job list, not as
13072
13077
  // chips elsewhere.
13073
13078
  // #744: render the Brand editor (its host #company-brand-editor is static in the info view).
13074
- if (typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
13079
+ // #1389: background conversation/status polls call tfRenderCompany(). Rebuilding
13080
+ // the editor while its draft is active replaces the focused node and resets its
13081
+ // closure-owned unsaved values. Save/Reset clears the guard below; the status
13082
+ // state keeps their feedback stable if the next poll then performs a render.
13083
+ if (!state.brandEditorDraftActive && typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
13075
13084
  const rail = document.getElementById('company-rail');
13076
13085
  if (rail) {
13077
13086
  rail.innerHTML = '';
@@ -14667,6 +14676,7 @@ function tfApplyOrgBrand(brand) {
14667
14676
  function tfRenderBrandEditor() {
14668
14677
  var host = document.getElementById('company-brand-editor');
14669
14678
  if (!host) return;
14679
+ state.brandEditorDraftActive = false;
14670
14680
  var draft = {
14671
14681
  name: (state.orgBrand && state.orgBrand.name) || '',
14672
14682
  color: (state.orgBrand && state.orgBrand.color) || '',
@@ -14689,13 +14699,13 @@ function tfRenderBrandEditor() {
14689
14699
  + ' <span class="brand-hint" id="be-contrast-hint" style="margin:0"></span></div></div>'
14690
14700
  + ' <div class="brand-editor-actions"><button class="send-button" type="button" id="be-save">Save brand</button>'
14691
14701
  + ' <button class="ghost" type="button" id="be-clear">Reset to FRAIM</button>'
14692
- + ' <span class="brand-hint" id="be-status" style="margin:0"></span></div>'
14702
+ + ' <span class="brand-hint" id="be-status" role="status" aria-live="polite" style="margin:0"></span></div>'
14693
14703
  + ' <div class="brand-preview"><div class="brand-preview-cap">Top nav preview</div>'
14694
- + ' <nav class="hub-tabs" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
14704
+ + ' <nav class="hub-tabs" aria-hidden="true" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
14695
14705
  + ' <span class="hub-brand-divider" id="be-prev-div"></span>'
14696
- + ' <button class="hub-tab on" type="button">Projects</button><button class="hub-tab" type="button">Company</button>'
14706
+ + ' <button class="hub-tab on" type="button" tabindex="-1">Projects</button><button class="hub-tab" type="button" tabindex="-1">Company</button>'
14697
14707
  + ' <div class="nav-right"><span class="hub-cobrand">powered by <span class="hub-cobrand-mark"><img src="' + FRAIM_MARK_SRC + '" alt="FRAIM"></span> FRAIM</span>'
14698
- + ' <button class="avatar-btn" type="button">SM</button></div></nav></div>'
14708
+ + ' <button class="avatar-btn" type="button" tabindex="-1">SM</button></div></nav></div>'
14699
14709
  + '</div>';
14700
14710
 
14701
14711
  var nameEl = host.querySelector('#be-name');
@@ -14704,13 +14714,21 @@ function tfRenderBrandEditor() {
14704
14714
  var dropEl = host.querySelector('#be-logo-drop');
14705
14715
  var swatches = host.querySelector('#be-swatches');
14706
14716
  var statusEl = host.querySelector('#be-status');
14717
+ host.addEventListener('focusin', function () { state.brandEditorDraftActive = true; });
14718
+ function setStatus(message, warn) {
14719
+ state.brandEditorStatus = message;
14720
+ state.brandEditorStatusWarn = !!warn;
14721
+ statusEl.textContent = message;
14722
+ statusEl.className = warn ? 'brand-hint warn' : 'brand-hint';
14723
+ }
14724
+ setStatus(state.brandEditorStatus || '', state.brandEditorStatusWarn);
14707
14725
  nameEl.value = draft.name;
14708
14726
  hexEl.value = draft.color;
14709
14727
 
14710
14728
  BRAND_COLOR_PRESETS.forEach(function (c) {
14711
14729
  var b = document.createElement('button');
14712
14730
  b.type = 'button'; b.className = 'brand-swatch'; b.style.background = c; b.title = c;
14713
- b.addEventListener('click', function () { draft.color = c; hexEl.value = c; refresh(); });
14731
+ b.addEventListener('click', function () { setStatus('', false); draft.color = c; hexEl.value = c; refresh(); });
14714
14732
  swatches.appendChild(b);
14715
14733
  });
14716
14734
 
@@ -14744,14 +14762,15 @@ function tfRenderBrandEditor() {
14744
14762
  }
14745
14763
  }
14746
14764
 
14747
- nameEl.addEventListener('input', function () { draft.name = nameEl.value; refresh(); });
14748
- hexEl.addEventListener('input', function () { draft.color = hexEl.value; refresh(); });
14765
+ nameEl.addEventListener('input', function () { setStatus('', false); draft.name = nameEl.value; refresh(); });
14766
+ hexEl.addEventListener('input', function () { setStatus('', false); draft.color = hexEl.value; refresh(); });
14749
14767
  dropEl.addEventListener('click', function () { fileEl.click(); });
14750
14768
  // Keyboard access (a11y): Enter/Space on the focusable drop zone opens the picker.
14751
14769
  dropEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileEl.click(); } });
14752
14770
  fileEl.addEventListener('change', function () {
14753
14771
  var f = fileEl.files && fileEl.files[0];
14754
14772
  if (!f) return;
14773
+ setStatus('', false);
14755
14774
  if (f.size > 512 * 1024) { host.querySelector('#be-logo-hint').textContent = 'That file is over 512 KB. Pick a smaller logo.'; host.querySelector('#be-logo-hint').className = 'brand-hint warn'; return; }
14756
14775
  var reader = new FileReader();
14757
14776
  reader.onload = function () {
@@ -14763,23 +14782,24 @@ function tfRenderBrandEditor() {
14763
14782
  });
14764
14783
 
14765
14784
  host.querySelector('#be-save').addEventListener('click', function () {
14766
- statusEl.textContent = 'Saving…'; statusEl.className = 'brand-hint';
14785
+ setStatus('Saving…', false);
14767
14786
  var body = { name: draft.name, color: draft.color, logo: draft.logo };
14768
14787
  if (state.projectPath) body.projectPath = state.projectPath;
14769
14788
  requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
14770
14789
  .then(function (resp) {
14771
14790
  tfApplyOrgBrand(resp && resp.brand ? resp.brand : null);
14772
- statusEl.textContent = 'Saved. Applied to your Hub.'; statusEl.className = 'brand-hint';
14791
+ state.brandEditorDraftActive = false;
14792
+ setStatus('Saved. Applied to your Hub.', false);
14773
14793
  tfShowBrandTeamNote(host);
14774
14794
  })
14775
- .catch(function (err) { statusEl.textContent = 'Save failed: ' + (err && err.message ? err.message : 'error'); statusEl.className = 'brand-hint warn'; });
14795
+ .catch(function (err) { setStatus('Save failed: ' + (err && err.message ? err.message : 'error'), true); });
14776
14796
  });
14777
14797
  host.querySelector('#be-clear').addEventListener('click', function () {
14778
14798
  var body = { name: '', color: '', logo: '' };
14779
14799
  if (state.projectPath) body.projectPath = state.projectPath;
14780
14800
  requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
14781
- .then(function () { tfApplyOrgBrand(null); tfRenderBrandEditor(); })
14782
- .catch(function () {});
14801
+ .then(function () { state.brandEditorDraftActive = false; setStatus('', false); tfApplyOrgBrand(null); tfRenderBrandEditor(); })
14802
+ .catch(function (err) { setStatus('Reset failed: ' + (err && err.message ? err.message : 'error'), true); });
14783
14803
  });
14784
14804
 
14785
14805
  refresh();
@@ -5407,11 +5407,15 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5407
5407
  .hub-cobrand[hidden] { display: none; }
5408
5408
  .hub-cobrand-mark { width: 17px; height: 17px; border-radius: 5px; display: block; }
5409
5409
  .hub-cobrand-mark img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 5px; }
5410
+ /* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark, and
5411
+ account control together. Keep the functional controls and shed the co-mark. */
5412
+ @media (max-width: 820px) {
5413
+ .hub-cobrand { display: none; }
5414
+ }
5410
5415
  /* Very narrow windows (the Hub is desktop/Electron-first, but keep the dense nav
5411
5416
  from forcing horizontal page scroll): drop the co-mark and the company name,
5412
5417
  keeping the company logo mark + tabs. */
5413
5418
  @media (max-width: 560px) {
5414
- .hub-cobrand { display: none; }
5415
5419
  .hub-brand-name { display: none; }
5416
5420
  .hub-brand { padding: 0 2px 0 10px; }
5417
5421
  .hub-brand-divider { margin: 0 4px 0 8px; }
@@ -5431,6 +5435,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5431
5435
  .brand-field .brand-hint { font-size: 12px; color: var(--muted); margin-top: 5px; }
5432
5436
  .brand-field .brand-hint.warn { color: var(--warn); }
5433
5437
  .brand-logo-drop { display: flex; align-items: center; gap: 14px; border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line)); border-radius: 12px; padding: 14px; background: var(--accent-soft); cursor: pointer; }
5438
+ .brand-logo-drop:focus-visible, .brand-swatch:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
5434
5439
  .brand-logo-drop .blp { width: 44px; height: 44px; border-radius: 10px; box-shadow: 0 0 0 1px var(--line); flex-shrink: 0; overflow: hidden; background: var(--surface); display: flex; align-items: center; justify-content: center; }
5435
5440
  .brand-logo-drop .blp svg, .brand-logo-drop .blp img { width: 100%; height: 100%; object-fit: contain; }
5436
5441
  .brand-logo-drop .blp-txt { font-size: 12.5px; color: var(--text); }
@@ -5439,6 +5444,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5439
5444
  .brand-swatch.sel { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
5440
5445
  .brand-color-hex { width: 96px; }
5441
5446
  .brand-editor-actions { display: flex; gap: 10px; margin-top: 6px; align-items: center; }
5447
+ [data-theme="dark"] .brand-editor-actions .send-button { color: var(--bg); }
5442
5448
  .brand-preview { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-top: 14px; }
5443
5449
  .brand-preview-cap { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 10px 14px; background: var(--bg); border-bottom: 1px solid var(--line); }
5444
5450