fraim-framework 2.0.188 → 2.0.190

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.
@@ -10,6 +10,7 @@ const fs_1 = __importDefault(require("fs"));
10
10
  const server_1 = require("./server");
11
11
  const cert_store_1 = require("./cert-store");
12
12
  const office_sideload_1 = require("./office-sideload");
13
+ const remote_hub_gateway_1 = require("./remote-hub-gateway");
13
14
  // ---------------------------------------------------------------------------
14
15
  // State
15
16
  // ---------------------------------------------------------------------------
@@ -50,6 +51,66 @@ function applyUserDataOverride() {
50
51
  fs_1.default.mkdirSync(userDataDir, { recursive: true });
51
52
  electron_1.app.setPath('userData', userDataDir);
52
53
  }
54
+ function isTrustedInAppNavigation(targetUrl, hubUrl) {
55
+ try {
56
+ const parsed = new URL(targetUrl);
57
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
58
+ return false;
59
+ return parsed.origin === new URL(hubUrl).origin || parsed.origin === new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)()).origin;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ function connectedSurfaceParam(value) {
66
+ return value === 'account' || value === 'analytics' || value === 'brain' ? value : null;
67
+ }
68
+ function connectedSurfaceForHostedUrl(targetUrl, hubUrl) {
69
+ try {
70
+ const parsed = new URL(targetUrl);
71
+ const hub = new URL(hubUrl);
72
+ const remote = new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
73
+ if (parsed.origin === hub.origin || parsed.origin !== remote.origin)
74
+ return null;
75
+ const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
76
+ if (pathname === '/account')
77
+ return 'account';
78
+ if (pathname === '/analytics')
79
+ return 'analytics';
80
+ if (pathname === '/fraim-brain' || pathname === '/fraim-brain.html')
81
+ return 'brain';
82
+ if (pathname === '/ai-hub') {
83
+ return connectedSurfaceParam(parsed.searchParams.get('connected') || parsed.searchParams.get('open'));
84
+ }
85
+ if (pathname === '/auth/sign-in.html' || pathname === '/auth/recovery.html' || pathname === '/auth/error.html') {
86
+ return connectedSurfaceParam(parsed.searchParams.get('surface'));
87
+ }
88
+ return null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function localHubConnectedUrl(hubUrl, surface, sourceUrl) {
95
+ const target = new URL(hubUrl);
96
+ target.search = '';
97
+ target.hash = '';
98
+ target.searchParams.set('connected', surface);
99
+ if (sourceUrl) {
100
+ try {
101
+ const source = new URL(sourceUrl);
102
+ const apiKey = source.searchParams.get('api-key') || source.searchParams.get('apiKey');
103
+ if (apiKey)
104
+ target.searchParams.set('api-key', apiKey);
105
+ }
106
+ catch { /* ignore malformed source URLs */ }
107
+ }
108
+ return target.toString();
109
+ }
110
+ function wrappedHostedNavigationUrl(targetUrl, hubUrl) {
111
+ const surface = connectedSurfaceForHostedUrl(targetUrl, hubUrl);
112
+ return surface ? localHubConnectedUrl(hubUrl, surface, targetUrl) : null;
113
+ }
53
114
  // ---------------------------------------------------------------------------
54
115
  // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
55
116
  // so the app never crashes if assets aren't present.
@@ -204,9 +265,42 @@ async function createWindow(url) {
204
265
  mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
205
266
  }
206
267
  mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
207
- void electron_1.shell.openExternal(targetUrl);
268
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
269
+ if (wrappedUrl) {
270
+ void mainWindow?.loadURL(wrappedUrl);
271
+ return { action: 'deny' };
272
+ }
273
+ if (isTrustedInAppNavigation(targetUrl, url)) {
274
+ void mainWindow?.loadURL(targetUrl);
275
+ }
208
276
  return { action: 'deny' };
209
277
  });
278
+ mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
279
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
280
+ if (!wrappedUrl)
281
+ return;
282
+ event.preventDefault();
283
+ if (mainWindow?.webContents.getURL() !== wrappedUrl) {
284
+ void mainWindow?.loadURL(wrappedUrl);
285
+ }
286
+ });
287
+ mainWindow.webContents.on('will-redirect', (details) => {
288
+ if (!details.isMainFrame)
289
+ return;
290
+ const wrappedUrl = wrappedHostedNavigationUrl(details.url, url);
291
+ if (!wrappedUrl)
292
+ return;
293
+ details.preventDefault();
294
+ if (mainWindow?.webContents.getURL() !== wrappedUrl) {
295
+ void mainWindow?.loadURL(wrappedUrl);
296
+ }
297
+ });
298
+ mainWindow.webContents.on('did-navigate', (_event, targetUrl) => {
299
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
300
+ if (!wrappedUrl || mainWindow?.webContents.getURL() === wrappedUrl)
301
+ return;
302
+ void mainWindow?.loadURL(wrappedUrl);
303
+ });
210
304
  mainWindow.once('ready-to-show', () => mainWindow?.show());
211
305
  // Closing the window hides it to the tray rather than quitting the app.
212
306
  // The server keeps running so Word can still reach the task pane.
@@ -128,6 +128,26 @@ function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
128
128
  const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
129
129
  return `/pricing?${params.toString()}`;
