@underpostnet/cyberia 3.3.77 → 3.4.0

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.
Files changed (105) hide show
  1. package/.env.example +1 -0
  2. package/.github/workflows/coverall.cyberia.ci.yml +12 -3
  3. package/.github/workflows/cyberia-client.cd.yml +2 -1
  4. package/.github/workflows/cyberia-server.cd.yml +2 -1
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  11. package/.github/workflows/engine-cyberia.ci.yml +6 -0
  12. package/.github/workflows/ghpkg.ci.yml +19 -2
  13. package/.github/workflows/gitlab.ci.yml +8 -6
  14. package/.github/workflows/hardhat.ci.yml +4 -1
  15. package/.github/workflows/npmpkg.ci.yml +9 -5
  16. package/.github/workflows/publish.ci.yml +21 -12
  17. package/.github/workflows/publish.cyberia.ci.yml +25 -14
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -2
  19. package/CHANGELOG.md +75 -140
  20. package/CLI-HELP.md +28 -1
  21. package/Dockerfile +1 -1
  22. package/Dockerfile.dev +1 -1
  23. package/Dockerfile.test +1 -1
  24. package/README.md +1 -1
  25. package/bin/build.js +2 -4
  26. package/bin/cyberia.js +4 -4
  27. package/bin/index.js +4 -4
  28. package/compose.env +1 -0
  29. package/conf.js +1 -1042
  30. package/deploy/cyberia-client/deploy.sh +13 -11
  31. package/deploy/cyberia-server/deploy.sh +13 -11
  32. package/deploy/dd-cyberia/sync-deploy.sh +7 -28
  33. package/deploy/lib/config.sh +1 -1
  34. package/deploy/lib/github-actions-logging.sh +3 -2
  35. package/deploy/pwa-microservices-template/deploy.sh +7 -1
  36. package/deployment.yaml +1 -1
  37. package/docker-compose.yml +7 -7
  38. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1 -1
  39. package/docs/coverage/cyberia/api/cyberia-instance/index.html +1 -1
  40. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +1 -1
  41. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +1 -1
  42. package/docs/coverage/cyberia/api/object-layer/index.html +1 -1
  43. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1 -1
  44. package/docs/coverage/cyberia/index.html +1 -1
  45. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1 -1
  46. package/docs/coverage/cyberia/projects/cyberia/index.html +1 -1
  47. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +1 -1
  48. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +1 -1
  49. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +1 -1
  50. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +1 -1
  51. package/hardhat/package-lock.json +6 -6
  52. package/hardhat/package.json +2 -2
  53. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  54. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  55. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  56. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  57. package/package.json +19 -17
  58. package/scripts/test-monitor.sh +1 -1
  59. package/src/api/file/file.router.js +7 -1
  60. package/src/cli/baremetal.js +1 -1
  61. package/src/cli/docker-compose.js +1 -1
  62. package/src/cli/index.js +36 -0
  63. package/src/cli/kubectl.js +4 -3
  64. package/src/cli/release.js +2 -0
  65. package/src/cli/repository.js +7 -2
  66. package/src/cli/run.js +1 -1
  67. package/src/cli/secrets.js +393 -200
  68. package/src/cli/socketsecurity.js +260 -0
  69. package/src/client/components/core/CssCore.js +3 -0
  70. package/src/client/components/core/Modal.js +160 -36
  71. package/src/client/components/core/SearchBox.js +0 -1
  72. package/src/client/components/underpost/AppShellUnderpost.js +13 -1
  73. package/src/client/components/underpost/CssUnderpost.js +15 -2
  74. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +8 -0
  75. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  76. package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +54 -56
  77. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  78. package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +30 -29
  79. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  80. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +54 -25
  81. package/src/client/ssr/head/Css.js +12 -10
  82. package/src/client/ssr/views/Cyberia404.js +6 -10
  83. package/src/client/ssr/views/NotFound.js +75 -0
  84. package/src/client-builder/client-bundle.js +2 -2
  85. package/src/index.js +13 -1
  86. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  87. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  88. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  89. package/src/runtime/engine-cyberia/compose.env +1 -0
  90. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  91. package/src/server/build/catalog.js +1 -0
  92. package/src/server/build/execution.js +19 -5
  93. package/src/server/build/package.js +166 -3
  94. package/src/server/build/testing.js +2 -1
  95. package/src/server/runtime/conf.js +21 -0
  96. package/src/server/runtime/config-scope.js +2 -0
  97. package/src/server/runtime/process.js +4 -3
  98. package/src/server/security/socketsecurity.js +1735 -0
  99. package/test/integration/infra/1-security/config-scope.test.js +3 -0
  100. package/test/integration/infra/1-security/socketsecurity.test.js +1041 -0
  101. package/test/integration/infra/1-security/sops-secret-store.test.js +201 -71
  102. package/test/unit/build-template.test.js +24 -0
  103. package/test/unit/deploy-log-table.test.js +38 -0
  104. package/test/unit/execution-profiles.test.js +1 -0
  105. package/test/unit/package.test.js +118 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Socket security domain: the security audit of a checkout — the packages it installs and its own
