servicemap 0.1.25 → 0.1.28

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
@@ -71,6 +71,8 @@ servicemap generate --root <project-path>
71
71
  servicemap --root <project-path>
72
72
  servicemap generate --root <project-path> --allow-emails founder@example.com,ops@example.com
73
73
  servicemap generate --root <project-path> --allow-email founder@example.com --allow-email ops@example.com
74
+ servicemap generate --root <project-path> --deploy
75
+ servicemap generate --root <project-path> --deploy-command "npm run deploy"
74
76
  servicemap --mode page
75
77
  servicemap --mode desktop
76
78
  servicemap --dry-run
@@ -85,6 +87,10 @@ servicemap skills install --agent codex
85
87
  servicemap update
86
88
  ```
87
89
 
90
+ When a website page is generated, Servicemap prompts before deployment when it detects a deployment command.
91
+ It prefers an existing `npm run deploy` script, then provider config such as Vercel or Netlify.
92
+ Use `--no-deploy` to skip the prompt.
93
+
88
94
  ## Contents
89
95
 
90
96
  - `servicemap/`: installable skill folder
@@ -168,6 +168,8 @@ function parseArgs() {
168
168
  installMethod: null,
169
169
  allowedEmails: [],
170
170
  open: true,
171
+ deploy: null,
172
+ deployCommand: null,
171
173
  invokedWithoutArgs: args.length === 0,
172
174
  explicitCommand: false,
173
175
  generationRequested: false,
@@ -210,6 +212,9 @@ function parseArgs() {
210
212
  if (arg === '--yes' || arg === '-y') options.yes = true;
211
213
  if (arg === '--dry-run') options.dryRun = true;
212
214
  if (arg === '--no-open') options.open = false;
215
+ if (arg === '--deploy') options.deploy = true;
216
+ if (arg === '--no-deploy') options.deploy = false;
217
+ if (arg === '--deploy-command') options.deployCommand = args[++index] ?? null;
213
218
  if (arg === '--help' || arg === '-h') options.help = true;
214
219
  }
215
220
 
@@ -222,8 +227,8 @@ function help() {
222
227
 
223
228
  Usage:
224
229
  servicemap
225
- servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open]
226
- servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open]
230
+ servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open] [--deploy|--no-deploy]
231
+ servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open] [--deploy|--no-deploy]
227
232
  servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>] [--install-method copy|symlink]
228
233
  servicemap skills install [--survey] [--agent codex|claude|grok|all]
229
234
  servicemap update
@@ -231,6 +236,8 @@ Usage:
231
236
  Examples:
232
237
  npx servicemap
233
238
  npx servicemap generate --root ../my-app --allow-emails founder@example.com,ops@example.com
239
+ npx servicemap generate --root ../my-app --deploy
240
+ npx servicemap generate --root ../my-app --deploy-command "npm run deploy"
234
241
  npx servicemap -- --root ../my-app
235
242
  npx servicemap install-skill
236
243
  npx servicemap install-skill --survey
@@ -257,6 +264,23 @@ function run(command, args, options = {}) {
257
264
  });
258
265
  }
259
266
 
267
+ function runShell(command, options = {}) {
268
+ const shell = process.platform === 'win32' ? 'cmd' : 'sh';
269
+ const args = process.platform === 'win32' ? ['/c', command] : ['-lc', command];
270
+ return run(shell, args, options);
271
+ }
272
+
273
+ function askYesNo(question) {
274
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(false);
275
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
276
+ return new Promise((resolvePromise) => {
277
+ rl.question(`${question} [y/N] `, (answer) => {
278
+ rl.close();
279
+ resolvePromise(/^y(es)?$/i.test(answer.trim()));
280
+ });
281
+ });
282
+ }
283
+
260
284
  function openUrl(url) {
261
285
  const command = process.platform === 'darwin'
262
286
  ? 'open'
@@ -352,6 +376,47 @@ async function readScan(root) {
352
376
  return JSON.parse(await readFile(scanPath, 'utf8'));
353
377
  }
354
378
 
379
+ async function packageJsonFor(root) {
380
+ return JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8').catch(() => '{}'));
381
+ }
382
+
383
+ async function detectDeployCommand(root, options) {
384
+ if (options.deployCommand) return options.deployCommand;
385
+ const packageJson = await packageJsonFor(root);
386
+ if (packageJson.scripts?.deploy) return 'npm run deploy';
387
+ if (existsSync(resolve(root, 'vercel.json')) || existsSync(resolve(root, '.vercel/project.json'))) {
388
+ return 'npx vercel --prod';
389
+ }
390
+ if (existsSync(resolve(root, 'netlify.toml'))) {
391
+ return 'npx netlify deploy --prod';
392
+ }
393
+ return null;
394
+ }
395
+
396
+ async function maybeDeploy(root, options) {
397
+ if (options.deploy === false) return;
398
+ const command = await detectDeployCommand(root, options);
399
+ if (!command) {
400
+ if (options.deploy === true) {
401
+ console.log('No deploy command was detected. Add a deploy script or pass --deploy-command "<command>".');
402
+ } else {
403
+ console.log('Generated /internalservicemap. Deploy manually, or pass --deploy-command next time.');
404
+ }
405
+ return;
406
+ }
407
+
408
+ const shouldDeploy = options.deploy === true
409
+ ? true
410
+ : await askYesNo(`Deploy /internalservicemap now with "${command}"?`);
411
+ if (!shouldDeploy) {
412
+ console.log(`Skipped deployment. To deploy later, run: ${command}`);
413
+ return;
414
+ }
415
+
416
+ console.log(`Deploying with: ${command}`);
417
+ await runShell(command, { cwd: root });
418
+ }
419
+
355
420
  function targetsByIds(ids) {
356
421
  const byId = new Map(targetCatalog.map((target) => [target.id, target]));
357
422
  const targets = [];
@@ -709,6 +774,7 @@ async function main() {
709
774
  } else {
710
775
  await run(process.execPath, [pageScript, '--root', options.root]);
711
776
  if (options.open) await openGeneratedPage(options.root, scan.project?.framework);
777
+ await maybeDeploy(options.root, options);
712
778
  }
713
779
  }
714
780
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.25",
3
+ "version": "0.1.28",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,10 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
24
24
  7. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
25
25
  8. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
26
26
  9. Run the project's relevant build, lint, and focused tests.
27
- 10. Report the page or desktop app path, service count, validation run, any services marked low or medium confidence, and whether requested email access was enforced.
27
+ 10. If a website page was created, tell the user it is only local until deployed and ask whether they want to deploy it now.
28
+ Do not deploy unless the user explicitly says yes.
29
+ If they approve, use the project's existing deployment path, preferring `npm run deploy` when present, then provider-specific project configuration such as Vercel or Netlify.
30
+ 11. Report the page or desktop app path, service count, validation run, any services marked low or medium confidence, whether requested email access was enforced, and whether deployment was skipped or completed.
28
31
 
29
32
  ## Safety Rules
30
33
 
@@ -32,7 +35,8 @@ Read environment variable names only.
32
35
  Never copy, expose, summarize, or commit secret values.
33
36
  The scan output may include env var names such as `STRIPE_SECRET_KEY`, but not their values.
34
37
 
35
- Do not add authentication, deployment, analytics, or external writes unless the user explicitly asks.
38
+ Do not add authentication, analytics, or external writes unless the user explicitly asks.
39
+ Deploy only after the generated website page exists and the user has confirmed deployment.
36
40
  If the repository already has route protection conventions for internal pages, follow them.
37
41
  When email access is requested, enforce it only through the app's existing server-side authentication.
38
42
  The built-in generator can enforce email allowlists for Next.js App Router projects using Clerk by checking `currentUser()` against the configured emails.
@@ -59,7 +59,9 @@ function pageCss() {
59
59
  .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
60
60
  .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
61
61
  .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
62
- .mode-pill { border-radius: 8px; background: #0b130f; color: #7cf1b5; padding: 8px 12px; }
62
+ .mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
63
+ .mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
64
+ .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
63
65
  .map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
64
66
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
65
67
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
@@ -68,6 +70,8 @@ function pageCss() {
68
70
  .hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
69
71
  .hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
70
72
  .service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
73
+ button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
74
+ .service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
71
75
  .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
72
76
  .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
73
77
  .service-copy { min-width: 0; }
@@ -80,10 +84,46 @@ function pageCss() {
80
84
  .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
81
85
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
82
86
  .access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
87
+ .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
88
+ .details-panel { position: absolute; right: 22px; top: 64px; bottom: 26px; z-index: 30; width: min(492px, calc(100vw - 44px)); overflow: hidden; border: 1px solid #294034; border-radius: 24px; background: linear-gradient(180deg, rgba(21,31,25,0.98), rgba(8,17,12,0.98)); box-shadow: 0 24px 80px rgba(0,0,0,0.52); }
89
+ .details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
90
+ .details-title { min-width: 0; flex: 1; }
91
+ .details-title span { display: block; color: #83978d; font-size: 13px; }
92
+ .details-title strong { display: block; margin-top: 6px; color: #f4fbf6; font-size: 28px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
93
+ .close-button, .link-button, .delete-button, .save-button { border: 0; cursor: pointer; font: inherit; }
94
+ .close-button { width: 36px; height: 36px; border-radius: 999px; background: transparent; color: #91a59b; font-size: 26px; }
95
+ .details-body { height: calc(100% - 100px); overflow: auto; padding: 26px; }
96
+ .details-section { padding: 0 0 28px; margin: 0 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); }
97
+ .details-section:last-child { border-bottom: 0; margin-bottom: 0; }
98
+ .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; color: #91a59b; font-size: 13px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
99
+ .field-label { display: block; margin: 16px 0 8px; color: #83978d; font-size: 13px; }
100
+ .details-input, .details-textarea { width: 100%; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #e9f5ee; font: inherit; padding: 13px 14px; outline: none; }
101
+ .details-textarea { min-height: 96px; resize: vertical; line-height: 1.45; }
102
+ .details-input:focus, .details-textarea:focus { border-color: #65f0ad; box-shadow: 0 0 0 2px rgba(101,240,173,0.16); }
103
+ .links-list { display: grid; gap: 12px; }
104
+ .link-card { display: grid; grid-template-columns: 1fr auto; gap: 8px 10px; align-items: center; border: 1px solid #26382f; border-radius: 10px; background: rgba(255,255,255,0.035); padding: 14px; }
105
+ .link-card strong { color: #eef7f2; font-size: 14px; }
106
+ .link-card span { grid-column: 1 / -1; color: #74867d; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
107
+ .link-button { color: #8dc9ae; background: transparent; padding: 4px; }
108
+ .details-actions { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 26px; border-top: 1px solid rgba(255,255,255,0.08); background: rgba(8,17,12,0.98); }
109
+ .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
110
+ .save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
111
+ .helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
112
+ .three-scene { position: absolute; inset: 0; perspective: 920px; overflow: hidden; cursor: grab; touch-action: none; }
113
+ .three-scene:active { cursor: grabbing; }
114
+ .three-space { position: absolute; left: 50%; top: 50%; width: 620px; height: 620px; transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 58deg)) rotateZ(var(--rz, -28deg)); transition: transform 140ms ease; pointer-events: none; }
115
+ .three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: translate3d(0, 0, 0) rotateZ(var(--angle)) translateY(var(--z)); background: linear-gradient(90deg, rgba(101,240,173,0.42), rgba(101,240,173,0.05)); }
116
+ .three-node { position: absolute; left: 50%; top: 50%; width: 82px; height: 82px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) rotateZ(28deg) rotateX(-58deg); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
117
+ .three-orb { display: grid; place-items: center; width: 38px; height: 38px; margin: 0 auto 7px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 44%); font-size: 12px; font-weight: 900; }
118
+ .three-node span { display: block; font-size: 10px; line-height: 1.15; text-shadow: 0 2px 8px #000; }
119
+ .three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; }
120
+ .three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 72px; height: 72px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 30px; box-shadow: 0 0 0 10px rgba(101,240,173,0.14), 0 0 46px rgba(101,240,173,0.22); }
121
+ .three-hint { position: absolute; left: 50%; bottom: 30px; z-index: 21; transform: translateX(-50%); border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 10px 18px; font-size: 12px; }
83
122
  @media (max-width: 920px) {
84
123
  .map { min-height: 1120px; }
85
124
  .service-node { width: 220px; }
86
125
  .top-actions { display: none; }
126
+ .details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
87
127
  }
88
128
  `;
