fraim-framework 2.0.195 → 2.0.197
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 +78 -45
- package/dist/src/api/personas/me.js +6 -6
- 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/dist/src/services/persona-entitlement-service.js +71 -7
- package/package.json +1 -1
- package/public/ai-hub/index.html +17 -1
- package/public/ai-hub/script.js +469 -22
- 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));
|
|
@@ -3277,6 +3304,10 @@ function convAwaitingReview(conv) {
|
|
|
3277
3304
|
// output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
|
|
3278
3305
|
// stakeholder-status-reporting) may legitimately surface artifacts for human review.
|
|
3279
3306
|
if (delegationLedgerForConversation(conv)) return false;
|
|
3307
|
+
const runArtifacts = Array.isArray(conv.artifacts) ? conv.artifacts : [];
|
|
3308
|
+
if (conv.status === 'completed' && runArtifacts.some((artifact) => artifactLocalPath(artifact) || artifactExternalUrl(artifact))) {
|
|
3309
|
+
return true;
|
|
3310
|
+
}
|
|
3280
3311
|
// #521: a plain completed turn is NOT awaiting review — the approve/reject card
|
|
3281
3312
|
// only belongs when the employee actually submits for review (a review_handoff,
|
|
3282
3313
|
// emitted by the submit phase). A turn that just finished (a question, a pause
|
|
@@ -3483,12 +3514,32 @@ function hasReviewHandoffSignal(text) {
|
|
|
3483
3514
|
return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
|
|
3484
3515
|
}
|
|
3485
3516
|
|
|
3517
|
+
function reviewSubmissionClaimText(text) {
|
|
3518
|
+
return String(stripMarkdownForDisplay(text) || '')
|
|
3519
|
+
.replace(/\s+/g, ' ')
|
|
3520
|
+
.trim();
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function hasExplicitReviewSubmissionClaim(text) {
|
|
3524
|
+
const cleaned = reviewSubmissionClaimText(text);
|
|
3525
|
+
if (!cleaned) return false;
|
|
3526
|
+
if (/\b(?:not\s+ready|not\s+yet\s+ready|will\s+be\s+ready|soon\s+be\s+ready)\b.{0,60}\breview\b/i.test(cleaned)) {
|
|
3527
|
+
return false;
|
|
3528
|
+
}
|
|
3529
|
+
return /\bready\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
|
|
3530
|
+
|| /\bplease\s+review\b/i.test(cleaned)
|
|
3531
|
+
|| /\bsubmitted\s+(?:the\s+|this\s+|my\s+)?(?:work|deliverable|artifact|bundle|pr|pull request|document|doc|file|files)\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
|
|
3532
|
+
|| /\b(?:review|open)\s+(?:the\s+)?(?:pr|pull request|artifact|document|doc|file|files|deliverable|bundle)\b/i.test(cleaned);
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3486
3535
|
function reviewHandoffIssueForConversation(conv) {
|
|
3487
3536
|
if (!conv) return '';
|
|
3488
3537
|
if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
|
|
3489
3538
|
return 'The review handoff contract is invalid.';
|
|
3490
3539
|
}
|
|
3491
3540
|
const messages = conv.messages || [];
|
|
3541
|
+
const hasRunArtifacts = Array.isArray(conv.artifacts) && conv.artifacts.length > 0;
|
|
3542
|
+
const latestEmployee = [...messages].reverse().find((message) => message.role === 'employee');
|
|
3492
3543
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
3493
3544
|
if (messages[i].role !== 'employee') continue;
|
|
3494
3545
|
if (!hasReviewHandoffSignal(messages[i].text)) continue;
|
|
@@ -3496,6 +3547,9 @@ function reviewHandoffIssueForConversation(conv) {
|
|
|
3496
3547
|
return 'The review handoff contract is malformed or missing required fields.';
|
|
3497
3548
|
}
|
|
3498
3549
|
}
|
|
3550
|
+
if (!conv.reviewHandoff && !hasRunArtifacts && latestEmployee && hasExplicitReviewSubmissionClaim(latestEmployee.text)) {
|
|
3551
|
+
return 'This run says it is ready for review, but did not include a structured review handoff.';
|
|
3552
|
+
}
|
|
3499
3553
|
return '';
|
|
3500
3554
|
}
|
|
3501
3555
|
|
|
@@ -3851,7 +3905,7 @@ function renderReviewExperience(conv) {
|
|
|
3851
3905
|
if (handoff) {
|
|
3852
3906
|
const hasArtifact = !!(fmt.actions && fmt.actions.length);
|
|
3853
3907
|
handoff.textContent = fmt.key === 'contract_error'
|
|
3854
|
-
? 'The review handoff contract is invalid. Ask the employee to resend it before review
|
|
3908
|
+
? `${fmt.issue || 'The review handoff contract is invalid.'} Ask the employee to resend it before review.`
|
|
3855
3909
|
: fmt.key === 'pull_request'
|
|
3856
3910
|
? 'Comment on the PR to leave inline notes, or approve / type changes below.'
|
|
3857
3911
|
: isManagerTemplateJob(conv && conv.jobId)
|
|
@@ -4448,7 +4502,7 @@ function renderCpAgentPicker() {
|
|
|
4448
4502
|
const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
|
|
4449
4503
|
if (!curOk) {
|
|
4450
4504
|
const firstAvail = list.find((e) => e.available !== false);
|
|
4451
|
-
|
|
4505
|
+
state.cpEmployee = firstAvail ? firstAvail.id : null;
|
|
4452
4506
|
}
|
|
4453
4507
|
for (const e of list) {
|
|
4454
4508
|
const pill = document.createElement('button');
|
|
@@ -4463,6 +4517,10 @@ function renderCpAgentPicker() {
|
|
|
4463
4517
|
}
|
|
4464
4518
|
picker.appendChild(pill);
|
|
4465
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();
|
|
4466
4524
|
}
|
|
4467
4525
|
|
|
4468
4526
|
function buildCpRow(row, flatIndex) {
|
|
@@ -4556,7 +4614,7 @@ function selectCpRow(index) {
|
|
|
4556
4614
|
}
|
|
4557
4615
|
|
|
4558
4616
|
const startBtn = document.getElementById('cp-start-btn');
|
|
4559
|
-
const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim(); };
|
|
4617
|
+
const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim() || !hubHasAvailableEmployee(); };
|
|
4560
4618
|
instr.oninput = syncStart;
|
|
4561
4619
|
syncStart();
|
|
4562
4620
|
instr.focus();
|
|
@@ -4568,6 +4626,11 @@ function submitCpRun() {
|
|
|
4568
4626
|
const instr = document.getElementById('cp-instructions');
|
|
4569
4627
|
const instructions = instr ? instr.value.trim() : '';
|
|
4570
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
|
+
}
|
|
4571
4634
|
const employeeId = state.cpEmployee || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude';
|
|
4572
4635
|
closePalette();
|
|
4573
4636
|
// Issue #540: if the job requires a persona not on the manager's team, show
|
|
@@ -4788,6 +4851,8 @@ function renderEmployeeSelect() {
|
|
|
4788
4851
|
els['employee-select'].appendChild(opt);
|
|
4789
4852
|
}
|
|
4790
4853
|
renderAgentInstallPanel();
|
|
4854
|
+
renderHubAgentSetupPanel();
|
|
4855
|
+
renderCpAgentInstallPanel();
|
|
4791
4856
|
updateAbToggleVisibility();
|
|
4792
4857
|
}
|
|
4793
4858
|
|
|
@@ -4800,6 +4865,18 @@ function updateAbToggleVisibility() {
|
|
|
4800
4865
|
wrap.hidden = !(emp && emp.supportsRaw);
|
|
4801
4866
|
}
|
|
4802
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
|
+
|
|
4803
4880
|
// ---------------------------------------------------------------------------
|
|
4804
4881
|
// Agent install panel — shown in Step 2 for unavailable agents
|
|
4805
4882
|
// ---------------------------------------------------------------------------
|
|
@@ -4808,19 +4885,65 @@ function updateAbToggleVisibility() {
|
|
|
4808
4885
|
const agentInstallState = {};
|
|
4809
4886
|
|
|
4810
4887
|
function renderAgentInstallPanel() {
|
|
4811
|
-
|
|
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) {
|
|
4812
4925
|
if (!panel) return;
|
|
4813
4926
|
panel.innerHTML = '';
|
|
4814
4927
|
|
|
4815
|
-
const
|
|
4816
|
-
|
|
4817
|
-
|
|
4928
|
+
const unavailable = hubEmployees().filter((e) => !e.available);
|
|
4929
|
+
if (unavailable.length === 0) {
|
|
4930
|
+
panel.hidden = true;
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
panel.hidden = false;
|
|
4818
4934
|
|
|
4819
4935
|
const heading = document.createElement('div');
|
|
4820
4936
|
heading.className = 'install-panel-heading';
|
|
4821
|
-
heading.textContent = 'Install missing agents';
|
|
4937
|
+
heading.textContent = options.heading || 'Install missing agents';
|
|
4822
4938
|
panel.appendChild(heading);
|
|
4823
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
|
+
|
|
4824
4947
|
for (const emp of unavailable) {
|
|
4825
4948
|
const row = document.createElement('div');
|
|
4826
4949
|
row.className = 'install-row';
|
|
@@ -4844,10 +4967,11 @@ function renderAgentInstallPanel() {
|
|
|
4844
4967
|
|
|
4845
4968
|
const st = agentInstallState[emp.id] || {};
|
|
4846
4969
|
if (!st.phase) {
|
|
4847
|
-
btn.textContent = `Install ${emp.label}`;
|
|
4970
|
+
btn.textContent = `Download / Install ${emp.label}`;
|
|
4971
|
+
btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
|
|
4848
4972
|
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
4849
4973
|
} else if (st.phase === 'installing') {
|
|
4850
|
-
btn.textContent = 'Installing
|
|
4974
|
+
btn.textContent = 'Installing...';
|
|
4851
4975
|
btn.disabled = true;
|
|
4852
4976
|
} else if (st.phase === 'needs-login') {
|
|
4853
4977
|
btn.textContent = 'Sign In';
|
|
@@ -4856,22 +4980,26 @@ function renderAgentInstallPanel() {
|
|
|
4856
4980
|
const checkBtn = document.createElement('button');
|
|
4857
4981
|
checkBtn.className = 'secondary small';
|
|
4858
4982
|
checkBtn.textContent = 'Check if Ready';
|
|
4983
|
+
checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
|
|
4859
4984
|
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
4860
4985
|
row.appendChild(checkBtn);
|
|
4861
4986
|
|
|
4862
4987
|
const skipBtn = document.createElement('button');
|
|
4863
4988
|
skipBtn.className = 'ghost small';
|
|
4864
4989
|
skipBtn.textContent = 'Choose another agent';
|
|
4990
|
+
skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
|
|
4865
4991
|
skipBtn.style.marginLeft = '6px';
|
|
4866
4992
|
skipBtn.addEventListener('click', () => {
|
|
4867
4993
|
delete agentInstallState[emp.id];
|
|
4868
4994
|
renderAgentInstallPanel();
|
|
4995
|
+
renderHubAgentSetupPanel();
|
|
4996
|
+
renderCpAgentInstallPanel();
|
|
4869
4997
|
});
|
|
4870
4998
|
row.appendChild(skipBtn);
|
|
4871
4999
|
panel.appendChild(row);
|
|
4872
5000
|
continue;
|
|
4873
5001
|
} else if (st.phase === 'ready') {
|
|
4874
|
-
btn.textContent = '
|
|
5002
|
+
btn.textContent = 'Ready';
|
|
4875
5003
|
btn.disabled = true;
|
|
4876
5004
|
btn.style.color = 'var(--accent)';
|
|
4877
5005
|
} else if (st.phase === 'error') {
|
|
@@ -4887,6 +5015,9 @@ function renderAgentInstallPanel() {
|
|
|
4887
5015
|
function setInstallState(hubId, phase, statusText) {
|
|
4888
5016
|
agentInstallState[hubId] = { phase, statusText: statusText || '' };
|
|
4889
5017
|
renderAgentInstallPanel();
|
|
5018
|
+
renderHubAgentSetupPanel();
|
|
5019
|
+
renderCpAgentInstallPanel();
|
|
5020
|
+
renderCpAgentPicker();
|
|
4890
5021
|
}
|
|
4891
5022
|
|
|
4892
5023
|
async function startAgentInstall(hubId) {
|
|
@@ -4941,8 +5072,9 @@ async function checkAgentReady(hubId) {
|
|
|
4941
5072
|
async function refreshEmployees() {
|
|
4942
5073
|
try {
|
|
4943
5074
|
const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
|
|
4944
|
-
|
|
5075
|
+
applyBootstrap(bootstrap);
|
|
4945
5076
|
renderEmployeeSelect();
|
|
5077
|
+
renderCpAgentPicker();
|
|
4946
5078
|
} catch { /* best-effort */ }
|
|
4947
5079
|
}
|
|
4948
5080
|
|
|
@@ -4994,6 +5126,12 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
4994
5126
|
// FRAIM jobs into host-facing invocations so the Hub UI and channel callers
|
|
4995
5127
|
// share one start/continue contract.
|
|
4996
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
|
+
}
|
|
4997
5135
|
const isFreeform = job.id === '__freeform__';
|
|
4998
5136
|
// In task-pane/extension mode: get a fresh selection snapshot and prepend
|
|
4999
5137
|
// document context to the instructions so the agent knows what the user is
|
|
@@ -6317,6 +6455,11 @@ const tf = {
|
|
|
6317
6455
|
activeProjectId: null,
|
|
6318
6456
|
projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
|
|
6319
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(),
|
|
6320
6463
|
npStep: 1,
|
|
6321
6464
|
npSelectedEmployees: [],
|
|
6322
6465
|
};
|
|
@@ -6401,6 +6544,8 @@ function tfMergeProjects(projects) {
|
|
|
6401
6544
|
const normalized = tfNormalizeProject(project);
|
|
6402
6545
|
if (!normalized) return;
|
|
6403
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;
|
|
6404
6549
|
const existing = merged.get(key);
|
|
6405
6550
|
if (existing) {
|
|
6406
6551
|
const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
|
|
@@ -6421,8 +6566,13 @@ function tfProjectForPath(folderPath) {
|
|
|
6421
6566
|
|
|
6422
6567
|
function tfEnsureCurrentProject() {
|
|
6423
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);
|
|
6424
6574
|
const current = tfNormalizeProject({
|
|
6425
|
-
id: tfProjectIdForPath(state.projectPath),
|
|
6575
|
+
id: existingForPath ? existingForPath.id : tfProjectIdForPath(state.projectPath),
|
|
6426
6576
|
name: friendlyProjectShortName(state.projectPath),
|
|
6427
6577
|
folderPath: state.projectPath,
|
|
6428
6578
|
}, state.projectPath);
|
|
@@ -6916,7 +7066,7 @@ async function tfRemoveProject(proj) {
|
|
|
6916
7066
|
let payload;
|
|
6917
7067
|
try {
|
|
6918
7068
|
payload = await requestJson(
|
|
6919
|
-
'/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 || ''),
|
|
6920
7070
|
{ method: 'DELETE' },
|
|
6921
7071
|
);
|
|
6922
7072
|
} catch (error) {
|
|
@@ -6936,6 +7086,9 @@ async function tfRemoveProject(proj) {
|
|
|
6936
7086
|
}
|
|
6937
7087
|
}
|
|
6938
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);
|
|
6939
7092
|
// R3/R10: drop every client layer, adopting the server list as authoritative.
|
|
6940
7093
|
tfAdoptServerProjects(payload && payload.projects);
|
|
6941
7094
|
delete tf.assignments[proj.id];
|
|
@@ -8585,6 +8738,8 @@ function tfRenderCompany() {
|
|
|
8585
8738
|
// #521 R4/R6: company-level jobs live ONLY in this left rail (organization-onboarding
|
|
8586
8739
|
// + organizational-learning-synthesis) — not in any project's job list, not as
|
|
8587
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();
|
|
8588
8743
|
const rail = document.getElementById('company-rail');
|
|
8589
8744
|
if (rail) {
|
|
8590
8745
|
rail.innerHTML = '';
|
|
@@ -8759,6 +8914,10 @@ function tfRenderManager() {
|
|
|
8759
8914
|
const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
|
|
8760
8915
|
let anyMgrEmployee = false;
|
|
8761
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;
|
|
8762
8921
|
if (!tfIsPersonaHired(persona.key)) continue;
|
|
8763
8922
|
if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
|
|
8764
8923
|
if (!anyMgrEmployee) {
|
|
@@ -9491,6 +9650,289 @@ async function tfRememberConnectedApiKey(apiKey) {
|
|
|
9491
9650
|
}
|
|
9492
9651
|
}
|
|
9493
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
|
+
|
|
9494
9936
|
// ---------------------------------------------------------------------------
|
|
9495
9937
|
// Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
|
|
9496
9938
|
// sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
|
|
@@ -9515,6 +9957,8 @@ function tfApplyTheme(theme, persist) {
|
|
|
9515
9957
|
document.documentElement.setAttribute('data-theme', t);
|
|
9516
9958
|
if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
|
|
9517
9959
|
tfSyncThemeToggle(t);
|
|
9960
|
+
// Issue #744: recompute the contrast-safe brand accent for the new surface.
|
|
9961
|
+
if (typeof tfApplyBrandAccent === 'function') tfApplyBrandAccent();
|
|
9518
9962
|
// Keep embedded surfaces in sync live, including the connected main-server frame.
|
|
9519
9963
|
try {
|
|
9520
9964
|
document.querySelectorAll('iframe').forEach((f) => {
|
|
@@ -9853,6 +10297,9 @@ async function tfCreateProject() {
|
|
|
9853
10297
|
};
|
|
9854
10298
|
tf.projects.push(project);
|
|
9855
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));
|
|
9856
10303
|
tfPersistProjects({ reviveRemovedPaths: [folderPath] });
|
|
9857
10304
|
tfPersistAssignments();
|
|
9858
10305
|
tf.activeProjectId = id;
|