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.
- package/dist/src/ai-hub/brand-store.js +232 -0
- package/dist/src/ai-hub/server.js +91 -1
- package/dist/src/cli/doctor/checks/fraim-mcp-diagnostics.js +283 -0
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +224 -18
- package/dist/src/cli/utils/org-pack-sync.js +35 -5
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +41 -0
- package/package.json +1 -1
- package/public/ai-hub/index.html +17 -1
- package/public/ai-hub/script.js +441 -21
- package/public/ai-hub/styles.css +67 -0
- package/public/first-run/script.js +98 -25
- package/public/first-run/styles.css +34 -8
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4843
|
-
|
|
4844
|
-
|
|
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 = '
|
|
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
|
-
|
|
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, ≤ 512 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;
|