3
+ * source code — and the patches it carries.
4
+ *
5
+ * {@link module:src/server/security/socketsecurity.js} owns the Socket CLI integration and the
6
+ * report; this domain is the operator surface over it: the audit a person or a workflow runs,
7
+ * the remediation pass, and the `prepare` hook that replays the patch manifest.
8
+ * @module src/cli/socketsecurity.js
9
+ * @namespace UnderpostSocketSecurity
10
+ */
11
+
12
+ import fs from 'fs-extra';
13
+ import nodePath from 'node:path';
14
+ import { auditRuntimeDependencies } from '../server/build/package.js';
15
+ import { loggerFactory } from '../server/ops/logger.js';
16
+ import SocketSecurityService, {
17
+ fix,
18
+ npmAudit,
19
+ packageAlerts,
20
+ npmAuditFix,
21
+ patchApply,
22
+ patchGet,
23
+ patchManifestFactory,
24
+ patchScan,
25
+ patchSelectionFactory,
26
+ patchSetup,
27
+ publishedPackagesFactory,
28
+ reportViolates,
29
+ scan,
30
+ securityReportFactory,
31
+ socketEnvFactory,
32
+ socketVersion,
33
+ writeSecurityReports,
34
+ } from '../server/security/socketsecurity.js';
35
+ import Underpost from '../index.js';
36
+
37
+ const logger = loggerFactory(import.meta);
38
+
39
+ /**
40
+ * @class UnderpostSocketSecurity
41
+ * @description Audits a checkout's dependencies and source code through Socket, and remediates and patches its dependencies.
42
+ * @memberof UnderpostSocketSecurity
43
+ */
44
+ class UnderpostSocketSecurity {
45
+ static API = {
46
+ /**
47
+ * @method audit
48
+ * @description Runs the audit and writes its reports. Dependency security: segregation,
49
+ * `npm audit`, the Socket patch scan and, with an API token, the Socket full scan with the
50
+ * local reachability analysis when asked. Source code risk: with an API token, the alerts
51
+ * Socket's registry analysis raises on this project's own published package, as refactoring
52
+ * tasks at their locations.
53
+ * @param {object} [params]
54
+ * @param {string} [params.root] - Checkout to audit.
55
+ * @param {string} [params.out] - Report directory.
56
+ * @param {boolean} [params.reach] - Run the reachability analysis with the scan.
57
+ * @param {object} [params.remediation] - Actions a remediation pass took before this audit.
58
+ * @returns {Promise<object>} The report, with the files written.
59
+ * @memberof UnderpostSocketSecurity
60
+ */
61
+ async audit({
62
+ root = process.cwd(),
63
+ out = nodePath.join(root, SocketSecurityService.REPORT_DIRECTORY),
64
+ reach = false,
65
+ remediation = {},
66
+ } = {}) {
67
+ const env = socketEnvFactory();
68
+ const dependencies = await auditRuntimeDependencies({ root });
69
+ const audit = npmAudit({ root });
70
+ const patches = patchScan({ root, env });
71
+ if (reach && !env.token)
72
+ logger.warn('Reachability analysis needs a Socket API token', { variable: SocketSecurityService.ENV.token });
73
+ const scanResult = env.token ? scan({ root, env, reach }) : null;
74
+ const packageJson = fs.readJsonSync(nodePath.join(root, 'package.json'));
75
+ const ownPackages = SocketSecurityService.ownPackageNamesFactory({ packageJson });
76
+ const ownAlerts = env.token
77
+ ? packageAlerts({ root, env, packages: publishedPackagesFactory({ root, packageJson, ownPackages }) })
78
+ : null;
79
+ const report = securityReportFactory({
80
+ root,
81
+ packageJson,
82
+ ownPackages,
83
+ dependencies,
84
+ npmAudit: audit.summary,
85
+ scan: scanResult,
86
+ packageAlerts: ownAlerts,
87
+ patchScan: patches,
88
+ manifest: patchManifestFactory(root),
89
+ remediation,
90
+ env,
91
+ cliVersion: socketVersion({ root }),
92
+ });
93
+ const files = writeSecurityReports({
94
+ report,
95
+ directory: out,
96
+ artifacts: {
97
+ npmAudit: audit.raw,
98
+ scan: scanResult?.report ?? null,
99
+ scanArtifacts: scanResult?.artifacts ?? null,
100
+ packageAlerts: ownAlerts?.artifacts ?? null,
101
+ reach: scanResult?.facts ?? null,
102
+ patchScan: patches.data,
103
+ },
104
+ });
105
+ const { dependencySecurity: dependency, sourceCodeRisk: source } = report;
106
+ logger.info('Security audit complete', {
107
+ scan: report.scan.skipped ?? { healthy: report.scan.healthy, url: report.scan.url },
108
+ dependencySecurity: {
109
+ findings: dependency.counts,
110
+ misplacedDependencies: dependency.segregation.misplaced.map(({ name }) => name),
111
+ patchesAvailable: dependency.patches.scan.available.length,
112
+ patchesInManifest: dependency.patches.manifest.patches.length,
113
+ deferred: dependency.deferred.length,
114
+ },
115
+ sourceCodeRisk: {
116
+ status: source.status,
117
+ analyzed: source.analyzed.map(({ name, version }) => `${name}@${version}`),
118
+ findings: source.counts,
119
+ tasks: source.tasks.map(({ label, locations }) => `${label}: ${locations.length}`),
120
+ },
121
+ files,
122
+ });
123
+ return { ...report, files };
124
+ },
125
+
126
+ /**
127
+ * @method remediate
128
+ * @description The non-breaking remediation pass: `npm audit fix`, every available Socket
129
+ * patch, `socket fix` without major upgrades unless allowed, and a final manifest apply.
130
+ * @param {object} [params]
131
+ * @param {string} [params.root] - Checkout to remediate.
132
+ * @param {string} [params.out] - Directory `socket fix` writes its upgrade list into.
133
+ * @param {boolean} [params.majorUpdates] - Let `socket fix` apply major upgrades.
134
+ * @returns {Promise<object>} Results by action name.
135
+ * @memberof UnderpostSocketSecurity
136
+ */
137
+ async remediate({
138
+ root = process.cwd(),
139
+ out = nodePath.join(root, SocketSecurityService.REPORT_DIRECTORY),
140
+ majorUpdates = false,
141
+ } = {}) {
142
+ const env = socketEnvFactory();
143
+ fs.ensureDirSync(out);
144
+ const actions = { 'npm audit fix': npmAuditFix({ root }) };
145
+ const { selected, skipped } = patchSelectionFactory(patchScan({ root, env }).summary.available);
146
+ actions['socket patch get'] = [
147
+ ...selected.map(({ uuid, purl }) => {
148
+ const { ok, message } = patchGet({ root, env, identifier: uuid });
149
+ return { uuid, purl, ok, message };
150
+ }),
151
+ ...skipped.map(({ uuid, purl, reason }) => ({ uuid, purl, skipped: reason })),
152
+ ];
153
+ actions['socket fix'] = env.token
154
+ ? fix({
155
+ root,
156
+ env,
157
+ majorUpdates,
158
+ outputFile: nodePath.join(out, SocketSecurityService.REPORT_FILES.fixUpgrades),
159
+ })
160
+ : { skipped: `${SocketSecurityService.ENV.token} is not set` };
161
+ actions['socket patch apply'] = patchApply({ root, env });
162
+ logger.info('Remediation pass complete', actions);
163
+ return actions;
164
+ },
165
+
166
+ /**
167
+ * @method applyPatches
168
+ * @description Applies the patch manifest; the `prepare` hook. A host without the
169
+ * Socket CLI and a checkout without a manifest are both no-ops, a patch that fails to apply
170
+ * fails the install.
171
+ * @param {object} [params]
172
+ * @param {string} [params.root] - Checkout to patch.
173
+ * @param {boolean} [params.dryRun] - Verify without writing.
174
+ * @returns {object} The apply result.
175
+ * @throws {Error} When a recorded patch does not apply.
176
+ * @memberof UnderpostSocketSecurity
177
+ */
178
+ applyPatches({ root = process.cwd(), dryRun = false } = {}) {
179
+ const result = patchApply({ root, dryRun });
180
+ if (result.unavailable) {
181
+ logger.info('Socket CLI not installed; patch manifest not applied', {
182
+ install: `npm install -g ${SocketSecurityService.CLI_PACKAGE}`,
183
+ });
184
+ return result;
185
+ }
186
+ if (!result.ok || result.failed > 0)
187
+ throw new Error(
188
+ `[socketsecurity] patch apply failed: ${result.message || `${result.failed} patch(es) failed`}`,
189
+ );
190
+ logger.info(result.status === 'no_manifest' ? 'No patch manifest to apply' : 'Patch manifest applied', {
191
+ manifest: SocketSecurityService.MANIFEST_PATH,
192
+ patchesApplied: result.patchesApplied,
193
+ alreadyPatched: result.alreadyPatched,
194
+ dryRun,
195
+ });
196
+ return result;
197
+ },
198
+
199
+ /**
200
+ * @method callback
201
+ * @description `underpost socketsecurity` entrypoint. Without an operation flag it runs the
202
+ * audit.
203
+ * @param {object} [options]
204
+ * @param {boolean} [options.audit] - Run the audit and write the reports.
205
+ * @param {boolean} [options.reach] - Include the reachability analysis in the scan.
206
+ * @param {boolean} [options.ci] - Audit, then fail on a policy violation or a finding at `--fail-on`.
207
+ * @param {string} [options.failOn] - Severity `--ci` fails at.
208
+ * @param {boolean} [options.fix] - Run the remediation pass before the audit.
209
+ * @param {boolean} [options.major] - Let the remediation pass apply major upgrades.
210
+ * @param {boolean} [options.patchScan] - Scan the installed packages for available patches.
211
+ * @param {string} [options.patchGet] - Download and apply one patch.
212
+ * @param {boolean} [options.patchApply] - Apply the patch manifest.
213
+ * @param {boolean} [options.patchSetup] - Install the `prepare` hook.
214
+ * @param {string} [options.out] - Report directory.
215
+ * @param {boolean} [options.dryRun] - Preview `--patch-setup`; verify only for `--patch-apply`.
216
+ * @returns {Promise<object>} The operation result.
217
+ * @memberof UnderpostSocketSecurity
218
+ */
219
+ async callback(options = {}) {
220
+ const root = process.cwd();
221
+ const out = nodePath.resolve(root, options.out || SocketSecurityService.REPORT_DIRECTORY);
222
+ const dryRun = options.dryRun === true;
223
+
224
+ if (options.patchSetup) {
225
+ const result = patchSetup({ root, dryRun });
226
+ logger.info(
227
+ result.changed ? `prepare hook ${dryRun ? 'to install' : 'installed'}` : 'prepare hook present',
228
+ result,
229
+ );
230
+ return result;
231
+ }
232
+ if (options.patchApply) return Underpost.socketSecurity.applyPatches({ root, dryRun });
233
+ if (options.patchGet) {
234
+ const result = patchGet({ root, identifier: options.patchGet });
235
+ if (!result.ok) throw new Error(`[socketsecurity] patch get failed: ${result.message}`);
236
+ logger.info('Socket patch recorded and applied', result.data);
237
+ return result;
238
+ }
239
+ if (options.patchScan) {
240
+ const result = patchScan({ root });
241
+ if (!result.ok) throw new Error(`[socketsecurity] patch scan failed: ${result.message}`);
242
+ logger.info('Socket patches available', result.summary);
243
+ return result;
244
+ }
245
+
246
+ const remediation = options.fix
247
+ ? await Underpost.socketSecurity.remediate({ root, out, majorUpdates: options.major === true })
248
+ : {};
249
+ const report = await Underpost.socketSecurity.audit({ root, out, reach: options.reach === true, remediation });
250
+ if (options.ci && reportViolates(report, options.failOn))
251
+ throw new Error(
252
+ `[socketsecurity] policy gate failed (fail-on ${options.failOn || 'high'}); ` +
253
+ `see ${nodePath.join(out, SocketSecurityService.REPORT_FILES.markdown)}`,
254
+ );
255
+ return report;
256
+ },
257
+ };
258
+ }
259
+
260
+ export default UnderpostSocketSecurity;
@@ -16,6 +16,9 @@ const CssCommonCore = async () => {
16
16
  );