89
129
  }
@@ -143,7 +183,10 @@ function htmlBody(graph) {
143
183
  </div>
144
184
  <div class="top-actions">
145
185
  <span>${services.length} services</span>
146
- <span class="mode-pill">2D</span>
186
+ <span class="mode-toggle" aria-label="View mode">
187
+ <span class="mode-button is-active">2D</span>
188
+ <span class="mode-button">3D</span>
189
+ </span>
147
190
  </div>
148
191
  </header>
149
192
  <section class="map">
@@ -185,6 +228,320 @@ ${htmlBody(graph)}
185
228
  `;
186
229
  }
187
230
 
231
+ function interactiveTsxPage(graph) {
232
+ return `'use client';
233
+
234
+ import { useMemo, useState, type CSSProperties } from 'react';
235
+
236
+ const css = ${JSON.stringify(pageCss())};
237
+ type Evidence = { type?: string; value?: string; confidence?: string };
238
+ type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
239
+ type ServiceNode = RawService & { color: string; x: number; y: number };
240
+ type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
241
+ type ServiceLink = { label: string; url: string };
242
+
243
+ const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string }; services?: RawService[] };
244
+ const palette = ${JSON.stringify(palette)} as string[];
245
+ const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
246
+
247
+ function categoryLabel(value?: string | null) {
248
+ return String(value ?? 'service').replace(/-/g, ' ');
249
+ }
250
+
251
+ function initials(name: string) {
252
+ return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
253
+ }
254
+
255
+ function edgePath(node: ServiceNode) {
256
+ const isLeft = node.x < 50;
257
+ const fromX = isLeft ? node.x + 8 : node.x - 8;
258
+ const endX = isLeft ? 47 : 53;
259
+ const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
260
+ return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
261
+ }
262
+
263
+ function threePosition(index: number, count: number) {
264
+ const angle = (index / Math.max(count, 1)) * Math.PI * 2 - Math.PI / 2;
265
+ const radius = index % 3 === 0 ? 270 : index % 3 === 1 ? 215 : 320;
266
+ const z = ((index % 5) - 2) * 32;
267
+ return {
268
+ x: Math.cos(angle) * radius,
269
+ y: Math.sin(angle) * radius,
270
+ z,
271
+ angle: (angle * 180) / Math.PI,
272
+ length: radius,
273
+ };
274
+ }
275
+
276
+ function threeNodeStyle(index: number, count: number, color: string): CSSProperties {
277
+ const position = threePosition(index, count);
278
+ return {
279
+ '--x': \`\${position.x}px\`,
280
+ '--y': \`\${position.y}px\`,
281
+ '--z': \`\${position.z}px\`,
282
+ '--color': color,
283
+ } as CSSProperties;
284
+ }
285
+
286
+ function threeLinkStyle(index: number, count: number): CSSProperties {
287
+ const position = threePosition(index, count);
288
+ return {
289
+ '--angle': \`\${position.angle}deg\`,
290
+ '--length': \`\${position.length}px\`,
291
+ '--z': \`\${position.z}px\`,
292
+ } as CSSProperties;
293
+ }
294
+
295
+ function primaryEvidence(service: RawService) {
296
+ const evidence = service.evidence?.[0];
297
+ return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
298
+ }
299
+
300
+ function serviceLinks(service: RawService): ServiceLink[] {
301
+ const lower = service.name.toLowerCase();
302
+ const links: ServiceLink[] = [];
303
+ if (service.iconUrl) links.push({ label: \`\${service.name} docs\`, url: service.iconUrl.replace('/icons/', '/') });
304
+ if (lower.includes('firebase')) links.push({ label: 'Firebase console', url: 'https://console.firebase.google.com/' });
305
+ if (lower.includes('google')) links.push({ label: 'Google Cloud console', url: 'https://console.cloud.google.com/' });
306
+ if (lower.includes('cloudflare')) links.push({ label: 'Cloudflare dashboard', url: 'https://dash.cloudflare.com/' });
307
+ if (lower.includes('github')) links.push({ label: 'GitHub', url: 'https://github.com/' });
308
+ if (lower.includes('stripe')) links.push({ label: 'Stripe dashboard', url: 'https://dashboard.stripe.com/' });
309
+ if (lower.includes('vercel')) links.push({ label: 'Vercel dashboard', url: 'https://vercel.com/dashboard' });
310
+ if (lower.includes('microsoft')) links.push({ label: 'Microsoft Azure portal', url: 'https://portal.azure.com/' });
311
+ if (links.length === 0) links.push({ label: \`\${service.name} website\`, url: \`https://www.google.com/search?q=\${encodeURIComponent(service.name)}\` });
312
+ return links.slice(0, 4);
313
+ }
314
+
315
+ function ServiceLogo({ service }: { service: RawService & { color: string } }) {
316
+ return (
317
+ <div className="logo" style={{ background: service.color }}>
318
+ {service.iconUrl ? (
319
+ // eslint-disable-next-line @next/next/no-img-element
320
+ <img src={service.iconUrl} alt="" />
321
+ ) : (
322
+ <span>{initials(service.name)}</span>
323
+ )}
324
+ </div>
325
+ );
326
+ }
327
+
328
+ function DetailsPanel({
329
+ service,
330
+ details,
331
+ onChange,
332
+ onClose,
333
+ }: {
334
+ service: ServiceNode;
335
+ details: DraftDetails;
336
+ onChange: (details: DraftDetails) => void;
337
+ onClose: () => void;
338
+ }) {
339
+ const links = serviceLinks(service);
340
+ return (
341
+ <>
342
+ <div className="drawer-scrim" />
343
+ <aside className="details-panel" aria-label={\`\${service.name} details\`}>
344
+ <div className="details-head">
345
+ <ServiceLogo service={service} />
346
+ <div className="details-title">
347
+ <span>{categoryLabel(service.category)}</span>
348
+ <strong>{service.name}</strong>
349
+ </div>
350
+ <button type="button" className="close-button" onClick={onClose} aria-label="Close details">×</button>
351
+ </div>
352
+
353
+ <div className="details-body">
354
+ <section className="details-section">
355
+ <div className="section-heading">Service details</div>
356
+ <label className="field-label" htmlFor="service-subtitle">Service subtitle</label>
357
+ <input
358
+ id="service-subtitle"
359
+ className="details-input"
360
+ value={details.subtitle}
361
+ onChange={(event) => onChange({ ...details, subtitle: event.target.value })}
362
+ />
363
+
364
+ <label className="field-label" htmlFor="service-use">How it is used</label>
365
+ <textarea
366
+ id="service-use"
367
+ className="details-textarea"
368
+ value={details.use}
369
+ onChange={(event) => onChange({ ...details, use: event.target.value })}
370
+ />
371
+ </section>
372
+
373
+ <section className="details-section">
374
+ <div className="section-heading">
375
+ <span>Relevant links</span>
376
+ <span>+ Add</span>
377
+ </div>
378
+ <div className="links-list">
379
+ {links.map((link) => (
380
+ <div className="link-card" key={link.label}>
381
+ <strong>{link.label}</strong>
382
+ <a className="link-button" href={link.url} target="_blank" rel="noreferrer">Open</a>
383
+ <span>{link.url}</span>
384
+ </div>
385
+ ))}
386
+ </div>
387
+ </section>
388
+
389
+ <section className="details-section">
390
+ <div className="section-heading">Account</div>
391
+ <label className="field-label" htmlFor="account-owner">Account owner or email</label>
392
+ <input
393
+ id="account-owner"
394
+ className="details-input"
395
+ value={details.account}
396
+ onChange={(event) => onChange({ ...details, account: event.target.value })}
397
+ placeholder="owner@example.com"
398
+ />
399
+
400
+ <label className="field-label" htmlFor="password-location">Password / vault item</label>
401
+ <input
402
+ id="password-location"
403
+ className="details-input"
404
+ value={details.passwordLocation}
405
+ onChange={(event) => onChange({ ...details, passwordLocation: event.target.value })}
406
+ placeholder="1Password item, vault record, or SSO note"
407
+ />
408
+ <p className="helper-text">Keep actual passwords in your password manager. Store only the vault item or access note here.</p>
409
+ </section>
410
+ </div>
411
+
412
+ <div className="details-actions">
413
+ <button type="button" className="delete-button">Delete</button>
414
+ <button type="button" className="save-button">Save changes</button>
415
+ </div>
416
+ </aside>
417
+ </>
418
+ );
419
+ }
420
+
421
+ export default function InternalServiceMapPage() {
422
+ const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
423
+ ...service,
424
+ ...layout[index],
425
+ color: palette[index % palette.length],
426
+ })), []);
427
+ const [mode, setMode] = useState<'2d' | '3d'>('2d');
428
+ const [rotation, setRotation] = useState({ x: 58, z: -28 });
429
+ const [selectedId, setSelectedId] = useState<string | null>(null);
430
+ const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
431
+ service.id,
432
+ {
433
+ subtitle: categoryLabel(service.category),
434
+ use: primaryEvidence(service),
435
+ account: '',
436
+ passwordLocation: '',
437
+ },
438
+ ])));
439
+ const selected = nodes.find((service) => service.id === selectedId) ?? null;
440
+
441
+ return (
442
+ <main className="servicemap-shell">
443
+ <style dangerouslySetInnerHTML={{ __html: css }} />
444
+ <header className="servicemap-topbar">
445
+ <div className="brand">
446
+ <div className="brand-mark">⌘</div>
447
+ <div>
448
+ <h1>servicemap</h1>
449
+ <p>{graph.project?.name ?? 'Project'} command center</p>
450
+ </div>
451
+ </div>
452
+ <div className="top-actions">
453
+ <span>{nodes.length} services</span>
454
+ <div className="mode-toggle" aria-label="View mode">
455
+ <button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
456
+ <button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
457
+ </div>
458
+ </div>
459
+ </header>
460
+
461
+ <section className="map">
462
+ <div className="intro">
463
+ <strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
464
+ <span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
465
+ </div>
466
+ {mode === '2d' ? (
467
+ <>
468
+ <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
469
+ {nodes.map((service) => (
470
+ <path key={service.id} d={edgePath(service)} fill="none" stroke="rgba(103, 130, 118, 0.48)" strokeWidth="0.16" vectorEffect="non-scaling-stroke" />
471
+ ))}
472
+ </svg>
473
+ <div className="hub"><div className="hub-inner">⌘</div></div>
474
+ {nodes.map((service) => (
475
+ <button
476
+ type="button"
477
+ key={service.id}
478
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
479
+ style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
480
+ onClick={() => setSelectedId(service.id)}
481
+ >
482
+ <ServiceLogo service={service} />
483
+ <div className="service-copy">
484
+ <strong>{service.name}</strong>
485
+ <span>{categoryLabel(service.category)}</span>
486
+ </div>
487
+ <i className={\`status-dot status-\${service.confidence}\`} />
488
+ </button>
489
+ ))}
490
+ <div className="legend">
491
+ <span><i style={{ background: '#4a7cff' }} />Cloud</span>
492
+ <span><i style={{ background: '#8be4b2' }} />Product</span>
493
+ <span><i style={{ background: '#65f0ad' }} />Active path</span>
494
+ </div>
495
+ </>
496
+ ) : (
497
+ <div className="three-scene" aria-label="3D service graph">
498
+ <div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
499
+ {nodes.map((service, index) => (
500
+ <span key={\`\${service.id}-link\`} className="three-link" style={threeLinkStyle(index, nodes.length)} />
501
+ ))}
502
+ <div className="three-core">⌘</div>
503
+ {nodes.map((service, index) => (
504
+ <button
505
+ type="button"
506
+ key={service.id}
507
+ className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
508
+ style={threeNodeStyle(index, nodes.length, service.color)}
509
+ onPointerMove={(event) => {
510
+ if (event.buttons !== 1) return;
511
+ setRotation((current) => ({
512
+ x: Math.max(24, Math.min(72, current.x - event.movementY * 0.25)),
513
+ z: current.z + event.movementX * 0.25,
514
+ }));
515
+ }}
516
+ onClick={() => setSelectedId(service.id)}
517
+ >
518
+ <span className="three-orb">{initials(service.name)}</span>
519
+ <span>{service.name}</span>
520
+ </button>
521
+ ))}
522
+ </div>
523
+ <div className="three-hint">Drag to rotate / Scroll to zoom / Click a service</div>
524
+ </div>
525
+ )}
526
+ <footer className="scan-foot">
527
+ <div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
528
+ <div>{nodes.length} services detected</div>
529
+ </footer>
530
+ {selected && drafts[selected.id] ? (
531
+ <DetailsPanel
532
+ service={selected}
533
+ details={drafts[selected.id]}
534
+ onClose={() => setSelectedId(null)}
535
+ onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
536
+ />
537
+ ) : null}
538
+ </section>
539
+ </main>
540
+ );
541
+ }
542
+ `;
543
+ }
544
+
188
545
  function tsxPage(graph) {
189
546
  const html = htmlBody(graph);
190
547
  const allowedEmails = graph.access?.allowedEmails ?? [];
@@ -217,18 +574,7 @@ export default async function InternalServiceMapPage() {
217
574
  `;
218
575
  }
219
576
 
220
- return `const css = ${JSON.stringify(pageCss())};
221
- const html = ${JSON.stringify(html)};
222
-
223
- export default function InternalServiceMapPage() {
224
- return (
225
- <>
226
- <style dangerouslySetInnerHTML={{ __html: css }} />
227
- <div dangerouslySetInnerHTML={{ __html: html }} />
228
- </>
229
- );
230
- }
231
- `;
577
+ return interactiveTsxPage(graph);
232
578
  }
233
579
 
234
580
  function astroPage(graph) {