130
130
  }
131
+ function buildLocalHubReturnUrl(req, surface) {
132
+ const proto = req.protocol || 'http';
133
+ const host = req.get('host') || '127.0.0.1';
134
+ const target = new URL('/ai-hub/', `${proto}://${host}`);
135
+ target.searchParams.set('connected', surface);
136
+ return target.toString();
137
+ }
138
+ function buildHostedAuthUrl(surface, redirectTo, hubReturn) {
139
+ const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
140
+ target.searchParams.set('surface', surface);
141
+ target.searchParams.set('redirect_to', redirectTo);
142
+ if (hubReturn)
143
+ target.searchParams.set('hub_return', hubReturn);
144
+ return target.toString();
145
+ }
146
+ function buildHostedPathUrl(pathname, queryString) {
147
+ const target = new URL(pathname, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
148
+ target.search = queryString;
149
+ return target.toString();
150
+ }
131
151
  function buildHubPersonaAvatarUrl(personaKey) {
132
152
  return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
133
153
  }
@@ -1407,6 +1427,7 @@ class AiHubServer {
1407
1427
  const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
1408
1428
  return {
1409
1429
  title: 'AI Hub',
1430
+ remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
1410
1431
  project,
1411
1432
  preferences,
1412
1433
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
@@ -1997,26 +2018,47 @@ class AiHubServer {
1997
2018
  // Issue #512: Serve the account and analytics pages from public/.
1998
2019
  // These live outside of /ai-hub so they need explicit routes.
1999
2020
  const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
2000
- this.app.get('/account', (_req, res) => {
2001
- const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2002
- if (fs_1.default.existsSync(filePath)) {
2003
- res.sendFile(filePath);
2004
- }
2005
- else {
2006
- res.status(404).send('Account page not found.');
2007
- }
2008
- });
2009
- this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2010
- this.app.get('/analytics', (_req, res) => {
2011
- const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2012
- if (fs_1.default.existsSync(filePath)) {
2013
- res.sendFile(filePath);
2014
- }
2015
- else {
2016
- res.status(404).send('Analytics page not found.');
2017
- }
2018
- });
2019
- this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2021
+ if (!this.dbService) {
2022
+ this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2023
+ const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2024
+ if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2025
+ const rawSurface = parsed.searchParams.get('surface');
2026
+ const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2027
+ ? rawSurface
2028
+ : 'analytics';
2029
+ parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2030
+ }
2031
+ res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2032
+ });
2033
+ this.app.get(['/account', '/account/'], (req, res) => {
2034
+ res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2035
+ });
2036
+ this.app.get(['/analytics', '/analytics/'], (req, res) => {
2037
+ res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2038
+ });
2039
+ }
2040
+ else {
2041
+ this.app.get('/account', (_req, res) => {
2042
+ const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2043
+ if (fs_1.default.existsSync(filePath)) {
2044
+ res.sendFile(filePath);
2045
+ }
2046
+ else {
2047
+ res.status(404).send('Account page not found.');
2048
+ }
2049
+ });
2050
+ this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2051
+ this.app.get('/analytics', (_req, res) => {
2052
+ const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2053
+ if (fs_1.default.existsSync(filePath)) {
2054
+ res.sendFile(filePath);
2055
+ }
2056
+ else {
2057
+ res.status(404).send('Analytics page not found.');
2058
+ }
2059
+ });
2060
+ this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2061
+ }
2020
2062
  // Issue #478: Serve the PowerPoint task pane HTML and manifest.
2021
2063
  // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
2022
2064
  // request, so we must strip them before resolving the file path. Use a custom
@@ -96,7 +96,7 @@ class AIMentor {
96
96
  const metadataPhases = Object.keys(workflow.metadata.phases || {});
97
97
  const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
98
98
  const overview = workflow.overview.trim();
99
- return [
99
+ const lines = [
100
100
  overview,
101
101
  '',
102
102
  '---',
@@ -104,7 +104,16 @@ class AIMentor {
104
104
  '## Start Here',
105
105
  `- Initial phase: \`${initialPhase}\``,
106
106
  '- Call `seekMentoring` to load the instructions for this phase.'
107
- ].join('\n');
107
+ ];
108
+ // A job that produces reviewable repository changes must resolve its workspace
109
+ // before starting. The mode conditional (branch/worktree vs in place) lives in
110
+ // the skill and is evaluated by the agent from its own local settings.
111
+ if (workflow.metadata.producesRepoChanges === true) {
112
+ lines.push('', '## Set Up Your Workspace First', '- This job changes the repository. Before phase work, read and follow'
113
+ + ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
114
+ + ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
115
+ }
116
+ return lines.join('\n');
108
117
  }
109
118
  async generateStartingMessage(workflow, phaseId, skipIncludes) {
110
119
  const entityType = 'Job';
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerAppRoutes = registerAppRoutes;
7
+ exports.registerProtectedAppRoutes = registerProtectedAppRoutes;
7
8
  const path_1 = __importDefault(require("path"));
8
9
  /**
9
10
  * Serve the unified tabs experience at the root path.
@@ -30,3 +31,95 @@ function registerAppRoutes(app, deps) {
30
31
  }
31
32
  });
32
33
  }
34
+ function requestBaseUrl(req) {
35
+ const fromEnv = process.env.FRAIM_PUBLIC_BASE_URL;
36
+ if (fromEnv && /^https?:\/\//i.test(fromEnv))
37
+ return fromEnv.replace(/\/+$/, '');
38
+ const firstHeader = (value) => Array.isArray(value) ? value[0] : value;
39
+ const proto = firstHeader(req.headers['x-forwarded-proto']) || req.protocol || 'http';
40
+ const host = firstHeader(req.headers['x-forwarded-host']) || req.get('host') || 'localhost';
41
+ return `${proto.split(',')[0]}://${host.split(',')[0]}`.replace(/\/+$/, '');
42
+ }
43
+ function hostedProjectSummary() {
44
+ const projectPath = process.cwd();
45
+ return {
46
+ path: projectPath,
47
+ exists: true,
48
+ hasFraim: true,
49
+ needsOnboarding: false,
50
+ message: '',
51
+ };
52
+ }
53
+ function hostedTeamContext() {
54
+ const emptyFile = { present: false, displayPath: '' };
55
+ return {
56
+ orgContext: emptyFile,
57
+ managerContext: emptyFile,
58
+ orgRules: emptyFile,
59
+ managerRules: emptyFile,
60
+ projectContext: emptyFile,
61
+ projectBrief: emptyFile,
62
+ projectQa: emptyFile,
63
+ projectRules: emptyFile,
64
+ };
65
+ }
66
+ /**
67
+ * Protected app routes for hosted web surfaces.
68
+ *
69
+ * The deployed server owns authentication/session identity and can provide a
70
+ * narrow bootstrap contract for the hosted Hub shell. It intentionally does not
71
+ * instantiate the local AiHubServer runtime, which belongs to the companion app.
72
+ */
73
+ function registerProtectedAppRoutes(app, deps) {
74
+ void deps;
75
+ app.get('/api/ai-hub/bootstrap', (req, res) => {
76
+ const apiKeyData = req.apiKeyData;
77
+ if (!apiKeyData?.userId) {
78
+ return res.status(401).json({ error: 'Authentication required' });
79
+ }
80
+ const project = hostedProjectSummary();
81
+ const projectName = path_1.default.basename(project.path) || 'FRAIM';
82
+ const projectEntry = {
83
+ id: 'hosted-fraim',
84
+ name: projectName,
85
+ folderPath: project.path,
86
+ intent: '',
87
+ outcome: '',
88
+ rules: '',
89
+ brief: '',
90
+ };
91
+ return res.json({
92
+ title: 'AI Hub',
93
+ remoteBaseUrl: requestBaseUrl(req),
94
+ project,
95
+ preferences: {
96
+ projectPath: project.path,
97
+ employeeId: 'claude',
98
+ categoryId: 'hosted',
99
+ recentJobIds: [],
100
+ recentJobInstructions: {},
101
+ personaKey: null,
102
+ projects: [projectEntry],
103
+ },
104
+ categories: [],
105
+ jobs: [],
106
+ managerTemplates: [],
107
+ employees: [
108
+ { id: 'claude', label: 'Claude', available: true, detail: 'Hosted' },
109
+ ],
110
+ personas: [],
111
+ subscriptionActive: false,
112
+ projects: [projectEntry],
113
+ firstRun: { install: true, company: true, hire: true, project: true },
114
+ teamContext: hostedTeamContext(),
115
+ brain: {
116
+ learnings: { organization: 0, manager: 0, project: 0, rawSignals: 0 },
117
+ catalog: { jobs: 0, skills: 0, rules: 0 },
118
+ },
119
+ userEmail: apiKeyData.userId,
120
+ assignments: { byProject: {}, source: 'client-localStorage' },
121
+ managerHiring: { qualities: [], services: [], roles: {} },
122
+ managerTeam: [],
123
+ });
124
+ });
125
+ }
@@ -238,7 +238,11 @@ function registerPublicAuthRoutes(app, deps) {
238
238
  if (surface === 'mobile') {
239
239
  return res.status(200).json({ ok: true, mobileCode: issueMobileExchangeCode(apiKeyData.key) });
240
240
  }
241
- return res.status(200).json({ ok: true, redirectTo: requestedRedirect });
241
+ // The account UI can reveal the user's API key immediately after a
242
+ // valid session is established. Return it here as well so embedded
243
+ // Hub auth can hand the key to its parent without depending on
244
+ // third-party iframe cookies.
245
+ return res.status(200).json({ ok: true, redirectTo: requestedRedirect, apiKey: apiKeyData.key });
242
246
  }
243
247
  catch (err) {
244
248
  console.error('[FRAIM AUTH] /api/auth/email/verify-code error:', err instanceof Error ? err.message : String(err));
@@ -61,6 +61,38 @@ function buildBaseUrl(req) {
61
61
  function callbackUrl(req, provider) {
62
62
  return `${buildBaseUrl(req)}/api/auth/oauth/${provider}/callback`;
63
63
  }
64
+ function isLoopbackHost(hostname) {
65
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
66
+ }
67
+ function safeLoopbackHubReturn(input) {
68
+ if (typeof input !== 'string' || !input)
69
+ return undefined;
70
+ if (input.includes('\n') || input.includes('\r'))
71
+ return undefined;
72
+ try {
73
+ const url = new URL(input);
74
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !isLoopbackHost(url.hostname))
75
+ return undefined;
76
+ if (url.username || url.password)
77
+ return undefined;
78
+ if (!url.pathname.startsWith('/ai-hub/'))
79
+ return undefined;
80
+ return url.toString();
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ function buildHubReturnRedirect(hubReturn, surface, apiKey) {
87
+ if (!hubReturn || !apiKey)
88
+ return null;
89
+ const target = new URL(hubReturn);
90
+ if (!target.searchParams.get('connected') && surface !== 'mobile') {
91
+ target.searchParams.set('connected', surface);
92
+ }
93
+ target.searchParams.set('api-key', apiKey);
94
+ return target.toString();
95
+ }
64
96
  function decodeJwtPayload(jwt) {
65
97
  const parts = jwt.split('.');
66
98
  if (parts.length !== 3)
@@ -86,9 +118,10 @@ function registerOAuthRoutes(app, deps) {
86
118
  });
87
119
  // Helper used by both providers to finalise authentication once we have a verified email.
88
120
  async function completeSignIn(req, res, params) {
89
- const { verifiedEmail, provider, surface, redirectTo, intent } = params;
121
+ const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn } = params;
90
122
  const lower = verifiedEmail.toLowerCase();
91
123
  const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
124
+ let apiKeyForHubReturn = apiKeyData?.key ?? '';
92
125
  if (!apiKeyData && intent !== 'signup') {
93
126
  // Sign-in is NOT a sign-up. If the verified email doesn't already match a
94
127
  // FRAIM account, route the user to the sign-up flow. Account creation is
@@ -107,7 +140,7 @@ function registerOAuthRoutes(app, deps) {
107
140
  return res.redirect(`/auth/error.html?reason=no_account`);
108
141
  }
109
142
  if (!apiKeyData && intent === 'signup') {
110
- await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
143
+ apiKeyForHubReturn = await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
111
144
  }
112
145
  const sessionId = (0, oauth_helpers_1.generateSessionId)();
113
146
  await dbService.createAuthSession({
@@ -121,6 +154,10 @@ function registerOAuthRoutes(app, deps) {
121
154
  await (0, audit_log_1.auditLog)('SESSION_CREATED', { userId: lower, sessionId, surface, provider, outcome: 'success' });
122
155
  (0, cookie_service_1.setSessionCookie)(res, sessionId);
123
156
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
157
+ const hubRedirect = buildHubReturnRedirect(hubReturn, surface, apiKeyForHubReturn);
158
+ if (hubRedirect) {
159
+ return res.redirect(hubRedirect);
160
+ }
124
161
  res.redirect(redirectTo);
125
162
  }
126
163
  app.get('/api/auth/oauth/:provider/start', (req, res, next) => {
@@ -136,6 +173,7 @@ function registerOAuthRoutes(app, deps) {
136
173
  const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
137
174
  const intent = pickIntent(req.query.intent);
138
175
  const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
176
+ const hubReturn = safeLoopbackHubReturn(req.query.hub_return);
139
177
  try {
140
178
  const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
141
179
  const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
@@ -148,6 +186,7 @@ function registerOAuthRoutes(app, deps) {
148
186
  codeVerifier,
149
187
  stateNonce,
150
188
  redirectTo,
189
+ hubReturn,
151
190
  });
152
191
  (0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
153
192
  await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
@@ -257,6 +296,7 @@ function registerOAuthRoutes(app, deps) {
257
296
  surface: pending.surface,
258
297
  redirectTo: pending.redirectTo,
259
298
  intent: pickIntent(pending.intent),
299
+ hubReturn: pending.hubReturn,
260
300
  });
261
301
  }
262
302
  catch (err) {
@@ -350,6 +390,7 @@ function registerOAuthRoutes(app, deps) {
350
390
  surface: pending.surface,
351
391
  redirectTo: pending.redirectTo,
352
392
  intent: pickIntent(pending.intent),
393
+ hubReturn: pending.hubReturn,
353
394
  });
354
395
  }
355
396
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.188",
3
+ "version": "2.0.190",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -33,18 +33,18 @@
33
33
  <div class="am-email" id="am-email"></div>
34
34
  </div>
35
35
  </div>
36
- <a class="am-item" id="am-account" href="/account">
36
+ <button class="am-item" id="am-account" type="button">
37
37
  <span class="am-ico">👤</span>
38
38
  <div><div class="am-label">Account</div><div class="am-sub">API key, sessions, billing</div></div>
39
- </a>
39
+ </button>
40
40
  <button class="am-item" id="am-brain" type="button">
41
41
  <span class="am-ico">🧠</span>
42
42
  <div><div class="am-label">Brain</div><div class="am-sub">Everything your team knows</div></div>
43
43
  </button>
44
- <a class="am-item" id="am-analytics" href="/analytics">
44
+ <button class="am-item" id="am-analytics" type="button">
45
45
  <span class="am-ico">📊</span>
46
46
  <div><div class="am-label">Analytics</div><div class="am-sub">Usage, cost, and ROI</div></div>
47
- </a>
47
+ </button>
48
48
  <button class="am-item am-theme" id="am-theme-toggle" type="button" role="switch" aria-checked="false">
49
49
  <span class="am-ico" id="am-theme-ico">🌙</span>
50
50
  <div><div class="am-label">Dark mode</div><div class="am-sub" id="am-theme-sub">Off</div></div>
@@ -141,7 +141,21 @@
141
141
  </div>
142
142
  </section>
143
143
 
144
- <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
144
+ <section class="hub-area" id="area-connected" hidden>
145
+ <div class="connected-shell">
146
+ <div class="connected-toolbar">
147
+ <div class="connected-title-block">
148
+ <div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
149
+ <div class="connected-title" id="connected-title">Account</div>
150
+ </div>
151
+ <div class="connected-origin" id="connected-origin"></div>
152
+ <button class="connected-close" id="connected-close" type="button">Back</button>
153
+ </div>
154
+ <iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
155
+ </div>
156
+ </section>
157
+
158
+ <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
145
159
  <section class="hub-area on" id="area-projects">
146
160
  <div class="proj-tabs" id="proj-tabs">
147
161
  <button class="ptab on" id="ptab-overview" type="button" data-view="overview">Overview</button>
@@ -47,6 +47,10 @@ const state = {
47
47
  selectedPersonaKey: null, // R4: null = "All employees"
48
48
  modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
49
49
  storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
50
+ remoteBaseUrl: null,
51
+ connectedReturnArea: 'projects',
52
+ connectedSurface: null,
53
+ pendingConnectedSurfaceAfterAuth: null,
50
54
  panelState: {}, // { [convId]: { coach?: boolean } }
51
55
  wordContext: null, // WordContext pushed from taskpane.html via postMessage
52
56
  // Pending coaching job selected via a quick-coach button or template picker.
@@ -144,6 +148,34 @@ async function requestJson(url, options) {
144
148
  return payload;
145
149
  }
146
150
 
151
+ function tfIsLoopbackHost(hostname) {
152
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
153
+ }
154
+
155
+ function tfIsLoopbackOrigin(origin) {
156
+ try {
157
+ const parsed = new URL(origin);
158
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && tfIsLoopbackHost(parsed.hostname);
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function tfConsumeReturnedApiKeyFromUrl() {
165
+ try {
166
+ if (!tfIsLoopbackHost(window.location.hostname)) return null;
167
+ const url = new URL(window.location.href);
168
+ const apiKey = url.searchParams.get('api-key') || url.searchParams.get('apiKey');
169
+ if (!apiKey) return null;
170
+ url.searchParams.delete('api-key');
171
+ url.searchParams.delete('apiKey');
172
+ window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
173
+ return apiKey;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
147
179
  function bootstrapCacheKey(projectPath, docUrl) {
148
180
  return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
149
181
  }
@@ -156,6 +188,10 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
156
188
  function applyBootstrap(bootstrap, docUrl) {
157
189
  state.bootstrap = bootstrap;
158
190
  state.projectPath = bootstrap.project.path;
191
+ state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
192
+ if (bootstrap.preferences && bootstrap.preferences.apiKey) {
193
+ state.storedApiKey = bootstrap.preferences.apiKey;
194
+ }
159
195
  state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
160
196
  // Restore persona selection persisted on the server side.
161
197
  if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
@@ -229,6 +265,20 @@ async function loadBootstrap(projectPath, docUrl, options) {
229
265
  return bootstrap;
230
266
  }
231
267
 
268
+ function hostedHubNeedsSignIn(error) {
269
+ if (!error || error.status !== 401) return false;
270
+ const host = window.location.hostname;
271
+ return host !== 'localhost' && host !== '127.0.0.1';
272
+ }
273
+
274
+ function redirectHostedHubToSignIn() {
275
+ const redirectTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/ai-hub/';
276
+ const target = new URL('/auth/sign-in.html', window.location.origin);
277
+ target.searchParams.set('surface', 'account');
278
+ target.searchParams.set('redirect_to', redirectTo);
279
+ window.location.href = target.toString();
280
+ }
281
+
232
282
  // ---------------------------------------------------------------------------
233
283
  // Word context bridge (postMessage from taskpane.html parent frame)
234
284
  // ---------------------------------------------------------------------------
@@ -5911,6 +5961,11 @@ function wireEvents() {
5911
5961
  const isFirstRun = urlParams.get('firstRun') === 'true';
5912
5962
  const surface = urlParams.get('surface') || 'hub';
5913
5963
  const docUrl = urlParams.get('docUrl') || '';
5964
+ const requestedConnectedSurface = tfRequestedConnectedSurface();
5965
+ const returnedApiKey = tfConsumeReturnedApiKeyFromUrl();
5966
+ if (returnedApiKey && requestedConnectedSurface) {
5967
+ state.pendingConnectedSurfaceAfterAuth = requestedConnectedSurface;
5968
+ }
5914
5969
 
5915
5970
  // Apply surface class before bootstrap so CSS takes effect immediately.
5916
5971
  if (surface !== 'hub') document.body.dataset.surface = surface;
@@ -5920,9 +5975,17 @@ function wireEvents() {
5920
5975
 
5921
5976
  try {
5922
5977
  await loadBootstrap(null, docUrl);
5978
+ if (returnedApiKey) {
5979
+ await tfRememberConnectedApiKey(returnedApiKey);
5980
+ tfOpenRequestedConnectedSurface();
5981
+ }
5923
5982
  await hydrateConversationsFromServer();
5924
5983
  startBgConvPoll();
5925
5984
  } catch (error) {
5985
+ if (hostedHubNeedsSignIn(error)) {
5986
+ redirectHostedHubToSignIn();
5987
+ return;
5988
+ }
5926
5989
  showStatus(error.message, true);
5927
5990
  return;
5928
5991
  }
@@ -6205,7 +6268,7 @@ const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
6205
6268
 
6206
6269
  const tf = {
6207
6270
  enabled: false,
6208
- area: 'projects', // company | manager | projects | brain
6271
+ area: 'projects', // company | manager | projects | brain | connected
6209
6272
  projectView: 'overview', // overview | workspace
6210
6273
  activeProjectId: null,
6211
6274
  projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
@@ -9241,11 +9304,143 @@ function tfCloseAccountMenu() {
9241
9304
  if (menu) menu.classList.remove('open');
9242
9305
  }
9243
9306
 
9307
+ const CONNECTED_SURFACES = {
9308
+ account: { title: 'Account', path: '/account/' },
9309
+ analytics: { title: 'Analytics', path: '/analytics/' },
9310
+ brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
9311
+ };
9312
+
9313
+ function tfConnectedRemoteBase() {
9314
+ const fallback = window.location.origin;
9315
+ const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
9316
+ try {
9317
+ return new URL(raw, fallback).toString();
9318
+ } catch {
9319
+ return fallback;
9320
+ }
9321
+ }
9322
+
9323
+ function tfConnectedApiKey() {
9324
+ return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
9325
+ }
9326
+
9327
+ function tfConnectedRedirectPath(surface, cfg, remoteBase) {
9328
+ try {
9329
+ if (new URL(remoteBase).origin === window.location.origin) {
9330
+ return `/ai-hub/?connected=${encodeURIComponent(surface)}`;
9331
+ }
9332
+ } catch {}
9333
+ return cfg.redirectPath || cfg.path;
9334
+ }
9335
+
9336
+ function tfConnectedHubReturnUrl(surface) {
9337
+ if (!tfIsLoopbackOrigin(window.location.origin)) return '';
9338
+ try {
9339
+ const target = new URL('/ai-hub/', window.location.origin);
9340
+ target.searchParams.set('connected', surface);
9341
+ return target.toString();
9342
+ } catch {
9343
+ return '';
9344
+ }
9345
+ }
9346
+
9347
+ function tfConnectedSurfaceUrl(surface) {
9348
+ const cfg = CONNECTED_SURFACES[surface];
9349
+ const remoteBase = tfConnectedRemoteBase();
9350
+ if (!cfg) return remoteBase;
9351
+ const apiKey = tfConnectedApiKey();
9352
+ try {
9353
+ if (new URL(remoteBase).origin === window.location.origin) {
9354
+ return new URL(cfg.path, remoteBase).toString();
9355
+ }
9356
+ } catch {}
9357
+ if (apiKey) {
9358
+ const target = new URL(cfg.path, remoteBase);
9359
+ target.searchParams.set('api-key', apiKey);
9360
+ return target.toString();
9361
+ }
9362
+ const signIn = new URL('/auth/sign-in.html', remoteBase);
9363
+ signIn.searchParams.set('surface', surface);
9364
+ signIn.searchParams.set('embedded', '1');
9365
+ signIn.searchParams.set('redirect_to', tfConnectedRedirectPath(surface, cfg, remoteBase));
9366
+ const hubReturn = tfConnectedHubReturnUrl(surface);
9367
+ if (hubReturn) signIn.searchParams.set('hub_return', hubReturn);
9368
+ return signIn.toString();
9369
+ }
9370
+
9371
+ function tfOpenConnectedSurface(surface) {
9372
+ const cfg = CONNECTED_SURFACES[surface];
9373
+ if (!cfg) return;
9374
+ tfCloseAccountMenu();
9375
+ state.connectedReturnArea = (tf.area && tf.area !== 'connected') ? tf.area : 'projects';
9376
+ state.connectedSurface = surface;
9377
+ const url = tfConnectedSurfaceUrl(surface);
9378
+ const title = document.getElementById('connected-title');
9379
+ const origin = document.getElementById('connected-origin');
9380
+ const frame = document.getElementById('connected-frame');
9381
+ if (title) title.textContent = cfg.title;
9382
+ if (origin) {
9383
+ try { origin.textContent = new URL(url).origin; }
9384
+ catch { origin.textContent = ''; }
9385
+ }
9386
+ if (frame) {
9387
+ frame.title = `FRAIM ${cfg.title}`;
9388
+ frame.src = url;
9389
+ }
9390
+ tfShowArea('connected');
9391
+ }
9392
+
9393
+ function tfCloseConnectedSurface() {
9394
+ const frame = document.getElementById('connected-frame');
9395
+ if (frame) frame.removeAttribute('src');
9396
+ state.connectedSurface = null;
9397
+ const returnArea = state.connectedReturnArea && state.connectedReturnArea !== 'connected'
9398
+ ? state.connectedReturnArea
9399
+ : 'projects';
9400
+ tfShowArea(returnArea);
9401
+ }
9402
+
9403
+ async function tfHandleConnectedAuthMessage(event) {
9404
+ const frame = document.getElementById('connected-frame');
9405
+ if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
9406
+ let remoteOrigin = '';
9407
+ try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
9408
+ catch { return; }
9409
+ if (event.origin !== remoteOrigin) return;
9410
+ const msg = event.data || {};
9411
+ if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
9412
+ await tfRememberConnectedApiKey(msg.apiKey);
9413
+ if (state.connectedSurface) {
9414
+ const nextUrl = tfConnectedSurfaceUrl(state.connectedSurface);
9415
+ const frameEl = document.getElementById('connected-frame');
9416
+ if (frameEl) frameEl.src = nextUrl;
9417
+ }
9418
+ }
9419
+
9420
+ async function tfRememberConnectedApiKey(apiKey) {
9421
+ const changed = apiKey !== state.storedApiKey;
9422
+ state.storedApiKey = apiKey;
9423
+ if (state.bootstrap && state.bootstrap.preferences) {
9424
+ state.bootstrap.preferences.apiKey = apiKey;
9425
+ }
9426
+ if (changed) {
9427
+ try {
9428
+ await requestJson('/api/ai-hub/api-key', {
9429
+ method: 'POST',
9430
+ headers: { 'Content-Type': 'application/json' },
9431
+ body: JSON.stringify({ apiKey }),
9432
+ });
9433
+ } catch (error) {
9434
+ console.warn('[ai-hub] Could not persist connected auth key:', error);
9435
+ }
9436
+ }
9437
+ }
9438
+
9244
9439
  // ---------------------------------------------------------------------------
9245
9440
  // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9246
9441
  // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
9247
9442
  // OS preference; here we reflect it in the toggle control and persist changes.
9248
- // Same-origin key sharing keeps /account and /analytics in sync on their next load.
9443
+ // Embedded connected surfaces receive live theme updates via postMessage.
9249
9444
  // ---------------------------------------------------------------------------
9250
9445
  function tfCurrentTheme() {
9251
9446
  return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
@@ -9265,10 +9460,15 @@ function tfApplyTheme(theme, persist) {
9265
9460
  document.documentElement.setAttribute('data-theme', t);
9266
9461
  if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
9267
9462
  tfSyncThemeToggle(t);
9268
- // Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
9463
+ // Keep embedded surfaces in sync live, including the connected main-server frame.
9269
9464
  try {
9270
9465
  document.querySelectorAll('iframe').forEach((f) => {
9271
- if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
9466
+ if (!f.contentWindow) return;
9467
+ let targetOrigin = location.origin;
9468
+ try {
9469
+ if (f.src) targetOrigin = new URL(f.src, location.href).origin;
9470
+ } catch (e) {}
9471
+ f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
9272
9472
  });
9273
9473
  } catch (e) {}
9274
9474
  }
@@ -10000,8 +10200,26 @@ function tfWireShell() {
10000
10200
  const avatar = document.getElementById('avatar-btn');
10001
10201
  if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
10002
10202
  tfWireThemeToggle();
10203
+ const accountItem = document.getElementById('am-account');
10204
+ if (accountItem) accountItem.addEventListener('click', (e) => {
10205
+ e.preventDefault();
10206
+ tfOpenConnectedSurface('account');
10207
+ });
10003
10208
  const brainItem = document.getElementById('am-brain');
10004
- if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
10209
+ if (brainItem) brainItem.addEventListener('click', () => {
10210
+ tfOpenConnectedSurface('brain');
10211
+ });
10212
+ const analyticsItem = document.getElementById('am-analytics');
10213
+ if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
10214
+ e.preventDefault();
10215
+ tfOpenConnectedSurface('analytics');
10216
+ });
10217
+ const connectedClose = document.getElementById('connected-close');
10218
+ if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
10219
+ if (!tf.connectedAuthMessageWired) {
10220
+ window.addEventListener('message', tfHandleConnectedAuthMessage);
10221
+ tf.connectedAuthMessageWired = true;
10222
+ }
10005
10223
  const signout = document.getElementById('am-signout');
10006
10224
  if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
10007
10225
  document.addEventListener('click', (e) => {
@@ -10113,6 +10331,11 @@ function tfWireShell() {
10113
10331
  ];
10114
10332
  document.addEventListener('keydown', (e) => {
10115
10333
  if (e.key !== 'Escape') return;
10334
+ if (tf.area === 'connected') {
10335
+ e.stopPropagation();
10336
+ tfCloseConnectedSurface();
10337
+ return;
10338
+ }
10116
10339
  for (const [id, fn] of escClosers) {
10117
10340
  const el = document.getElementById(id);
10118
10341
  if (el && !el.hidden) { e.stopPropagation(); fn(); return; }
@@ -10149,6 +10372,36 @@ function tfPopulateAccountMenu() {
10149
10372
  if (avatarBtn) avatarBtn.textContent = id.initials;
10150
10373
  }
10151
10374
 
10375
+ function tfRequestedConnectedSurface() {
10376
+ try {
10377
+ const params = new URLSearchParams(window.location.search);
10378
+ const surface = params.get('connected') || params.get('open');
10379
+ return CONNECTED_SURFACES[surface] ? surface : null;
10380
+ } catch {
10381
+ return null;
10382
+ }
10383
+ }
10384
+
10385
+ function tfClearConnectedSurfaceRequest() {
10386
+ try {
10387
+ const url = new URL(window.location.href);
10388
+ url.searchParams.delete('connected');
10389
+ url.searchParams.delete('open');
10390
+ window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
10391
+ } catch {}
10392
+ }
10393
+
10394
+ function tfOpenRequestedConnectedSurface() {
10395
+ if (!tf.enabled) return;
10396
+ const pendingAfterAuth = state.pendingConnectedSurfaceAfterAuth;
10397
+ if (pendingAfterAuth && !tfConnectedApiKey()) return;
10398
+ const surface = pendingAfterAuth || tfRequestedConnectedSurface();
10399
+ if (!surface) return;
10400
+ state.pendingConnectedSurfaceAfterAuth = null;
10401
+ tfClearConnectedSurfaceRequest();
10402
+ setTimeout(() => tfOpenConnectedSurface(surface), 0);
10403
+ }
10404
+
10152
10405
  function tfInitShell() {
10153
10406
  if (typeof surface !== 'undefined' && surface !== 'hub') return;
10154
10407
  if (document.body.dataset.surface && document.body.dataset.surface !== 'hub') return;
@@ -10187,6 +10440,7 @@ function tfInitShell() {
10187
10440
  // the project workspace. Re-render once the workspace is selected so manager
10188
10441
  // delegation boards are visible on first load, not only after a refresh tick.
10189
10442
  renderActive();
10443
+ tfOpenRequestedConnectedSurface();
10190
10444
  }
10191
10445
 
10192
10446
  // Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
@@ -2752,7 +2752,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2752
2752
  .am-av { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
2753
2753
  .am-name { font-size: 13px; font-weight: 700; }
2754
2754
  .am-email { font-size: 11px; color: var(--muted); }
2755
- .am-item { display: flex; align-items: flex-start; gap: 10px; padding: 9px 16px; cursor: pointer; font-size: 13px; }
2755
+ .am-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 16px; cursor: pointer; font-size: 13px; color: var(--text); text-align: left; text-decoration: none; border: 0; background: none; }
2756
2756
  .am-item:hover { background: var(--bg); }
2757
2757
  .am-ico { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; margin-top: 1px; }
2758
2758
  .am-label { font-weight: 600; }
@@ -2768,12 +2768,75 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2768
2768
  .am-theme[aria-checked="true"] .am-switch-knob { transform: translateX(16px); }
2769
2769
 
2770
2770
  /* Hub areas */
2771
- .hub-area { display: none; flex: 1; overflow: hidden; }
2772
- .hub-area.on { display: flex; flex-direction: column; }
2773
- .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2774
-
2775
- /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2776
- .area-shell { display: flex; flex: 1; min-height: 0; }
2771
+ .hub-area { display: none; flex: 1; overflow: hidden; }
2772
+ .hub-area.on { display: flex; flex-direction: column; }
2773
+ .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2774
+
2775
+ .connected-shell {
2776
+ display: flex;
2777
+ flex: 1;
2778
+ min-height: 0;
2779
+ flex-direction: column;
2780
+ background: var(--bg);
2781
+ }
2782
+ .connected-toolbar {
2783
+ display: flex;
2784
+ align-items: center;
2785
+ gap: 14px;
2786
+ min-height: 52px;
2787
+ padding: 8px 16px;
2788
+ background: var(--surface);
2789
+ border-bottom: 1px solid var(--line);
2790
+ flex-shrink: 0;
2791
+ }
2792
+ .connected-title-block {
2793
+ min-width: 0;
2794
+ display: flex;
2795
+ flex-direction: column;
2796
+ gap: 1px;
2797
+ }
2798
+ .connected-kicker {
2799
+ font-size: 10px;
2800
+ font-weight: 700;
2801
+ letter-spacing: .04em;
2802
+ text-transform: uppercase;
2803
+ color: var(--muted);
2804
+ }
2805
+ .connected-title {
2806
+ font-size: 14px;
2807
+ font-weight: 700;
2808
+ color: var(--text);
2809
+ }
2810
+ .connected-origin {
2811
+ min-width: 0;
2812
+ flex: 1;
2813
+ color: var(--muted);
2814
+ font-size: 12px;
2815
+ overflow: hidden;
2816
+ text-overflow: ellipsis;
2817
+ white-space: nowrap;
2818
+ }
2819
+ .connected-close {
2820
+ border: 1px solid var(--line);
2821
+ background: var(--surface);
2822
+ color: var(--text);
2823
+ border-radius: 8px;
2824
+ padding: 6px 12px;
2825
+ font-size: 13px;
2826
+ font-weight: 600;
2827
+ flex-shrink: 0;
2828
+ }
2829
+ .connected-close:hover { background: var(--bg); }
2830
+ .connected-frame {
2831
+ flex: 1;
2832
+ min-height: 0;
2833
+ width: 100%;
2834
+ border: 0;
2835
+ background: var(--surface);
2836
+ }
2837
+
2838
+ /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2839
+ .area-shell { display: flex; flex: 1; min-height: 0; }
2777
2840
  .area-rail { width: 244px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; padding: 6px 0; }
2778
2841
  .area-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
2779
2842
  .area-rail-head { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 14px 16px 6px; }