@wanbicoi/next-steps 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,707 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>next-steps workflow viewer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f8fafc;
10
+ --panel: #ffffff;
11
+ --border: #e2e8f0;
12
+ --text: #0f172a;
13
+ --muted: #64748b;
14
+ --accent: #2563eb;
15
+ --accent-soft: #eff6ff;
16
+ --code: #f1f5f9;
17
+ }
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+ html,
22
+ body {
23
+ height: 100%;
24
+ }
25
+ body {
26
+ margin: 0;
27
+ font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
28
+ color: var(--text);
29
+ background: var(--bg);
30
+ }
31
+ #app {
32
+ display: grid;
33
+ grid-template-columns: 250px minmax(0, 1fr) 300px;
34
+ height: 100vh;
35
+ overflow: hidden;
36
+ }
37
+ aside,
38
+ main {
39
+ min-width: 0;
40
+ min-height: 0;
41
+ }
42
+ #sidebar {
43
+ display: flex;
44
+ flex-direction: column;
45
+ border-right: 1px solid var(--border);
46
+ background: var(--panel);
47
+ overflow: hidden;
48
+ }
49
+ #sidebar header,
50
+ #details {
51
+ padding: 16px;
52
+ }
53
+ #sidebar header {
54
+ border-bottom: 1px solid var(--border);
55
+ }
56
+ h1 {
57
+ margin: 0;
58
+ font-size: 15px;
59
+ letter-spacing: -0.01em;
60
+ }
61
+ h1 span {
62
+ color: var(--muted);
63
+ font-weight: 500;
64
+ }
65
+ #count {
66
+ margin: 4px 0 12px;
67
+ color: var(--muted);
68
+ font-size: 12px;
69
+ }
70
+ #search {
71
+ width: 100%;
72
+ padding: 7px 9px;
73
+ border: 1px solid var(--border);
74
+ border-radius: 7px;
75
+ font: inherit;
76
+ background: var(--bg);
77
+ color: var(--text);
78
+ outline: none;
79
+ }
80
+ #search:focus {
81
+ border-color: var(--accent);
82
+ box-shadow: 0 0 0 2px var(--accent-soft);
83
+ }
84
+ #workflow-list {
85
+ flex: 1;
86
+ overflow: auto;
87
+ padding: 8px;
88
+ }
89
+ .wf {
90
+ display: block;
91
+ width: 100%;
92
+ padding: 8px 10px;
93
+ margin: 2px 0;
94
+ border: 1px solid transparent;
95
+ border-radius: 7px;
96
+ background: transparent;
97
+ text-align: left;
98
+ font: inherit;
99
+ color: var(--text);
100
+ cursor: pointer;
101
+ }
102
+ .wf:hover {
103
+ background: var(--bg);
104
+ }
105
+ .wf.active {
106
+ background: var(--accent-soft);
107
+ border-color: #bfdbfe;
108
+ }
109
+ .wf strong {
110
+ display: block;
111
+ font-weight: 600;
112
+ }
113
+ .wf small {
114
+ color: var(--muted);
115
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
116
+ font-size: 11px;
117
+ }
118
+ #legend {
119
+ border-top: 1px solid var(--border);
120
+ padding: 12px 16px 16px;
121
+ font-size: 12px;
122
+ color: var(--muted);
123
+ }
124
+ #legend div {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 8px;
128
+ margin-top: 6px;
129
+ }
130
+ .swatch {
131
+ width: 14px;
132
+ height: 14px;
133
+ border-radius: 4px;
134
+ border: 2px solid;
135
+ flex: none;
136
+ }
137
+ main {
138
+ display: flex;
139
+ flex-direction: column;
140
+ overflow: hidden;
141
+ }
142
+ #toolbar {
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: space-between;
146
+ gap: 16px;
147
+ padding: 12px 16px;
148
+ border-bottom: 1px solid var(--border);
149
+ background: var(--panel);
150
+ }
151
+ #toolbar h2 {
152
+ margin: 0;
153
+ font-size: 14px;
154
+ }
155
+ #subtitle {
156
+ color: var(--muted);
157
+ font-size: 12px;
158
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
159
+ }
160
+ #controls {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 8px;
164
+ flex-wrap: wrap;
165
+ }
166
+ button,
167
+ label.toggle {
168
+ font: inherit;
169
+ font-size: 12px;
170
+ color: var(--text);
171
+ background: var(--panel);
172
+ border: 1px solid var(--border);
173
+ border-radius: 7px;
174
+ padding: 6px 9px;
175
+ cursor: pointer;
176
+ }
177
+ button:hover,
178
+ label.toggle:hover {
179
+ background: var(--bg);
180
+ }
181
+ button:active {
182
+ transform: translateY(1px);
183
+ }
184
+ button.primary {
185
+ color: #fff;
186
+ background: var(--accent);
187
+ border-color: var(--accent);
188
+ }
189
+ #back {
190
+ display: none;
191
+ }
192
+ #back.visible {
193
+ display: inline-block;
194
+ }
195
+ #cy {
196
+ flex: 1;
197
+ min-height: 0;
198
+ background: radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 0) 0 0 / 20px 20px;
199
+ }
200
+ #details {
201
+ border-left: 1px solid var(--border);
202
+ background: var(--panel);
203
+ overflow: auto;
204
+ }
205
+ #details h3 {
206
+ margin: 0 0 4px;
207
+ font-size: 14px;
208
+ }
209
+ #details .muted {
210
+ color: var(--muted);
211
+ }
212
+ #details dl {
213
+ margin: 12px 0 0;
214
+ display: grid;
215
+ grid-template-columns: 72px 1fr;
216
+ gap: 6px 10px;
217
+ }
218
+ #details dt {
219
+ color: var(--muted);
220
+ }
221
+ #details dd {
222
+ margin: 0;
223
+ word-break: break-word;
224
+ }
225
+ #details code {
226
+ background: var(--code);
227
+ padding: 1px 4px;
228
+ border-radius: 4px;
229
+ font-size: 11px;
230
+ }
231
+ #details .hint {
232
+ margin-top: 16px;
233
+ padding: 10px;
234
+ border-radius: 7px;
235
+ background: var(--bg);
236
+ color: var(--muted);
237
+ font-size: 12px;
238
+ }
239
+ #details button {
240
+ margin-top: 12px;
241
+ }
242
+ @media (max-width: 1100px) {
243
+ #app {
244
+ grid-template-columns: 220px minmax(0, 1fr);
245
+ }
246
+ #details {
247
+ display: none;
248
+ }
249
+ }
250
+ @media (max-width: 720px) {
251
+ #app {
252
+ grid-template-columns: 1fr;
253
+ }
254
+ #sidebar {
255
+ display: none;
256
+ }
257
+ }
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div id="app">
262
+ <aside id="sidebar">
263
+ <header>
264
+ <h1>next-steps <span>viewer</span></h1>
265
+ <p id="count"></p>
266
+ <input id="search" type="search" placeholder="Filter workflows" autocomplete="off" />
267
+ </header>
268
+ <nav id="workflow-list"></nav>
269
+ <section id="legend">
270
+ <strong>Legend</strong>
271
+ <div><span class="swatch" style="background:#fff;border-color:#94a3b8"></span> Node</div>
272
+ <div><span class="swatch" style="background:#f5f3ff;border-color:#7c3aed"></span> Subgraph</div>
273
+ <div><span class="swatch" style="background:#f0fdf4;border-color:#16a34a"></span> Start</div>
274
+ <div><span class="swatch" style="background:#0f172a;border-color:#0f172a;border-radius:50%"></span> END</div>
275
+ <div>
276
+ <span class="swatch" style="background:#f59e0b;border-color:#f59e0b;height:3px;border-radius:2px"></span>
277
+ Conditional edge
278
+ </div>
279
+ </section>
280
+ </aside>
281
+ <main>
282
+ <header id="toolbar">
283
+ <div>
284
+ <button id="back" type="button">← Back</button>
285
+ <h2 id="title">Loading…</h2>
286
+ <div id="subtitle"></div>
287
+ </div>
288
+ <div id="controls">
289
+ <label class="toggle">
290
+ <input id="show-edge-labels" type="checkbox" checked />
291
+ Edge labels
292
+ </label>
293
+ <label class="toggle">
294
+ <input id="show-conditions" type="checkbox" checked />
295
+ Conditions
296
+ </label>
297
+ <button id="fit" type="button">Fit</button>
298
+ <button id="relayout" type="button">Relayout</button>
299
+ <button id="png" type="button">PNG</button>
300
+ </div>
301
+ </header>
302
+ <div id="cy"></div>
303
+ </main>
304
+ <aside id="details">
305
+ <h3>Workflow viewer</h3>
306
+ <p class="muted">Click a node or an edge. Double-click a subgraph node to open it.</p>
307
+ <div class="hint">
308
+ The data is generated from <code>test/fixtures/workflows/</code>. Run
309
+ <code>bun run viz</code> after editing a graph.
310
+ </div>
311
+ </aside>
312
+ </div>
313
+
314
+ <script src="/vendor/cytoscape.min.js"></script>
315
+ <script src="/vendor/cytoscape-dagre.js"></script>
316
+ <script>
317
+ const DATA = __WORKFLOW_DATA__;
318
+
319
+ if (typeof cytoscape === "undefined") {
320
+ document.getElementById("details").innerHTML =
321
+ '<h3>Could not load Cytoscape</h3><p class="muted">Check the jsDelivr CDN connection.</p>';
322
+ throw new Error("cytoscape is not available");
323
+ }
324
+
325
+ if (typeof cytoscapeDagre !== "undefined") {
326
+ cytoscape.use(cytoscapeDagre);
327
+ }
328
+
329
+ const $ = (selector) => document.querySelector(selector);
330
+
331
+ const COLORS = {
332
+ nodeBg: "#ffffff",
333
+ nodeBorder: "#94a3b8",
334
+ nodeText: "#0f172a",
335
+ subgraphBg: "#f5f3ff",
336
+ subgraphBorder: "#7c3aed",
337
+ startBg: "#f0fdf4",
338
+ startBorder: "#16a34a",
339
+ endBg: "#0f172a",
340
+ endText: "#ffffff",
341
+ edge: "#94a3b8",
342
+ conditional: "#f59e0b",
343
+ accent: "#2563eb",
344
+ };
345
+
346
+ // Customise the graph here. Cytoscape selectors support classes,
347
+ // data attributes (`node[type = "subgraph"]`), and states (`:selected`).
348
+ const STYLE = [
349
+ {
350
+ selector: "node",
351
+ style: {
352
+ "background-color": COLORS.nodeBg,
353
+ "border-color": COLORS.nodeBorder,
354
+ "border-width": 2,
355
+ shape: "round-rectangle",
356
+ width: "label",
357
+ height: "label",
358
+ padding: 10,
359
+ label: "data(label)",
360
+ color: COLORS.nodeText,
361
+ "font-size": 10,
362
+ "text-wrap": "wrap",
363
+ "text-max-width": "150px",
364
+ "text-valign": "center",
365
+ "text-halign": "center",
366
+ },
367
+ },
368
+ {
369
+ selector: "node.subgraph",
370
+ style: {
371
+ "background-color": COLORS.subgraphBg,
372
+ "border-color": COLORS.subgraphBorder,
373
+ shape: "round-hexagon",
374
+ },
375
+ },
376
+ {
377
+ selector: "node.start",
378
+ style: {
379
+ "background-color": COLORS.startBg,
380
+ "border-color": COLORS.startBorder,
381
+ "border-width": 3,
382
+ },
383
+ },
384
+ {
385
+ selector: "node.end",
386
+ style: {
387
+ "background-color": COLORS.endBg,
388
+ "border-color": COLORS.endBg,
389
+ color: COLORS.endText,
390
+ shape: "ellipse",
391
+ width: 52,
392
+ height: 52,
393
+ "font-size": 11,
394
+ "font-weight": "bold",
395
+ },
396
+ },
397
+ {
398
+ selector: "node:selected",
399
+ style: {
400
+ "border-color": COLORS.accent,
401
+ "border-width": 4,
402
+ },
403
+ },
404
+ {
405
+ selector: "edge",
406
+ style: {
407
+ width: 1.5,
408
+ "line-color": COLORS.edge,
409
+ "target-arrow-color": COLORS.edge,
410
+ "target-arrow-shape": "triangle",
411
+ "curve-style": "bezier",
412
+ label: "data(label)",
413
+ "font-size": 8,
414
+ color: "#475569",
415
+ "text-wrap": "wrap",
416
+ "text-max-width": "140px",
417
+ "text-rotation": "autorotate",
418
+ "text-background-color": "#ffffff",
419
+ "text-background-opacity": 0.9,
420
+ "text-background-padding": 2,
421
+ },
422
+ },
423
+ {
424
+ selector: "edge.conditional",
425
+ style: {
426
+ "line-style": "dashed",
427
+ "line-color": COLORS.conditional,
428
+ "target-arrow-color": COLORS.conditional,
429
+ },
430
+ },
431
+ {
432
+ selector: "edge:selected",
433
+ style: {
434
+ width: 3,
435
+ "line-color": COLORS.accent,
436
+ "target-arrow-color": COLORS.accent,
437
+ },
438
+ },
439
+ ];
440
+
441
+ const state = {
442
+ current: null,
443
+ history: [],
444
+ labels: true,
445
+ conditions: true,
446
+ };
447
+
448
+ let cy = null;
449
+
450
+ function esc(value) {
451
+ return String(value ?? "").replace(
452
+ /[&<>"']/g,
453
+ (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
454
+ );
455
+ }
456
+
457
+ function edgeLabel(edge) {
458
+ if (!state.labels) return "";
459
+ const parts = [edge.description || ""];
460
+ if (state.conditions && edge.condition) parts.push(`when: ${edge.condition}`);
461
+ return parts.filter(Boolean).join("\n");
462
+ }
463
+
464
+ function renderList() {
465
+ const list = $("#workflow-list");
466
+ const query = $("#search").value.trim().toLowerCase();
467
+ list.innerHTML = "";
468
+
469
+ for (const id of DATA.order) {
470
+ const workflow = DATA.workflows[id];
471
+ const haystack = `${id} ${workflow.title}`.toLowerCase();
472
+ if (query && !haystack.includes(query)) continue;
473
+
474
+ const button = document.createElement("button");
475
+ button.type = "button";
476
+ button.className = `wf${id === state.current ? " active" : ""}`;
477
+ button.innerHTML = `<strong>${esc(workflow.title)}</strong><small>${esc(id)}</small>`;
478
+ button.addEventListener("click", () => openWorkflow(id));
479
+ list.appendChild(button);
480
+ }
481
+
482
+ $("#count").textContent = `${DATA.order.length} workflows`;
483
+ }
484
+
485
+ function openWorkflow(id, push = true) {
486
+ if (!DATA.workflows[id]) return;
487
+ if (push && state.current && state.current !== id) state.history.push(state.current);
488
+ state.current = id;
489
+ render();
490
+ }
491
+
492
+ function goBack() {
493
+ const previous = state.history.pop();
494
+ if (!previous) return;
495
+ state.current = previous;
496
+ render();
497
+ }
498
+
499
+ function render() {
500
+ const workflow = DATA.workflows[state.current];
501
+ if (!workflow) return;
502
+
503
+ $("#title").textContent = workflow.title;
504
+ $("#subtitle").textContent = `${workflow.id} · start: ${workflow.start}`;
505
+ $("#back").classList.toggle("visible", state.history.length > 0);
506
+ document.title = `${workflow.title} · next-steps`;
507
+
508
+ renderList();
509
+ renderGraph(workflow);
510
+ }
511
+
512
+ function renderGraph(workflow) {
513
+ const elements = [];
514
+
515
+ for (const node of workflow.nodes) {
516
+ const classes = [node.type === "subgraph" ? "subgraph" : "node"];
517
+ if (node.id === workflow.start) classes.push("start");
518
+ elements.push({
519
+ data: {
520
+ id: node.id,
521
+ label: node.title,
522
+ title: node.title,
523
+ type: node.type,
524
+ description: node.description,
525
+ workflow: node.workflow || "",
526
+ },
527
+ classes: classes.join(" "),
528
+ });
529
+ }
530
+
531
+ if (workflow.edges.some((edge) => edge.to === "END")) {
532
+ elements.push({
533
+ data: {
534
+ id: "END",
535
+ label: "END",
536
+ title: "END",
537
+ type: "end",
538
+ description: "This scope is finished.",
539
+ },
540
+ classes: "end",
541
+ });
542
+ }
543
+
544
+ for (const edge of workflow.edges) {
545
+ elements.push({
546
+ data: {
547
+ id: edge.id,
548
+ source: edge.from,
549
+ target: edge.to,
550
+ label: edgeLabel(edge),
551
+ from: edge.from,
552
+ to: edge.to,
553
+ description: edge.description,
554
+ condition: edge.condition || "",
555
+ },
556
+ classes: edge.condition ? "conditional" : "edge",
557
+ });
558
+ }
559
+
560
+ if (cy) {
561
+ cy.destroy();
562
+ cy = null;
563
+ }
564
+
565
+ cy = cytoscape({
566
+ container: $("#cy"),
567
+ elements,
568
+ style: STYLE,
569
+ wheelSensitivity: 1,
570
+ minZoom: 0.15,
571
+ maxZoom: 2.5,
572
+ });
573
+
574
+ cy.on("tap", "node", (event) => showNode(event.target));
575
+ cy.on("tap", "edge", (event) => showEdge(event.target));
576
+ cy.on("tap", (event) => {
577
+ if (event.target === cy) showIntro();
578
+ });
579
+ cy.on("dblclick", "node", (event) => {
580
+ const workflowId = event.target.data("workflow");
581
+ if (workflowId) openWorkflow(workflowId);
582
+ });
583
+
584
+ cy.ready(() => runLayout());
585
+ showIntro();
586
+ }
587
+
588
+ function runLayout() {
589
+ if (!cy) return;
590
+ try {
591
+ cy.layout({
592
+ name: "dagre",
593
+ rankDir: "TB",
594
+ nodeSep: 35,
595
+ rankSep: 80,
596
+ edgeSep: 20,
597
+ padding: 30,
598
+ animate: false,
599
+ fit: true,
600
+ }).run();
601
+ } catch {
602
+ cy.layout({
603
+ name: "breadthfirst",
604
+ directed: true,
605
+ padding: 30,
606
+ animate: false,
607
+ fit: true,
608
+ }).run();
609
+ }
610
+ cy.fit(undefined, 30);
611
+ }
612
+
613
+ function refreshEdgeLabels() {
614
+ if (!cy) return;
615
+ cy.edges().forEach((edge) => {
616
+ const data = edge.data();
617
+ edge.data(
618
+ "label",
619
+ edgeLabel({ description: data.description, condition: data.condition }),
620
+ );
621
+ });
622
+ }
623
+
624
+ function showIntro() {
625
+ $("#details").innerHTML = `
626
+ <h3>Workflow viewer</h3>
627
+ <p class="muted">Click a node or an edge. Double-click a subgraph node to open it.</p>
628
+ <div class="hint">
629
+ The data is generated from <code>test/fixtures/workflows/</code>. Run
630
+ <code>bun run viz</code> after editing a graph.
631
+ </div>
632
+ `;
633
+ }
634
+
635
+ function showNode(node) {
636
+ const data = node.data();
637
+ const kind = data.type === "subgraph" ? "subgraph" : data.type === "end" ? "END" : "node";
638
+
639
+ $("#details").innerHTML = `
640
+ <h3>${esc(data.title)}</h3>
641
+ <div class="muted"><code>${esc(data.id)}</code> · ${esc(kind)}</div>
642
+ <dl>
643
+ <dt>Description</dt><dd>${esc(data.description || "—")}</dd>
644
+ </dl>
645
+ `;
646
+
647
+ if (data.type === "subgraph" && data.workflow) {
648
+ const button = document.createElement("button");
649
+ button.type = "button";
650
+ button.className = "primary";
651
+ button.textContent = `Open ${data.workflow} →`;
652
+ button.addEventListener("click", () => openWorkflow(data.workflow));
653
+ $("#details").appendChild(button);
654
+ }
655
+
656
+ if (data.type !== "end") {
657
+ const hint = document.createElement("div");
658
+ hint.className = "hint";
659
+ hint.innerHTML = `Log this step with <code>next-steps log --key &lt;key&gt; --node ${esc(
660
+ data.id,
661
+ )}</code>.`;
662
+ $("#details").appendChild(hint);
663
+ }
664
+ }
665
+
666
+ function showEdge(edge) {
667
+ const data = edge.data();
668
+ $("#details").innerHTML = `
669
+ <h3>Transition ${esc(data.id)}</h3>
670
+ <div class="muted"><code>${esc(data.from)}</code> → <code>${esc(data.to)}</code></div>
671
+ <dl>
672
+ <dt>Description</dt><dd>${esc(data.description || "—")}</dd>
673
+ <dt>Condition</dt><dd>${esc(data.condition || "—")}</dd>
674
+ </dl>
675
+ <div class="hint">
676
+ Conditions are prose. The agent decides; the logged edge is the audit trail.
677
+ </div>
678
+ `;
679
+ }
680
+
681
+ function exportPng() {
682
+ if (!cy) return;
683
+ const link = document.createElement("a");
684
+ link.href = cy.png({ full: true, scale: 2, bg: "#ffffff" });
685
+ link.download = `${state.current}.png`;
686
+ link.click();
687
+ }
688
+
689
+ $("#search").addEventListener("input", renderList);
690
+ $("#back").addEventListener("click", goBack);
691
+ $("#fit").addEventListener("click", () => cy?.fit(undefined, 30));
692
+ $("#relayout").addEventListener("click", runLayout);
693
+ $("#png").addEventListener("click", exportPng);
694
+ $("#show-edge-labels").addEventListener("change", (event) => {
695
+ state.labels = event.target.checked;
696
+ refreshEdgeLabels();
697
+ });
698
+ $("#show-conditions").addEventListener("change", (event) => {
699
+ state.conditions = event.target.checked;
700
+ refreshEdgeLabels();
701
+ });
702
+
703
+ state.current = DATA.workflows["support-ticket"] ? "support-ticket" : DATA.order[0];
704
+ render();
705
+ </script>
706
+ </body>
707
+ </html>