17
17
  await AgGrid.RenderStyle();
18
18
  return html`<style>
19
+ .top-bar {
20
+ transition: 0.3s;
21
+ }
19
22
  .top-bar-app-icon {
20
23
  width: 40px;
21
24
  height: 40px;
@@ -129,6 +129,9 @@ class Modal {
129
129
  /** @type {Object.<string, ModalDataEntry>} */
130
130
  static Data = {};
131
131
 
132
+ /** @type {Function|null} Recomputes visibility of the search/home toggle button; set once the slide-menu modal renders it. */
133
+ static updateBarCustomVisibility = null;
134
+
132
135
  /**
133
136
  * Bottom offset of the slide-menu area for the given options.
134
137
  * @param {ModalRenderOptions} options
@@ -228,6 +231,10 @@ class Modal {
228
231
  const originHeightTopBar = 50;
229
232
  options.heightBottomBar = 0;
230
233
  options.heightTopBar = 100;
234
+ // A view sits under its slide menu's bars, so it follows that menu's bar mode unless it
235
+ // names one — otherwise a 'top-bottom-bar' shell opens every view 100px down instead of 50px.
236
+ if (options && !options.barMode && options.slideMenu && Modal.Data[options.slideMenu]?.options?.barMode)
237
+ options.barMode = Modal.Data[options.slideMenu].options.barMode;
231
238
  if (options && options.barMode && options.barMode === 'top-bottom-bar') {
232
239
  options.heightTopBar = 50;
233
240
  options.heightBottomBar = 50;
@@ -297,6 +304,9 @@ class Modal {
297
304
  route: options.route,
298
305
  RouterInstance: options.RouterInstance,
299
306
  });
307
+ // Path just changed away from (or back to) home — recompute the search/home
308
+ // toggle's visibility instead of leaving it stuck on its last computed state.
309
+ setTimeout(() => Modal.updateBarCustomVisibility?.());
300
310
  }
301
311
 
302
312
  break;
@@ -319,10 +329,15 @@ class Modal {
319
329
  padding: 5px;
320
330
  }
321
331
  .default-slide-menu-top-bar-fix-title-container-text {
322
- font-size: 26px;
323
- top: 8px;
324
332
  color: ${darkTheme ? '#ffffff' : '#000000'};
325
333
  }
334
+ /* Themes size and offset the title for the 100px banner with !important;
335
+ the 50px one has to win over them or the title spills into the menu. */
336
+ .slide-menu-top-bar-fix .default-slide-menu-top-bar-fix-title-container-text {
337
+ font-size: 26px !important;
338
+ top: 8px !important;
339
+ left: 0px !important;
340
+ }
326
341
  </style>`;
