fraim-hub 2.0.268 → 2.0.269

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.
@@ -7,6 +7,7 @@ exports.summarizeProject = summarizeProject;
7
7
  exports.discoverEmployeeJobs = discoverEmployeeJobs;
8
8
  exports.discoverManagerTemplates = discoverManagerTemplates;
9
9
  exports.loadJobPhases = loadJobPhases;
10
+ exports.resolveJobPhaseTransition = resolveJobPhaseTransition;
10
11
  exports.loadAllJobPhaseIds = loadAllJobPhaseIds;
11
12
  exports.labelForPhaseId = labelForPhaseId;
12
13
  exports.getAiHubCategories = getAiHubCategories;
@@ -470,6 +471,19 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
470
471
  // this job before appending it to the tracker, so cross-job pollution
471
472
  // (agent calling seekMentoring for a different job mid-run) cannot
472
473
  // surface stages from elsewhere.
474
+ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discriminant = 'feature') {
475
+ const stubPath = findJobStubPath(projectPath, jobId);
476
+ if (!stubPath)
477
+ return null;
478
+ const fm = readJobFrontmatter(stubPath);
479
+ if (!fm || !fm.phases)
480
+ return null;
481
+ const phaseDef = fm.phases[phaseId];
482
+ if (!phaseDef)
483
+ return null;
484
+ const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
485
+ return nextPhase(edge, discriminant);
486
+ }
473
487
  function loadAllJobPhaseIds(jobId, projectPath) {
474
488
  const stubPath = findJobStubPath(projectPath, jobId);
475
489
  if (!stubPath)
@@ -36,6 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.DESKTOP_HUB_READY_TIMEOUT_ENV = void 0;
40
+ exports.resolveDesktopReadyTimeoutMs = resolveDesktopReadyTimeoutMs;
39
41
  exports.waitForDesktopHubReady = waitForDesktopHubReady;
40
42
  exports.runHub = runHub;
41
43
  // Hub-owned launcher used by the fraim-hub package. Keep this outside
@@ -207,9 +209,55 @@ function fetchRunningHubVersion(port) {
207
209
  req.on('timeout', () => { req.destroy(); resolve(null); });
208
210
  });
209
211
  }
212
+ // #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
213
+ // Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
214
+ // evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
215
+ // trust call, and finally the listens. The same path measured 3.3s on an idle machine and
216
+ // 20.5s on a real `npx fraim-hub@latest --restart` immediately after a release, when npm has
217
+ // just written a fresh Electron dist. A fixed budget inside that spread turns a healthy launch
218
+ // into a reported failure, which is what issue #1110 reports.
219
+ //
220
+ // The only positive evidence of a failed launch is the child dying, and that is handled
221
+ // separately below. A live child that has not answered yet is still starting, so the wait is
222
+ // long, says so out loud while it waits, and stays tunable for slower machines.
223
+ exports.DESKTOP_HUB_READY_TIMEOUT_ENV = 'FRAIM_HUB_READY_TIMEOUT_MS';
224
+ const DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS = 120000;
225
+ const DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS = 15000;
226
+ function resolveDesktopReadyTimeoutMs() {
227
+ const configured = Number(process.env[exports.DESKTOP_HUB_READY_TIMEOUT_ENV]);
228
+ if (Number.isFinite(configured) && configured > 0)
229
+ return configured;
230
+ return DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS;
231
+ }
232
+ // Whole seconds read best at the real cadence (15s, 30s, 120s), but rounding a sub-second
233
+ // duration to whole seconds prints "after 0s" or repeats "1s elapsed" on consecutive notices.
234
+ // Keep one decimal below this threshold so the number is never wrong at any budget.
235
+ const READY_SECONDS_DECIMAL_BELOW_MS = 10000;
236
+ function formatReadySeconds(durationMs) {
237
+ return durationMs >= READY_SECONDS_DECIMAL_BELOW_MS
238
+ ? `${Math.round(durationMs / 1000)}s`
239
+ : `${Math.round(durationMs / 100) / 10}s`;
240
+ }
241
+ function reportDesktopReadyProgress(progress) {
242
+ console.log(`Still waiting for the FRAIM Hub desktop shell on port ${progress.port} `
243
+ + `(${formatReadySeconds(progress.elapsedMs)} elapsed, giving up at ${formatReadySeconds(progress.timeoutMs)}). `
244
+ + 'A first launch after an upgrade unpacks Electron, so this can take a while.');
245
+ }
246
+ // Where the Hub could be answering: the shell records its chosen port in the runtime file once
247
+ // it is listening, which is the only way the launcher learns about a port other than the one it
248
+ // asked for. Re-read every poll, because that file is written mid-wait.
249
+ function readinessProbePorts(fraimDir, runtimeId, preferredPort) {
250
+ const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
251
+ return runtime?.port && runtime.port !== preferredPort
252
+ ? [runtime.port, preferredPort]
253
+ : [preferredPort];
254
+ }
210
255
  async function waitForDesktopHubReady(child, preferredPort, options = {}) {
211
- const timeoutMs = options.timeoutMs ?? 15000;
256
+ const timeoutMs = options.timeoutMs ?? resolveDesktopReadyTimeoutMs();
212
257
  const pollMs = options.pollMs ?? 250;
258
+ const progressAfterMs = options.progressAfterMs ?? DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS;
259
+ const progressIntervalMs = options.progressIntervalMs ?? progressAfterMs;
260
+ const onProgress = options.onProgress ?? reportDesktopReadyProgress;
213
261
  const runtimeId = options.runtimeId || 'hub';
214
262
  const fraimDir = options.fraimDir || (0, project_fraim_paths_1.getUserFraimDirPath)();
215
263
  const start = Date.now();
@@ -220,7 +268,10 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
220
268
  child.once('error', (error) => {
221
269
  childState.error = error;
222
270
  });
223
- while (Date.now() - start < timeoutMs) {
271
+ // A dead child is the only positive evidence that the launch failed, so it always wins over
272
+ // the elapsed budget. Checked at the top of every poll and once more after the loop, so a
273
+ // child that dies during the final sleep is still reported as an exit and not as a timeout.
274
+ const failIfChildDied = () => {
224
275
  if (childState.error) {
225
276
  throw new Error(`FRAIM Hub desktop shell failed to launch: ${childState.error.message}`);
226
277
  }
@@ -228,19 +279,30 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
228
279
  const detail = childState.exit.signal ? `signal ${childState.exit.signal}` : `exit code ${childState.exit.code ?? 'unknown'}`;
229
280
  throw new Error(`FRAIM Hub desktop shell exited before the Hub became ready (${detail}).`);
230
281
  }
231
- const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
232
- const ports = runtime?.port && runtime.port !== preferredPort
233
- ? [runtime.port, preferredPort]
234
- : [preferredPort];
235
- for (const port of ports) {
282
+ };
283
+ let nextProgressAtMs = progressAfterMs;
284
+ while (Date.now() - start < timeoutMs) {
285
+ failIfChildDied();
286
+ for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
236
287
  const version = await fetchRunningHubVersion(port);
237
288
  if (version) {
238
289
  return { port, version };
239
290
  }
240
291
  }
292
+ const elapsedMs = Date.now() - start;
293
+ if (elapsedMs >= nextProgressAtMs) {
294
+ onProgress({ elapsedMs, timeoutMs, port: preferredPort });
295
+ nextProgressAtMs = elapsedMs + progressIntervalMs;
296
+ }
241
297
  await new Promise((r) => setTimeout(r, pollMs));
242
298
  }
243
- throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort}.`);
299
+ failIfChildDied();
300
+ const stillRunning = typeof child.pid === 'number'
301
+ ? ` The desktop shell (pid ${child.pid}) is still running and may finish starting on its own.`
302
+ : '';
303
+ throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort} after ${formatReadySeconds(timeoutMs)}.`
304
+ + `${stillRunning}`
305
+ + ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
244
306
  }
