plexus-peers 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Plexus
2
2
 
3
- CLI and a small web UI to inspect **direct dependencies** from a `package.json`: peer dependency ranges vs what is installed, optional **BundlePhobia**-style bundle size hints, and a **`--fix`** mode that queries npm for upgrade and cascade notes.
3
+ CLI and a small web UI to inspect **direct dependencies** from a `package.json`: peer dependency ranges vs what is installed, optional **[bundlejs.com](https://bundlejs.com/)** bundled gzip hints (esbuild), and a **`--fix`** mode that queries npm for upgrade and cascade notes.
4
4
 
5
5
  Requires **Node.js 18+** (uses the built-in `fetch` API).
6
6
 
@@ -37,7 +37,7 @@ npx plexus-peers -f ./path/to/package.json
37
37
  | `--out <path>` | HTML output path (with `--html`) |
38
38
  | `--conflicts-only` | Only list packages that have peer issues |
39
39
  | `--fix` | Call `npm` for latest metadata and print resolution hints (chatty; avoid on CI if logs matter) |
40
- | `--bundlesize` | With `--html`, query [BundlePhobia](https://bundlephobia.com/) per direct dependency (slow, rate limits may apply) |
40
+ | `--bundlesize` | With `--html`, query [bundlejs.com](https://bundlejs.com/) per direct dependency (slow) |
41
41
  | `--pkg <name>` | Focus on one direct dependency and related peer rows |
42
42
 
43
43
  Examples:
package/bin/plexus.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const path = require('path');
5
+ const { color } = require('../lib/ansi');
5
6
  const { runFilesystemAnalysis } = require('../lib/engine');
6
7
  const { startServer } = require('../lib/server');
7
8
 
@@ -20,7 +21,7 @@ Options:
20
21
  --out <path> HTML output path (with --html)
21
22
  --conflicts-only Terminal: only packages with peer issues
22
23
  --fix Query npm for upgrade / cascade hints (noisy on CI)
23
- --bundlesize With --html: fetch gzip sizes from BundlePhobia (slow)
24
+ --bundlesize With --html: fetch bundled gzip sizes from bundlejs.com (slow)
24
25
  --pkg <name> Focus on one direct dependency and its peers
25
26
 
26
27
  Serve:
@@ -125,6 +126,15 @@ async function main() {
125
126
  return;
126
127
  }
127
128
 
129
+ if (parsed.flags.bundleSize && !parsed.flags.html) {
130
+ console.error(
131
+ color(
132
+ 'Note: --bundlesize only applies with --html (bundlejs.com gzip sizes in the report file). Terminal output is unchanged — use e.g. --html --bundlesize.\n',
133
+ 'yellow',
134
+ ),
135
+ );
136
+ }
137
+
128
138
  await runFilesystemAnalysis({ rootDir: parsed.rootDir, flags: parsed.flags });
129
139
  }
130
140
 
package/lib/fix.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { color } = require('./ansi');
4
4
  const { satisfies } = require('./graph');
5
- const { queryNpm } = require('./npm');
5
+ const { fetchLatestPackageManifest, registryFetchConcurrency } = require('./npm');
6
6
 
7
7
  /**
8
8
  * For each real conflict (version mismatch, not just missing optional):
@@ -10,7 +10,7 @@ const { queryNpm } = require('./npm');
10
10
  * 2. Check if latest fixes the conflict (its new peer range accepts the installed version).
11
11
  * 3. Check if latest introduces NEW conflicts (cascade detection).
12
12
  */
13
- function resolveConflicts(graph, directDeps, getInstalledVersion, options = {}) {
13
+ async function resolveConflicts(graph, directDeps, getInstalledVersion, options = {}) {
14
14
  const silent = options.silent === true;
15
15
  const log = (...args) => {
16
16
  if (!silent) console.log(...args);
@@ -40,21 +40,33 @@ function resolveConflicts(graph, directDeps, getInstalledVersion, options = {})
40
40
 
41
41
  const pkgsToCheck = [...new Set(conflicts.map(c => c.pkg))];
42
42
 
43
- log(color(`\nQuerying npm for ${pkgsToCheck.length} package(s)…`, 'gray'));
43
+ log(color(`\nQuerying npm registry for ${pkgsToCheck.length} package(s)…`, 'gray'));
44
+
45
+ const limit = registryFetchConcurrency();
46
+ const manifestByPkg = new Map();
47
+ for (let i = 0; i < pkgsToCheck.length; i += limit) {
48
+ const batch = pkgsToCheck.slice(i, i + limit);
49
+ await Promise.all(
50
+ batch.map(async pkg => {
51
+ const m = await fetchLatestPackageManifest(pkg);
52
+ manifestByPkg.set(pkg, m);
53
+ }),
54
+ );
55
+ }
44
56
 
45
57
  const resolutions = {};
46
58
 
47
59
  for (const pkg of pkgsToCheck) {
48
60
  write(` ${color(pkg, 'cyan')} … `);
49
- const info = queryNpm(pkg);
61
+ const info = manifestByPkg.get(pkg);
50
62
  if (!info) {
51
63
  log(color('not found on npm', 'yellow'));
52
64
  continue;
53
65
  }
54
66
 
55
- const latest = info['dist-tags']?.latest;
67
+ const latest = info.version;
56
68
  if (!latest) {
57
- log(color('no dist-tags.latest', 'yellow'));
69
+ log(color('no version on latest', 'yellow'));
58
70
  continue;
59
71
  }
60
72
 
package/lib/npm.js CHANGED
@@ -17,21 +17,62 @@ function queryNpm(pkg) {
17
17
  }
18
18
  }
19
19
 
20
+ /** Strip "v"; bundlejs needs a concrete version string. */
21
+ function normalizePkgVersionForBundleQuery(version) {
22
+ if (version == null || version === '') return null;
23
+ const v = String(version).trim().replace(/^v/i, '');
24
+ return v || null;
25
+ }
26
+
27
+ const BUNDLE_API_UA = 'plexus-peers (+https://github.com/arnaudmanaranche/plexus; bundle gzip check)';
28
+
29
+ /**
30
+ * [bundlejs](https://bundlejs.com/) — esbuild bundles `export * from 'pkg@ver'`, minifies, reports gzip.
31
+ */
20
32
  async function getBundleSize(pkgName, version) {
21
- try {
22
- const url = `https://bundlephobia.com/api/size?package=${encodeURIComponent(pkgName)}@${version}`;
23
- const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
24
- if (!res.ok) return null;
25
- const data = await res.json();
26
- return { size: data.size, gzip: data.gzip };
27
- } catch {
28
- return null;
33
+ const ver = normalizePkgVersionForBundleQuery(version);
34
+ if (!ver) return null;
35
+ const pkgSpec = `${pkgName}@${ver}`;
36
+ const url = `https://deno.bundlejs.com/?q=${encodeURIComponent(pkgSpec)}`;
37
+ const maxAttempts = 3;
38
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
39
+ if (attempt > 0) await new Promise(r => setTimeout(r, 900 * attempt));
40
+ try {
41
+ const res = await fetch(url, {
42
+ headers: {
43
+ Accept: 'application/json',
44
+ 'User-Agent': BUNDLE_API_UA,
45
+ },
46
+ signal: AbortSignal.timeout(60000),
47
+ });
48
+ if (res.status === 429 || res.status === 503) continue;
49
+ if (!res.ok) return null;
50
+ const ct = res.headers.get('content-type') || '';
51
+ if (!ct.includes('application/json')) return null;
52
+ const data = await res.json();
53
+ const s = data.size;
54
+ if (!s || typeof s !== 'object') return null;
55
+ const rawU = s.rawUncompressedSize;
56
+ const rawC = s.rawCompressedSize;
57
+ if (typeof rawU !== 'number' || typeof rawC !== 'number') return null;
58
+ if (!Number.isFinite(rawU) || !Number.isFinite(rawC)) return null;
59
+ return { size: rawU, gzip: rawC };
60
+ } catch {
61
+ if (attempt === maxAttempts - 1) return null;
62
+ }
29
63
  }
64
+ return null;
30
65
  }
31
66
 
67
+ /** Smaller/faster packument for semver resolution (full version list, slim per-version docs). */
32
68
  async function fetchResolvedPackageManifest(name, range) {
33
69
  const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
34
- const res = await fetch(url, { signal: AbortSignal.timeout(20000) });
70
+ const res = await fetch(url, {
71
+ headers: {
72
+ Accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8',
73
+ },
74
+ signal: AbortSignal.timeout(20000),
75
+ });
35
76
  if (!res.ok) return null;
36
77
  const data = await res.json();
37
78
  const versions = Object.keys(data.versions || {});
@@ -40,11 +81,25 @@ async function fetchResolvedPackageManifest(name, range) {
40
81
  return data.versions[best];
41
82
  }
42
83
 
84
+ /** Latest dist manifest only — used by --fix (avoids full `npm info` + huge JSON per package). */
85
+ async function fetchLatestPackageManifest(name) {
86
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
87
+ const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
88
+ if (!res.ok) return null;
89
+ return res.json();
90
+ }
91
+
43
92
  function registryFetchConcurrency() {
44
93
  const n = Number(process.env.PLEXUS_REGISTRY_CONCURRENCY || 10);
45
94
  return Math.max(1, Math.min(50, n > 0 ? n : 10));
46
95
  }
47
96
 
97
+ /** bundlejs.com builds are slow; keep concurrency low (`PLEXUS_BUNDLE_SIZE_CONCURRENCY`, default 3). */
98
+ function bundleSizeFetchConcurrency() {
99
+ const n = Number(process.env.PLEXUS_BUNDLE_SIZE_CONCURRENCY || 3);
100
+ return Math.max(1, Math.min(8, n > 0 ? n : 3));
101
+ }
102
+
48
103
  async function createRegistryContext(directDeps) {
49
104
  const pkgs = new Map();
50
105
  const names = Object.keys(directDeps);
@@ -80,6 +135,8 @@ module.exports = {
80
135
  queryNpm,
81
136
  getBundleSize,
82
137
  fetchResolvedPackageManifest,
138
+ fetchLatestPackageManifest,
83
139
  createRegistryContext,
84
140
  registryFetchConcurrency,
141
+ bundleSizeFetchConcurrency,
85
142
  };
@@ -8,6 +8,22 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
8
8
  meta.source === 'registry'
9
9
  ? ' — versions resolved from npm registry (no local node_modules)'
10
10
  : '';
11
+ const bundleSizeRequested = meta.bundleSizeRequested === true;
12
+ const directDepCount = Object.keys(directDeps).length;
13
+ const bundleResolvedCount = Object.keys(bundleSizes).length;
14
+ const bundleNote =
15
+ bundleSizeRequested && directDepCount > 0
16
+ ? bundleResolvedCount === 0
17
+ ? ' — Bundled gzip (bundlejs.com) was requested, but no sizes were returned (timeouts, build failures, or rate limits). Try again or lower PLEXUS_BUNDLE_SIZE_CONCURRENCY.'
18
+ : bundleResolvedCount < directDepCount
19
+ ? ` — Bundled gzip: ${bundleResolvedCount} of ${directDepCount} direct dependencies; missing entries show “gzip n/a” on the card.`
20
+ : ' — Bundled gzip estimates (esbuild via bundlejs.com) are shown on each direct dependency card.'
21
+ : '';
22
+ const rootVerTrim =
23
+ rootPkg.version != null && String(rootPkg.version).trim() !== ''
24
+ ? String(rootPkg.version).trim()
25
+ : '';
26
+ const heroVerHtml = rootVerTrim !== '' ? ` <span class="hero-ver">v${esc(rootVerTrim)}</span>` : '';
11
27
  const directSet = new Set(Object.keys(directDeps));
12
28
 
13
29
  const entries = Object.entries(graph).sort(([a], [b]) => a.localeCompare(b));
@@ -22,8 +38,9 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
22
38
  return acc + info.peerDeps.filter(p => p.ok).length;
23
39
  }, 0);
24
40
 
25
- // Size stats
41
+ // Size stats (registry / upload paths have no disk data — omit stat instead of "0 B")
26
42
  const totalDiskSize = entries.reduce((acc, [, info]) => acc + (info.diskSize ?? 0), 0);
43
+ const showDiskTotalStat = totalDiskSize > 0;
27
44
 
28
45
  // Abbreviate long semver ranges for compact display
29
46
  function shortRange(range) {
@@ -111,13 +128,28 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
111
128
  ? `<div class="required-by">peer of: ${info.requiredBy.map(r => `<a href="#pkg-${slug(r)}">${esc(r)}</a>`).join(', ')}</div>`
112
129
  : '';
113
130
 
114
- return `<div class="card ${statusClass}" id="pkg-${slug(pkgName)}" data-conflicts="${conflictPeers.length}" data-pkg="${esc(pkgName)}" data-size="${info.diskSize ?? 0}">
131
+ const versionTrim =
132
+ info.version != null && String(info.version).trim() !== '' ? String(info.version).trim() : '';
133
+ const versionBadge =
134
+ versionTrim !== ''
135
+ ? `<span class="pkg-version">v${esc(versionTrim)}</span>`
136
+ : info.missing
137
+ ? `<span class="pkg-version" title="Not installed">?</span>`
138
+ : '';
139
+
140
+ return `<div class="card ${statusClass}" id="pkg-${slug(pkgName)}" data-conflicts="${conflictPeers.length}" data-pkg="${esc(pkgName)}" data-size="${info.diskSize ?? 0}" data-bundle-gzip="${bundleInfo ? bundleInfo.gzip : 0}">
115
141
  <div class="card-header">
116
142
  <a href="${esc(npmPackageUrl(pkgName))}" target="_blank" class="pkg-name">${esc(pkgName)}</a>
117
143
  <span class="pkg-meta">
118
- <span class="pkg-version">v${info.missing ? '?' : esc(info.version)}</span>
144
+ ${versionBadge}
119
145
  ${sizeStr ? `<span class="size-badge ${sizeCls}">${esc(sizeStr)}</span>` : ''}
120
- ${bundleInfo ? `<span class="size-badge size-bundle" title="Bundle size (gzip)">gzip ${esc(formatBytes(bundleInfo.gzip))}</span>` : ''}
146
+ ${
147
+ bundleInfo
148
+ ? `<span class="size-badge size-bundle" title="Bundled + minified + gzip (bundlejs.com export * from pkg)">gzip ${esc(formatBytes(bundleInfo.gzip))}</span>`
149
+ : bundleSizeRequested && isDirect
150
+ ? `<span class="size-badge size-bundle-missing" title="bundlejs.com could not build this entry (invalid version, native-only package, timeout, or rate limit).">gzip n/a</span>`
151
+ : ''
152
+ }
121
153
  </span>
122
154
  <div class="badges">
123
155
  ${isDirect ? '<span class="badge badge-direct">direct</span>' : ''}
@@ -130,6 +162,30 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
130
162
  })
131
163
  .join('\n');
132
164
 
165
+ const hasConflictCards = entries.some(([, info]) =>
166
+ info.peerDeps.some(p => !p.ok && p.installed),
167
+ );
168
+ /** Show "No issues" only when there is at least one conflict (otherwise redundant with "All"). */
169
+ const showNoIssuesFilter = conflictCount > 0;
170
+
171
+ const toolbarRows = [
172
+ '<input type="text" id="search" placeholder="Search packages…">',
173
+ '<button type="button" class="filter-btn active" data-filter="all">All</button>',
174
+ ];
175
+ if (hasConflictCards) {
176
+ toolbarRows.push(
177
+ '<button type="button" class="filter-btn" data-filter="conflicts" id="plexus-filter-conflicts">Conflicts only</button>',
178
+ );
179
+ }
180
+ toolbarRows.push('<button type="button" class="filter-btn" data-filter="direct">Direct only</button>');
181
+ if (showNoIssuesFilter) {
182
+ toolbarRows.push('<button type="button" class="filter-btn" data-filter="ok">No issues</button>');
183
+ }
184
+ toolbarRows.push(
185
+ '<button type="button" class="filter-btn" id="sort-size-btn" style="margin-left:auto">Sort by size ↕</button>',
186
+ );
187
+ const toolbarHtml = `<div class="toolbar">\n ${toolbarRows.join('\n ')}\n</div>`;
188
+
133
189
  const conflictSummaryRows = entries
134
190
  .flatMap(([pkgName, info]) =>
135
191
  info.peerDeps
@@ -285,10 +341,23 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
285
341
  .copy-json-btn.copied { color: var(--green); border-color: rgba(134,239,172,0.35); }
286
342
  .json-block-wrap { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--code); }
287
343
  .json-block { margin: 0; background: transparent; border: none; border-radius: 0; padding: 20px 22px; font-size: 12px; font-family: ui-monospace, monospace; overflow-x: auto; white-space: pre; color: var(--text); line-height: 1.65; }
288
- .json-block mark { background: rgba(255,255,255,0.12); color: var(--text-bright); border-radius: 3px; padding: 0 2px; }
344
+ .json-block.json-block-suggested { line-height: 1.42; }
345
+ .json-block-header-text { min-width: 0; flex: 1; }
346
+ .json-block .json-line-changed {
347
+ display: inline;
348
+ padding: 0 6px 0 9px;
349
+ border-left: 3px solid var(--green);
350
+ border-radius: 0 2px 2px 0;
351
+ margin: 0 0 0 -12px;
352
+ background: linear-gradient(90deg, rgba(20,83,45,0.4) 0%, rgba(20,83,45,0.07) 62%, transparent 100%);
353
+ box-decoration-break: clone;
354
+ -webkit-box-decoration-break: clone;
355
+ color: var(--text-bright);
356
+ }
289
357
  .version-ok { color: var(--green); }
290
358
  .size-badge { font-size: 10px; padding: 2px 7px; border-radius: 6px; font-family: ui-monospace, monospace; }
291
359
  .size-bundle { background: rgba(63,63,70,0.45); color: var(--muted); border: 1px solid var(--border); }
360
+ .size-bundle-missing { background: rgba(63,63,70,0.25); color: var(--muted2); border: 1px dashed var(--border-hover); font-style: italic; }
292
361
  .size-large { background: rgba(127,29,29,0.35); color: #fca5a5; }
293
362
  .size-medium { background: rgba(120,53,15,0.35); color: var(--yellow); }
294
363
  .size-small { background: rgba(20,83,45,0.35); color: var(--green); }
@@ -303,8 +372,8 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
303
372
  <div class="shell">
304
373
  <header class="hero">
305
374
  <h1>Plexus</h1>
306
- <p class="hero-meta"><strong>${esc(rootPkg.name ?? '(unnamed)')}</strong> <span class="hero-ver">v${esc(rootPkg.version ?? '?')}</span></p>
307
- <p class="hero-sub">Peer dependency analysis${sourceNote}</p>
375
+ <p class="hero-meta"><strong>${esc(rootPkg.name ?? '(unnamed)')}</strong>${heroVerHtml}</p>
376
+ <p class="hero-sub">Peer dependency analysis${sourceNote}${bundleNote}</p>
308
377
  <p class="generated">Generated ${esc(new Date().toLocaleString())}</p>
309
378
  </header>
310
379
 
@@ -314,17 +383,14 @@ function renderHtml(graph, directDeps, rootPkg, resolutions = {}, bundleSizes =
314
383
  <div class="stat"><div class="stat-num num-ok">${okCount}</div><div class="stat-label">Satisfied peers</div></div>
315
384
  <div class="stat"><div class="stat-num num-conflict">${conflictCount}</div><div class="stat-label">Conflicts</div></div>
316
385
  <div class="stat"><div class="stat-num num-warning">${missingOptionalCount}</div><div class="stat-label">Missing optional</div></div>
317
- <div class="stat"><div class="stat-num num-neutral" style="font-size:17px">${esc(formatBytes(totalDiskSize))}</div><div class="stat-label">Total disk (node_modules)</div></div>
386
+ ${
387
+ showDiskTotalStat
388
+ ? ` <div class="stat"><div class="stat-num num-neutral" style="font-size:17px">${esc(formatBytes(totalDiskSize))}</div><div class="stat-label">Total disk (node_modules)</div></div>`
389
+ : ''
390
+ }
318
391
  </div>
319
392
 
320
- <div class="toolbar">
321
- <input type="text" id="search" placeholder="Search packages…">
322
- <button class="filter-btn active" data-filter="all">All</button>
323
- <button class="filter-btn" data-filter="conflicts">Conflicts only</button>
324
- <button class="filter-btn" data-filter="direct">Direct only</button>
325
- <button class="filter-btn" data-filter="ok">No issues</button>
326
- <button class="filter-btn" id="sort-size-btn" style="margin-left:auto">Sort by size ↕</button>
327
- </div>
393
+ ${toolbarHtml}
328
394
 
329
395
  <div class="grid-wrap">
330
396
  <h2 class="grid-heading">Packages</h2>
@@ -368,7 +434,7 @@ ${
368
434
  return `<tr>
369
435
  <td><a href="${esc(npmPackageUrl(pkg))}" target="_blank" class="pkg-link">${esc(pkg)}</a></td>
370
436
  <td><code>${esc(r.current)}</code> → <code class="version-ok">^${esc(r.latest)}</code></td>
371
- <td>${fixesHtml}${stillHtml}</td>
437
+ <td>${fixesHtml}${stillHtml ? ` ${stillHtml}` : ''}</td>
372
438
  <td>${cascadeHtml}</td>
373
439
  </tr>`;
374
440
  })
@@ -393,33 +459,32 @@ ${
393
459
  if (suggested.dependencies?.[pkg]) suggested.dependencies[pkg] = `^${r.latest}`;
394
460
  if (suggested.devDependencies?.[pkg]) suggested.devDependencies[pkg] = `^${r.latest}`;
395
461
  }
396
- const changed = Object.keys(resolutions);
397
- const markRe = new RegExp(
398
- `"(${changed.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})"`,
399
- 'g',
400
- );
401
- const markHolders = [];
462
+ const changedSet = new Set(Object.keys(resolutions));
463
+ const depLineRe = /^(\s*)("([^"]+)":\s*"[^"]*")(,?)$/;
402
464
  let depsJson = JSON.stringify(
403
465
  { dependencies: suggested.dependencies, devDependencies: suggested.devDependencies },
404
466
  null,
405
467
  2,
406
468
  );
407
- depsJson = depsJson.replace(markRe, (_, pkgName) => {
408
- const h = `<mark>&quot;${esc(pkgName)}&quot;</mark>`;
409
- markHolders.push(h);
410
- return `__PLEXUS_M${markHolders.length - 1}__`;
411
- });
412
- depsJson = esc(depsJson);
413
- markHolders.forEach((h, i) => {
414
- depsJson = depsJson.replace(`__PLEXUS_M${i}__`, h);
415
- });
469
+ depsJson = depsJson
470
+ .split('\n')
471
+ .map(line => {
472
+ const m = line.match(depLineRe);
473
+ if (m && changedSet.has(m[3])) {
474
+ return `<span class="json-line-changed">${esc(m[1] + m[2] + (m[4] || ''))}</span>`;
475
+ }
476
+ return esc(line);
477
+ })
478
+ .join('\n');
416
479
  return `<div class="conflict-table-section">
417
480
  <div class="json-block-header">
418
- <h2 class="h2-fix">Suggested package.json (highlighted = changed)</h2>
481
+ <div class="json-block-header-text">
482
+ <h2 class="h2-fix">Suggested package.json</h2>
483
+ </div>
419
484
  <button type="button" class="copy-json-btn" id="copy-suggested-json" aria-label="Copy JSON to clipboard">Copy JSON</button>
420
485
  </div>
421
486
  <div class="json-block-wrap">
422
- <pre class="json-block" id="suggested-json-pre">${depsJson}</pre>
487
+ <pre class="json-block json-block-suggested" id="suggested-json-pre">${depsJson}</pre>
423
488
  </div>
424
489
  </div>`;
425
490
  })()
@@ -437,43 +502,53 @@ ${
437
502
  arrow.textContent = rows.classList.contains('hidden') ? '▸' : '▾';
438
503
  }
439
504
 
440
- document.addEventListener('DOMContentLoaded', function() {
505
+ function fallbackCopyJson(text) {
506
+ try {
507
+ var ta = document.createElement('textarea');
508
+ ta.value = text;
509
+ ta.setAttribute('readonly', '');
510
+ ta.style.position = 'fixed';
511
+ ta.style.left = '-9999px';
512
+ document.body.appendChild(ta);
513
+ ta.select();
514
+ var ok = document.execCommand('copy');
515
+ document.body.removeChild(ta);
516
+ return ok;
517
+ } catch (e) {
518
+ return false;
519
+ }
520
+ }
521
+
522
+ /* Run immediately: DOMContentLoaded often does not fire after document.write() / blob report. */
523
+ (function () {
441
524
  var currentFilter = 'all';
442
525
  var sortedBySize = false;
443
526
  var searchInput = document.getElementById('search');
444
527
  var grid = document.getElementById('grid');
528
+ if (!grid) return;
445
529
 
446
- searchInput.addEventListener('input', filterCards);
530
+ var conflictsBtn = document.getElementById('plexus-filter-conflicts');
531
+ if (conflictsBtn && !grid.querySelector('.card-conflict')) {
532
+ conflictsBtn.remove();
533
+ }
447
534
 
448
- document.querySelectorAll('.filter-btn[data-filter]').forEach(function(btn) {
449
- btn.addEventListener('click', function() {
450
- currentFilter = btn.getAttribute('data-filter');
451
- document.querySelectorAll('.filter-btn[data-filter]').forEach(function(b) { b.classList.remove('active'); });
452
- btn.classList.add('active');
453
- filterCards();
454
- });
455
- });
535
+ function effectiveSize(card) {
536
+ var disk = parseInt(card.getAttribute('data-size') || '0', 10) || 0;
537
+ var gzip = parseInt(card.getAttribute('data-bundle-gzip') || '0', 10) || 0;
538
+ return disk > 0 ? disk : gzip;
539
+ }
456
540
 
457
- var sortBtn = document.getElementById('sort-size-btn');
458
- if (sortBtn) {
459
- sortBtn.addEventListener('click', function() {
460
- sortedBySize = !sortedBySize;
461
- sortBtn.classList.toggle('active', sortedBySize);
462
- sortBtn.textContent = sortedBySize ? 'Sort by name ↕' : 'Sort by size ↕';
463
- var cards = Array.from(grid.querySelectorAll('.card'));
464
- cards.sort(function(a, b) {
465
- if (sortedBySize) {
466
- return parseInt(b.getAttribute('data-size') || 0) - parseInt(a.getAttribute('data-size') || 0);
467
- }
468
- return (a.getAttribute('data-pkg') || '').localeCompare(b.getAttribute('data-pkg') || '');
469
- });
470
- cards.forEach(function(card) { grid.appendChild(card); });
541
+ function getGridCards() {
542
+ var g = document.getElementById('grid');
543
+ if (!g) return [];
544
+ return Array.from(g.children).filter(function (el) {
545
+ return el.nodeType === 1 && el.classList.contains('card');
471
546
  });
472
547
  }
473
548
 
474
549
  function filterCards() {
475
- var search = searchInput.value.toLowerCase();
476
- document.querySelectorAll('.card').forEach(function(card) {
550
+ var search = (searchInput && searchInput.value) ? searchInput.value.toLowerCase() : '';
551
+ getGridCards().forEach(function (card) {
477
552
  var pkg = (card.getAttribute('data-pkg') || '').toLowerCase();
478
553
  var hasConflict = card.classList.contains('card-conflict');
479
554
  var hasWarning = card.classList.contains('card-warning');
@@ -490,6 +565,89 @@ ${
490
565
  });
491
566
  }
492
567
 
568
+ if (searchInput) searchInput.addEventListener('input', filterCards);
569
+
570
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(function (btn) {
571
+ btn.addEventListener('click', function () {
572
+ currentFilter = btn.getAttribute('data-filter');
573
+ document.querySelectorAll('.filter-btn[data-filter]').forEach(function (b) {
574
+ b.classList.remove('active');
575
+ });
576
+ btn.classList.add('active');
577
+ filterCards();
578
+ });
579
+ });
580
+
581
+ /* Delegated click: survives edge cases where target is a text node; always use live #grid. */
582
+ document.addEventListener(
583
+ 'click',
584
+ function (e) {
585
+ var t = e.target;
586
+ while (t && t.nodeType !== 1) t = t.parentNode;
587
+ var sortBtn = t && t.closest ? t.closest('#sort-size-btn') : null;
588
+ if (!sortBtn) return;
589
+ try {
590
+ var gridEl = document.getElementById('grid');
591
+ if (!gridEl) return;
592
+
593
+ var nextBySize = !sortedBySize;
594
+ var cards = Array.from(gridEl.children).filter(function (el) {
595
+ return el.nodeType === 1 && el.classList.contains('card');
596
+ });
597
+ if (cards.length === 0) return;
598
+
599
+ var pkgKey = function (c) {
600
+ return (c.getAttribute('data-pkg') || '').toLowerCase();
601
+ };
602
+ var eff = cards.map(function (c) {
603
+ return effectiveSize(c);
604
+ });
605
+ var allSameSizes =
606
+ eff.length > 0 && eff.every(function (s) {
607
+ return s === eff[0];
608
+ });
609
+
610
+ cards.sort(function (a, b) {
611
+ if (nextBySize) {
612
+ var sa = effectiveSize(a);
613
+ var sb = effectiveSize(b);
614
+ if (sb !== sa) return sb - sa;
615
+ /* All sizes tied (often 0): reverse name vs initial report order so the grid visibly changes */
616
+ if (allSameSizes) return pkgKey(b).localeCompare(pkgKey(a));
617
+ return pkgKey(a).localeCompare(pkgKey(b));
618
+ }
619
+ return pkgKey(a).localeCompare(pkgKey(b));
620
+ });
621
+
622
+ while (gridEl.firstChild) gridEl.removeChild(gridEl.firstChild);
623
+ var frag = document.createDocumentFragment();
624
+ for (var i = 0; i < cards.length; i++) {
625
+ frag.appendChild(cards[i]);
626
+ }
627
+ gridEl.appendChild(frag);
628
+ void gridEl.offsetWidth;
629
+
630
+ sortedBySize = nextBySize;
631
+ sortBtn.classList.toggle('active', sortedBySize);
632
+ sortBtn.textContent = sortedBySize ? 'Sort by name ↕' : 'Sort by size ↕';
633
+
634
+ console.log('[plexus] sort applied', {
635
+ mode: sortedBySize ? 'by_size' : 'by_name',
636
+ cards: cards.length,
637
+ allSameSizes: nextBySize ? allSameSizes : null,
638
+ firstThree: Array.prototype.slice.call(gridEl.children, 0, 3).map(function (n) {
639
+ return n.getAttribute('data-pkg');
640
+ }),
641
+ });
642
+
643
+ filterCards();
644
+ } catch (err) {
645
+ console.error('[plexus] sort failed', err);
646
+ }
647
+ },
648
+ false,
649
+ );
650
+
493
651
  var copyJsonBtn = document.getElementById('copy-suggested-json');
494
652
  var suggestedJsonPre = document.getElementById('suggested-json-pre');
495
653
  if (copyJsonBtn && suggestedJsonPre) {
@@ -505,34 +663,22 @@ ${
505
663
  }, 2000);
506
664
  }
507
665
  if (navigator.clipboard && navigator.clipboard.writeText) {
508
- navigator.clipboard.writeText(text).then(function () { done(true); }).catch(function () {
509
- if (fallbackCopyJson(text)) done(true);
510
- else done(false);
511
- });
666
+ navigator.clipboard
667
+ .writeText(text)
668
+ .then(function () {
669
+ done(true);
670
+ })
671
+ .catch(function () {
672
+ if (fallbackCopyJson(text)) done(true);
673
+ else done(false);
674
+ });
512
675
  } else {
513
676
  if (fallbackCopyJson(text)) done(true);
514
677
  else done(false);
515
678
  }
516
679
  });
517
680
  }
518
- });
519
-
520
- function fallbackCopyJson(text) {
521
- try {
522
- var ta = document.createElement('textarea');
523
- ta.value = text;
524
- ta.setAttribute('readonly', '');
525
- ta.style.position = 'fixed';
526
- ta.style.left = '-9999px';
527
- document.body.appendChild(ta);
528
- ta.select();
529
- var ok = document.execCommand('copy');
530
- document.body.removeChild(ta);
531
- return ok;
532
- } catch (e) {
533
- return false;
534
- }
535
- }
681
+ })();
536
682
  </script>
537
683
  </body>
538
684
  </html>`;
@@ -8,7 +8,7 @@ const { color } = require('./ansi');
8
8
  const { formatBytes } = require('./format');
9
9
  const { createFsContext, buildGraph } = require('./graph');
10
10
  const { resolveConflicts } = require('./fix');
11
- const { getBundleSize, createRegistryContext, registryFetchConcurrency } = require('./npm');
11
+ const { getBundleSize, createRegistryContext, bundleSizeFetchConcurrency } = require('./npm');
12
12
  const { renderHtml } = require('./render-html');
13
13
  const { printGraph, printSummary } = require('./terminal');
14
14
 
@@ -46,6 +46,12 @@ async function runFilesystemAnalysis(opts) {
46
46
  ...(rootPkg.devDependencies ?? {}),
47
47
  };
48
48
 
49
+ if (Object.keys(directDeps).length === 0) {
50
+ console.log(color('No dependencies or devDependencies in package.json — nothing to analyze.', 'red'));
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+
49
55
  if (focusPkg && !directDeps[focusPkg]) {
50
56
  console.log(color(`Package "${focusPkg}" not found in dependencies.`, 'red'));
51
57
  process.exitCode = 1;
@@ -56,7 +62,7 @@ async function runFilesystemAnalysis(opts) {
56
62
  const graph = buildGraph(directDeps, ctx);
57
63
 
58
64
  const resolutions = fixMode
59
- ? resolveConflicts(graph, directDeps, ctx.getInstalledVersion).resolutions
65
+ ? (await resolveConflicts(graph, directDeps, ctx.getInstalledVersion)).resolutions
60
66
  : {};
61
67
 
62
68
  if (fixMode && !htmlMode) {
@@ -94,7 +100,7 @@ async function runFilesystemAnalysis(opts) {
94
100
  let bundleSizes = {};
95
101
  if (bundleSizeMode) {
96
102
  const directKeys = Object.keys(directDeps);
97
- console.log(color(`\nQuerying bundlephobia for ${directKeys.length} packages…`, 'gray'));
103
+ console.log(color(`\nQuerying bundlejs.com sizes for ${directKeys.length} packages…`, 'gray'));
98
104
  for (const pkg of directKeys) {
99
105
  const ver = ctx.getInstalledVersion(pkg);
100
106
  if (!ver) continue;
@@ -111,6 +117,7 @@ async function runFilesystemAnalysis(opts) {
111
117
 
112
118
  const html = renderHtml(graph, directDeps, rootPkg, resolutions, bundleSizes, {
113
119
  source: 'filesystem',
120
+ bundleSizeRequested: bundleSizeMode,
114
121
  });
115
122
  const outPath = outFile ? path.resolve(outFile) : path.join(rootDir, 'dep-graph.html');
116
123
  fs.writeFileSync(outPath, html, 'utf8');
@@ -141,6 +148,13 @@ async function runRegistryAnalysis(rootPkg, flags = {}) {
141
148
  };
142
149
  const maxDirect = Math.max(1, Number(process.env.PLEXUS_MAX_DIRECT_DEPS || 250) || 250);
143
150
  const directCount = Object.keys(directDeps).length;
151
+ if (directCount === 0) {
152
+ const err = new Error(
153
+ 'This package.json has no dependencies or devDependencies — nothing to analyze.',
154
+ );
155
+ err.statusCode = 400;
156
+ throw err;
157
+ }
144
158
  if (directCount > maxDirect) {
145
159
  const err = new Error(`Too many direct dependencies (${directCount}; max ${maxDirect}).`);
146
160
  err.statusCode = 400;
@@ -150,12 +164,12 @@ async function runRegistryAnalysis(rootPkg, flags = {}) {
150
164
  const graph = buildGraph(directDeps, ctx);
151
165
  const fixMode = flags.fix === true;
152
166
  const resolutions = fixMode
153
- ? resolveConflicts(graph, directDeps, ctx.getInstalledVersion, { silent: true }).resolutions
167
+ ? (await resolveConflicts(graph, directDeps, ctx.getInstalledVersion, { silent: true })).resolutions
154
168
  : {};
155
169
  let bundleSizes = {};
156
170
  if (flags.bundleSize) {
157
171
  const names = Object.keys(directDeps);
158
- const limit = registryFetchConcurrency();
172
+ const limit = bundleSizeFetchConcurrency();
159
173
  for (let i = 0; i < names.length; i += limit) {
160
174
  const batch = names.slice(i, i + limit);
161
175
  await Promise.all(
@@ -170,6 +184,7 @@ async function runRegistryAnalysis(rootPkg, flags = {}) {
170
184
  }
171
185
  const html = renderHtml(graph, directDeps, rootPkg, resolutions, bundleSizes, {
172
186
  source: 'registry',
187
+ bundleSizeRequested: flags.bundleSize === true,
173
188
  });
174
189
  return { graph, directDeps, resolutions, bundleSizes, html };
175
190
  }
package/lib/server.js CHANGED
@@ -27,10 +27,28 @@ function corsForPages(req, res, next) {
27
27
  next();
28
28
  }
29
29
 
30
+ /** One JSON line per /api request for Render logs and log drains (duration_ms, status). */
31
+ function structuredApiLog(req, res, next) {
32
+ if (!req.path.startsWith('/api')) return next();
33
+ const start = Date.now();
34
+ res.on('finish', () => {
35
+ const line = JSON.stringify({
36
+ event: 'api_call',
37
+ path: req.path,
38
+ method: req.method,
39
+ duration_ms: Date.now() - start,
40
+ status: res.statusCode,
41
+ });
42
+ console.log(line);
43
+ });
44
+ next();
45
+ }
46
+
30
47
  function createApp() {
31
48
  const app = express();
32
49
  app.use(corsForPages);
33
50
  app.use(express.json({ limit: '3mb' }));
51
+ app.use(structuredApiLog);
34
52
 
35
53
  const publicDir = path.join(__dirname, '..', 'public');
36
54
  app.use(express.static(publicDir));
package/lib/terminal.js CHANGED
@@ -18,9 +18,13 @@ function printGraph(graph, directDeps, options = {}) {
18
18
  if (conflictsOnly && info.peerDeps.every(p => p.ok)) continue;
19
19
 
20
20
  const isDirect = directSet.has(pkgName);
21
+ const vTrim =
22
+ info.version != null && String(info.version).trim() !== '' ? String(info.version).trim() : '';
21
23
  const versionStr = info.missing
22
24
  ? color('NOT INSTALLED', 'red', 'bold')
23
- : color(`v${info.version}`, 'gray');
25
+ : vTrim !== ''
26
+ ? color(`v${vTrim}`, 'gray')
27
+ : color('—', 'gray');
24
28
 
25
29
  const tag = isDirect ? color(' [direct]', 'blue') : '';
26
30
  console.log(`\n${color(pkgName, 'bold', 'cyan')}${tag} ${versionStr}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plexus-peers",
3
- "version": "0.2.0",
4
- "description": "Plexus — CLI and mini web UI for peer dependency analysis and BundlePhobia-style size hints",
3
+ "version": "0.2.2",
4
+ "description": "Plexus — CLI and mini web UI for peer dependency analysis",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -21,11 +21,17 @@
21
21
  },
22
22
  "scripts": {
23
23
  "start": "node bin/plexus.js serve",
24
+ "start:dev": "nodemon -q --watch lib --watch bin --watch public --ext js,cjs,mjs,html,css --delay 200ms bin/plexus.js serve",
24
25
  "plexus": "node bin/plexus.js",
25
- "plexus-peers": "node bin/plexus.js"
26
+ "plexus-peers": "node bin/plexus.js",
27
+ "build:css": "tailwindcss -i ./tailwind.source.css -o ./public/plexus.css --minify"
26
28
  },
27
29
  "dependencies": {
28
30
  "express": "^4.21.2",
29
31
  "semver": "^7.7.1"
32
+ },
33
+ "devDependencies": {
34
+ "nodemon": "^3.1.11",
35
+ "tailwindcss": "^3.4.17"
30
36
  }
31
37
  }
package/public/index.html CHANGED
@@ -6,24 +6,7 @@
6
6
  <!-- Optional: override API host (e.g. custom GitHub Pages domain). Empty = rules in script below. -->
7
7
  <meta name="plexus-api-base" content="">
8
8
  <title>Plexus — package.json analysis</title>
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script>
11
- tailwind.config = {
12
- theme: {
13
- extend: {
14
- colors: {
15
- plexus: {
16
- void: '#050505',
17
- base: '#0a0a0a',
18
- raised: '#111111',
19
- subtle: '#171717',
20
- code: '#1c1c1c',
21
- },
22
- },
23
- },
24
- },
25
- };
26
- </script>
9
+ <link rel="stylesheet" href="plexus.css">
27
10
  </head>
28
11
  <body class="relative box-border min-h-screen overflow-x-hidden bg-plexus-base text-zinc-300 font-sans antialiased">
29
12
  <!-- Ambient background (low contrast, non-interactive) -->
@@ -42,7 +25,7 @@
42
25
  <h1 class="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.85),0_0_40px_rgba(10,10,10,0.9)]">
43
26
  Plexus
44
27
  </h1>
45
- <span class="inline-flex items-center rounded-lg border border-zinc-700/80 bg-plexus-code px-2.5 py-1 text-xs sm:text-sm font-mono font-medium text-zinc-400 tabular-nums shrink-0" translate="no">v0.2.0</span>
28
+ <span class="inline-flex items-center rounded-lg border border-zinc-700/80 bg-plexus-code px-2.5 py-1 text-xs sm:text-sm font-mono font-medium text-zinc-400 tabular-nums shrink-0" translate="no">v0.2.1</span>
46
29
  </div>
47
30
  <p class="text-sm text-zinc-500 mb-5 md:mb-6">
48
31
  <a href="https://github.com/arnaudmanaranche/plexus" target="_blank" rel="noopener noreferrer" class="text-zinc-400 hover:text-white underline-offset-4 hover:underline transition-colors">GitHub</a>
@@ -54,34 +37,40 @@
54
37
  <code class="text-base sm:text-lg md:text-xl font-mono text-zinc-400 bg-plexus-code px-2 py-0.5 rounded-md">node_modules</code>.
55
38
  </p>
56
39
 
57
- <div class="relative z-[1] w-full max-w-2xl mx-auto rounded-2xl border border-zinc-800/90 bg-plexus-raised/92 backdrop-blur-md p-8 sm:p-10 md:p-12 text-left shadow-[0_24px_80px_rgba(0,0,0,0.45),0_0_0_1px_rgba(255,255,255,0.04)]">
58
- <label class="block border-2 border-dashed border-zinc-800 rounded-xl py-14 sm:py-16 px-6 text-center cursor-pointer text-zinc-500 text-lg sm:text-xl transition-colors duration-200 hover:border-zinc-600 hover:text-zinc-300 hover:bg-plexus-subtle/50">
59
- <input type="file" id="file" class="hidden" accept=".json,application/json">
60
- <span id="fileLabel">Choose a package.json file</span>
61
- </label>
62
-
63
- <div class="mt-8 flex flex-col gap-4">
64
- <label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
65
- <input type="checkbox" id="optFix" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
66
- <span>Include <code class="text-sm font-mono text-zinc-400 bg-plexus-code px-1.5 py-0.5 rounded">--fix</code> resolution plan (extra npm requests)</span>
67
- </label>
68
- <label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
69
- <input type="checkbox" id="optBundle" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
70
- <span>BundlePhobia sizes (slower)</span>
40
+ <div id="zone" class="relative z-[1] w-full max-w-2xl mx-auto rounded-2xl border border-zinc-800/90 bg-plexus-raised/92 backdrop-blur-md p-8 sm:p-10 md:p-12 text-left shadow-[0_24px_80px_rgba(0,0,0,0.45),0_0_0_1px_rgba(255,255,255,0.04)]">
41
+ <div id="zone-panel-upload">
42
+ <label class="block border-2 border-dashed border-zinc-800 rounded-xl py-14 sm:py-16 px-6 text-center cursor-pointer text-zinc-500 text-lg sm:text-xl transition-colors duration-200 hover:border-zinc-600 hover:text-zinc-300 hover:bg-plexus-subtle/50">
43
+ <input type="file" id="file" class="hidden" accept=".json,application/json">
44
+ <span id="fileLabel">Choose a package.json file</span>
71
45
  </label>
72
- </div>
73
46
 
74
- <button type="button" id="run" disabled
75
- class="mt-10 w-full py-4 sm:py-5 rounded-xl bg-zinc-200 text-zinc-950 text-lg sm:text-xl font-semibold tracking-tight cursor-pointer transition-colors hover:bg-white disabled:opacity-40 disabled:hover:bg-zinc-200 disabled:cursor-not-allowed">
76
- Generate report
77
- </button>
78
-
79
- <p class="mt-8 text-center text-sm sm:text-base text-zinc-600 leading-relaxed">
80
- CLI:
81
- <code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">npx plexus-peers --html</code>
82
- from your project root for disk sizes and transitive peers via
83
- <code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">node_modules</code>.
84
- </p>
47
+ <div class="mt-8 flex flex-col gap-4">
48
+ <label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
49
+ <input type="checkbox" id="optFix" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
50
+ <span>Include resolution plan (extra npm requests)</span>
51
+ </label>
52
+ <label class="flex items-start gap-3 cursor-pointer text-zinc-500 text-base sm:text-lg select-none leading-snug">
53
+ <input type="checkbox" id="optBundle" class="mt-1 size-4 shrink-0 rounded border-zinc-700 bg-plexus-subtle text-zinc-200 focus:ring-2 focus:ring-zinc-600 focus:ring-offset-0 focus:ring-offset-plexus-base accent-zinc-300">
54
+ <span>Bundle size via bundlejs.com (slower)</span>
55
+ </label>
56
+ </div>
57
+
58
+ <button type="button" id="run" disabled
59
+ class="mt-10 w-full py-4 sm:py-5 rounded-xl bg-zinc-200 text-zinc-950 text-lg sm:text-xl font-semibold tracking-tight cursor-pointer transition-colors hover:bg-white disabled:opacity-40 disabled:hover:bg-zinc-200 disabled:cursor-not-allowed">
60
+ Generate report
61
+ </button>
62
+ <p class="mt-3 text-center text-xs sm:text-sm text-zinc-600 leading-snug">
63
+ Large manifests can take 20–90s (longer with <span class="text-zinc-500">bundlejs.com</span> size checks).
64
+ </p>
65
+
66
+ <p class="mt-8 text-center text-sm sm:text-base text-zinc-600 leading-relaxed">
67
+ CLI:
68
+ <code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">npx plexus-peers --html</code>
69
+ from your project root for disk sizes and transitive peers via
70
+ <code class="font-mono text-zinc-500 bg-plexus-code px-2 py-0.5 rounded-md">node_modules</code>.
71
+ </p>
72
+ </div>
73
+ <div id="zone-panel-progress" class="hidden" aria-live="polite" aria-busy="true"></div>
85
74
  </div>
86
75
  </div>
87
76
  </main>
@@ -104,7 +93,267 @@
104
93
  var toastTimer = null;
105
94
  var optFix = document.getElementById('optFix');
106
95
  var optBundle = document.getElementById('optBundle');
96
+ var uploadPanel = document.getElementById('zone-panel-upload');
97
+ var progressPanel = document.getElementById('zone-panel-progress');
98
+ var zoneEl = document.getElementById('zone');
107
99
  var parsed = null;
100
+ var progressSimTimer = null;
101
+
102
+ function directDepCount(pkg) {
103
+ if (!pkg || typeof pkg !== 'object') return 0;
104
+ var d = pkg.dependencies;
105
+ var dev = pkg.devDependencies;
106
+ var n = 0;
107
+ if (d && typeof d === 'object' && !Array.isArray(d)) n += Object.keys(d).length;
108
+ if (dev && typeof dev === 'object' && !Array.isArray(dev)) n += Object.keys(dev).length;
109
+ return n;
110
+ }
111
+
112
+ /** Direct dependency names in deterministic order (matches server merge: deps then devDeps keys, sorted). */
113
+ function getDirectDepNames(pkg) {
114
+ var d =
115
+ pkg.dependencies && typeof pkg.dependencies === 'object' && !Array.isArray(pkg.dependencies)
116
+ ? pkg.dependencies
117
+ : {};
118
+ var dev =
119
+ pkg.devDependencies &&
120
+ typeof pkg.devDependencies === 'object' &&
121
+ !Array.isArray(pkg.devDependencies)
122
+ ? pkg.devDependencies
123
+ : {};
124
+ var seen = Object.create(null);
125
+ var out = [];
126
+ Object.keys(d).forEach(function (k) {
127
+ if (!seen[k]) {
128
+ seen[k] = true;
129
+ out.push(k);
130
+ }
131
+ });
132
+ Object.keys(dev).forEach(function (k) {
133
+ if (!seen[k]) {
134
+ seen[k] = true;
135
+ out.push(k);
136
+ }
137
+ });
138
+ out.sort(function (a, b) {
139
+ return a.localeCompare(b);
140
+ });
141
+ return out;
142
+ }
143
+
144
+ function stopProgressSimulation() {
145
+ if (progressSimTimer) {
146
+ clearInterval(progressSimTimer);
147
+ progressSimTimer = null;
148
+ }
149
+ }
150
+
151
+ function showDependencyProgress(depNames) {
152
+ stopProgressSimulation();
153
+ uploadPanel.classList.add('hidden');
154
+ progressPanel.classList.remove('hidden');
155
+ progressPanel.setAttribute('aria-busy', 'true');
156
+ zoneEl.setAttribute('aria-busy', 'true');
157
+
158
+ progressPanel.innerHTML = '';
159
+ var n = depNames.length;
160
+
161
+ var title = document.createElement('p');
162
+ title.className = 'text-zinc-200 text-lg font-medium mb-4 text-center';
163
+ title.textContent = 'Analyzing dependencies…';
164
+
165
+ var pill = document.createElement('div');
166
+ pill.className =
167
+ 'mx-auto max-w-md rounded-full border border-zinc-700/75 bg-plexus-void/60 px-6 py-5 sm:px-8 sm:py-6 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]';
168
+
169
+ var stack = document.createElement('div');
170
+ stack.className = 'flex flex-col items-center justify-center gap-1.5 text-center font-mono';
171
+
172
+ var rowTop = document.createElement('div');
173
+ rowTop.className =
174
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center gap-2 text-xs sm:text-sm transition-all duration-500 ease-out';
175
+ var rowMid = document.createElement('div');
176
+ rowMid.className =
177
+ 'plexus-pill-row-mid flex min-h-[1.6rem] w-full max-w-[20rem] items-center justify-center gap-2 px-2 text-sm sm:text-base font-medium text-zinc-100 transition-all duration-500 ease-out';
178
+ var rowBot = document.createElement('div');
179
+ rowBot.className =
180
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center gap-2 text-xs sm:text-sm transition-all duration-500 ease-out';
181
+
182
+ stack.appendChild(rowTop);
183
+ stack.appendChild(rowMid);
184
+ stack.appendChild(rowBot);
185
+ pill.appendChild(stack);
186
+
187
+ var countEl = document.createElement('p');
188
+ countEl.className = 'mt-4 text-center text-xs text-zinc-600 tabular-nums';
189
+
190
+ progressPanel.appendChild(title);
191
+ progressPanel.appendChild(pill);
192
+ progressPanel.appendChild(countEl);
193
+
194
+ function rowLine(iconText, iconClass, pkgName) {
195
+ var frag = document.createDocumentFragment();
196
+ if (iconText) {
197
+ var ic = document.createElement('span');
198
+ ic.className = iconClass;
199
+ ic.textContent = iconText;
200
+ ic.setAttribute('aria-hidden', 'true');
201
+ frag.appendChild(ic);
202
+ }
203
+ var nm = document.createElement('span');
204
+ nm.className = 'truncate';
205
+ nm.textContent = pkgName || '';
206
+ frag.appendChild(nm);
207
+ return frag;
208
+ }
209
+
210
+ function clearRow(el) {
211
+ while (el.firstChild) {
212
+ el.removeChild(el.firstChild);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * step = number of packages already finished; middle scans depNames[step] when step < n.
218
+ * step === n: all done (briefly before report opens).
219
+ */
220
+ function paint(step) {
221
+ clearRow(rowTop);
222
+
223
+ if (step < n) {
224
+ if (step > 0) {
225
+ rowTop.className =
226
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center gap-2 text-xs sm:text-sm opacity-45 text-zinc-500 transition-all duration-500 ease-out';
227
+ rowTop.appendChild(
228
+ rowLine('✓', 'shrink-0 text-emerald-400/85', depNames[step - 1]),
229
+ );
230
+ } else {
231
+ rowTop.className =
232
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center text-xs sm:text-sm opacity-0 pointer-events-none transition-all duration-500 ease-out';
233
+ }
234
+
235
+ clearRow(rowMid);
236
+ rowMid.className =
237
+ 'plexus-pill-row-mid flex min-h-[1.6rem] w-full max-w-[20rem] items-center justify-center gap-2 px-2 text-sm sm:text-base font-medium text-zinc-100 transition-all duration-500 ease-out';
238
+ var scanIcon = document.createElement('span');
239
+ scanIcon.className =
240
+ 'shrink-0 text-amber-400 animate-[pulse_2.2s_ease-in-out_infinite]';
241
+ scanIcon.textContent = '◐';
242
+ scanIcon.setAttribute('aria-hidden', 'true');
243
+ rowMid.appendChild(scanIcon);
244
+ var midName = document.createElement('span');
245
+ midName.className = 'truncate text-zinc-100';
246
+ midName.textContent = depNames[step];
247
+ rowMid.appendChild(midName);
248
+
249
+ clearRow(rowBot);
250
+ if (step < n - 1) {
251
+ rowBot.className =
252
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center gap-2 text-xs sm:text-sm opacity-40 text-zinc-500 transition-all duration-500 ease-out';
253
+ var inIcon = document.createElement('span');
254
+ inIcon.className = 'shrink-0 text-zinc-600';
255
+ inIcon.textContent = '·';
256
+ inIcon.setAttribute('aria-hidden', 'true');
257
+ rowBot.appendChild(inIcon);
258
+ var botName = document.createElement('span');
259
+ botName.className = 'truncate';
260
+ botName.textContent = depNames[step + 1];
261
+ rowBot.appendChild(botName);
262
+ } else {
263
+ rowBot.className =
264
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center text-xs sm:text-sm opacity-0 pointer-events-none transition-all duration-500 ease-out';
265
+ }
266
+
267
+ countEl.textContent = Math.min(step + 1, n) + ' / ' + n;
268
+ } else {
269
+ clearRow(rowTop);
270
+ clearRow(rowMid);
271
+ clearRow(rowBot);
272
+
273
+ if (n === 1) {
274
+ rowTop.className =
275
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center text-xs sm:text-sm opacity-0';
276
+ rowMid.className =
277
+ 'plexus-pill-row-mid flex min-h-[1.6rem] w-full max-w-[20rem] items-center justify-center gap-2 px-2 text-sm sm:text-base font-medium text-zinc-100';
278
+ rowMid.appendChild(rowLine('✓', 'shrink-0 text-emerald-400', depNames[0]));
279
+ rowBot.className =
280
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center opacity-0';
281
+ } else {
282
+ rowTop.className =
283
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center gap-2 text-xs sm:text-sm opacity-45 text-zinc-500';
284
+ rowTop.appendChild(
285
+ rowLine('✓', 'shrink-0 text-emerald-400/85', depNames[n - 2]),
286
+ );
287
+ rowMid.className =
288
+ 'plexus-pill-row-mid flex min-h-[1.6rem] w-full max-w-[20rem] items-center justify-center gap-2 px-2 text-sm sm:text-base font-medium text-zinc-100';
289
+ rowMid.appendChild(rowLine('✓', 'shrink-0 text-emerald-400', depNames[n - 1]));
290
+ rowBot.className =
291
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center opacity-0';
292
+ }
293
+ countEl.textContent = n + ' / ' + n;
294
+ }
295
+ }
296
+
297
+ var step = 0;
298
+ var serverWaitShown = false;
299
+ paint(step);
300
+
301
+ function showServerWaitingUi() {
302
+ if (serverWaitShown) return;
303
+ serverWaitShown = true;
304
+ title.textContent = 'Finishing report on server…';
305
+ countEl.className = 'mt-4 text-center text-xs sm:text-sm text-zinc-500 leading-relaxed max-w-lg mx-auto';
306
+ countEl.textContent =
307
+ 'The dependency list above is only a visual rhythm — work continues until /api/analyze responds. Bundle size mode can take several minutes.';
308
+ clearRow(rowTop);
309
+ clearRow(rowMid);
310
+ clearRow(rowBot);
311
+ rowTop.className =
312
+ 'plexus-pill-row-top flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center opacity-0 pointer-events-none';
313
+ rowBot.className =
314
+ 'plexus-pill-row-bot flex min-h-[1.35rem] w-full max-w-[18rem] items-center justify-center opacity-0 pointer-events-none';
315
+ rowMid.className =
316
+ 'plexus-pill-row-mid flex min-h-[2.5rem] w-full max-w-[22rem] items-center justify-center gap-2 px-3 text-sm text-zinc-300';
317
+ var dot = document.createElement('span');
318
+ dot.className = 'shrink-0 text-amber-400 animate-pulse';
319
+ dot.textContent = '●';
320
+ dot.setAttribute('aria-hidden', 'true');
321
+ var msg = document.createElement('span');
322
+ msg.textContent = 'Waiting for response…';
323
+ rowMid.appendChild(dot);
324
+ rowMid.appendChild(msg);
325
+ }
326
+
327
+ function flushRemaining() {
328
+ stopProgressSimulation();
329
+ paint(n);
330
+ }
331
+
332
+ if (n > 0) {
333
+ /* Advance through dependency names only; never imply “all done” until the POST returns. */
334
+ var intervalMs = Math.min(2800, Math.max(950, Math.floor(42000 / n)));
335
+ progressSimTimer = setInterval(function () {
336
+ if (step < n - 1) {
337
+ step++;
338
+ paint(step);
339
+ } else {
340
+ stopProgressSimulation();
341
+ showServerWaitingUi();
342
+ }
343
+ }, intervalMs);
344
+ }
345
+
346
+ return { flushRemaining: flushRemaining };
347
+ }
348
+
349
+ function hideDependencyProgress() {
350
+ stopProgressSimulation();
351
+ progressPanel.innerHTML = '';
352
+ progressPanel.classList.add('hidden');
353
+ progressPanel.setAttribute('aria-busy', 'false');
354
+ zoneEl.setAttribute('aria-busy', 'false');
355
+ uploadPanel.classList.remove('hidden');
356
+ }
108
357
 
109
358
  function analyzeUrl() {
110
359
  var meta = document.querySelector('meta[name="plexus-api-base"]');
@@ -149,6 +398,14 @@
149
398
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
150
399
  throw new Error('JSON must be a package.json object.');
151
400
  }
401
+ if (directDepCount(parsed) === 0) {
402
+ showToast(
403
+ 'This package.json has no dependencies or devDependencies — nothing to analyze.',
404
+ );
405
+ parsed = null;
406
+ runBtn.disabled = true;
407
+ return;
408
+ }
152
409
  runBtn.disabled = false;
153
410
  } catch (e) {
154
411
  showToast(e.message || 'Invalid JSON.');
@@ -159,9 +416,16 @@
159
416
 
160
417
  runBtn.addEventListener('click', function () {
161
418
  if (!parsed) return;
419
+ if (directDepCount(parsed) === 0) {
420
+ showToast(
421
+ 'This package.json has no dependencies or devDependencies — nothing to analyze.',
422
+ );
423
+ return;
424
+ }
162
425
  hideToast();
426
+ var depNames = getDirectDepNames(parsed);
427
+ var progressCtl = showDependencyProgress(depNames);
163
428
  runBtn.disabled = true;
164
- runBtn.textContent = 'Analyzing…';
165
429
  var pkgForApi = Object.assign({}, parsed);
166
430
  delete pkgForApi.private;
167
431
  delete pkgForApi.main;
@@ -189,13 +453,19 @@
189
453
  return res.text();
190
454
  })
191
455
  .then(function (html) {
192
- var w = window.open('', '_blank');
456
+ stopProgressSimulation();
457
+ if (progressCtl && progressCtl.flushRemaining) progressCtl.flushRemaining();
458
+ var blob = new Blob([html], { type: 'text/html;charset=utf-8' });
459
+ var url = URL.createObjectURL(blob);
460
+ var w = window.open('about:blank', '_blank');
193
461
  if (w) {
194
- w.document.write(html);
195
- w.document.close();
462
+ w.location.href = url;
463
+ setTimeout(function () {
464
+ try {
465
+ URL.revokeObjectURL(url);
466
+ } catch (e) {}
467
+ }, 60000);
196
468
  } else {
197
- var blob = new Blob([html], { type: 'text/html' });
198
- var url = URL.createObjectURL(blob);
199
469
  window.location.href = url;
200
470
  }
201
471
  })
@@ -207,6 +477,7 @@
207
477
  showToast(m);
208
478
  })
209
479
  .finally(function () {
480
+ hideDependencyProgress();
210
481
  runBtn.disabled = false;
211
482
  runBtn.textContent = 'Generate report';
212
483
  });
@@ -0,0 +1 @@
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-left-36{left:-9rem}.-right-28{right:-7rem}.bottom-\[18\%\]{bottom:18%}.left-1\/2{left:50%}.right-4{right:1rem}.top-0{top:0}.top-6{top:1.5rem}.top-\[28\%\]{top:28%}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-14{margin-bottom:3.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.hidden{display:none}.size-4{width:1rem;height:1rem}.h-\[min\(22rem\2c 45vw\)\]{height:min(22rem,45vw)}.h-\[min\(28rem\2c 50vw\)\]{height:min(28rem,50vw)}.h-\[min\(78vh\2c 600px\)\]{height:min(78vh,600px)}.min-h-\[1\.35rem\]{min-height:1.35rem}.min-h-\[1\.6rem\]{min-height:1.6rem}.min-h-screen{min-height:100vh}.w-\[calc\(100vw_-_2rem\)\]{width:calc(100vw - 2rem)}.w-\[min\(22rem\2c 45vw\)\]{width:min(22rem,45vw)}.w-\[min\(28rem\2c 50vw\)\]{width:min(28rem,50vw)}.w-\[min\(92vw\2c 820px\)\]{width:min(92vw,820px)}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[20rem\]{max-width:20rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/3{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/3{--tw-translate-y:-33.333333%}.-translate-y-2{--tw-translate-y:-0.5rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-\[pulse_2\.2s_ease-in-out_infinite\]{animation:pulse 2.2s ease-in-out infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-r-xl{border-top-right-radius:.75rem;border-bottom-right-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-dashed{border-style:dashed}.border-red-900\/80{border-color:rgba(127,29,29,.8)}.border-zinc-700{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.border-zinc-700\/75{border-color:rgba(63,63,70,.75)}.border-zinc-700\/80{border-color:rgba(63,63,70,.8)}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.border-zinc-800\/90{border-color:rgba(39,39,42,.9)}.bg-plexus-base{--tw-bg-opacity:1;background-color:rgb(10 10 10/var(--tw-bg-opacity,1))}.bg-plexus-code{--tw-bg-opacity:1;background-color:rgb(28 28 28/var(--tw-bg-opacity,1))}.bg-plexus-subtle{--tw-bg-opacity:1;background-color:rgb(23 23 23/var(--tw-bg-opacity,1))}.bg-plexus-void\/60{background-color:rgba(5,5,5,.6)}.bg-red-950\/95{background-color:rgba(69,10,10,.95)}.bg-white\/\[0\.028\]{background-color:hsla(0,0%,100%,.028)}.bg-zinc-200{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}.bg-zinc-400\/\[0\.04\]{background-color:hsla(240,5%,65%,.04)}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.from-plexus-void\/25{--tw-gradient-from:rgba(5,5,5,.25) var(--tw-gradient-from-position);--tw-gradient-to:rgba(5,5,5,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-white\/\[0\.055\]{--tw-gradient-from:hsla(0,0%,100%,.055) var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),transparent var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-zinc-500\/\[0\.04\]{--tw-gradient-to:hsla(240,4%,46%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),hsla(240,4%,46%,.04) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-plexus-void\/75{--tw-gradient-to:rgba(5,5,5,.75) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-light{font-weight:300}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-emerald-400\/85{color:rgba(52,211,153,.85)}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.text-red-300\/90{color:hsla(0,94%,82%,.9)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-100{--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1))}.text-zinc-200{--tw-text-opacity:1;color:rgb(228 228 231/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.text-zinc-950{--tw-text-opacity:1;color:rgb(9 9 11/var(--tw-text-opacity,1))}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-zinc-300{accent-color:#d4d4d8}.opacity-0{opacity:0}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-\[0\.4\]{opacity:.4}.shadow-\[0_24px_80px_rgba\(0\2c 0\2c 0\2c 0\.45\)\2c 0_0_0_1px_rgba\(255\2c 255\2c 255\2c 0\.04\)\]{--tw-shadow:0 24px 80px rgba(0,0,0,.45),0 0 0 1px hsla(0,0%,100%,.04);--tw-shadow-colored:0 24px 80px var(--tw-shadow-color),0 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_30px_rgba\(0\2c 0\2c 0\2c 0\.45\)\]{--tw-shadow:0 8px 30px rgba(0,0,0,.45);--tw-shadow-colored:0 8px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[inset_0_1px_0_0_rgba\(255\2c 255\2c 255\2c 0\.05\)\]{--tw-shadow:inset 0 1px 0 0 hsla(0,0%,100%,.05);--tw-shadow-colored:inset 0 1px 0 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.\[background-image\:radial-gradient\(rgba\(255\2c 255\2c 255\2c 0\.042\)_1px\2c transparent_1px\)\]{background-image:radial-gradient(hsla(0,0%,100%,.042) 1px,transparent 0)}.\[background-size\:40px_40px\]{background-size:40px 40px}.\[text-shadow\:0_1px_2px_rgba\(0\2c 0\2c 0\2c 0\.85\)\2c 0_0_40px_rgba\(10\2c 10\2c 10\2c 0\.9\)\]{text-shadow:0 1px 2px rgba(0,0,0,.85),0 0 40px hsla(0,0%,4%,.9)}.hover\:border-zinc-600:hover{--tw-border-opacity:1;border-color:rgb(82 82 91/var(--tw-border-opacity,1))}.hover\:bg-plexus-subtle\/50:hover{background-color:hsla(0,0%,9%,.5)}.hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:text-zinc-300:hover{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-zinc-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(82 82 91/var(--tw-ring-opacity,1))}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px}.focus\:ring-offset-plexus-base:focus{--tw-ring-offset-color:#0a0a0a}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:hover\:bg-zinc-200:hover:disabled{--tw-bg-opacity:1;background-color:rgb(228 228 231/var(--tw-bg-opacity,1))}@media (min-width:640px){.sm\:-left-24{left:-6rem}.sm\:-right-20{right:-5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:px-8{padding-left:2rem;padding-right:2rem}.sm\:py-16{padding-top:4rem;padding-bottom:4rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-5{padding-top:1.25rem;padding-bottom:1.25rem}.sm\:py-6{padding-top:1.5rem;padding-bottom:1.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mb-16{margin-bottom:4rem}.md\:mb-4{margin-bottom:1rem}.md\:mb-6{margin-bottom:1.5rem}.md\:p-12{padding:3rem}.md\:py-28{padding-top:7rem;padding-bottom:7rem}.md\:text-2xl{font-size:1.5rem;line-height:2rem}.md\:text-6xl{font-size:3.75rem;line-height:1}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:text-7xl{font-size:4.5rem;line-height:1}}