327
342
  } else {
328
343
  style = html`<style>
@@ -428,6 +443,28 @@ class Modal {
428
443
  s('body').classList[collapsed ? 'add' : 'remove']('main-btn-menu-top-fixed-active');
429
444
  s('body').classList[collapsed && menuClosed ? 'add' : 'remove']('main-btn-menu-top-fixed-title-offset');
430
445
  };
446
+ // Hides the search/home toggle only while it's showing the "close" (home) icon
447
+ // AND the body is already scrolled to top — nothing left for it to do there.
448
+ // Reactive: called on every scroll tick and state change, so it restores to its
449
+ // normal visible state the moment either condition stops holding.
450
+ const updateBarCustomVisibility = () => {
451
+ const barCustom = s(`.main-body-btn-bar-custom`);
452
+ if (!barCustom || !options?.slideMenuTopBarBannerFix) return;
453
+ const mainBody = s(`.main-body`);
454
+ const atTop = !!mainBody && mainBody.scrollTop <= 0;
455
+ const bannerFix = s(`.slide-menu-top-bar-fix`);
456
+ const bannerVisible = !!bannerFix && parseInt(bannerFix.style.top, 10) >= 0;
457
+ const isHomePath = location.pathname === getProxyPath();
458
+ // Never hide while the open (magnifying-glass) icon is showing — that's the
459
+ // idle "click to search" state, not the "close/home" state this hide targets.
460
+ const openIconHidden =
461
+ !!s(`.main-body-btn-ui-bar-custom-open`) &&
462
+ s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide');
463
+ barCustom.classList[bannerVisible && isHomePath && atTop && openIconHidden ? 'add' : 'remove']('hide');
464
+ };
465
+ // Exposed statically so other code paths (e.g. opening a view/route) can force a
466
+ // recompute — otherwise the hidden state goes stale once the path stops being home.
467
+ Modal.updateBarCustomVisibility = updateBarCustomVisibility;
431
468
  barConfig.buttons.menu.onClick = () => {
432
469
  Modal.Data[idModal][options.mode].width = slideMenuWidth;
433
470
  s(`.btn-menu-${idModal}`).classList.add('hide');
@@ -452,6 +489,10 @@ class Modal {
452
489
  }
453
490
  syncFixedMenuButton();
454
491
  Responsive.triggerChanged(`slide-menu-${idModal}`);
492
+ if (options.onExtendMenu) options.onExtendMenu();
493
+ Object.keys(this.Data[idModal].onExtendMenuListener).map((keyListener) =>
494
+ this.Data[idModal].onExtendMenuListener[keyListener](),
495
+ );
455
496
  };
456
497
  barConfig.buttons.close.onClick = () => {
457
498
  Modal.Data[idModal][options.mode].width = 0;
@@ -476,6 +517,10 @@ class Modal {
476
517
  }
477
518
  syncFixedMenuButton();
478
519
  Responsive.triggerChanged(`slide-menu-${idModal}`);
520
+ if (options.onCollapseMenu) options.onCollapseMenu();
521
+ Object.keys(this.Data[idModal].onCollapseMenuListener).map((keyListener) =>
522
+ this.Data[idModal].onCollapseMenuListener[keyListener](),
523
+ );
479
524
  };
480
525
  transition += `, width 0.3s`;
481
526
 
@@ -532,14 +577,11 @@ class Modal {
532
577
  Modal.actionBtnCenter();
533
578
  };
534
579
 
535
- // Fixed top-left hamburger, shown only while the top bar is collapsed
536
- // (main-body-btn-ui-close hidden). Not needed for 'top-bottom-bar' which
537
- // keeps its own center action button in the bottom bar.
538
- if (
539
- idModal === 'modal-menu' &&
540
- options.mode !== 'slide-menu-right' &&
541
- options.barMode !== 'top-bottom-bar'
542
- ) {
580
+ // Fixed top-left hamburger, shown only while the bars are collapsed
581
+ // (main-body-btn-ui-close hidden): it stands in for the left menu hamburger the
582
+ // collapse hid the top bar's, or the bottom bar's under 'top-bottom-bar'. A right
583
+ // menu keeps its floating .main-body-btn-menu beside the menu edge instead.
584
+ if (idModal === 'modal-menu' && options.mode !== 'slide-menu-right') {
543
585
  append(
544
586
  'body',
545
587
  html`
@@ -587,19 +629,38 @@ class Modal {
587
629
  s(`.main-body-btn-ui-bar-custom-open`).classList.remove('hide');
588
630
  s(`.main-body-btn-ui-bar-custom-close`).classList.add('hide');
589
631
  s(`.slide-menu-top-bar-fix`).style.top = '0px';
632
+
633
+ setTimeout(async () => {
634
+ setTimeout(() => {
635
+ s(`.action-btn-home`).click();
636
+ });
637
+ const mainBody = s(`.main-body`);
638
+ if (mainBody && mainBody.scrollTop > 0) {
639
+ mainBody.scrollTo({ top: 0, behavior: 'smooth' });
640
+ }
641
+ updateBarCustomVisibility();
642
+ });
590
643
  } else {
591
644
  s(`.main-body-btn-ui-bar-custom-open`).classList.add('hide');
592
645
  s(`.main-body-btn-ui-bar-custom-close`).classList.remove('hide');
593
646
  s(`.slide-menu-top-bar-fix`).style.top = '-100px';
594
647
  s(`.top-bar-search-box-container`).click();
595
648
  }
649
+ updateBarCustomVisibility();
596
650
  if (Modal.mobileModal()) {
597
651
  btnCloseEvent();
598
652
  }
599
653
  };
