fraim-framework 2.0.196 → 2.0.198

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.
@@ -20,6 +20,12 @@ const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements
20
20
  // listed here — a persona can run a job from a project OR from the Manager tab, and a
21
21
  // run's placement is decided per-invocation by conv.invokedArea, not by job id.
22
22
  const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
23
+ // Issue #702: personas whose home is the MANAGER tab because their work is inherently
24
+ // cross-project and manager-facing (not project work). Per the spec (R1b), Ashley — the
25
+ // AI Executive Assistant — is Manager-scoped. Every other hired persona works inside
26
+ // projects and belongs on the Projects tab only, NOT the Manager rail. Add a key here
27
+ // to surface a new manager-scoped persona.
28
+ const MANAGER_PERSONA_KEYS = new Set(['ashley']);
23
29
  // Issue #708: project-independent conversation scope buckets (match server sentinels).
24
30
  const MANAGER_CONV_KEY = '@manager';
25
31
  const COMPANY_CONV_KEY = '@company';
@@ -100,7 +106,7 @@ function gatherElements() {
100
106
  'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
101
107
  'job-persona-filter',
102
108
  'picked-name', 'picked-desc', 'instructions',
103
- 'employee-select', 'agent-install-panel', 'active-employee-select',
109
+ 'employee-select', 'agent-install-panel', 'hub-agent-setup-panel', 'cp-agent-install-panel', 'active-employee-select',
104
110
  // Issue #347 additions: tracker, template picker, totals.
105
111
  'tracker', 'tracker-rows', 'tracker-note',
106
112
  'template-picker-btn', 'template-popover',
@@ -192,7 +198,15 @@ function applyBootstrap(bootstrap, docUrl) {
192
198
  if (bootstrap.preferences && bootstrap.preferences.apiKey) {
193
199
  state.storedApiKey = bootstrap.preferences.apiKey;
194
200
  }
195
- state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
201
+ const employees = bootstrap.employees || [];
202
+ const selectedAvailable = employees.some((employee) => employee.id === state.selectedEmployeeId && employee.available);
203
+ const preferredAvailable = employees.some((employee) => employee.id === bootstrap.preferences.employeeId && employee.available);
204
+ if (!state.selectedEmployeeId || !selectedAvailable) {
205
+ const firstAvailable = employees.find((employee) => employee.available);
206
+ state.selectedEmployeeId = preferredAvailable
207
+ ? bootstrap.preferences.employeeId
208
+ : (firstAvailable ? firstAvailable.id : state.selectedEmployeeId);
209
+ }
196
210
  // Restore persona selection persisted on the server side.
197
211
  if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
198
212
  state.selectedPersonaKey = bootstrap.preferences.personaKey;
@@ -224,6 +238,9 @@ function applyBootstrap(bootstrap, docUrl) {
224
238
  // Issue #540: render the persona grid and manager team pool.
225
239
  renderPersonaGrid();
226
240
  renderManagerTeamPool();
241
+ // Issue #744: apply the org cobrand (nav lockup, accent, title, favicon) or
242
+ // fall back to FRAIM identity when none is set.
243
+ tfApplyOrgBrand(bootstrap.orgBrand || null);
227
244
  cacheBootstrapPayload(bootstrap, docUrl);
228
245
  }
229
246
 
@@ -1036,13 +1053,21 @@ function renderRail() {
1036
1053
  els['conv-list'].querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
1037
1054
  const key = details.dataset ? details.dataset.employeeKey : '';
1038
1055
  const projectKey = details.dataset ? (details.dataset.projectKey || activeRailProjectKey) : activeRailProjectKey;
1039
- if (key) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
1056
+ // Only preserve the toggle state of groups that actually have runs. A hired persona
1057
+ // renders as a zero-run group BEFORE conversations hydrate; capturing that transient
1058
+ // collapsed state would wrongly keep the group collapsed after it gains runs. No-run
1059
+ // groups always follow the default (collapsed); with-run groups default open (#726 UX).
1060
+ const hasRuns = !!details.querySelector('.conv-item');
1061
+ if (key && hasRuns) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
1040
1062
  });
1041
1063
  const employeeGroupStateKey = (key) => activeRailProjectKey + ':' + key;
1042
- const preservedEmployeeGroupOpen = (key) => (
1064
+ // Preserve the user's open/closed toggle across re-renders; otherwise fall back to
1065
+ // defaultOpen. An employee with no runs defaults to COLLAPSED so the rail stays scannable
1066
+ // — expand it to see "No jobs assigned yet. Click + to assign a job."
1067
+ const preservedEmployeeGroupOpen = (key, defaultOpen) => (
1043
1068
  employeeGroupOpenState.has(employeeGroupStateKey(key))
1044
1069
  ? employeeGroupOpenState.get(employeeGroupStateKey(key))
1045
- : true
1070
+ : defaultOpen
1046
1071
  );
1047
1072
  els['conv-list'].innerHTML = '';
1048
1073
  // R4: filter by selected persona when one is active.
@@ -1202,7 +1227,7 @@ function renderRail() {
1202
1227
  details.className = 'conv-employee-group';
1203
1228
  details.dataset.employeeKey = group.key;
1204
1229
  details.dataset.projectKey = activeRailProjectKey;
1205
- details.open = preservedEmployeeGroupOpen(group.key);
1230
+ details.open = preservedEmployeeGroupOpen(group.key, group.conversations.length > 0);
1206
1231
  const summary = document.createElement('summary');
1207
1232
  summary.className = 'conv-employee-tab';
1208
1233
  const avatar = buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
@@ -1257,7 +1282,7 @@ function renderRail() {
1257
1282
  emptyWrap.className = 'conv-employee-list';
1258
1283
  const em = document.createElement('div');
1259
1284
  em.className = 'eh-empty';
1260
- em.textContent = 'No runs yet + to assign a job.';
1285
+ em.textContent = 'No jobs assigned yet. Click + to assign a job.';
1261
1286
  emptyWrap.appendChild(em);
1262
1287
  details.appendChild(emptyWrap);
1263
1288
  }
@@ -1274,7 +1299,7 @@ function renderRail() {
1274
1299
  wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
1275
1300
  wcDetails.dataset.employeeKey = '__watercooler__';
1276
1301
  wcDetails.dataset.projectKey = activeRailProjectKey;
1277
- wcDetails.open = preservedEmployeeGroupOpen('__watercooler__');
1302
+ wcDetails.open = preservedEmployeeGroupOpen('__watercooler__', true);
1278
1303
  const wcSummary = document.createElement('summary');
1279
1304
  wcSummary.className = 'conv-employee-tab';
1280
1305
  // #693.8: watercooler icon (replaces the blank dashed placeholder).
@@ -1773,6 +1798,7 @@ function renderActive() {
1773
1798
  if (!conv) {
1774
1799
  els['empty'].hidden = false;
1775
1800
  els['active-conv'].hidden = true;
1801
+ renderHubAgentSetupPanel();
1776
1802
  const _c = document.getElementById('conversation');
1777
1803
  if (_c) _c.classList.remove('ab-mode');
1778
1804
  if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
@@ -1791,6 +1817,7 @@ function renderActive() {
1791
1817
  }
1792
1818
  els['empty'].hidden = true;
1793
1819
  els['active-conv'].hidden = false;
1820
+ if (els['hub-agent-setup-panel']) els['hub-agent-setup-panel'].hidden = true;
1794
1821
  els['active-conv'].dataset.runId = conv.runId || '';
1795
1822
  els['active-title'].textContent = conversationTitle(conv);
1796
1823
  els['active-conv'].classList.toggle('has-delegation-board', !!normalizeDelegationLedger(conv.delegation));
@@ -4475,7 +4502,7 @@ function renderCpAgentPicker() {
4475
4502
  const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
4476
4503
  if (!curOk) {
4477
4504
  const firstAvail = list.find((e) => e.available !== false);
4478
- if (firstAvail) state.cpEmployee = firstAvail.id;
4505
+ state.cpEmployee = firstAvail ? firstAvail.id : null;
4479
4506
  }
4480
4507
  for (const e of list) {
4481
4508
  const pill = document.createElement('button');
@@ -4490,6 +4517,10 @@ function renderCpAgentPicker() {
4490
4517
  }
4491
4518
  picker.appendChild(pill);
4492
4519
  }
4520
+ renderCpAgentInstallPanel();
4521
+ const startBtn = document.getElementById('cp-start-btn');
4522
+ const instr = document.getElementById('cp-instructions');
4523
+ if (startBtn && instr) startBtn.disabled = !instr.value.trim() || !hubHasAvailableEmployee();
4493
4524
  }
4494
4525
 
4495
4526
  function buildCpRow(row, flatIndex) {
@@ -4583,7 +4614,7 @@ function selectCpRow(index) {
4583
4614
  }
4584
4615
 
4585
4616
  const startBtn = document.getElementById('cp-start-btn');
4586
- const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim(); };
4617
+ const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim() || !hubHasAvailableEmployee(); };
4587
4618
  instr.oninput = syncStart;
4588
4619
  syncStart();
4589
4620
  instr.focus();
@@ -4595,6 +4626,11 @@ function submitCpRun() {
4595
4626
  const instr = document.getElementById('cp-instructions');
4596
4627
  const instructions = instr ? instr.value.trim() : '';
4597
4628
  if (!instructions) return;
4629
+ if (!hubHasAvailableEmployee()) {
4630
+ renderCpAgentInstallPanel();
4631
+ showStatus('Set up a Hub CLI agent before starting work in FRAIM Hub.', true);
4632
+ return;
4633
+ }
4598
4634
  const employeeId = state.cpEmployee || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude';
4599
4635
  closePalette();
4600
4636
  // Issue #540: if the job requires a persona not on the manager's team, show
@@ -4815,6 +4851,8 @@ function renderEmployeeSelect() {
4815
4851
  els['employee-select'].appendChild(opt);
4816
4852
  }
4817
4853
  renderAgentInstallPanel();
4854
+ renderHubAgentSetupPanel();
4855
+ renderCpAgentInstallPanel();
4818
4856
  updateAbToggleVisibility();
4819
4857
  }
4820
4858
 
@@ -4827,6 +4865,18 @@ function updateAbToggleVisibility() {
4827
4865
  wrap.hidden = !(emp && emp.supportsRaw);
4828
4866
  }
4829
4867
 
4868
+ function hubEmployees() {
4869
+ return state.bootstrap?.employees || [];
4870
+ }
4871
+
4872
+ function hubHasAvailableEmployee() {
4873
+ return hubEmployees().some((employee) => employee.available);
4874
+ }
4875
+
4876
+ function hubEmployeeIsAvailable(employeeId) {
4877
+ return hubEmployees().some((employee) => employee.id === employeeId && employee.available);
4878
+ }
4879
+
4830
4880
  // ---------------------------------------------------------------------------
4831
4881
  // Agent install panel — shown in Step 2 for unavailable agents
4832
4882
  // ---------------------------------------------------------------------------
@@ -4835,19 +4885,65 @@ function updateAbToggleVisibility() {
4835
4885
  const agentInstallState = {};
4836
4886
 
4837
4887
  function renderAgentInstallPanel() {
4838
- const panel = els['agent-install-panel'];
4888
+ renderAgentInstallPanelInto(els['agent-install-panel'], {
4889
+ heading: 'Install missing agents',
4890
+ testPrefix: 'hub-agent',
4891
+ });
4892
+ }
4893
+
4894
+ function renderHubAgentSetupPanel() {
4895
+ const panel = els['hub-agent-setup-panel'];
4896
+ if (!panel) return;
4897
+ if (hubHasAvailableEmployee()) {
4898
+ panel.hidden = true;
4899
+ panel.innerHTML = '';
4900
+ return;
4901
+ }
4902
+ renderAgentInstallPanelInto(panel, {
4903
+ heading: 'Set up a Hub CLI agent',
4904
+ intro: 'FRAIM Hub runs jobs through CLI agents. Download or install one, sign in, then check readiness before starting Hub work with that agent.',
4905
+ testPrefix: 'hub-agent',
4906
+ });
4907
+ }
4908
+
4909
+ function renderCpAgentInstallPanel() {
4910
+ const panel = els['cp-agent-install-panel'];
4911
+ if (!panel) return;
4912
+ if (hubHasAvailableEmployee()) {
4913
+ panel.hidden = true;
4914
+ panel.innerHTML = '';
4915
+ return;
4916
+ }
4917
+ renderAgentInstallPanelInto(panel, {
4918
+ heading: 'Set up a CLI agent before starting',
4919
+ intro: 'Install, sign in, and verify one Hub agent to enable Start.',
4920
+ testPrefix: 'cp-agent',
4921
+ });
4922
+ }
4923
+
4924
+ function renderAgentInstallPanelInto(panel, options) {
4839
4925
  if (!panel) return;
4840
4926
  panel.innerHTML = '';
4841
4927
 
4842
- const employees = state.bootstrap?.employees || [];
4843
- const unavailable = employees.filter((e) => !e.available);
4844
- if (unavailable.length === 0) return;
4928
+ const unavailable = hubEmployees().filter((e) => !e.available);
4929
+ if (unavailable.length === 0) {
4930
+ panel.hidden = true;
4931
+ return;
4932
+ }
4933
+ panel.hidden = false;
4845
4934
 
4846
4935
  const heading = document.createElement('div');
4847
4936
  heading.className = 'install-panel-heading';
4848
- heading.textContent = 'Install missing agents';
4937
+ heading.textContent = options.heading || 'Install missing agents';
4849
4938
  panel.appendChild(heading);
4850
4939
 
4940
+ if (options.intro) {
4941
+ const intro = document.createElement('p');
4942
+ intro.className = 'install-panel-copy';
4943
+ intro.textContent = options.intro;
4944
+ panel.appendChild(intro);
4945
+ }
4946
+
4851
4947
  for (const emp of unavailable) {
4852
4948
  const row = document.createElement('div');
4853
4949
  row.className = 'install-row';
@@ -4871,10 +4967,11 @@ function renderAgentInstallPanel() {
4871
4967
 
4872
4968
  const st = agentInstallState[emp.id] || {};
4873
4969
  if (!st.phase) {
4874
- btn.textContent = `Install ${emp.label}`;
4970
+ btn.textContent = `Download / Install ${emp.label}`;
4971
+ btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
4875
4972
  btn.addEventListener('click', () => startAgentInstall(emp.id));
4876
4973
  } else if (st.phase === 'installing') {
4877
- btn.textContent = 'Installing';
4974
+ btn.textContent = 'Installing...';
4878
4975
  btn.disabled = true;
4879
4976
  } else if (st.phase === 'needs-login') {
4880
4977
  btn.textContent = 'Sign In';
@@ -4883,22 +4980,26 @@ function renderAgentInstallPanel() {
4883
4980
  const checkBtn = document.createElement('button');
4884
4981
  checkBtn.className = 'secondary small';
4885
4982
  checkBtn.textContent = 'Check if Ready';
4983
+ checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
4886
4984
  checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
4887
4985
  row.appendChild(checkBtn);
4888
4986
 
4889
4987
  const skipBtn = document.createElement('button');
4890
4988
  skipBtn.className = 'ghost small';
4891
4989
  skipBtn.textContent = 'Choose another agent';
4990
+ skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
4892
4991
  skipBtn.style.marginLeft = '6px';
4893
4992
  skipBtn.addEventListener('click', () => {
4894
4993
  delete agentInstallState[emp.id];
4895
4994
  renderAgentInstallPanel();
4995
+ renderHubAgentSetupPanel();
4996
+ renderCpAgentInstallPanel();
4896
4997
  });
4897
4998
  row.appendChild(skipBtn);
4898
4999
  panel.appendChild(row);
4899
5000
  continue;
4900
5001
  } else if (st.phase === 'ready') {
4901
- btn.textContent = 'Ready';
5002
+ btn.textContent = 'Ready';
4902
5003
  btn.disabled = true;
4903
5004
  btn.style.color = 'var(--accent)';
4904
5005
  } else if (st.phase === 'error') {
@@ -4914,6 +5015,9 @@ function renderAgentInstallPanel() {
4914
5015
  function setInstallState(hubId, phase, statusText) {
4915
5016
  agentInstallState[hubId] = { phase, statusText: statusText || '' };
4916
5017
  renderAgentInstallPanel();
5018
+ renderHubAgentSetupPanel();
5019
+ renderCpAgentInstallPanel();
5020
+ renderCpAgentPicker();
4917
5021
  }
4918
5022
 
4919
5023
  async function startAgentInstall(hubId) {
@@ -4968,8 +5072,9 @@ async function checkAgentReady(hubId) {
4968
5072
  async function refreshEmployees() {
4969
5073
  try {
4970
5074
  const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
4971
- state.bootstrap = bootstrap;
5075
+ applyBootstrap(bootstrap);
4972
5076
  renderEmployeeSelect();
5077
+ renderCpAgentPicker();
4973
5078
  } catch { /* best-effort */ }
4974
5079
  }
4975
5080
 
@@ -5021,6 +5126,12 @@ function deriveTitle(jobTitle, instructions) {
5021
5126
  // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
5022
5127
  // share one start/continue contract.
5023
5128
  async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
5129
+ if (!hubEmployeeIsAvailable(employeeId)) {
5130
+ renderHubAgentSetupPanel();
5131
+ renderCpAgentInstallPanel();
5132
+ showStatus('Set up a Hub CLI agent before starting work in FRAIM Hub.', true);
5133
+ return;
5134
+ }
5024
5135
  const isFreeform = job.id === '__freeform__';
5025
5136
  // In task-pane/extension mode: get a fresh selection snapshot and prepend
5026
5137
  // document context to the instructions so the agent knows what the user is
@@ -6344,6 +6455,11 @@ const tf = {
6344
6455
  activeProjectId: null,
6345
6456
  projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
6346
6457
  assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
6458
+ // #726: canonical paths just removed this session. A delete switches workspaces first,
6459
+ // and that switch's (possibly cached) bootstrap merge would otherwise re-add the project
6460
+ // client-side before the next full reload. Mirroring the server tombstone here keeps a
6461
+ // removed project gone immediately; cleared when a project is (re-)created at that path.
6462
+ removedPaths: new Set(),
6347
6463
  npStep: 1,
6348
6464
  npSelectedEmployees: [],
6349
6465
  };
@@ -6428,6 +6544,8 @@ function tfMergeProjects(projects) {
6428
6544
  const normalized = tfNormalizeProject(project);
6429
6545
  if (!normalized) return;
6430
6546
  const key = tfCanonicalProjectPath(normalized.folderPath);
6547
+ // #726: never re-add a project removed this session (stale/cached bootstrap merge).
6548
+ if (tf.removedPaths && tf.removedPaths.has(key)) return;
6431
6549
  const existing = merged.get(key);
6432
6550
  if (existing) {
6433
6551
  const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
@@ -6448,8 +6566,13 @@ function tfProjectForPath(folderPath) {
6448
6566
 
6449
6567
  function tfEnsureCurrentProject() {
6450
6568
  if (!state.projectPath) return null;
6569
+ // Reuse the id the server already assigned this path (from bootstrap.projects) when
6570
+ // one exists. The client and server hash a path to an id differently, so minting a
6571
+ // fresh client id here would shadow the server's id on the CURRENT project and break
6572
+ // delete-by-id — you could never remove the project you're viewing (#726).
6573
+ const existingForPath = tfProjectForPath(state.projectPath);
6451
6574
  const current = tfNormalizeProject({
6452
- id: tfProjectIdForPath(state.projectPath),
6575
+ id: existingForPath ? existingForPath.id : tfProjectIdForPath(state.projectPath),
6453
6576
  name: friendlyProjectShortName(state.projectPath),
6454
6577
  folderPath: state.projectPath,
6455
6578
  }, state.projectPath);
@@ -6943,7 +7066,7 @@ async function tfRemoveProject(proj) {
6943
7066
  let payload;
6944
7067
  try {
6945
7068
  payload = await requestJson(
6946
- '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath),
7069
+ '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath) + '&folderPath=' + encodeURIComponent(proj.folderPath || ''),
6947
7070
  { method: 'DELETE' },
6948
7071
  );
6949
7072
  } catch (error) {
@@ -6963,6 +7086,9 @@ async function tfRemoveProject(proj) {
6963
7086
  }
6964
7087
  }
6965
7088
 
7089
+ // #726: remember this path as removed so an in-flight switch/bootstrap merge cannot
7090
+ // re-add it before the next full reload (the server tombstone handles reloads).
7091
+ tf.removedPaths.add(removedCanonical);
6966
7092
  // R3/R10: drop every client layer, adopting the server list as authoritative.
6967
7093
  tfAdoptServerProjects(payload && payload.projects);
6968
7094
  delete tf.assignments[proj.id];
@@ -8612,6 +8738,8 @@ function tfRenderCompany() {
8612
8738
  // #521 R4/R6: company-level jobs live ONLY in this left rail (organization-onboarding
8613
8739
  // + organizational-learning-synthesis) — not in any project's job list, not as
8614
8740
  // chips elsewhere.
8741
+ // #744: render the Brand editor (its host #company-brand-editor is static in the info view).
8742
+ if (typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
8615
8743
  const rail = document.getElementById('company-rail');
8616
8744
  if (rail) {
8617
8745
  rail.innerHTML = '';
@@ -8786,6 +8914,10 @@ function tfRenderManager() {
8786
8914
  const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
8787
8915
  let anyMgrEmployee = false;
8788
8916
  for (const persona of mgrPersonas) {
8917
+ // Only manager-scoped personas (Ashley) live on the Manager tab; project employees
8918
+ // are shown on Projects, not here (#702 R1b). Without this, an all-hired legacy
8919
+ // workspace surfaced every employee on the Manager rail.
8920
+ if (!MANAGER_PERSONA_KEYS.has(persona.key)) continue;
8789
8921
  if (!tfIsPersonaHired(persona.key)) continue;
8790
8922
  if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
8791
8923
  if (!anyMgrEmployee) {
@@ -9518,6 +9650,289 @@ async function tfRememberConnectedApiKey(apiKey) {
9518
9650
  }
9519
9651
  }
9520
9652
 
9653
+ // ---------------------------------------------------------------------------
9654
+ // Issue #744 — Hub cobranding. Renders the company brand (name/logo/color) from
9655
+ // bootstrap.orgBrand into the top nav, recolors the Hub via the single --accent
9656
+ // token (contrast-guarded per theme), sets the window title + favicon, and keeps
9657
+ // a "powered by FRAIM" co-mark (co-branding, not white-label). Falls back to the
9658
+ // shipped FRAIM identity when no brand is set. The authoritative sanitize/contrast
9659
+ // logic lives server-side in src/ai-hub/brand-store.ts; this is the render mirror.
9660
+ // ---------------------------------------------------------------------------
9661
+ var FRAIM_COMARK_SVG = '<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="11" fill="#3d8a6e"></rect><text x="24" y="33" text-anchor="middle" font-family="Helvetica,Arial,sans-serif" font-size="24" font-weight="700" fill="#fff">F</text></svg>';
9662
+ var BRAND_COLOR_PRESETS = ['#0d9488', '#0071e3', '#6d28d9', '#b0421f', '#1f7a34'];
9663
+
9664
+ function tfHexToRgb(hex) {
9665
+ var m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
9666
+ if (!m) return null;
9667
+ var n = m[1];
9668
+ return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
9669
+ }
9670
+ function tfNormalizeHex(input) {
9671
+ var raw = (typeof input === 'string' ? input : '').trim().replace(/^#/, '').toLowerCase();
9672
+ if (/^[0-9a-f]{3}$/.test(raw)) return '#' + raw.split('').map(function (c) { return c + c; }).join('');
9673
+ if (/^[0-9a-f]{6}$/.test(raw)) return '#' + raw;
9674
+ return null;
9675
+ }
9676
+ function tfLuminance(hex) {
9677
+ var rgb = tfHexToRgb(hex);
9678
+ if (!rgb) return 0;
9679
+ var a = rgb.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
9680
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
9681
+ }
9682
+ function tfContrast(a, b) {
9683
+ var la = tfLuminance(a), lb = tfLuminance(b);
9684
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
9685
+ }
9686
+ function tfMixHex(hex, target, amt) {
9687
+ var a = tfHexToRgb(hex), b = tfHexToRgb(target);
9688
+ if (!a || !b) return hex;
9689
+ return '#' + a.map(function (v, i) { return ('0' + Math.round(v + (b[i] - v) * amt).toString(16)).slice(-2); }).join('');
9690
+ }
9691
+ // Canonical Hub surface colors — mirror --surface in styles.css. Used only by the
9692
+ // editor's dual-theme contrast preview; tfApplyBrandAccent reads the LIVE --surface.
9693
+ var HUB_SURFACE_LIGHT = '#ffffff';
9694
+ var HUB_SURFACE_DARK = '#2a2a2a';
9695
+ // Contrast-safe accent for a given surface (R2.3): returns the derived accent trio,
9696
+ // or null when even a derived variant can't be read (caller uses the FRAIM default).
9697
+ function tfBrandAccentForTheme(color, surface) {
9698
+ var norm = tfNormalizeHex(color);
9699
+ if (!norm) return null;
9700
+ var dark = tfLuminance(surface) < 0.5;
9701
+ var toward = dark ? '#ffffff' : '#000000';
9702
+ var accent = norm;
9703
+ if (tfContrast(accent, surface) < 3.0) {
9704
+ accent = tfMixHex(norm, toward, 0.35);
9705
+ if (tfContrast(accent, surface) < 3.0) return null;
9706
+ }
9707
+ var rgb = tfHexToRgb(accent);
9708
+ return {
9709
+ accent: accent,
9710
+ strong: tfMixHex(accent, toward, 0.18),
9711
+ soft: 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + (dark ? '0.16' : '0.13') + ')'
9712
+ };
9713
+ }
9714
+ function tfApplyBrandAccent() {
9715
+ var root = document.documentElement;
9716
+ var brand = state.orgBrand;
9717
+ // Read the live --surface token so contrast tracks the actual theme (no
9718
+ // hardcoded surface duplication); fall back to the canonical values pre-paint.
9719
+ var surface = (getComputedStyle(root).getPropertyValue('--surface') || '').trim()
9720
+ || (root.getAttribute('data-theme') === 'dark' ? HUB_SURFACE_DARK : HUB_SURFACE_LIGHT);
9721
+ var trio = brand && brand.color ? tfBrandAccentForTheme(brand.color, surface) : null;
9722
+ if (!trio) {
9723
+ root.style.removeProperty('--accent');
9724
+ root.style.removeProperty('--accent-strong');
9725
+ root.style.removeProperty('--accent-soft');
9726
+ return;
9727
+ }
9728
+ root.style.setProperty('--accent', trio.accent);
9729
+ root.style.setProperty('--accent-strong', trio.strong);
9730
+ root.style.setProperty('--accent-soft', trio.soft);
9731
+ }
9732
+ // Resolve a logo value to a safe <img> src. SVG is rendered as a data URI via
9733
+ // <img> (never innerHTML) so the browser's non-scripting image context, not the
9734
+ // live DOM, parses it — this neutralizes SVG-borne script even if the server
9735
+ // allowlist sanitizer were ever bypassed (defense in depth, #744 security review).
9736
+ function tfBrandLogoSrc(logo) {
9737
+ if (typeof logo !== 'string' || !logo) return '';
9738
+ if (/^<svg/i.test(logo)) return 'data:image/svg+xml;utf8,' + encodeURIComponent(logo);
9739
+ if (/^data:image/i.test(logo)) return logo;
9740
+ return '';
9741
+ }
9742
+ function tfApplyBrandFavicon(brand) {
9743
+ var link = document.querySelector('link[rel="icon"]');
9744
+ if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
9745
+ var src = brand ? tfBrandLogoSrc(brand.logo) : '';
9746
+ if (src) link.href = src;
9747
+ // No logo → leave the shipped FRAIM favicon in place.
9748
+ }
9749
+ // Build the lockup DOM for a brand into a container (shared by nav + editor preview).
9750
+ function tfFillBrandLockup(container, brand) {
9751
+ container.innerHTML = '';
9752
+ var logoSrc = brand ? tfBrandLogoSrc(brand.logo) : '';
9753
+ if (logoSrc) {
9754
+ var logo = document.createElement('span');
9755
+ logo.className = 'hub-brand-logo';
9756
+ var img = document.createElement('img');
9757
+ img.src = logoSrc; img.alt = '';
9758
+ logo.appendChild(img);
9759
+ container.appendChild(logo);
9760
+ }
9761
+ if (brand && brand.name) {
9762
+ var nm = document.createElement('span');
9763
+ nm.className = 'hub-brand-name';
9764
+ nm.textContent = brand.name;
9765
+ nm.title = brand.name;
9766
+ container.appendChild(nm);
9767
+ }
9768
+ }
9769
+ function tfApplyOrgBrand(brand) {
9770
+ state.orgBrand = (brand && (brand.name || brand.color || brand.logo)) ? brand : null;
9771
+ try {
9772
+ if (state.orgBrand) localStorage.setItem('fraim-org-brand', JSON.stringify({ name: state.orgBrand.name || '', color: state.orgBrand.color || '' }));
9773
+ else localStorage.removeItem('fraim-org-brand');
9774
+ } catch (e) {}
9775
+
9776
+ var brandEl = document.getElementById('hub-brand');
9777
+ var divEl = document.getElementById('hub-brand-divider');
9778
+ var coEl = document.getElementById('hub-cobrand');
9779
+ var show = !!(state.orgBrand && (state.orgBrand.name || state.orgBrand.logo));
9780
+ if (brandEl) {
9781
+ if (show) tfFillBrandLockup(brandEl, state.orgBrand);
9782
+ brandEl.hidden = !show;
9783
+ }
9784
+ if (divEl) divEl.hidden = !show;
9785
+ if (coEl) {
9786
+ if (show) coEl.innerHTML = 'powered by <span class="hub-cobrand-mark">' + FRAIM_COMARK_SVG + '</span> FRAIM';
9787
+ coEl.hidden = !show;
9788
+ }
9789
+ document.title = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name + ' Hub' : 'FRAIM AI Hub';
9790
+ // R4.1: Company/Manager area eyebrows reflect the company name (the FRAIM
9791
+ // co-mark stays in the nav — co-branding, not white-label).
9792
+ var eyebrow = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name : 'FRAIM Hub';
9793
+ ['company-info-view', 'manager-info-view'].forEach(function (id) {
9794
+ var hostEl = document.getElementById(id);
9795
+ var el = hostEl && hostEl.querySelector('.eyebrow');
9796
+ if (el) el.textContent = eyebrow;
9797
+ });
9798
+ tfApplyBrandFavicon(state.orgBrand);
9799
+ tfApplyBrandAccent();
9800
+ }
9801
+
9802
+ // ── Brand editor (Company tab → Brand accordion) ────────────────────────────
9803
+ function tfRenderBrandEditor() {
9804
+ var host = document.getElementById('company-brand-editor');
9805
+ if (!host) return;
9806
+ var draft = {
9807
+ name: (state.orgBrand && state.orgBrand.name) || '',
9808
+ color: (state.orgBrand && state.orgBrand.color) || '',
9809
+ logo: (state.orgBrand && state.orgBrand.logo) || ''
9810
+ };
9811
+
9812
+ host.innerHTML = ''
9813
+ + '<div class="brand-editor">'
9814
+ + ' <div class="brand-field"><label for="be-name">Company name</label>'
9815
+ + ' <input class="search" id="be-name" type="text" placeholder="e.g. Wellness at Work" style="margin:0">'
9816
+ + ' <div class="brand-hint">Shown on the top nav and the window title, for every teammate.</div></div>'
9817
+ + ' <div class="brand-field"><label>Logo</label>'
9818
+ + ' <div class="brand-logo-drop" id="be-logo-drop" role="button" tabindex="0" aria-label="Upload company logo"><span class="blp" id="be-logo-preview"></span>'
9819
+ + ' <span class="blp-txt" id="be-logo-txt">Click to upload a PNG or SVG — square mark, &le; 512&nbsp;KB</span></div>'
9820
+ + ' <input type="file" id="be-logo-file" accept="image/svg+xml,image/png,image/jpeg" hidden>'
9821
+ + ' <div class="brand-hint" id="be-logo-hint">SVG uploads are sanitized (scripts and handlers stripped) before storage.</div></div>'
9822
+ + ' <div class="brand-field"><label for="be-hex">Brand color</label>'
9823
+ + ' <div class="brand-swatch-row" id="be-swatches"></div>'
9824
+ + ' <div class="brand-swatch-row" style="margin-top:8px"><input class="search brand-color-hex" id="be-hex" type="text" placeholder="#0d9488" style="margin:0">'
9825
+ + ' <span class="brand-hint" id="be-contrast-hint" style="margin:0"></span></div></div>'
9826
+ + ' <div class="brand-editor-actions"><button class="send-button" type="button" id="be-save">Save brand</button>'
9827
+ + ' <button class="ghost" type="button" id="be-clear">Reset to FRAIM</button>'
9828
+ + ' <span class="brand-hint" id="be-status" style="margin:0"></span></div>'
9829
+ + ' <div class="brand-preview"><div class="brand-preview-cap">Top nav preview</div>'
9830
+ + ' <nav class="hub-tabs" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
9831
+ + ' <span class="hub-brand-divider" id="be-prev-div"></span>'
9832
+ + ' <button class="hub-tab on" type="button">Projects</button><button class="hub-tab" type="button">Company</button>'
9833
+ + ' <div class="nav-right"><span class="hub-cobrand">powered by <span class="hub-cobrand-mark">' + FRAIM_COMARK_SVG + '</span> FRAIM</span>'
9834
+ + ' <button class="avatar-btn" type="button">SM</button></div></nav></div>'
9835
+ + '</div>';
9836
+
9837
+ var nameEl = host.querySelector('#be-name');
9838
+ var hexEl = host.querySelector('#be-hex');
9839
+ var fileEl = host.querySelector('#be-logo-file');
9840
+ var dropEl = host.querySelector('#be-logo-drop');
9841
+ var swatches = host.querySelector('#be-swatches');
9842
+ var statusEl = host.querySelector('#be-status');
9843
+ nameEl.value = draft.name;
9844
+ hexEl.value = draft.color;
9845
+
9846
+ BRAND_COLOR_PRESETS.forEach(function (c) {
9847
+ var b = document.createElement('button');
9848
+ b.type = 'button'; b.className = 'brand-swatch'; b.style.background = c; b.title = c;
9849
+ b.addEventListener('click', function () { draft.color = c; hexEl.value = c; refresh(); });
9850
+ swatches.appendChild(b);
9851
+ });
9852
+
9853
+ function updatePreviewLogo() {
9854
+ var lp = host.querySelector('#be-logo-preview');
9855
+ if (!lp) return;
9856
+ lp.innerHTML = '';
9857
+ // Preview via <img> (safe image context) so even a not-yet-sanitized upload
9858
+ // can't execute script while the manager is still editing.
9859
+ var src = tfBrandLogoSrc(draft.logo);
9860
+ if (src) { var im = document.createElement('img'); im.src = src; im.alt = ''; lp.appendChild(im); }
9861
+ }
9862
+ function refresh() {
9863
+ // Live nav preview.
9864
+ tfFillBrandLockup(host.querySelector('#be-prev-brand'), draft);
9865
+ updatePreviewLogo();
9866
+ // Selected swatch.
9867
+ Array.prototype.forEach.call(swatches.children, function (b) {
9868
+ b.classList.toggle('sel', tfNormalizeHex(b.title) === tfNormalizeHex(draft.color));
9869
+ });
9870
+ // Contrast hint (R2.3).
9871
+ var hint = host.querySelector('#be-contrast-hint');
9872
+ var norm = tfNormalizeHex(draft.color);
9873
+ if (!draft.color) { hint.textContent = ''; hint.className = 'brand-hint'; }
9874
+ else if (!norm) { hint.textContent = 'Enter a valid hex color (e.g. #0d9488).'; hint.className = 'brand-hint warn'; }
9875
+ else {
9876
+ var lightOk = !!tfBrandAccentForTheme(norm, HUB_SURFACE_LIGHT);
9877
+ var darkOk = !!tfBrandAccentForTheme(norm, HUB_SURFACE_DARK);
9878
+ if (lightOk && darkOk) { hint.textContent = '✓ Readable in light and dark.'; hint.className = 'brand-hint'; }
9879
+ else { hint.textContent = 'Low contrast in ' + (!lightOk && !darkOk ? 'light and dark' : (!lightOk ? 'light' : 'dark')) + ' mode — the Hub will keep the FRAIM accent there.'; hint.className = 'brand-hint warn'; }
9880
+ }
9881
+ }
9882
+
9883
+ nameEl.addEventListener('input', function () { draft.name = nameEl.value; refresh(); });
9884
+ hexEl.addEventListener('input', function () { draft.color = hexEl.value; refresh(); });
9885
+ dropEl.addEventListener('click', function () { fileEl.click(); });
9886
+ // Keyboard access (a11y): Enter/Space on the focusable drop zone opens the picker.
9887
+ dropEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileEl.click(); } });
9888
+ fileEl.addEventListener('change', function () {
9889
+ var f = fileEl.files && fileEl.files[0];
9890
+ if (!f) return;
9891
+ 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; }
9892
+ var reader = new FileReader();
9893
+ reader.onload = function () {
9894
+ draft.logo = String(reader.result || '');
9895
+ host.querySelector('#be-logo-txt').textContent = f.name;
9896
+ refresh();
9897
+ };
9898
+ if (/svg/i.test(f.type)) reader.readAsText(f); else reader.readAsDataURL(f);
9899
+ });
9900
+
9901
+ host.querySelector('#be-save').addEventListener('click', function () {
9902
+ statusEl.textContent = 'Saving…'; statusEl.className = 'brand-hint';
9903
+ var body = { name: draft.name, color: draft.color, logo: draft.logo };
9904
+ if (state.projectPath) body.projectPath = state.projectPath;
9905
+ requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
9906
+ .then(function (resp) {
9907
+ tfApplyOrgBrand(resp && resp.brand ? resp.brand : null);
9908
+ statusEl.textContent = 'Saved — applied to your Hub.'; statusEl.className = 'brand-hint';
9909
+ tfShowBrandTeamNote(host);
9910
+ })
9911
+ .catch(function (err) { statusEl.textContent = 'Save failed: ' + (err && err.message ? err.message : 'error'); statusEl.className = 'brand-hint warn'; });
9912
+ });
9913
+ host.querySelector('#be-clear').addEventListener('click', function () {
9914
+ var body = { name: '', color: '', logo: '' };
9915
+ if (state.projectPath) body.projectPath = state.projectPath;
9916
+ requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
9917
+ .then(function () { tfApplyOrgBrand(null); tfRenderBrandEditor(); })
9918
+ .catch(function () {});
9919
+ });
9920
+
9921
+ refresh();
9922
+ }
9923
+ // The brand is stored WITH company info in the org context, so it reaches the
9924
+ // team by the SAME path org context does: publish org context, teammates sync.
9925
+ // (There is no separate one-click Hub push endpoint; propagation is org-pack sync.)
9926
+ function tfShowBrandTeamNote(host) {
9927
+ var existing = host.querySelector('.brand-team-note');
9928
+ if (existing) existing.remove();
9929
+ var note = document.createElement('div');
9930
+ note.className = 'brand-hint brand-team-note';
9931
+ note.style.cssText = 'margin-top:12px;padding-left:12px;border-left:2px solid var(--accent-soft)';
9932
+ note.textContent = 'Saved to your organization context. Teammates pick it up on their next sync once your org context is published (it travels with your company info).';
9933
+ host.appendChild(note);
9934
+ }
9935
+
9521
9936
  // ---------------------------------------------------------------------------
9522
9937
  // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9523
9938
  // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
@@ -9542,6 +9957,8 @@ function tfApplyTheme(theme, persist) {
9542
9957
  document.documentElement.setAttribute('data-theme', t);
9543
9958
  if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
9544
9959
  tfSyncThemeToggle(t);
9960
+ // Issue #744: recompute the contrast-safe brand accent for the new surface.
9961
+ if (typeof tfApplyBrandAccent === 'function') tfApplyBrandAccent();
9545
9962
  // Keep embedded surfaces in sync live, including the connected main-server frame.
9546
9963
  try {
9547
9964
  document.querySelectorAll('iframe').forEach((f) => {
@@ -9880,6 +10297,9 @@ async function tfCreateProject() {
9880
10297
  };
9881
10298
  tf.projects.push(project);
9882
10299
  tf.assignments[id] = [];
10300
+ // #726: re-creating a project at a previously-removed path lifts the client guard
10301
+ // (the server revive is handled via reviveRemovedPaths below).
10302
+ tf.removedPaths.delete(tfCanonicalProjectPath(folderPath));
9883
10303
  tfPersistProjects({ reviveRemovedPaths: [folderPath] });
9884
10304
  tfPersistAssignments();
9885
10305
  tf.activeProjectId = id;