245
307
  async function reconcileRunningHub(flags, runtimeId = 'hub') {
246
308
  const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
@@ -5,7 +5,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.launchDesktopShell = launchDesktopShell;
7
7
  const electron_1 = require("electron");
8
- const electron_updater_1 = require("electron-updater");
9
8
  const path_1 = __importDefault(require("path"));
10
9
  const fs_1 = __importDefault(require("fs"));
11
10
  const server_1 = require("./server");
@@ -91,8 +90,14 @@ function ensureLoginItem() {
91
90
  function configureAutoUpdater() {
92
91
  if (!electron_1.app.isPackaged)
93
92
  return;
94
- electron_updater_1.autoUpdater.autoDownload = true;
95
- electron_updater_1.autoUpdater.checkForUpdatesAndNotify().catch((err) => {
93
+ // #1110: electron-updater compiles ~114 files (js-yaml, builder-util-runtime, ...) that a
94
+ // non-packaged `npx fraim-hub` launch never uses, and this whole function returns early
95
+ // there. Requiring it lazily keeps those file reads off the cold-start path, which is what
96
+ // dominates time-to-ready on a freshly unpacked install.
97
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
98
+ const { autoUpdater } = require('electron-updater');
99
+ autoUpdater.autoDownload = true;
100
+ autoUpdater.checkForUpdatesAndNotify().catch((err) => {
96
101
  console.warn('[fraim] auto-update check failed:', err);
97
102
  });
98
103
  }
@@ -329,13 +334,10 @@ async function launchDesktopShell(options) {
329
334
  const httpsPort = await (0, server_1.findAvailablePortExcluding)(43092, new Set([httpPort]));
330
335
  // Generate (or load cached) self-signed cert for HTTPS.
331
336
  // Fast on subsequent launches (file read); ~200ms on first launch (key gen).
337
+ // The cert bundle itself is needed here because server.start() binds the HTTPS listener with
338
+ // it. Trusting it in the OS store is a separate, Word-Online-only concern and happens after
339
+ // the Hub is serving (#1110) - see below.
332
340
  const certBundle = await (0, cert_store_1.loadOrCreateCert)();
333
- try {
334
- (0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
335
- }
336
- catch (err) {
337
- console.warn('[fraim] could not trust localhost certificate:', err);
338
- }
339
341
  server = new server_1.AiHubServer({
340
342
  ...(options.projectPath ? { projectPath: options.projectPath } : {}),
341
343
  // Issue #701: no local DB. Persona/manager-team state resolves through the hosted
@@ -366,6 +368,16 @@ async function launchDesktopShell(options) {
366
368
  catch (err) {
367
369
  console.warn('[fraim] could not write hub runtime file:', err);
368
370
  }
371
+ // #1110: trusting the loopback cert in the OS store is a blocking `certutil` subprocess and
372
+ // is only needed so Word *Online* will render the task pane over HTTPS. The Hub does not need
373
+ // it to serve, so it runs after the server is listening rather than in front of it. Grouped
374
+ // with the Office sideload because both are Word-only first-run housekeeping.
375
+ try {
376
+ (0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
377
+ }
378
+ catch (err) {
379
+ console.warn('[fraim] could not trust localhost certificate:', err);
380
+ }
369
381
  ensureWordSideload(resolvedProjectPath, httpsPort);
370
382
  const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
371
383
  createTray(hubUrl, runtimeId);
@@ -33,7 +33,6 @@ const os_1 = __importDefault(require("os"));
33
33
  const path_1 = __importDefault(require("path"));
34
34
  const manager_turns_1 = require("./manager-turns");
35
35
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
36
- const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
37
36
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
38
37
  const configured_agents_1 = require("./configured-agents");
39
38
  const pack_home_1 = require("../cli/utils/pack-home");
@@ -1155,7 +1154,13 @@ function prepareCodexBrowserHome(cdp, env = process.env) {
1155
1154
  const realConfig = path_1.default.join(real, 'config.toml');
1156
1155
  const existing = fs_1.default.existsSync(realConfig) ? fs_1.default.readFileSync(realConfig, 'utf8') : '';
1157
1156
  const pwBlock = `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "${cdp}"]\n`;
1158
- const merged = (0, mcp_config_generator_1.mergeTomlMCPServers)(existing, pwBlock, ['playwright']).content;
1157
+ // #1110: this is the only use of mcp-config-generator here, and it runs when a run launches,
1158
+ // never at Hub startup. Importing it at module scope pulled the whole
1159
+ // mcp-server-builder -> mcp-server-registry -> provider-registry -> provider-client -> axios
1160
+ // chain (~30 files) into the graph the Hub must compile before it can listen.
1161
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1162
+ const { mergeTomlMCPServers } = require('../cli/setup/mcp-config-generator');
1163
+ const merged = mergeTomlMCPServers(existing, pwBlock, ['playwright']).content;
1159
1164
  fs_1.default.writeFileSync(path_1.default.join(home, 'config.toml'), merged, 'utf8');
1160
1165
  // Auth + the session index (so resume can find existing rollouts by thread id).
1161
1166
  for (const f of ['auth.json', 'session_index.jsonl', 'history.jsonl']) {
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.HttpHubRemoteGateway = void 0;
7
4
  exports.resolveFraimRemoteUrl = resolveFraimRemoteUrl;
8
- const axios_1 = __importDefault(require("axios"));
9
5
  function resolveFraimRemoteUrl(explicit) {
10
6
  return (explicit || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me').replace(/\/+$/, '');
11
7
  }
@@ -18,7 +14,12 @@ class HttpHubRemoteGateway {
18
14
  this.baseURL = resolveFraimRemoteUrl(serverUrl);
19
15
  }
20
16
  client(apiKey) {
21
- return axios_1.default.create({
17
+ // #1110: the Hub server module graph is loaded before the local server can listen, and the
18
+ // axios subtree is ~28 files of that graph. Nothing here runs during startup - every caller
19
+ // is a request handler - so axios is required on first use instead of at import time.
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const axios = require('axios');
22
+ return axios.create({
22
23
  baseURL: this.baseURL,
23
24
  headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
24
25
  timeout: 10000,
@@ -68,13 +68,21 @@ const remote_hub_gateway_1 = require("./remote-hub-gateway");
68
68
  const managed_browser_1 = require("./managed-browser");
69
69
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
70
70
  const user_config_1 = require("../cli/utils/user-config");
71
- const org_publish_1 = require("../cli/utils/org-publish");
72
71
  const version_utils_1 = require("../cli/utils/version-utils");
73
72
  const hub_latest_version_1 = require("./hub-latest-version");
74
73
  const ui_runtime_1 = require("./ui-runtime");
75
74
  const ui_cache_1 = require("./ui-cache");
76
- const semver = __importStar(require("semver"));
77
75
  const tree_kill_1 = __importDefault(require("tree-kill"));
76
+ // #1110: semver compiles 45 files and this module graph must load before the Hub can listen,
77
+ // so it is required on use rather than at import time. `latest` is null until the best-effort
78
+ // npm registry lookup succeeds, and on that path semver is never needed at all.
79
+ function isNewerPublishedVersion(latest, current) {
80
+ if (!latest)
81
+ return false;
82
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
83
+ const semver = require('semver');
84
+ return !!(semver.valid(current) && semver.valid(latest) && semver.gt(latest, current));
85
+ }
78
86
  const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
79
87
  let personaHiringModule;
80
88
  let managerHiringModule;
@@ -956,6 +964,28 @@ function normalizePersistedPhaseHistory(value) {
956
964
  })
957
965
  .filter((entry) => Boolean(entry));
958
966
  }
967
+ function normalizePersistedPhaseVisits(value) {
968
+ if (!Array.isArray(value))
969
+ return [];
970
+ return value
971
+ .map((entry) => {
972
+ if (!entry || typeof entry !== 'object')
973
+ return null;
974
+ const raw = entry;
975
+ if (typeof raw.phaseId !== 'string' || !raw.phaseId)
976
+ return null;
977
+ const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
978
+ ? raw.latestStatus
979
+ : null;
980
+ return {
981
+ phaseId: raw.phaseId,
982
+ enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
983
+ latestStatus,
984
+ latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
985
+ };
986
+ })
987
+ .filter((entry) => Boolean(entry));
988
+ }
959
989
  function readPersistedRunProjection(conversation) {
960
990
  const rawRun = conversation?.run;
961
991
  if (!rawRun || typeof rawRun !== 'object')
@@ -964,6 +994,7 @@ function readPersistedRunProjection(conversation) {
964
994
  return {
965
995
  currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
966
996
  phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
997
+ phaseVisits: normalizePersistedPhaseVisits(value.phaseVisits),
967
998
  stages: Array.isArray(value.stages) ? value.stages : [],
968
999
  totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
969
1000
  runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
@@ -1140,6 +1171,28 @@ function applyAgentIdentitySignal(run, identity) {
1140
1171
  run.agentName = identity.agentName;
1141
1172
  run.agentModel = identity.agentModel;
1142
1173
  }
1174
+ function recordPhaseVisit(run, phaseId, latestStatus, latestText = null) {
1175
+ run.phaseVisits = run.phaseVisits || [];
1176
+ const last = run.phaseVisits[run.phaseVisits.length - 1];
1177
+ if (last && last.phaseId === phaseId) {
1178
+ last.latestStatus = latestStatus;
1179
+ if (latestText)
1180
+ last.latestText = latestText;
1181
+ return;
1182
+ }
1183
+ run.phaseVisits.push({
1184
+ phaseId,
1185
+ enteredAt: new Date().toISOString(),
1186
+ latestStatus,
1187
+ latestText,
1188
+ });
1189
+ }
1190
+ function appendSyntheticPhaseVisit(run, phaseId) {
1191
+ const last = run.phaseVisits?.[run.phaseVisits.length - 1];
1192
+ if (last?.phaseId === phaseId)
1193
+ return;
1194
+ recordPhaseVisit(run, phaseId, 'starting', null);
1195
+ }
1143
1196
  function stripStructuredHostPayloads(text) {
1144
1197
  return text
1145
1198
  .replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
@@ -1256,7 +1309,25 @@ function applySeekMentoringSignal(run, signal) {
1256
1309
  };
1257
1310
  run.phaseHistory.push(entry);
1258
1311
  }
1312
+ recordPhaseVisit(run, signal.phaseId, signal.phaseStatus, signal.findingsText || null);
1259
1313
  run.currentPhase = signal.phaseId;
1314
+ if (signal.phaseStatus === 'failure') {
1315
+ const nextPhase = (0, catalog_1.resolveJobPhaseTransition)(run.jobId, run.projectPath, signal.phaseId, 'failure', run.runDiscriminant || 'feature');
1316
+ if (nextPhase) {
1317
+ appendSyntheticPhaseVisit(run, nextPhase);
1318
+ run.currentPhase = nextPhase;
1319
+ }
1320
+ }
1321
+ }
1322
+ function phaseVisitsForProjection(run) {
1323
+ if (Array.isArray(run.phaseVisits) && run.phaseVisits.length > 0)
1324
+ return run.phaseVisits;
1325
+ return (run.phaseHistory || []).map((entry) => ({
1326
+ phaseId: entry.phaseId,
1327
+ enteredAt: entry.enteredAt,
1328
+ latestStatus: entry.latestStatus,
1329
+ latestText: entry.latestText,
1330
+ }));
1260
1331
  }
1261
1332
  // Build the stage list for a run. Combines the FSM's reachable path with
1262
1333
  // any phases the run has actually visited (in case the run took an
@@ -1275,7 +1346,8 @@ function deriveStages(run, projectPath) {
1275
1346
  // phase id NOT in the frontmatter — that last filter prevents
1276
1347
  // cross-job pollution from showing up on the tracker.
1277
1348
  const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
1278
- const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
1349
+ const visits = phaseVisitsForProjection(run);
1350
+ const visited = visits.map((entry) => entry.phaseId);
1279
1351
  const known = new Set(declaredPath.map((p) => p.id));
1280
1352
  for (const visitedId of visited) {
1281
1353
  if (visitedId === 'starting' || visitedId === '__discriminant__')
@@ -1291,29 +1363,51 @@ function deriveStages(run, projectPath) {
1291
1363
  ? declaredPath.findIndex((p) => p.id === run.currentPhase)
1292
1364
  : -1;
1293
1365
  const historyMap = new Map((run.phaseHistory || []).map((e) => [e.phaseId, e]));
1366
+ const visitsByPhase = new Map();
1367
+ for (const visit of visits) {
1368
+ const existing = visitsByPhase.get(visit.phaseId) || [];
1369
+ existing.push(visit);
1370
+ visitsByPhase.set(visit.phaseId, existing);
1371
+ }
1294
1372
  const completedWithoutPhaseTelemetry = run.status === 'completed' &&
1295
1373
  currentIndex < 0 &&
1296
- historyMap.size === 0;
1374
+ historyMap.size === 0 &&
1375
+ visits.length === 0;
1297
1376
  if (completedWithoutPhaseTelemetry) {
1298
1377
  return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
1299
1378
  }
1300
1379
  return declaredPath.map((phase, index) => {
1301
1380
  let state;
1302
1381
  const entry = historyMap.get(phase.id);
1382
+ const phaseVisits = visitsByPhase.get(phase.id) || [];
1383
+ const latestVisit = phaseVisits[phaseVisits.length - 1] || null;
1384
+ const latestStatus = latestVisit?.latestStatus ?? entry?.latestStatus ?? null;
1385
+ const latestText = latestVisit?.latestText ?? entry?.latestText ?? null;
1303
1386
  if (index === currentIndex) {
1304
1387
  // If the agent has already reported this phase as 'complete', advance
1305
1388
  // its visual state to 'done' so the tracker doesn't look frozen while
1306
1389
  // waiting for the agent to start the next phase (e.g. after
1307
1390
  // implement-submission completes but before address-feedback starts).
1308
- state = entry?.latestStatus === 'complete' ? 'done' : 'current';
1391
+ state = latestStatus === 'complete' ? 'done' : 'current';
1309
1392
  }
1310
- else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
1393
+ else if (latestStatus === 'complete' ||
1394
+ latestStatus === 'failure' ||
1395
+ latestStatus === 'incomplete' ||
1396
+ (currentIndex >= 0 && index < currentIndex && phaseVisits.length > 0)) {
1311
1397
  state = 'done';
1312
1398
  }
1313
1399
  else {
1314
1400
  state = 'upcoming';
1315
1401
  }
1316
- return { phaseId: phase.id, label: phase.label, state };
1402
+ return {
1403
+ phaseId: phase.id,
1404
+ label: phase.label,
1405
+ state,
1406
+ visitCount: phaseVisits.length,
1407
+ currentVisit: phase.id === run.currentPhase && phaseVisits.length > 0 ? phaseVisits.length : null,
1408
+ latestStatus,
1409
+ latestText,
1410
+ };
1317
1411
  });
1318
1412
  }
1319
1413
  // ---------------------------------------------------------------------------
@@ -2522,6 +2616,7 @@ class AiHubServer {
2522
2616
  stages,
2523
2617
  currentPhase: run.currentPhase || null,
2524
2618
  phaseHistory: run.phaseHistory || [],
2619
+ phaseVisits: run.phaseVisits || [],
2525
2620
  totals: run.totals || null,
2526
2621
  runDiscriminant: run.runDiscriminant || null,
2527
2622
  },
@@ -2791,6 +2886,7 @@ class AiHubServer {
2791
2886
  ],
2792
2887
  currentPhase: persistedRun?.currentPhase || null,
2793
2888
  phaseHistory: persistedRun?.phaseHistory || [],
2889
+ phaseVisits: persistedRun?.phaseVisits || [],
2794
2890
  totals: persistedRun?.totals || emptyTotals(),
2795
2891
  runDiscriminant: persistedRun?.runDiscriminant || undefined,
2796
2892
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
@@ -3148,6 +3244,7 @@ class AiHubServer {
3148
3244
  events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
3149
3245
  currentPhase: null,
3150
3246
  phaseHistory: [],
3247
+ phaseVisits: [],
3151
3248
  totals: emptyTotals(),
3152
3249
  lastStatusChangeAt: now,
3153
3250
  personaKey: task.personaKey,
@@ -4151,6 +4248,7 @@ class AiHubServer {
4151
4248
  ],
4152
4249
  currentPhase: persistedRun?.currentPhase || null,
4153
4250
  phaseHistory: persistedRun?.phaseHistory || [],
4251
+ phaseVisits: persistedRun?.phaseVisits || [],
4154
4252
  stages: persistedRun?.stages || [],
4155
4253
  totals: persistedRun?.totals || emptyTotals(),
4156
4254
  lastStatusChangeAt: now,
@@ -4423,7 +4521,7 @@ class AiHubServer {
4423
4521
  this.app.get('/api/ai-hub/version', async (_req, res) => {
4424
4522
  const version = (0, version_utils_1.getFraimVersion)();
4425
4523
  const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
4426
- const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
4524
+ const updateAvailable = isNewerPublishedVersion(latest, version);
4427
4525
  const uiRuntime = await this.resolveHubUiRuntimeResponse();
4428
4526
  return res.json({
4429
4527
  version,
@@ -4588,7 +4686,12 @@ class AiHubServer {
4588
4686
  return res.json({ ok: true, brand: stored, published: false });
4589
4687
  }
4590
4688
  try {
4591
- await (0, org_publish_1.publishOrgArtifacts)([{
4689
+ // #1110: org-publish drags in pack-git-publish and the whole axios subtree (~28 files)
4690
+ // that the Hub must otherwise compile before it can listen. Only this one brand-publish
4691
+ // handler needs it, so it is required on use rather than at import time.
4692
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4693
+ const { publishOrgArtifacts } = require('../cli/utils/org-publish');
4694
+ await publishOrgArtifacts([{
4592
4695
  relativePath: 'context/org_brand.json',
4593
4696
  content: JSON.stringify(stored, null, 2),
4594
4697
  }]);
@@ -4857,6 +4960,7 @@ class AiHubServer {
4857
4960
  // Issue #347 — seed phase + totals state on creation.
4858
4961
  currentPhase: null,
4859
4962
  phaseHistory: [],
4963
+ phaseVisits: [],
4860
4964
  totals: emptyTotals(),
4861
4965
  lastStatusChangeAt: startTimestamp,
4862
4966
  personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
@@ -5288,6 +5392,7 @@ class AiHubServer {
5288
5392
  // resuming the session starts a fresh job, so the tracker must be blank.
5289
5393
  currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
5290
5394
  phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
5395
+ phaseVisits: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseVisits || []) : [],
5291
5396
  totals: persistedRun?.totals || emptyTotals(),
5292
5397
  lastStatusChangeAt: now,
5293
5398
  runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
@@ -5965,6 +6070,7 @@ class AiHubServer {
5965
6070
  events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${configuredAgent.label} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
5966
6071
  currentPhase: null,
5967
6072
  phaseHistory: [],
6073
+ phaseVisits: [],
5968
6074
  totals: emptyTotals(),
5969
6075
  lastStatusChangeAt: startTimestamp,
5970
6076
  personaKey: getHubPersonaForJob(jobName),
@@ -6116,6 +6222,7 @@ class AiHubServer {
6116
6222
  events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type}) using ${configuredAgent.label}`)],
6117
6223
  currentPhase: null,
6118
6224
  phaseHistory: [],
6225
+ phaseVisits: [],
6119
6226
  totals: emptyTotals(),
6120
6227
  lastStatusChangeAt: startTimestamp,
6121
6228
  personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
@@ -41,7 +8,9 @@ exports.evaluateHubUiManifestCompatibility = evaluateHubUiManifestCompatibility;
41
8
  exports.resolveHubUiRuntime = resolveHubUiRuntime;
42
9
  const fs_1 = __importDefault(require("fs"));
43
10
  const path_1 = __importDefault(require("path"));
44
- const semver = __importStar(require("semver"));
11
+ // #1110: semver compiles 45 files. This module is on the AI Hub server's import graph, which
12
+ // must load before the Hub can listen, and the only use below runs when a UI release manifest
13
+ // is evaluated - never during startup. Required on use to keep it off the cold-start path.
45
14
  const version_utils_1 = require("../cli/utils/version-utils");
46
15
  const ui_cache_1 = require("./ui-cache");
47
16
  const ui_manifest_client_1 = require("./ui-manifest-client");
@@ -72,6 +41,8 @@ function evaluateHubUiManifestCompatibility(manifest, bridge) {
72
41
  if (manifest.schemaVersion !== 1) {
73
42
  return { compatible: false, updateRequired: true, reason: `schema-version:${manifest.schemaVersion}` };
74
43
  }
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const semver = require('semver');
75
46
  if (semver.valid(manifest.minBridgeVersion) && semver.valid(bridge.bridgeVersion) && semver.lt(bridge.bridgeVersion, manifest.minBridgeVersion)) {
76
47
  return { compatible: false, updateRequired: true, reason: `min-bridge-version:${manifest.minBridgeVersion}` };
77
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.268",
3
+ "version": "2.0.269",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -163,7 +163,7 @@
163
163
  "electron": "^41.2.2",
164
164
  "electron-updater": "^6.8.9",
165
165
  "express": "^5.2.1",
166
- "fraim": "2.0.268",
166
+ "fraim": "2.0.269",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -760,19 +760,19 @@ function ensureTaskPaneLauncher() {
760
760
  root.appendChild(actions);
761
761
 
762
762
  document.body.insertBefore(root, document.body.firstChild);
763
- document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
764
- const statusEl = document.getElementById('task-pane-start-status');
765
- const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
766
- if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
767
- });
768
- document.getElementById('task-pane-employee-select')?.addEventListener('change', (event) => {
769
- const value = event.target && event.target.value;
770
- if (value && hubConfiguredAgents().some((agent) => agent.id === value)) {
771
- state.selectedEmployeeId = value;
772
- }
773
- });
774
- return root;
775
- }
763
+ document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
764
+ const statusEl = document.getElementById('task-pane-start-status');
765
+ const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
766
+ if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
767
+ });
768
+ document.getElementById('task-pane-employee-select')?.addEventListener('change', (event) => {
769
+ const value = event.target && event.target.value;
770
+ if (value && hubConfiguredAgents().some((agent) => agent.id === value)) {
771
+ state.selectedEmployeeId = value;
772
+ }
773
+ });
774
+ return root;
775
+ }
776
776
 
777
777
  function renderTaskPaneLauncher() {
778
778
  if (!isTaskPaneSurface() || !state.bootstrap) return;
@@ -857,23 +857,23 @@ async function taskPaneStartSelectedJob() {
857
857
  if (start) start.disabled = true;
858
858
  if (statusEl) statusEl.textContent = 'Starting...';
859
859
  try {
860
- const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
861
- if (result && result.ok) {
862
- taskPaneLauncherForcedOpen = false;
863
- if (statusEl) statusEl.textContent = 'Started.';
864
- renderActive();
865
- syncTaskPaneSurfaceMode();
866
- } else if (statusEl) {
867
- statusEl.textContent = (result && result.error) || 'Could not start.';
868
- }
860
+ const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
861
+ if (result && result.ok) {
862
+ taskPaneLauncherForcedOpen = false;
863
+ if (statusEl) statusEl.textContent = 'Started.';
864
+ renderActive();
865
+ syncTaskPaneSurfaceMode();
866
+ } else if (statusEl) {
867
+ statusEl.textContent = (result && result.error) || 'Could not start.';
868
+ }
869
869
  } catch (error) {
870
870
  if (statusEl) statusEl.textContent = 'Could not start.';
871
871
  console.warn('[ai-hub] task-pane start failed:', error);
872
- } finally {
873
- renderTaskPaneLauncher();
874
- syncTaskPaneSurfaceMode();
875
- }
876
- }
872
+ } finally {
873
+ renderTaskPaneLauncher();
874
+ syncTaskPaneSurfaceMode();
875
+ }
876
+ }
877
877
 
878
878
  // ---------------------------------------------------------------------------
879
879
  // Concept popover population (drives the welcome-line "see ... jobs" lists)
@@ -1037,18 +1037,18 @@ function normalizeGeminiConversationMessages(conv) {
1037
1037
  // request 413s, the error is swallowed, and the client-owned fields in the SAME
1038
1038
  // payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
1039
1039
  // here; the server's PUT handler merges them back from the stored record.
1040
- const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1041
- // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1042
- // body, which is a different claim from "the record has body fields". A run can
1043
- // legitimately finish with no messages, events, artifacts, run, or delegation;
1040
+ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1041
+ // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1042
+ // body, which is a different claim from "the record has body fields". A run can
1043
+ // legitimately finish with no messages, events, artifacts, run, or delegation;
1044
1044
  // before this marker existed such a body was indistinguishable from one that had
1045
1045
  // never been hydrated, so the transcript hung on "Loading conversation…" and
1046
1046
  // re-fetched on every poll tick forever.
1047
1047
  //
1048
- // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1049
- // any inbound value. That is what preserves issue #913: a marker arriving over
1050
- // the wire is never trusted, only one this client set after a real fetch.
1051
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1048
+ // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1049
+ // any inbound value. That is what preserves issue #913: a marker arriving over
1050
+ // the wire is never trusted, only one this client set after a real fetch.
1051
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1052
1052
  function slimConversationForPersist(conv) {
1053
1053
  if (!conv || typeof conv !== 'object') return conv;
1054
1054
  const slim = { ...conv };
@@ -3914,7 +3914,7 @@ function renderTracker(conv) {
3914
3914
  const rowCapacity = trackerRowCapacity(tracker, stages);
3915
3915
 
3916
3916
  // Cache key so we only rebuild the DOM when the stage data changes.
3917
- const key = stages.map((s) => `${s.phaseId}:${s.state}`).join('|') +
3917
+ const key = stages.map((s) => `${s.phaseId}:${s.state}:${s.visitCount || 0}:${s.currentVisit || 0}:${s.latestStatus || ''}`).join('|') +
3918
3918
  '#' + (conv.run.currentPhase || '') +
3919
3919
  '#' + rowCapacity;
3920
3920
  if (key === renderedTrackerKey) {
@@ -3936,7 +3936,9 @@ function renderTracker(conv) {
3936
3936
  rowsHost.parentNode.insertBefore(activeLabelHost, els['tracker-note']);
3937
3937
  }
3938
3938
  const currentStage = stages.find((s) => s.state === 'current');
3939
- activeLabelHost.textContent = currentStage ? currentStage.label : '';
3939
+ activeLabelHost.textContent = currentStage
3940
+ ? `${currentStage.label}${currentStage.currentVisit && currentStage.currentVisit > 1 ? ` - visit ${currentStage.currentVisit}` : ''}`
3941
+ : '';
3940
3942
  // The pizza tracker is always a single horizontal row. When the pane is too
3941
3943
  // narrow to fit every stage label, collapse to circles + the active-stage
3942
3944
  // label (CSS .tracker-compact) instead of wrapping onto stacked rows.
@@ -3948,12 +3950,21 @@ function renderTracker(conv) {
3948
3950
  const stageEl = document.createElement('div');
3949
3951
  stageEl.className = 'stage ' + stage.state;
3950
3952
  const tooltipText = buildStageTooltip(conv, stage);
3951
- if (tooltipText) stageEl.setAttribute('title', tooltipText);
3953
+ if (tooltipText) {
3954
+ stageEl.setAttribute('title', tooltipText);
3955
+ stageEl.setAttribute('aria-label', tooltipText);
3956
+ }
3952
3957
  const circle = document.createElement('span');
3953
3958
  circle.className = 'stage-circle';
3954
3959
  if (stage.state === 'done') circle.textContent = '✓';
3955
3960
  else if (stage.state === 'current') circle.textContent = '●';
3956
3961
  else circle.textContent = String(index + 1);
3962
+ if (stage.visitCount && stage.visitCount > 1) {
3963
+ const badge = document.createElement('span');
3964
+ badge.className = 'stage-visit-badge';
3965
+ badge.textContent = `x${stage.visitCount}`;
3966
+ circle.appendChild(badge);
3967
+ }
3957
3968
  const label = document.createElement('span');
3958
3969
  label.className = 'stage-label';
3959
3970
  label.textContent = stage.label;
@@ -3967,6 +3978,11 @@ function renderTracker(conv) {
3967
3978
  function buildStageTooltip(conv, stage) {
3968
3979
  const history = (conv.run && conv.run.phaseHistory) || [];
3969
3980
  const entry = history.find((h) => h.phaseId === stage.phaseId);
3981
+ const details = [stage.phaseId];
3982
+ if (stage.visitCount && stage.visitCount > 1) details.push(`visit ${stage.currentVisit || stage.visitCount} of ${stage.visitCount}`);
3983
+ if (stage.latestStatus) details.push(stage.latestStatus);
3984
+ if (stage.latestText) details.push(stage.latestText);
3985
+ if (details.length > 1) return details.join(' - ');
3970
3986
  const findings = entry && entry.latestText;
3971
3987
  if (findings) return `${stage.phaseId} · ${findings}`;
3972
3988
  return stage.phaseId;
@@ -7696,6 +7712,7 @@ function foldRunIntoConversation(conv, run) {
7696
7712
  stages: run.stages || [],
7697
7713
  currentPhase: run.currentPhase || null,
7698
7714
  phaseHistory: run.phaseHistory || [],
7715
+ phaseVisits: run.phaseVisits || [],
7699
7716
  totals: run.totals || null,
7700
7717
  runDiscriminant: run.runDiscriminant || null,
7701
7718
  };
@@ -2380,6 +2380,26 @@ button.small { padding: 4px 10px; font-size: 12px; }
2380
2380
  color: #fff;
2381
2381
  box-shadow: 0 0 0 4px rgba(176, 132, 66, 0.15);
2382
2382
  }
2383
+ .tracker .stage-visit-badge {
2384
+ position: absolute;
2385
+ right: -13px;
2386
+ top: -10px;
2387
+ min-width: 18px;
2388
+ height: 16px;
2389
+ padding: 0 4px;
2390
+ border-radius: 999px;
2391
+ display: inline-flex;
2392
+ align-items: center;
2393
+ justify-content: center;
2394
+ border: 1px solid var(--surface);
2395
+ background: var(--text);
2396
+ color: var(--surface);
2397
+ font-size: 9px;
2398
+ font-weight: 800;
2399
+ line-height: 1;
2400
+ z-index: 3;
2401
+ box-sizing: border-box;
2402
+ }
2383
2403
  .tracker .stage-label {
2384
2404
  font-size: 10px;
2385
2405
  color: var(--muted);
@@ -2426,6 +2446,13 @@ button.small { padding: 4px 10px; font-size: 12px; }
2426
2446
  border-width: 1px;
2427
2447
  font-size: 10px;
2428
2448
  }
2449
+ .tracker .stage-visit-badge {
2450
+ right: -12px;
2451
+ top: -9px;
2452
+ min-width: 17px;
2453
+ height: 15px;
2454
+ font-size: 8px;
2455
+ }
2429
2456
  .tracker .stage::before { top: 10px; }
2430
2457
  .tracker-active-label { display: block; }
2431
2458
  }
@@ -2866,27 +2893,27 @@ button.small { padding: 4px 10px; font-size: 12px; }
2866
2893
  body:is([data-surface="task-pane"],[data-surface="extension"]). No media queries — this is a surface
2867
2894
  flag, not a viewport breakpoint. */
2868
2895
 
2869
- body:is([data-surface="task-pane"],[data-surface="extension"]) {
2870
- font-size: 13px;
2871
- overflow: auto;
2872
- background: var(--bg);
2873
- color: var(--text);
2874
- }
2896
+ body:is([data-surface="task-pane"],[data-surface="extension"]) {
2897
+ font-size: 13px;
2898
+ overflow: auto;
2899
+ background: var(--bg);
2900
+ color: var(--text);
2901
+ }
2875
2902
  body:is([data-surface="task-pane"],[data-surface="extension"]) .hub-tabs,
2876
2903
  body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .hub-area,
2877
2904
  body:is([data-surface="task-pane"],[data-surface="extension"]) .proj-tabs {
2878
2905
  display: none !important;
2879
2906
  }
2880
2907
  .task-pane-launcher { display: none; }
2881
- body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher {
2882
- display: flex;
2883
- flex-direction: column;
2884
- gap: 10px;
2885
- padding: 12px;
2886
- border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
2887
- background: var(--surface, #fff);
2888
- color: var(--text, #171717);
2889
- }
2908
+ body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher {
2909
+ display: flex;
2910
+ flex-direction: column;
2911
+ gap: 10px;
2912
+ padding: 12px;
2913
+ border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
2914
+ background: var(--surface, #fff);
2915
+ color: var(--text, #171717);
2916
+ }
2890
2917
  body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active #area-projects {
2891
2918
  display: flex !important;
2892
2919
  min-height: 100vh;
@@ -2925,11 +2952,11 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2925
2952
  font-weight: 650;
2926
2953
  color: var(--text, #171717);
2927
2954
  }
2928
- .tp-launcher-context,
2929
- .tp-launcher-status {
2930
- min-height: 16px;
2931
- font-size: 11px;
2932
- color: var(--muted);
2955
+ .tp-launcher-context,
2956
+ .tp-launcher-status {
2957
+ min-height: 16px;
2958
+ font-size: 11px;
2959
+ color: var(--muted);
2933
2960
  overflow: hidden;
2934
2961
  text-overflow: ellipsis;
2935
2962
  white-space: nowrap;
@@ -2943,27 +2970,27 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2943
2970
  font-weight: 600;
2944
2971
  color: var(--muted);
2945
2972
  }
2946
- .tp-launcher-field select,
2947
- .tp-launcher-field textarea {
2948
- width: 100%;
2949
- min-width: 0;
2950
- border: 1px solid var(--line, rgba(0,0,0,0.12));
2951
- border-radius: 8px;
2952
- background: var(--surface, #fff);
2953
- color: var(--text, #171717);
2954
- font: inherit;
2955
- font-size: 12px;
2956
- }
2957
- .tp-launcher-field select:focus,
2958
- .tp-launcher-field textarea:focus {
2959
- outline: 2px solid var(--accent, #0071e3);
2960
- outline-offset: 1px;
2961
- border-color: var(--accent, #0071e3);
2962
- }
2963
- .tp-launcher-field textarea::placeholder {
2964
- color: var(--muted);
2965
- opacity: 1;
2966
- }
2973
+ .tp-launcher-field select,
2974
+ .tp-launcher-field textarea {
2975
+ width: 100%;
2976
+ min-width: 0;
2977
+ border: 1px solid var(--line, rgba(0,0,0,0.12));
2978
+ border-radius: 8px;
2979
+ background: var(--surface, #fff);
2980
+ color: var(--text, #171717);
2981
+ font: inherit;
2982
+ font-size: 12px;
2983
+ }
2984
+ .tp-launcher-field select:focus,
2985
+ .tp-launcher-field textarea:focus {
2986
+ outline: 2px solid var(--accent, #0071e3);
2987
+ outline-offset: 1px;
2988
+ border-color: var(--accent, #0071e3);
2989
+ }
2990
+ .tp-launcher-field textarea::placeholder {
2991
+ color: var(--muted);
2992
+ opacity: 1;
2993
+ }
2967
2994
  .tp-launcher-field select {
2968
2995
  height: 34px;
2969
2996
  padding: 0 8px;
@@ -2974,98 +3001,98 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2974
3001
  padding: 8px;
2975
3002
  line-height: 1.35;
2976
3003
  }
2977
- .tp-launcher-actions {
2978
- display: grid;
2979
- grid-template-columns: minmax(0, 1fr) auto auto;
2980
- align-items: center;
2981
- gap: 8px;
2982
- }
2983
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-start-job,
2984
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job {
2985
- min-height: 34px;
2986
- padding: 7px 12px;
2987
- border-radius: 8px;
2988
- font-size: 12px;
2989
- }
2990
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job[hidden] {
2991
- display: none;
2992
- }
2993
- .task-pane-run-nav {
2994
- display: none;
2995
- }
2996
- body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
2997
- display: flex;
2998
- align-items: center;
2999
- justify-content: space-between;
3000
- gap: 10px;
3001
- min-width: 0;
3002
- padding: 10px 12px;
3003
- border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
3004
- background: var(--surface, #fff);
3005
- color: var(--text, #171717);
3006
- }
3007
- .tp-run-copy {
3008
- min-width: 0;
3009
- display: flex;
3010
- flex-direction: column;
3011
- gap: 1px;
3012
- }
3013
- .tp-run-eyebrow,
3014
- .tp-run-meta {
3015
- font-size: 11px;
3016
- line-height: 1.25;
3017
- color: var(--muted);
3018
- }
3019
- .tp-run-title {
3020
- min-width: 0;
3021
- font-size: 13px;
3022
- line-height: 1.3;
3023
- font-weight: 650;
3024
- color: var(--text, #171717);
3025
- overflow: hidden;
3026
- text-overflow: ellipsis;
3027
- white-space: nowrap;
3028
- }
3029
- .tp-run-actions {
3030
- flex: 0 0 auto;
3031
- }
3032
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-new-job {
3033
- min-height: 34px;
3034
- padding: 7px 12px;
3035
- border-radius: 8px;
3036
- font-size: 12px;
3037
- white-space: nowrap;
3038
- }
3039
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) {
3040
- background: var(--bg);
3041
- }
3042
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher,
3043
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3044
- background: #242424;
3045
- border-color: rgba(255, 255, 255, 0.16);
3046
- }
3047
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select,
3048
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field textarea {
3049
- background: #171717;
3050
- color: #f2f2f2;
3051
- border-color: rgba(255, 255, 255, 0.22);
3052
- }
3053
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select option {
3054
- background: #171717;
3055
- color: #f2f2f2;
3056
- }
3057
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-context,
3058
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-status,
3059
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field,
3060
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-eyebrow,
3061
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-meta {
3062
- color: #c7c7c7;
3063
- }
3064
- body:is([data-surface="task-pane"],[data-surface="extension"]) .page {
3065
- padding: 10px 12px 8px;
3066
- gap: 10px;
3067
- height: auto;
3068
- min-height: 100vh;
3004
+ .tp-launcher-actions {
3005
+ display: grid;
3006
+ grid-template-columns: minmax(0, 1fr) auto auto;
3007
+ align-items: center;
3008
+ gap: 8px;
3009
+ }
3010
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-start-job,
3011
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job {
3012
+ min-height: 34px;
3013
+ padding: 7px 12px;
3014
+ border-radius: 8px;
3015
+ font-size: 12px;
3016
+ }
3017
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job[hidden] {
3018
+ display: none;
3019
+ }
3020
+ .task-pane-run-nav {
3021
+ display: none;
3022
+ }
3023
+ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3024
+ display: flex;
3025
+ align-items: center;
3026
+ justify-content: space-between;
3027
+ gap: 10px;
3028
+ min-width: 0;
3029
+ padding: 10px 12px;
3030
+ border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
3031
+ background: var(--surface, #fff);
3032
+ color: var(--text, #171717);
3033
+ }
3034
+ .tp-run-copy {
3035
+ min-width: 0;
3036
+ display: flex;
3037
+ flex-direction: column;
3038
+ gap: 1px;
3039
+ }
3040
+ .tp-run-eyebrow,
3041
+ .tp-run-meta {
3042
+ font-size: 11px;
3043
+ line-height: 1.25;
3044
+ color: var(--muted);
3045
+ }
3046
+ .tp-run-title {
3047
+ min-width: 0;
3048
+ font-size: 13px;
3049
+ line-height: 1.3;
3050
+ font-weight: 650;
3051
+ color: var(--text, #171717);
3052
+ overflow: hidden;
3053
+ text-overflow: ellipsis;
3054
+ white-space: nowrap;
3055
+ }
3056
+ .tp-run-actions {
3057
+ flex: 0 0 auto;
3058
+ }
3059
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-new-job {
3060
+ min-height: 34px;
3061
+ padding: 7px 12px;
3062
+ border-radius: 8px;
3063
+ font-size: 12px;
3064
+ white-space: nowrap;
3065
+ }
3066
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) {
3067
+ background: var(--bg);
3068
+ }
3069
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher,
3070
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3071
+ background: #242424;
3072
+ border-color: rgba(255, 255, 255, 0.16);
3073
+ }
3074
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select,
3075
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field textarea {
3076
+ background: #171717;
3077
+ color: #f2f2f2;
3078
+ border-color: rgba(255, 255, 255, 0.22);
3079
+ }
3080
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select option {
3081
+ background: #171717;
3082
+ color: #f2f2f2;
3083
+ }
3084
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-context,
3085
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-status,
3086
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field,
3087
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-eyebrow,
3088
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-meta {
3089
+ color: #c7c7c7;
3090
+ }
3091
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .page {
3092
+ padding: 10px 12px 8px;
3093
+ gap: 10px;
3094
+ height: auto;
3095
+ min-height: 100vh;
3069
3096
  }
3070
3097
  /* Hide the full-width welcome header — task pane has no room for it */
3071
3098
  body:is([data-surface="task-pane"],[data-surface="extension"]) .header { display: none; }
@@ -3451,8 +3478,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
3451
3478
  .pending-banner { background: var(--warn-soft); border: 1px solid rgba(176,132,66,.25); border-radius: 12px; padding: 12px 16px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
3452
3479
 
3453
3480
  /* Projects sub-tabs — HTML uses .proj-tabs (not .proj-tabs-bar) and .ptab-add (not .ptab-add-btn) */
3454
- .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
3455
- #proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
3481
+ .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
3482
+ #proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
3456
3483
  .ptab { padding: 9px 16px; font-size: 13px; font-weight: 500; color: var(--muted); border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; margin-bottom: -1px; }
3457
3484
  .ptab.on { color: var(--text); border-bottom-color: var(--text); font-weight: 600; }
3458
3485
  .ptab:hover { color: var(--text); }
@@ -4206,18 +4233,18 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4206
4233
  .modal-hdr { padding: 18px 22px; border-bottom: 1px solid var(--line); }
4207
4234
  .modal-hdr h2 { font-size: 18px; font-weight: 700; margin: 0 0 4px; }
4208
4235
  .modal-hdr p { font-size: 13px; color: var(--muted); margin: 0; line-height: 1.5; }
4209
- .modal-body { padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
4210
- .modal-close { float: right; background: var(--bg); border: none; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted); line-height: 1; }
4211
- .modal-close:hover { color: var(--text); }
4212
- .modal-close:focus, .modal-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4213
- .aom-cancel-btn { background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent-strong) 52%, var(--line)); border-radius: 20px; padding: 9px 20px; font-size: 14px; cursor: pointer; color: var(--accent-strong); }
4214
- .aom-cancel-btn:hover { border-color: var(--accent-strong); color: var(--accent-strong); }
4215
- .aom-cancel-btn:focus, .aom-cancel-btn:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4216
- .modal-footer { padding: 12px 22px 20px; display: flex; align-items: center; justify-content: space-between; }
4217
- .modal-next { padding: 9px 22px; background: var(--accent-strong); color: var(--bg); border: none; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
4218
- .modal-next:hover { opacity: .9; }
4219
- .modal-next:focus, .modal-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4220
- .modal-back { font-size: 13px; color: var(--muted); background: none; border: none; cursor: pointer; }
4236
+ .modal-body { padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
4237
+ .modal-close { float: right; background: var(--bg); border: none; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted); line-height: 1; }
4238
+ .modal-close:hover { color: var(--text); }
4239
+ .modal-close:focus, .modal-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4240
+ .aom-cancel-btn { background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent-strong) 52%, var(--line)); border-radius: 20px; padding: 9px 20px; font-size: 14px; cursor: pointer; color: var(--accent-strong); }
4241
+ .aom-cancel-btn:hover { border-color: var(--accent-strong); color: var(--accent-strong); }
4242
+ .aom-cancel-btn:focus, .aom-cancel-btn:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4243
+ .modal-footer { padding: 12px 22px 20px; display: flex; align-items: center; justify-content: space-between; }
4244
+ .modal-next { padding: 9px 22px; background: var(--accent-strong); color: var(--bg); border: none; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
4245
+ .modal-next:hover { opacity: .9; }
4246
+ .modal-next:focus, .modal-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4247
+ .modal-back { font-size: 13px; color: var(--muted); background: none; border: none; cursor: pointer; }
4221
4248
  .modal-step-dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 18px; }
4222
4249
  .modal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--line); }
4223
4250
  .modal-dot.on { background: var(--text); }
@@ -4674,8 +4701,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4674
4701
  align-items: center;
4675
4702
  justify-content: center;
4676
4703
  }
4677
- .np-close:hover { color: var(--text); }
4678
- .np-close:focus, .np-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4704
+ .np-close:hover { color: var(--text); }
4705
+ .np-close:focus, .np-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4679
4706
 
4680
4707
  .np-step-dots {
4681
4708
  display: flex;
@@ -4737,29 +4764,29 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4737
4764
  justify-content: space-between;
4738
4765
  }
4739
4766
 
4740
- .np-next {
4741
- padding: 9px 22px;
4742
- background: var(--accent-strong);
4743
- color: var(--bg);
4744
- border: none;
4745
- border-radius: 20px;
4746
- font-size: 14px;
4747
- font-weight: 600;
4748
- cursor: pointer;
4749
- }
4750
- .np-next:hover { opacity: .88; }
4751
- .np-next:focus, .np-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4752
- .np-next:disabled { opacity: .55; cursor: not-allowed; }
4753
-
4754
- .np-back {
4755
- font-size: 13px;
4756
- color: var(--accent-strong);
4757
- background: none;
4758
- border: none;
4759
- cursor: pointer;
4760
- }
4761
- .np-back:hover { color: var(--accent-strong); text-decoration: underline; }
4762
- .np-back:focus, .np-back:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4767
+ .np-next {
4768
+ padding: 9px 22px;
4769
+ background: var(--accent-strong);
4770
+ color: var(--bg);
4771
+ border: none;
4772
+ border-radius: 20px;
4773
+ font-size: 14px;
4774
+ font-weight: 600;
4775
+ cursor: pointer;
4776
+ }
4777
+ .np-next:hover { opacity: .88; }
4778
+ .np-next:focus, .np-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4779
+ .np-next:disabled { opacity: .55; cursor: not-allowed; }
4780
+
4781
+ .np-back {
4782
+ font-size: 13px;
4783
+ color: var(--accent-strong);
4784
+ background: none;
4785
+ border: none;
4786
+ cursor: pointer;
4787
+ }
4788
+ .np-back:hover { color: var(--accent-strong); text-decoration: underline; }
4789
+ .np-back:focus, .np-back:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4763
4790
 
4764
4791
  /* ── Discard-guard confirm (shown when a dirty wizard is dismissed) ── */
4765
4792
  .np-discard {
@@ -4784,11 +4811,11 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4784
4811
  .np-discard-msg { font-size: 13px; color: var(--muted); margin: 0 0 18px; line-height: 1.5; }
4785
4812
  .np-discard-row { display: flex; gap: 8px; justify-content: flex-end; }
4786
4813
  .np-discard-row button { font: inherit; font-size: 13px; font-weight: 600; border-radius: 9px; padding: 8px 16px; cursor: pointer; }
4787
- .np-discard-keep { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
4788
- .np-discard-keep:hover { border-color: var(--muted); }
4789
- .np-discard-yes { background: var(--danger, #d2261f); color: #fff; border: none; }
4790
- .np-discard-yes:hover { opacity: .9; }
4791
- .np-discard-row button:focus, .np-discard-row button:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4814
+ .np-discard-keep { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
4815
+ .np-discard-keep:hover { border-color: var(--muted); }
4816
+ .np-discard-yes { background: var(--danger, #d2261f); color: #fff; border: none; }
4817
+ .np-discard-yes:hover { opacity: .9; }
4818
+ .np-discard-row button:focus, .np-discard-row button:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4792
4819
 
4793
4820
  /* ── D11: Source attachment row (step 2) ── */
4794
4821
  .np-source {
@@ -5275,9 +5302,9 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5275
5302
  /* ── New-project: folder picker row ── */
5276
5303
  .np-folder-row { display:flex; gap:8px; }
5277
5304
  .np-folder-row input { flex:1; }
5278
- .np-browse-btn { padding:9px 16px; background:var(--accent-strong); color:var(--bg); border:none; border-radius:9px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
5279
- .np-browse-btn:hover { opacity:.85; }
5280
- .np-browse-btn:focus, .np-browse-btn:focus-visible { outline:2px solid var(--accent-strong); outline-offset:2px; }
5305
+ .np-browse-btn { padding:9px 16px; background:var(--accent-strong); color:var(--bg); border:none; border-radius:9px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
5306
+ .np-browse-btn:hover { opacity:.85; }
5307
+ .np-browse-btn:focus, .np-browse-btn:focus-visible { outline:2px solid var(--accent-strong); outline-offset:2px; }
5281
5308
  .np-field-hint { font-size:11px; color:var(--muted); margin-top:5px; line-height:1.5; }
5282
5309
 
5283
5310
  @media (max-width: 520px) {