600
654
 
601
- let _heightTopBar, _heightBottomBar, _topMenu;
655
+ let _heightTopBar, _heightBottomBar, _topMenu, _barsAnimationTimeout;
656
+ // The layout offsets below are set inline, so they only ease while this class is on.
657
+ const animateBarsToggle = () => {
658
+ s('body').classList.add('ui-bars-animating');
659
+ clearTimeout(_barsAnimationTimeout);
660
+ _barsAnimationTimeout = setTimeout(() => s('body').classList.remove('ui-bars-animating'), 300);
661
+ };
602
662
  s(`.main-body-btn-ui`).onclick = () => {
663
+ animateBarsToggle();
603
664
  if (s(`.main-body-btn-ui-open`).classList.contains('hide')) {
604
665
  s(`.main-body-btn-ui-open`).classList.remove('hide');
605
666
  s(`.main-body-btn-ui-close`).classList.add('hide');
@@ -608,8 +669,7 @@ class Modal {
608
669
  _topMenu = newInstance(s(`.modal-menu`).style.top);
609
670
  options.heightTopBar = 0;
610
671
  options.heightBottomBar = 0;
611
- s(`.slide-menu-top-bar`).classList.add('hide');
612
- s(`.bottom-bar`).classList.add('hide');
672
+ s('body').classList.add('ui-bars-collapsed');
613
673
  syncFixedMenuButton();
614
674
  s(`.modal-menu`).style.top = '0px';
615
675
  s(`.main-body-btn-container`).style.top = '50px';
@@ -624,8 +684,7 @@ class Modal {
624
684
  options.heightBottomBar = _heightBottomBar;
625
685
  s(`.modal-menu`).style.top = _topMenu;
626
686
  s(`.main-body-btn-container`).style.top = `${options.heightTopBar + 50}px`;
627
- s(`.slide-menu-top-bar`).classList.remove('hide');
628
- s(`.bottom-bar`).classList.remove('hide');
687
+ s('body').classList.remove('ui-bars-collapsed');
629
688
  syncFixedMenuButton();
630
689
  s(`.main-body`).style.top = `${options.heightTopBar}px`;
631
690
  s(`.main-body`).style.height = `${windowGetH() - options.heightTopBar}px`;
@@ -649,13 +708,62 @@ class Modal {
649
708
  );
650
709
  }
651
710
  };
711
+ updateBarCustomVisibility();
652
712
  Modal.setTopBannerLink();
653
713
  });
654
714
 
655
715
  const inputSearchBoxId = `top-bar-search-box`;
656
716
  append(
657
717
  'body',
658
- html` <div class="fix modal slide-menu-top-bar">
718
+ html`<style>
719
+ /* Collapsing slides the bars off-screen instead of removing them; they turn
720
+ invisible only once the slide ends, so no hidden control stays focusable. */
721
+ .slide-menu-top-bar,
722
+ .bottom-bar {
723
+ transition:
724
+ transform 0.3s ease,
725
+ opacity 0.3s ease,
726
+ visibility 0s linear 0s !important;
727
+ }
728
+ body.ui-bars-collapsed .slide-menu-top-bar {
729
+ transform: translateY(-100%);
730
+ }
731
+ body.ui-bars-collapsed .bottom-bar {
732
+ transform: translateY(100%) !important;
733
+ }
734
+ /* A bottom bar rendered inside the top bar leaves with it. */
735
+ body.ui-bars-collapsed .slide-menu-top-bar .bottom-bar {
736
+ transform: none !important;
737
+ }
738
+ body.ui-bars-collapsed .slide-menu-top-bar,
739
+ body.ui-bars-collapsed .bottom-bar {
740
+ visibility: hidden;
741
+ pointer-events: none;
742
+ transition:
743
+ transform 0.3s ease,
744
+ opacity 0.3s ease,
745
+ visibility 0s linear 0.3s !important;
746
+ }
747
+ body.ui-bars-animating .main-body,
748
+ body.ui-bars-animating .main-body-btn-container {
749
+ transition:
750
+ top 0.3s ease,
751
+ left 0.3s ease,
752
+ height 0.3s ease,
753
+ opacity 0.3s ease !important;
754
+ }
755
+ @media (prefers-reduced-motion: reduce) {
756
+ .slide-menu-top-bar,
757
+ .bottom-bar,
758
+ body.ui-bars-collapsed .slide-menu-top-bar,
759
+ body.ui-bars-collapsed .bottom-bar,
760
+ body.ui-bars-animating .main-body,
761
+ body.ui-bars-animating .main-body-btn-container {
762
+ transition: none !important;
763
+ }
764
+ }
765
+ </style>
766
+ <div class="fix modal slide-menu-top-bar">
659
767
  <div
660
768
  class="fl top-bar ${options.barClass ? options.barClass : ''}"
661
769
  style="height: ${originHeightTopBar}px;"
@@ -748,24 +856,28 @@ class Modal {
748
856
  </div>`
749
857
  : ''}
750
858
  ${idModal === 'modal-menu' && options.mode !== 'slide-menu-right'
751
- ? html`<div
752
- class="abs main-btn-menu-top-container"
753
- style="bottom: 0px; left: 0px; z-index: 10; height: ${originHeightTopBar}px; width: ${originHeightTopBar}px"
754
- >
755
- ${await BtnIcon.instance({
756
- style: `height: 100%`,
757
- class: `in fll main-btn-menu-top action-bar-box action-btn-center-top`,
758
- label: html`<div class="abs center">
759
- <i class="far fa-square btn-bar-center-icon-square hide"></i>
760
- <span class="btn-bar-center-icon-close hide">${barConfig.buttons.close.label}</span>
761
- <span class="btn-bar-center-icon-menu">${barConfig.buttons.menu.label}</span>
762
- </div>`,
763
- })}
764
- </div>
859
+ ? html`${options.barMode === 'top-bottom-bar'
860
+ ? ''
861
+ : html`<div
862
+ class="abs main-btn-menu-top-container"
863
+ style="bottom: 0px; left: 0px; z-index: 10; height: ${originHeightTopBar}px; width: ${originHeightTopBar}px"
864
+ >
865
+ ${await BtnIcon.instance({
866
+ style: `height: 100%`,
867
+ class: `in fll main-btn-menu-top action-bar-box action-btn-center-top`,
868
+ label: html`<div class="abs center">
869
+ <i class="far fa-square btn-bar-center-icon-square hide"></i>
870
+ <span class="btn-bar-center-icon-close hide">${barConfig.buttons.close.label}</span>
871
+ <span class="btn-bar-center-icon-menu">${barConfig.buttons.menu.label}</span>
872
+ </div>`,
873
+ })}
874
+ </div>`}
765
875
 
766
876
  <style>
877
+ /* Under 'top-bottom-bar' the left menu hamburger is the bottom bar's, so the
878
+ banner has no top bar button to clear. */
767
879
  .a-link-top-banner {
768
- padding-left: 35px;
880
+ padding-left: ${options.barMode === 'top-bottom-bar' ? 0 : 35}px;
769
881
  }
770
882
  .main-body-btn-bar-custom {
771
883
  top: 50px !important;
@@ -789,7 +901,11 @@ class Modal {
789
901
  }
790
902
  s(`.main-btn-sign-up`).click();
791
903
  });
792
- if (idModal === 'modal-menu' && options.mode !== 'slide-menu-right') {
904
+ if (
905
+ idModal === 'modal-menu' &&
906
+ options.mode !== 'slide-menu-right' &&
907
+ options.barMode !== 'top-bottom-bar'
908
+ ) {
793
909
  EventsUI.onClick(`.action-btn-center-top`, (e) => {
794
910
  e.preventDefault();
795
911
  Modal.actionBtnCenter();
@@ -1160,6 +1276,7 @@ class Modal {
1160
1276
  } catch (e) {}
1161
1277
  }
1162
1278
  Modal.removeModal(searchBoxHistoryId);
1279
+ updateBarCustomVisibility();
1163
1280
  };
1164
1281
  s('.top-bar-search-box').onblur = () => {
1165
1282
  hoverFocusCtl.checkDismiss();
@@ -1385,10 +1502,15 @@ class Modal {
1385
1502
  >
1386
1503
  ${await BtnIcon.instance({
1387
1504
  style: `height: 100%`,
1388
- class: `in fl${
1389
- options.mode === 'slide-menu-right' ? 'r' : 'l'
1390
- } main-btn-menu action-bar-box action-btn-center ${
1391
- options?.disableTools?.includes('center') ? 'hide' : ''
1505
+ // The left menu hamburger under 'top-bottom-bar'. Otherwise a left menu is
1506
+ // toggled from the top bar and a right menu from the floating
1507
+ // .main-body-btn-menu, so a copy here would only duplicate one of them.
1508
+ class: `in fl${options.mode === 'slide-menu-right' ? 'r' : 'l'} main-btn-menu action-bar-box action-btn-center ${
1509
+ options?.disableTools?.includes('center') ||
1510
+ options.mode === 'slide-menu-right' ||
1511
+ options.barMode !== 'top-bottom-bar'
1512
+ ? 'hide'
1513
+ : ''
1392
1514
  }`,
1393
1515
  label: html`
1394
1516
  <div class="${contentIconClass}">
@@ -1662,6 +1784,8 @@ class Modal {
1662
1784
  removeEvent();
1663
1785
  }
1664
1786
  });
1787
+ Scroll.setEvent('.main-body', () => updateBarCustomVisibility());
1788
+ updateBarCustomVisibility();
1665
1789
  setTimeout(window.onresize);
1666
1790
  setRouterReady();
1667
1791
  });
@@ -832,7 +832,6 @@ class SearchBox {
832
832
 
833
833
  .search-result-route {
834
834
  padding: 10px 12px;
835
- margin: 2px;
836
835
  text-align: left;
837
836
  min-height: 40px;
838
837
  }
@@ -227,7 +227,9 @@ class AppShellUnderpost {
227
227
  titleRender: () => {
228
228
  return '';
229
229
  },
230
- mode: 'slide-menu-right',
230
+ // mode: 'slide-menu-right',
231
+ barMode: 'top-bottom-bar',
232
+ mode: 'slide-menu',
231
233
  RouterInstance,
232
234
  searchCustomImgClass: 'underpost-menu-icon',
233
235
  htmlMainBody: async () => {
@@ -305,6 +307,16 @@ class AppShellUnderpost {
305
307
  return panelFormInstance;
306
308
  },
307
309
  });
310
+ for (const eventFn of ['onCollapseMenuListener', 'onExtendMenuListener', 'onExpandUiListener', 'onCloseListener']) {
311
+ Modal.Data['modal-menu'][eventFn]['underpost-panel-form-padding'] = (arg) => {
312
+ if (s(`.underpost-panel-form-container`))
313
+ s(`.underpost-panel-form-container`).style.paddingLeft =
314
+ !s('.main-body-btn-ui-open').classList.contains('hide') &&
315
+ s(`.btn-bar-center-icon-close`).classList.contains('hide')
316
+ ? '50px'
317
+ : '0px';
318
+ };
319
+ }
308
320
 
309
321
  ThemeEvents['underpost-main-theme-event'] = () => {
310
322
  const srcLogo = darkTheme