@dikshanty94/webnav 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,1343 @@
1
+ // The dashboard shell: a single self-contained page (vanilla JS + fetch) for the
2
+ // SITES and CREDENTIALS operator views. No build step — matches webnav's ethos
3
+ // for its OWN UI. Kept deliberately plain. (The heavy graph viewer left with the
4
+ // webnav-site repo; inspect a map via `dev outline` / `dev mermaid` instead.)
5
+ export const SHELL_HTML = `<!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>webnav</title>
11
+ <!-- Favicon: the webnav waypoint mark (a map pin with a hollow center), inline data-URI so
12
+ the tab icon is self-contained — no external asset, no build step. Accent blue on transparent. -->
13
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235b9dff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22c4.5-5 7-8.4 7-12A7 7 0 0 0 5 10c0 3.6 2.5 7 7 12z'/%3E%3Ccircle cx='12' cy='10' r='2.6' fill='%230f1115'/%3E%3C/svg%3E" />
14
+ <!-- Theme bootstrap: set data-theme BEFORE first paint so there's no flash. Saved choice
15
+ (localStorage) wins; otherwise follow the OS preference. Runs inline, synchronously. -->
16
+ <script>(function(){try{var s=localStorage.getItem('webnav-theme');var t=s||(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark');document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
17
+ <style>
18
+ /* THEME TOKENS. Dark is the default (identity-preserving); light is a companion ramp tuned to
19
+ pass WCAG on the same accent. The theme is chosen by data-theme on <html> (set by the toggle;
20
+ first load follows prefers-color-scheme). --bg-sunken = inputs/code (darker than bg in dark,
21
+ LIGHTER-surface in light — not an inverted literal); --on-accent = text on the accent button;
22
+ --overlay/--shadow = modal scrim + shadow, retuned per theme. */
23
+ :root, :root[data-theme="dark"] {
24
+ color-scheme: dark;
25
+ --bg:#0f1115; --panel:#171a21; --bg-sunken:#0b0d11; --border:#262b36;
26
+ --fg:#e6e9ef; --muted:#8b93a3; --accent:#5b9dff; --on-accent:#08111f;
27
+ --danger:#ff6b6b; --ok:#3fb950; --warn:#e2b93d; --rec:#e5484d;
28
+ --overlay:rgba(4,6,10,.6); --shadow:0 16px 48px rgba(0,0,0,.5); --shadow-sm:0 4px 16px rgba(0,0,0,.4);
29
+ }
30
+ :root[data-theme="light"] {
31
+ color-scheme: light;
32
+ --bg:#f6f7f9; --panel:#ffffff; --bg-sunken:#eef0f3; --border:#d6dae1;
33
+ --fg:#1a1d23; --muted:#5c6470; --accent:#2563d9; --on-accent:#ffffff;
34
+ --danger:#d13c3c; --ok:#1a883f; --warn:#9a6b00; --rec:#d6342c;
35
+ --overlay:rgba(20,24,31,.35); --shadow:0 16px 48px rgba(20,30,50,.18); --shadow-sm:0 4px 16px rgba(20,30,50,.12);
36
+ }
37
+ * { box-sizing: border-box; }
38
+ body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; background:var(--bg); color:var(--fg); }
39
+ header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--border); }
40
+ header .brand { display:inline-flex; align-items:center; gap:8px; }
41
+ header .brand .mark { width:20px; height:20px; color:var(--accent); flex:none; }
42
+ header .brand .word { font-size:16px; font-weight:600; letter-spacing:-0.01em; }
43
+ header .sub { color:var(--muted); font-size:12px; }
44
+ .iconbtn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; padding:0;
45
+ background:none; border:1px solid var(--border); border-radius:8px; color:var(--muted); cursor:pointer; transition:color .15s ease, border-color .15s ease; }
46
+ .iconbtn:hover { color:var(--fg); border-color:var(--accent); }
47
+ .iconbtn svg { width:17px; height:17px; }
48
+ /* LEFT-PANE nav: a vertical sidebar below the header; content to its right. Scoped to
49
+ .shell > nav (direct child) so it does NOT restyle the detail view's inner sub-tab nav
50
+ (Steps/Videos/Review/Logs), which stays a horizontal tab row via its own rules below. */
51
+ .shell { display:flex; align-items:stretch; min-height:calc(100vh - 53px); }
52
+ .shell > nav { display:flex; flex-direction:column; gap:2px; padding:14px 10px; width:180px; flex:none;
53
+ border-right:1px solid var(--border); }
54
+ .shell > nav button { display:block; width:100%; text-align:left; background:none; border:none; color:var(--muted);
55
+ padding:8px 12px; border-radius:7px; cursor:pointer; font:inherit; }
56
+ .shell > nav button.active { color:var(--fg); background:var(--panel); box-shadow:inset 2px 0 0 var(--accent); }
57
+ .shell > nav button:hover:not(.active) { color:var(--fg); background:var(--panel); }
58
+ main { flex:1; min-width:0; padding:20px; display:grid; grid-template-columns:280px 1fr; gap:20px; }
59
+ /* detail sub-tabs: horizontal row (unchanged from the original top-nav look) */
60
+ .detail nav { display:flex; flex-direction:row; gap:4px; width:auto; padding:0; border-right:none; }
61
+ .detail nav button { display:inline-block; width:auto; background:none; border:none; color:var(--muted); padding:8px 12px; cursor:pointer; font:inherit; border-bottom:2px solid transparent; border-radius:0; }
62
+ .detail nav button.active { color:var(--fg); border-bottom-color:var(--accent); }
63
+ .detail nav button:hover { color:var(--fg); }
64
+ @media (max-width: 640px) { .shell { flex-direction:column; } .shell > nav { flex-direction:row; width:auto; flex-wrap:wrap; border-right:none; border-bottom:1px solid var(--border); } }
65
+ .list { border:1px solid var(--border); border-radius:8px; overflow:hidden; align-self:start; }
66
+ .list .row { padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--border); }
67
+ .list .row:last-child { border-bottom:none; }
68
+ .list .row:hover { background:var(--panel); }
69
+ .list .row.active { background:var(--panel); box-shadow:inset 2px 0 0 var(--accent); }
70
+ .list .row .name { font-weight:500; }
71
+ .list .row .meta { color:var(--muted); font-size:12px; }
72
+ .detail { border:1px solid var(--border); border-radius:8px; padding:16px; min-height:200px; }
73
+ /* ── Sessions: full-width list-primary layout ── */
74
+ /* One row = a grid so name+badges+meta line up in COLUMNS and never wrap (the old
75
+ 280px column crammed everything into a block). Columns: select · name+badges ·
76
+ site · steps · video · date · delete. Meta columns hide on narrow viewports. */
77
+ .srow { display:grid; grid-template-columns:auto minmax(0,1fr) 160px 90px 56px 150px auto;
78
+ align-items:center; gap:14px; padding:11px 14px; cursor:pointer; border-bottom:1px solid var(--border); }
79
+ .srow:last-child { border-bottom:none; }
80
+ .srow:hover { background:var(--panel); }
81
+ .srow.active { background:var(--panel); box-shadow:inset 2px 0 0 var(--accent); }
82
+ .srow .nm { display:flex; align-items:center; gap:8px; min-width:0; }
83
+ .srow .nm .t { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
84
+ .srow .col { color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
85
+ .srow .col.r { text-align:right; }
86
+ .badge { border:1px solid currentColor; border-radius:4px; padding:1px 6px; font-size:10px; line-height:1.5;
87
+ white-space:nowrap; display:inline-flex; align-items:center; gap:3px; }
88
+ .badge.origin-agent { color:var(--accent); }
89
+ .badge.origin-extension { color:var(--ok); }
90
+ .badge.origin-manual { color:var(--muted); }
91
+ .badge.ok { color:var(--ok); }
92
+ .badge.warn { color:var(--warn); }
93
+ .badge.fail { color:var(--danger); }
94
+ .badge.unrev { color:var(--muted); }
95
+ /* review VERDICT banner (outcome-first) + collapsed prose report */
96
+ .verdict { border:1px solid var(--border); border-radius:8px; padding:12px 14px; margin:8px 0 10px; }
97
+ .verdict.ok { border-color:var(--ok); background:color-mix(in srgb, var(--ok) 8%, transparent); }
98
+ .verdict.fail { border-color:var(--danger); background:color-mix(in srgb, var(--danger) 8%, transparent); }
99
+ .verdict .vh { font-weight:600; font-size:14px; }
100
+ .verdict.ok .vh { color:var(--ok); } .verdict.fail .vh { color:var(--danger); }
101
+ .verdict .vs { font-size:12px; margin-top:3px; }
102
+ .verdict .vm { font-size:11px; color:var(--muted); margin-top:5px; }
103
+ .reprep { border:1px solid var(--border); border-radius:6px; }
104
+ .reprep summary { cursor:pointer; padding:8px 12px; font-size:12px; color:var(--muted); user-select:none; }
105
+ .reprep summary:hover { color:var(--fg); }
106
+ .reprep[open] summary { border-bottom:1px solid var(--border); }
107
+ .reprep .repbody { padding:12px; font-size:13px; }
108
+ @media (max-width: 1100px) { .srow { grid-template-columns:auto minmax(0,1fr) 90px 150px auto; }
109
+ .srow .col.site, .srow .col.vid { display:none; } }
110
+ @media (max-width: 760px) { .srow { grid-template-columns:auto minmax(0,1fr) auto; }
111
+ .srow .col.steps, .srow .col.date { display:none; } }
112
+ /* breadcrumb shown when a session is selected (list hidden, detail full-width) */
113
+ .crumbs { display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:14px; }
114
+ .crumbs a { color:var(--muted); cursor:pointer; text-decoration:none; }
115
+ .crumbs a:hover { color:var(--fg); }
116
+ .crumbs .sep { color:var(--border); }
117
+ .crumbs .cur { color:var(--fg); font-weight:600; }
118
+ .listwrap.fade { animation: fadein .16s ease-out; }
119
+ @keyframes fadein { from { opacity:0 } to { opacity:1 } }
120
+ @media (prefers-reduced-motion: reduce) { .listwrap.fade { animation:none } }
121
+ pre { background:var(--bg-sunken); border:1px solid var(--border); border-radius:6px; padding:12px; overflow:auto; font-size:12px; max-height:70vh; }
122
+ table { width:100%; border-collapse:collapse; }
123
+ td, th { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); }
124
+ th { color:var(--muted); font-weight:500; font-size:12px; }
125
+ code.val { font-family:ui-monospace,monospace; }
126
+ button.btn { background:var(--panel); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:5px 10px; cursor:pointer; font:inherit; transition:border-color .15s ease, background .15s ease; }
127
+ button.btn:hover:not(:disabled) { border-color:var(--accent); }
128
+ button.btn.danger:hover:not(:disabled) { border-color:var(--danger); color:var(--danger); }
129
+ button.btn.primary { background:var(--accent); border-color:var(--accent); color:var(--on-accent); font-weight:600; }
130
+ button.btn.primary:hover:not(:disabled) { filter:brightness(1.08); }
131
+ /* New-session MODAL (native <dialog>) */
132
+ dialog.dlg { border:1px solid var(--border); border-radius:12px; background:var(--panel); color:var(--fg); padding:0; width:min(440px,92vw); box-shadow:var(--shadow); }
133
+ dialog.dlg::backdrop { background:var(--overlay); backdrop-filter:blur(2px); }
134
+ .dlgform { display:flex; flex-direction:column; gap:14px; padding:22px 22px 18px; }
135
+ .dlgform h2 { margin:0; font-size:16px; font-weight:600; }
136
+ .dlgform label { display:flex; flex-direction:column; gap:5px; font-size:12px; color:var(--muted); }
137
+ .dlgform input { background:var(--bg-sunken); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:8px 10px; font:14px/1.4 inherit; }
138
+ /* keep the accent-border cue on focus, but DON'T strip the a11y outline on keyboard focus
139
+ (critique B: input:focus{outline:none} was killing :focus-visible for modal inputs). */
140
+ .dlgform input:focus { border-color:var(--accent); }
141
+ .dlgform input:focus:not(:focus-visible) { outline:none; }
142
+ .dlgmsg { font-size:12px; min-height:16px; }
143
+ .dlgbtns { display:flex; justify-content:flex-end; gap:8px; margin-top:2px; }
144
+ button.btn:disabled { opacity:.4; cursor:not-allowed; } /* disabled must LOOK disabled (was clickable-looking, live #3) */
145
+ .addrow input, .addrow select { background:var(--bg-sunken); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:6px 8px; font:inherit; margin-right:6px; }
146
+ select { background:var(--bg-sunken); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:4px 6px; font:inherit; }
147
+ input.inline { background:var(--bg-sunken); border:1px solid var(--accent); color:var(--fg); border-radius:6px; padding:5px 8px; font:ui-monospace,monospace; width:90%; }
148
+ .cat-head { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.04em; margin:10px 0 2px; }
149
+ .info { color:var(--muted); font-size:13px; cursor:help; font-weight:400; }
150
+ /* Visual/Raw segmented toggle */
151
+ .seg { display:inline-flex; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
152
+ .segbtn { background:none; border:none; color:var(--muted); padding:4px 12px; cursor:pointer; font:inherit; font-size:12px; }
153
+ .segbtn.active { background:var(--panel); color:var(--fg); }
154
+ .segbtn:not(.active):hover { color:var(--fg); }
155
+ /* Visual graph */
156
+ .graphwrap { border:1px solid var(--border); border-radius:8px; background:var(--bg-sunken); padding:8px; }
157
+ .graph { display:block; max-height:70vh; }
158
+ .gnode:hover rect { fill:var(--panel); filter:brightness(1.15); }
159
+ .glegend { display:flex; flex-wrap:wrap; gap:16px; padding:8px 6px 2px; color:var(--muted); font-size:11px; }
160
+ .glegend span { display:inline-flex; align-items:center; gap:5px; }
161
+ .glegend .dot { width:16px; height:2px; border-radius:2px; display:inline-block; }
162
+ .glegend .dot.acc { background:var(--accent); height:3px; }
163
+ .glegend .dot.mut { background:var(--border); }
164
+ .gnote { color:var(--muted); font-size:11px; padding:6px 8px 0; font-style:italic; }
165
+ .gnode:focus-visible rect { stroke-width:2.5; }
166
+ /* node-detail panel (opens under the graph on click) */
167
+ .gpanel { margin-top:12px; border:1px solid var(--border); border-radius:8px; background:var(--panel); padding:14px 16px; }
168
+ .gpanel .phead { font-size:14px; margin-bottom:2px; }
169
+ .gpanel .psec { margin-top:12px; }
170
+ .gpanel .ptitle { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); margin-bottom:5px; }
171
+ .gpanel .chips { display:flex; flex-wrap:wrap; gap:6px; }
172
+ .gpanel .chip { border:1px solid var(--border); border-radius:5px; padding:2px 8px; font-size:12px; background:var(--bg-sunken); white-space:nowrap; }
173
+ button.morebtn { border-radius:5px; padding:2px 8px; font-size:12px; } /* .chips already wraps */
174
+ .muted { color:var(--muted); }
175
+ .empty { color:var(--muted); padding:40px 0; text-align:center; }
176
+ /* placeholder must meet contrast (critique: browser-default gray was 4.22:1) — --muted passes. */
177
+ input::placeholder, textarea::placeholder { color:var(--muted); opacity:1; }
178
+ /* keyboard/tap target floor: the row select checkbox was 13px (critique B) — bump it. */
179
+ .srow input[type=checkbox] { width:18px; height:18px; cursor:pointer; }
180
+ /* P0 (critique A): a long unbroken line in a review/markdown report grew a bare <div> to
181
+ 4600px and dragged the layout off-screen with no scrollbar. Wrap + clip the detail pane. */
182
+ .detail { overflow-x:auto; }
183
+ .detail div, .detail p, .detail li { overflow-wrap:anywhere; }
184
+ .pulse { animation: webnavpulse 1.2s ease-in-out infinite; }
185
+ @keyframes webnavpulse { 50% { opacity:.35; } }
186
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
187
+ .list .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
188
+ @media (prefers-reduced-motion: reduce) { .pulse { animation: none; } * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }
189
+ #toast { position:fixed; z-index:100; bottom:16px; right:16px; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
190
+ #toast .t { background:var(--panel); border:1px solid var(--danger); color:var(--fg); border-radius:8px; padding:10px 14px; font-size:13px; max-width:360px; box-shadow:var(--shadow-sm); animation: toastin .18s ease-out; }
191
+ @keyframes toastin { from { opacity:0; transform:translateY(8px); } }
192
+ </style>
193
+ </head>
194
+ <body>
195
+ <header>
196
+ <span class="brand">
197
+ <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22c4.5-5 7-8.4 7-12A7 7 0 0 0 5 10c0 3.6 2.5 7 7 12z"/><circle cx="12" cy="10" r="2.6" fill="var(--bg)"/></svg>
198
+ <span class="word">webnav</span>
199
+ </span>
200
+ <span class="sub" id="env"></span>
201
+ <span style="flex:1"></span>
202
+ <button id="themebtn" class="iconbtn" title="Toggle light / dark" aria-label="Toggle light / dark theme"></button>
203
+ </header>
204
+ <div class="shell">
205
+ <nav>
206
+ <button data-tab="recordings" class="active">Sessions</button>
207
+ <button data-tab="sites">Sites</button>
208
+ <button data-tab="creds">Credentials</button>
209
+ <button data-tab="profiles">Profiles</button>
210
+ </nav>
211
+ <main id="main"></main>
212
+ </div>
213
+
214
+ <script>
215
+ const main = document.getElementById('main');
216
+ let tab = 'recordings';
217
+ let recordingsOriginFilter = 'all'; // Sessions list source filter: all | extension | agent | manual
218
+
219
+ document.querySelectorAll('nav button[data-tab]').forEach(b => {
220
+ b.onclick = () => {
221
+ tab = b.dataset.tab;
222
+ document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
223
+ render();
224
+ };
225
+ });
226
+
227
+ // ---------- THEME TOGGLE ----------
228
+ // data-theme is already set pre-paint by the <head> bootstrap. This button flips it, persists
229
+ // the choice, and swaps its own icon (moon when dark → click for light; sun when light).
230
+ const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
231
+ const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
232
+ function paintThemeBtn() {
233
+ const dark = document.documentElement.getAttribute('data-theme') !== 'light';
234
+ const btn = document.getElementById('themebtn');
235
+ if (btn) btn.innerHTML = dark ? MOON : SUN; // moon = currently dark (click → light); sun = currently light
236
+ }
237
+ document.getElementById('themebtn').onclick = () => {
238
+ const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
239
+ document.documentElement.setAttribute('data-theme', next);
240
+ try { localStorage.setItem('webnav-theme', next); } catch (e) {}
241
+ paintThemeBtn();
242
+ };
243
+ paintThemeBtn();
244
+
245
+ async function getJSON(u, opts) { const r = await fetch(u, opts); if (!r.ok) throw new Error(u + ' -> ' + r.status); return r.json(); }
246
+ function el(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
247
+ function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
248
+ function toast(msg) {
249
+ let box = document.getElementById('toast');
250
+ if (!box) { box = document.createElement('div'); box.id = 'toast'; document.body.appendChild(box); }
251
+ const t = document.createElement('div'); t.className = 't'; t.textContent = String(msg);
252
+ box.appendChild(t);
253
+ setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4000);
254
+ }
255
+ // WCAG 2.1.1: clickable row divs need keyboard access. Delegates to the row's
256
+ // own click handler (row.click()) so mouse and keyboard paths can't diverge.
257
+ function rowKeyboard(row, activate) {
258
+ row.setAttribute('role', 'button');
259
+ row.setAttribute('tabindex', '0');
260
+ row.addEventListener('keydown', (e) => {
261
+ if (e.key === 'Enter' || e.key === ' ') {
262
+ if (e.target !== row) return; // let inner checkbox/button handle their own keys
263
+ e.preventDefault(); activate();
264
+ }
265
+ });
266
+ }
267
+
268
+ async function render() {
269
+ if (replayPoll) { clearInterval(replayPoll); replayPoll = null; } // no stray status polls across tab switches (review finding)
270
+ main.innerHTML = '<div class="empty">loading…</div>';
271
+ main.style.gridTemplateColumns = tab === 'sites' ? '280px 1fr' : '1fr';
272
+ if (tab === 'sites') return renderSites();
273
+ if (tab === 'creds') return renderCreds();
274
+ if (tab === 'recordings') return renderRecordings();
275
+ if (tab === 'profiles') return renderProfiles();
276
+ }
277
+
278
+ // ---------- SITES ----------
279
+ async function renderSites() {
280
+ const sites = await getJSON('/api/sites');
281
+ main.innerHTML = '';
282
+ const list = el('<div class="list"></div>');
283
+ const detail = el('<div class="detail"><div class="empty">select a site to see its JSON map</div></div>');
284
+ if (!sites.length) list.append(el('<div class="empty">no sites mapped yet</div>'));
285
+ sites.forEach(s => {
286
+ const row = el('<div class="row"><div class="name">'+esc(s.id)+'</div><div class="meta">'+s.stateCount+' states</div></div>');
287
+ row.onclick = async () => {
288
+ list.querySelectorAll('.row').forEach(r => r.classList.remove('active'));
289
+ row.classList.add('active');
290
+ detail.innerHTML = '<div class="empty">loading…</div>';
291
+ const full = await getJSON('/api/sites/' + encodeURIComponent(s.id));
292
+ detail.innerHTML = '';
293
+ // Header + a Visual / Raw toggle. Visual = a flow graph (states + navigate edges); Raw = the JSON map.
294
+ const head = el('<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px"><strong>'+esc(s.id)+'</strong><span class="muted" style="flex:1">'+esc(s.homeUrl||'')+'</span><div class="seg"><button class="segbtn active" data-v="visual">Visual</button><button class="segbtn" data-v="raw">Raw</button></div></div>');
295
+ const body = el('<div></div>');
296
+ const draw = (view) => {
297
+ head.querySelectorAll('.segbtn').forEach(b => b.classList.toggle('active', b.dataset.v === view));
298
+ body.innerHTML = '';
299
+ if (view === 'raw') { body.append(el('<pre>'+esc(JSON.stringify(full, null, 2))+'</pre>')); }
300
+ else { body.append(graphView(full.states || [])); }
301
+ };
302
+ head.querySelectorAll('.segbtn').forEach(b => b.onclick = () => draw(b.dataset.v));
303
+ detail.append(head, body);
304
+ draw('visual');
305
+ };
306
+ rowKeyboard(row, () => row.click());
307
+ list.append(row);
308
+ });
309
+ main.append(list, detail);
310
+ }
311
+
312
+ // What we KNOW about one page, filtered of noise. The map stores every affordance, but a lot is
313
+ // (a) shared sidebar chrome (Close sidebar / Dark Mode / Switch to Classic / the account switcher)
314
+ // and (b) individual DATA rows captured as mutates ("Demo Report - Jul 8…"). Neither describes the
315
+ // PAGE. This extracts the signal: key actions, search/filters, what it operates on, sub-views.
316
+ const CHROME_RE = /\\b(close sidebar|open sidebar|dark mode|light mode|switch to classic|logo|announcements|help center|dashboards|downloads|reports|analytics agent|admin)\\b/i;
317
+ // a label that looks like a DATA ROW, not a control: contains a date/time or a "- <name>" byline.
318
+ const DATAROW_RE = /\\b(\\d{1,2}:\\d{2}|\\d{4}|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\\b.*(IST|UTC|GMT|AM|PM)|·|—/i;
319
+ const ROWSEL_RE = /press space to toggle|column with header selection|row selection/i;
320
+ // controls that live INSIDE a sub-dialog/widget, not top-level page actions: pagination, chart-
321
+ // axis config, date-range presets, toggles. Real but low-signal for "what can I do here".
322
+ const SUBCTRL_RE = /\\b(first|previous|next|last) page\\b|move .* to secondary axis|remove (ad impressions|revenue|win rate|ecpm|united states)|\\b(daily|monthly|yesterday|custom|last 7 days|last 30 days|last month|this month|this year|mtd|ytd)\\b|select all|clear all|dismiss toast|drilldown|open setup panel|^(layout|category|series|cc|bcc)$/i;
323
+ function pageFacts(state, chromeLabels) {
324
+ const aff = state.affordances || [];
325
+ const uniq = (arr) => [...new Set(arr)];
326
+ // a VALUE, not a control: a bare number (a chosen page-size like "20" — the option clicked in a
327
+ // dropdown, not an action), a date/number range, a currency, or very long free text.
328
+ const isValueLike = (s) => /^\\d+$/.test(s.trim()) || /\\d{1,2}\\s?(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)|\\d+:\\d\\d|UTC|IST|\\d{4}-\\d|\\$|%/i.test(s) || s.length > 32;
329
+ // CHROME is computed from the DATA (a label present on most pages = a shared sidebar/global
330
+ // control), NOT a hardcoded list — that generically catches this account's "O Overview Merged
331
+ // Change" workspace-switcher, pagination, "Clear input", etc. without naming them. CHROME_RE is
332
+ // a small backstop for obvious theme controls on a single-page map.
333
+ const clean = (label) => label && !(chromeLabels && chromeLabels.has(label)) && !CHROME_RE.test(label) && !ROWSEL_RE.test(label) && !DATAROW_RE.test(label) && !SUBCTRL_RE.test(label) && !isValueLike(label);
334
+ // OPERATES-ON first (so we can exclude it from actions): domain vocabulary — shadow collection
335
+ // columns + checkbox fields naming a dimension/metric/column (report's Publisher/Revenue/eCPM).
336
+ const filtersRaw = ((state.declaredShadow||{}).filters||[]).filter(f => f.field && !ROWSEL_RE.test(f.field) && !CHROME_RE.test(f.field));
337
+ const columns = uniq(((state.declaredShadow||{}).collections||[]).flatMap(c => c.columns||[]).filter(clean));
338
+ const checkFields = uniq(filtersRaw.filter(f => f.control==='checkbox').map(f => f.field.replace(/ Renamed Previously .*/,'').trim()).filter(clean));
339
+ const operatesOn = uniq([...columns, ...checkFields]);
340
+ const domainSet = new Set(operatesOn);
341
+ // SEARCH + non-domain, non-checkbox filters (search boxes, selects, date pickers)
342
+ const filters = filtersRaw.filter(f => clean(f.field) && f.control!=='checkbox' && !/search/i.test(f.field));
343
+ const hasSearch = filtersRaw.some(f => /search/i.test(f.field)) || aff.some(a => a.kind==='input' && /search/i.test(a.label||''));
344
+ // key ACTIONS = mutate/reveal controls that are NOT chrome, NOT a domain field (those go under
345
+ // Operates-on), NOT a value, NOT a sidebar link. This is what you can DO on the page.
346
+ const actions = uniq(aff.filter(a => (a.kind==='mutate'||a.kind==='reveal') && clean(a.label)
347
+ && !domainSet.has(a.label)).map(a => a.label));
348
+ // SUB-VIEWS = self-loop nav (Table/Charts, Nested/Flat)
349
+ const subviews = uniq(aff.filter(a => a.kind==='navigate' && a.toState===state.id).map(a => a.label));
350
+ const counts = { navigate:0, reveal:0, mutate:0, input:0 };
351
+ aff.forEach(a => { if (counts[a.kind]!==undefined) counts[a.kind]++; });
352
+ return { actions, hasSearch, filters, operatesOn, subviews, counts };
353
+ }
354
+
355
+ // ---------- VISUAL GRAPH (zero-dep SVG, LAYERED TREE) ----------
356
+ // A website is a HIERARCHY, not a peer-mesh: a hub → sections (report-list / dashboard-list /
357
+ // downloads / help / announcements) → detail pages (a report, a dashboard) → deeper. The
358
+ // "every page links to every other page" edges are just the SHARED SIDEBAR present on every
359
+ // page — that's chrome, not structure, and drawing it as edges made a hairball (the radial
360
+ // version's failure). So: SUPPRESS the sidebar mesh, keep only STRUCTURAL edges (a link from
361
+ // few pages to a specific target), and lay the result out top-down by depth. The shared sidebar
362
+ // is stated once as a caption, not 30 crossing lines.
363
+ function graphView(allStates) {
364
+ // the site-wide chrome record (nav/header/footer, present on every page) isn't a page itself —
365
+ // it's shown once as its own card above the tree, not as a node inside it (Task 13).
366
+ const isShell = (s) => s.role === 'shell' || (s.id||'').split(':').pop() === '_shell';
367
+ const shellState = allStates.find(isShell);
368
+ const states = allStates.filter(s => !isShell(s));
369
+ const lbl = (s) => s.semanticName || (s.id||'').split(':').pop();
370
+ const byId = {}; states.forEach(s => byId[s.id] = s);
371
+ const idLbl = (id) => byId[id] ? lbl(byId[id]) : (id||'').split(':').pop();
372
+ const names = states.map(lbl);
373
+ const N = names.length;
374
+ if (!N) return el('<div class="empty">no states to graph</div>');
375
+ const svgEsc = (x) => String(x).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
376
+
377
+ // HIERARCHY IS READ FROM THE MAP, not guessed. Each state carries role + parentState, set at
378
+ // analyse time from the observed nav structure (a link on most pages = shared sidebar; a page
379
+ // reached only via a content link = a child). The viewer just lays it out — no frequency/URL
380
+ // heuristics here. parentState is a full id; map it to a label.
381
+ const parentLbl = (s) => (s.parentState && byId[s.parentState]) ? lbl(byId[s.parentState]) : null;
382
+ // structural (parent→child) edges come straight from parentState. Keep the drill-in link label
383
+ // by matching the parent's navigate affordance that points at this child.
384
+ const structural = [];
385
+ states.forEach(s => { const p = parentLbl(s); if (!p) return;
386
+ const parentSt = states.find(x => lbl(x) === p);
387
+ const via = (parentSt?.affordances || []).find(a => a.kind === 'navigate' && idLbl(a.toState) === lbl(s));
388
+ structural.push({ from: p, to: lbl(s), via: via ? (via.label || via.semanticStep || '') : '' });
389
+ });
390
+ const selfBy = {};
391
+ states.forEach(s => (s.affordances||[]).forEach(a => { if (a.kind === 'navigate' && a.toState && idLbl(a.toState) === lbl(s)) (selfBy[lbl(s)] = selfBy[lbl(s)] || []).push(a.label || a.semanticStep || ''); }));
392
+ // the shared sidebar = the distinct link labels that reach a SECTION (a parentless state) from
393
+ // more than one page — surfaced as a caption, not drawn.
394
+ const sectionSet = new Set(states.filter(s => !parentLbl(s)).map(lbl));
395
+ const sidebarLabels = [...new Set(states.flatMap(s => (s.affordances||[])
396
+ .filter(a => a.kind === 'navigate' && a.toState && sectionSet.has(idLbl(a.toState)) && idLbl(a.toState) !== lbl(s))
397
+ .map(a => a.label || '')))].filter(v => v && !/\b(logo|home)\b/i.test(v));
398
+
399
+ // LAYOUT: synthetic hub row 0; sections (no parent) row 1; a child sits one row below its
400
+ // parent — depth = length of the parentState chain.
401
+ const ROOT = '⌂ ' + (states[0] ? (states[0].id.split(':')[0]) : 'site');
402
+ const depthOf = (s, guard) => { let d = 1, cur = s, g = 0; while (parentLbl(cur) && g++ < 20) { const p = states.find(x => lbl(x) === parentLbl(cur)); if (!p) break; d++; cur = p; } return d; };
403
+ const depth = {}; names.forEach(n => { const s = states.find(x => lbl(x) === n); depth[n] = depthOf(s); });
404
+ const maxDepth = Math.max(1, ...Object.values(depth));
405
+
406
+ // LAYOUT: rows top→down, nodes spread across each row.
407
+ const NW = 158, NH = 48, rowGap = 118, colGap = 22, padX = 30, padTop = 54; // taller box: name + summary line
408
+ const byRow = {}; for (let d=0; d<=maxDepth; d++) byRow[d] = [];
409
+ byRow[0] = [ROOT];
410
+ names.forEach(n => byRow[depth[n]].push(n));
411
+ const rowW = (r) => r.length * NW + (r.length-1) * colGap;
412
+ const maxRowW = Math.max(...Object.values(byRow).map(rowW));
413
+ const W = Math.max(560, maxRowW + padX*2), H = padTop + (maxDepth)*rowGap + NH + 40;
414
+ const pos = {};
415
+ for (let d=0; d<=maxDepth; d++){ const r=byRow[d]; const startX=(W - rowW(r))/2; r.forEach((n,i)=>{ pos[n]={x:startX + i*(NW+colGap) + NW/2, y:padTop + d*rowGap}; }); }
416
+
417
+ // edge from parent-center-bottom to child-center-top (clean vertical-ish flow, no center cross)
418
+ const link = (from,to,cls,label) => {
419
+ const a=pos[from], b=pos[to]; if(!a||!b) return '';
420
+ const ay=a.y+NH/2, by=b.y-NH/2, midY=(ay+by)/2;
421
+ const d='M '+a.x+' '+ay+' C '+a.x+' '+midY+', '+b.x+' '+midY+', '+b.x+' '+by;
422
+ const stroke = cls==='struct' ? 'var(--accent)' : 'var(--border)';
423
+ const sw = cls==='struct' ? 2 : 1.2;
424
+ let out = '<path d="'+d+'" stroke="'+stroke+'" stroke-width="'+sw+'" fill="none" marker-end="url(#'+(cls==='struct'?'ga':'gm')+')"'+(cls!=='struct'?' opacity="0.6"':'')+'/>';
425
+ if (label) out += '<text x="'+((a.x+b.x)/2)+'" y="'+(midY-3)+'" fill="var(--fg)" font-size="10" text-anchor="middle" paint-order="stroke" stroke="var(--bg-sunken)" stroke-width="3.5">'+svgEsc(label.length>24?label.slice(0,23)+'…':label)+'</text>';
426
+ return out;
427
+ };
428
+
429
+ let s = '<svg class="graph" viewBox="0 0 '+W+' '+H+'" width="100%" preserveAspectRatio="xMidYMid meet">';
430
+ s += '<defs><marker id="ga" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="var(--accent)"/></marker>'
431
+ + '<marker id="gm" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0 0 L7 3.5 L0 7 z" fill="var(--border)"/></marker></defs>';
432
+ // root → sections (faint, unlabeled — it's just "these are the sections")
433
+ byRow[1].forEach(n => { s += link(ROOT, n, 'sect', ''); });
434
+ // structural edges (bold, labeled) — parent → child (from stored parentState)
435
+ structural.forEach(e => { if (pos[e.from] && pos[e.to]) s += link(e.from, e.to, 'struct', e.via); });
436
+ // per-node facts (filtered) → a compact ONE-LINE summary shown under the name.
437
+ // CHROME LABELS = any affordance label present on ≥60% of pages (a shared sidebar/global
438
+ // control — the account switcher, pagination, Clear input, …). Computed from the data, so it
439
+ // catches site-specific chrome no hardcoded list would (e.g. "O Overview Merged Change").
440
+ const labelPages = {};
441
+ states.forEach(s => { const seen = new Set(); (s.affordances||[]).forEach(a => { if (a.label && !seen.has(a.label)) { seen.add(a.label); labelPages[a.label] = (labelPages[a.label]||0)+1; } }); });
442
+ const chromeCut2 = Math.max(3, Math.ceil(states.length * 0.6));
443
+ const chromeLabels = new Set(Object.entries(labelPages).filter(([,c]) => c >= chromeCut2).map(([l]) => l));
444
+ const factsBy = {}; states.forEach(st => factsBy[lbl(st)] = pageFacts(st, chromeLabels));
445
+ const summaryLine = (n) => { const f = factsBy[n]; if (!f) return '';
446
+ const bits = [];
447
+ if (f.actions.length) bits.push(f.actions.length + (f.actions.length===1?' action':' actions'));
448
+ if (f.hasSearch) bits.push('search');
449
+ if (f.operatesOn.length) bits.push(f.operatesOn.length + ' fields');
450
+ if (f.subviews.length) bits.push(f.subviews.length + ' views');
451
+ return bits.join(' · ');
452
+ };
453
+ // nodes (root first, then states). NH grows to fit the summary line.
454
+ const drawNode = (n, isRoot) => { const p=pos[n]; const summ = isRoot ? '' : summaryLine(n);
455
+ const st = isRoot ? null : states.find(x => lbl(x) === n);
456
+ const provMark = st && st.provisional ? ' ◌' : ''; // grey "seen once" marker; full note in the click panel
457
+ let g = '<g class="gnode" data-state="'+svgEsc(n)+'" tabindex="0" role="button" style="cursor:pointer">';
458
+ g += '<rect x="'+(p.x-NW/2)+'" y="'+(p.y-NH/2)+'" width="'+NW+'" height="'+NH+'" rx="8" fill="var(--panel)" stroke="'+(isRoot?'var(--muted)':'var(--accent)')+'" stroke-width="1.5"'+(isRoot?' stroke-dasharray="4 3"':'')+'/>';
459
+ g += '<text x="'+p.x+'" y="'+(p.y+(summ?-3:4))+'" fill="var(--fg)" font-size="12" font-weight="600" text-anchor="middle">'+svgEsc(n)+(provMark?'<tspan fill="var(--muted)">'+provMark+'</tspan>':'')+'</text>';
460
+ if (summ) g += '<text x="'+p.x+'" y="'+(p.y+13)+'" fill="var(--muted)" font-size="9" text-anchor="middle">'+svgEsc(summ)+'</text>';
461
+ return g + '</g>'; };
462
+ s += drawNode(ROOT, true);
463
+ names.forEach(n => { s += drawNode(n, false); });
464
+ s += '</svg>';
465
+
466
+ const sidebarNote = sidebarLabels.length
467
+ ? '<div class="gnote">Every page also shares a common sidebar (' + sidebarLabels.map(svgEsc).join(' · ') + ') — omitted here to show real structure.</div>'
468
+ : '';
469
+ // "Site shell" card: the chrome record's affordances (nav/header/footer present on every page)
470
+ // shown ONCE above the tree, not as a node inside it — see isShell above.
471
+ const shellCard = (() => {
472
+ if (!shellState) return '';
473
+ const affs = shellState.affordances || [];
474
+ if (!affs.length) return '';
475
+ const chip = (a) => '<span class="chip">'+esc(a.label)+' <span class="muted">'+esc(a.kind)+'</span>'
476
+ + (a.kind === 'navigate' && a.toState ? ' <span class="muted">&rarr; '+esc(idLbl(a.toState))+'</span>' : '')+'</span>';
477
+ return '<div class="gpanel" style="margin-bottom:12px"><div class="phead"><strong>Site shell</strong> <span class="muted">chrome on every page</span></div>'
478
+ + '<div class="chips" style="margin-top:8px">'+affs.map(chip).join('')+'</div></div>';
479
+ })();
480
+ const wrap = el('<div class="graphwrap">'+shellCard+s+sidebarNote
481
+ + '<div class="glegend"><span><i class="dot acc"></i> navigates to a specific page</span><span><i class="dot mut"></i> section of the hub</span><span>↻ in-page sub-view</span><span class="muted">click a page for details</span></div>'
482
+ + '<div class="gpanel" style="display:none"></div></div>');
483
+ // click a node → render its full filtered facts into the panel
484
+ const panel = wrap.querySelector('.gpanel');
485
+ // affordances scoped 'row' (a folded per-row repeat, e.g. 50 identical row-buttons folded to
486
+ // one) get a "per row" suffix chip so the count isn't misread as 50 distinct page actions.
487
+ // scope 'widget' (a folded repeated widget/card subtree, e.g. 3 dashboard chart widgets folded
488
+ // to one template) gets the same treatment with a "widget" suffix chip — no count is stored.
489
+ const rowScoped = (state, label) => (state.affordances||[]).some(a => a.label === label && a.scope === 'row');
490
+ const widgetScoped = (state, label) => (state.affordances||[]).some(a => a.label === label && a.scope === 'widget');
491
+ const openPanel = (name) => {
492
+ const st = states.find(x => lbl(x) === name); if (!st) { panel.style.display='none'; return; }
493
+ const f = factsBy[name];
494
+ const actionChip = (x) => '<span class="chip">'+esc(x)+(rowScoped(st, x)?' <span class="muted">per row</span>':'')+(widgetScoped(st, x)?' <span class="muted">widget</span>':'')+'</span>';
495
+ const chips = (items) => items.length ? items.map(x => '<span class="chip">'+esc(x)+'</span>').join('') : '<span class="muted">—</span>';
496
+ const sec = (title, inner) => '<div class="psec"><div class="ptitle">'+title+'</div>'+inner+'</div>';
497
+ let html = '<div class="phead"><strong>'+esc(name)+'</strong> <span class="badge '+(st.role==='detail'?'origin-manual':'ok')+'">'+esc(st.role||'page')+'</span>'
498
+ + (st.provisional ? ' <span class="badge unrev" title="'+esc(st.provisional)+'">◌ Seen once</span>' : '')
499
+ + (st.parentState ? ' <span class="muted">under '+esc((st.parentState||'').split(':').pop())+'</span>' : '')
500
+ + '<button class="btn" style="float:right;padding:1px 8px" data-close>✕</button></div>';
501
+ // urlPattern may carry a template placeholder ({param}) — render verbatim, never linkify.
502
+ html += '<div class="muted" style="font-size:12px;margin-bottom:8px">'+esc(st.urlPattern||'')+'</div>';
503
+ if (st.provisional) html += '<div class="muted" style="font-size:12px;margin-bottom:8px">◌ '+esc(st.provisional)+'</div>';
504
+ const actShown = f.actions.slice(0, 24);
505
+ const moreCount = f.actions.length - actShown.length;
506
+ html += sec('Key actions ('+f.actions.length+')', '<div class="chips" data-actions>'+actShown.map(actionChip).join('')
507
+ + (moreCount > 0 ? '<button class="btn morebtn" data-more>+'+moreCount+' more</button>' : '')+'</div>');
508
+ if (f.hasSearch || f.filters.length) html += sec('Search & filters', '<div class="chips">'+(f.hasSearch?'<span class="chip">🔍 search</span>':'')+chips(f.filters.filter(x=>!/search/i.test(x.field)).map(x=>x.field+' ('+x.control+')'))+'</div>');
509
+ if (f.operatesOn.length) html += sec('Operates on ('+f.operatesOn.length+')', '<div class="chips">'+chips(f.operatesOn)+'</div>');
510
+ if (f.subviews.length) html += sec('In-page sub-views', '<div class="chips">'+chips(f.subviews)+'</div>');
511
+ html += sec('Affordances', '<span class="muted" style="font-size:12px">'+f.counts.navigate+' navigate · '+f.counts.reveal+' reveal · '+f.counts.mutate+' mutate · '+f.counts.input+' input</span>');
512
+ panel.innerHTML = html; panel.style.display='';
513
+ panel.querySelector('[data-close]').onclick = () => { panel.style.display='none'; };
514
+ const moreBtn = panel.querySelector('[data-more]');
515
+ if (moreBtn) moreBtn.onclick = () => {
516
+ const rest = f.actions.slice(actShown.length).map(actionChip).join('');
517
+ moreBtn.insertAdjacentHTML('beforebegin', rest);
518
+ moreBtn.remove();
519
+ };
520
+ };
521
+ wrap.querySelectorAll('.gnode').forEach(g => {
522
+ const name = g.getAttribute('data-state');
523
+ if (name.indexOf('⌂') === 0) return; // root isn't a real page
524
+ g.onclick = () => openPanel(name);
525
+ g.addEventListener('keydown', (e) => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); openPanel(name); } });
526
+ });
527
+ return wrap;
528
+ }
529
+
530
+ // ---------- PROFILES (named, shared logged-in browser states) ----------
531
+ async function renderProfiles() {
532
+ main.style.gridTemplateColumns = '1fr';
533
+ const profs = await getJSON('/api/profiles');
534
+ main.innerHTML = '';
535
+ const wrap = el('<div></div>');
536
+ // Title only + info icon (project rule: no descriptive subtitle prose; explanation behind an
537
+ // info affordance — matches the badge+title pattern used elsewhere). Was a ~190-char paragraph
538
+ // forced into the tiny-uppercase eyebrow style (critique A, P1).
539
+ wrap.append(el('<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;display:flex;align-items:center;gap:6px">Browser profiles <span class="info" title="A logged-in browser state kept on disk (Cloudflare / SSO / 2FA done once by hand). Every session under a profile reuses its login; a walk reuses it via --profile &lt;name&gt;. New sessions use default." aria-label="about profiles">&#9432;</span></h2>'));
540
+ const newBar = el('<div style="display:flex;gap:8px;margin:8px 0"><button class="btn">+ New profile</button></div>');
541
+ newBar.querySelector('button').onclick = async () => {
542
+ const name = prompt('New profile name (e.g. default, work-google):', 'default');
543
+ if (!name) return;
544
+ const res = await fetch('/api/profiles', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ name }) });
545
+ if (!res.ok) { toast((await res.json()).error); return; }
546
+ const o = await fetch('/api/profiles/'+encodeURIComponent(name)+'/open', { method:'POST' }); // straight into log-in
547
+ if (!o.ok) toast((await o.json()).error);
548
+ renderProfiles();
549
+ };
550
+ wrap.append(newBar);
551
+ if (!profs.length) { wrap.append(el('<div class="empty">no profiles yet — create one and log in once, or just start a session (it uses "default")</div>')); main.append(wrap); startEvents(); return; }
552
+ const tbl = el('<table><thead><tr><th>Profile</th><th>Site</th><th>Status</th><th>Sessions</th><th>Size</th><th>Last used</th><th></th></tr></thead><tbody></tbody></table>');
553
+ const tb = tbl.querySelector('tbody');
554
+ profs.forEach(pf => {
555
+ // Presence (window open) is plain — NOT styled like the Sessions-tab recording
556
+ // pulse (no --rec color, no pulse animation): a login window is not a recording.
557
+ const tr = el('<tr><td><code>'+esc(pf.name)+'</code>'+(pf.open?' <span class="muted">🪟 window open</span>':'')+'</td><td class="muted">'+esc(pf.site||'—')+'</td><td></td><td class="muted">'+pf.sessions+'</td><td class="muted">'+pf.sizeMb+' MB</td><td class="muted" style="font-size:12px">'+(pf.lastUsed?new Date(pf.lastUsed).toLocaleString():'—')+'</td><td style="text-align:right"></td></tr>');
558
+ tr.children[2].append(statusChip(pf));
559
+ const act = tr.children[6];
560
+ const authValid = pf.status && pf.status.auth === 'valid';
561
+ const openB = el('<button class="btn">Open to log in</button>');
562
+ // Open to log in is valid ONLY when needs-login/unknown (or no check yet) AND no
563
+ // window is already live — never while a check shows the profile is already ✓
564
+ // Valid (the exact live-use complaint: a still-enabled button after "I'm already
565
+ // logged in").
566
+ openB.disabled = pf.open || !!authValid;
567
+ if (pf.open) openB.title = 'a login window is already open for this profile';
568
+ else if (authValid) openB.title = 'already ✓ Valid — no need to log in again';
569
+ openB.onclick = async () => {
570
+ openB.disabled = true; openB.textContent = 'opening…';
571
+ const res = await fetch('/api/profiles/'+encodeURIComponent(pf.name)+'/open', { method:'POST' });
572
+ if (!res.ok) toast((await res.json()).error);
573
+ renderProfiles();
574
+ };
575
+ const renB = el('<button class="btn" style="margin-left:6px">Rename</button>');
576
+ renB.onclick = async () => {
577
+ const to = prompt('Rename profile "'+pf.name+'" to:', pf.name);
578
+ if (!to || to === pf.name) return;
579
+ const res = await fetch('/api/profiles/'+encodeURIComponent(pf.name)+'/rename', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ to }) });
580
+ if (!res.ok) toast((await res.json()).error);
581
+ renderProfiles();
582
+ };
583
+ const resetB = el('<button class="btn danger" style="margin-left:6px" title="logs out ALL sites in this profile — use to log in with a different account">Reset profile</button>');
584
+ resetB.onclick = async () => {
585
+ if (prompt('Reset profile "'+pf.name+'"? This logs out ALL sites in it — use to log in with a different account. Type "reset '+pf.name+'" to confirm:') !== 'reset '+pf.name) return;
586
+ const res = await fetch('/api/profiles/'+encodeURIComponent(pf.name)+'/reset', { method:'POST' });
587
+ if (!res.ok) toast((await res.json()).error);
588
+ renderProfiles();
589
+ };
590
+ const delB = el('<button class="btn danger" style="margin-left:6px">Delete</button>');
591
+ delB.onclick = async () => {
592
+ if (!confirm('Delete profile '+pf.name+'? This logs it out — sessions using it hit the login wall next time.')) return;
593
+ await fetch('/api/profiles/'+encodeURIComponent(pf.name), { method:'DELETE' });
594
+ renderProfiles();
595
+ };
596
+ act.append(openB, renB, resetB, delB);
597
+ tb.append(tr);
598
+ });
599
+ wrap.append(tbl);
600
+ main.append(wrap);
601
+ startEvents(); // 'sessions' events (profile open/close/rename/delete) refresh this tab
602
+ }
603
+
604
+ // Coarse relative time (minutes/hours/days) — just enough for a "checked Ns ago" label.
605
+ function timeAgo(iso) {
606
+ const ms = Date.now() - new Date(iso).getTime();
607
+ const m = Math.round(ms / 60000);
608
+ if (m < 1) return 'just now';
609
+ if (m < 60) return m + 'm ago';
610
+ const h = Math.round(m / 60);
611
+ if (h < 24) return h + 'h ago';
612
+ return Math.round(h / 24) + 'd ago';
613
+ }
614
+
615
+ // Status chip (✓ Valid / ⚠ Needs login / – Unknown) + last-checked + a per-row "Check"
616
+ // button that runs the SAME classifyAuthLanding engine as the dev profile-status verb (server
617
+ // caches the result; this just triggers a fresh check and re-renders on completion).
618
+ function statusChip(pf) {
619
+ const box = el('<span style="display:inline-flex;align-items:center;gap:6px"></span>');
620
+ if (!pf.site) {
621
+ box.append(el('<span class="badge unrev" title="no site associated — check via a session or set on first login">– Unknown</span>'));
622
+ return box;
623
+ }
624
+ const s = pf.status;
625
+ const chip = !s ? el('<span class="badge unrev" title="not checked yet">– Unknown</span>')
626
+ : s.auth === 'valid' ? el('<span class="badge ok" title="landed on a known logged-in state">&#10003; Valid</span>')
627
+ : s.auth === 'needs-login' ? el('<span class="badge warn" title="'+esc(s.loginUrl||'login required')+'">&#9888; Needs login</span>')
628
+ : el('<span class="badge unrev" title="no map yet / ambiguous landing">– Unknown</span>');
629
+ box.append(chip);
630
+ if (s) box.append(el('<span class="muted" style="font-size:11px">'+timeAgo(s.checkedAt)+'</span>'));
631
+ const checkB = el('<button class="btn" style="padding:1px 8px;font-size:11px">Check</button>');
632
+ checkB.onclick = async (e) => {
633
+ e.stopPropagation();
634
+ checkB.disabled = true; checkB.textContent = 'checking…';
635
+ const res = await fetch('/api/profiles/'+encodeURIComponent(pf.name)+'/status', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ site: pf.site }) });
636
+ if (!res.ok) toast((await res.json()).error || 'check failed');
637
+ renderProfiles();
638
+ };
639
+ box.append(checkB);
640
+ return box;
641
+ }
642
+
643
+ // ---------- CREDENTIALS ----------
644
+ const CATEGORIES = [
645
+ { key: 'login', label: 'Login credentials' },
646
+ { key: 'personal', label: 'Personal info' },
647
+ { key: 'other', label: 'Other' },
648
+ ];
649
+ function catLabel(c) { const f = CATEGORIES.find(x => x.key === c); return f ? f.label : c; }
650
+
651
+ async function renderCreds() {
652
+ const sites = await getJSON('/api/creds'); // [{site, keys:[{name,category}]}]
653
+ main.innerHTML = '';
654
+ main.style.gridTemplateColumns = '1fr';
655
+ const wrap = el('<div></div>');
656
+ if (!sites.length) wrap.append(el('<div class="empty">no credentials stored. Add one below for any site.</div>'));
657
+ sites.forEach(s => wrap.append(credCard(s.site, s.keys)));
658
+ wrap.append(addSiteCard());
659
+ main.append(wrap);
660
+ }
661
+
662
+ function credCard(site, keys) {
663
+ const card = el('<div class="detail" style="margin-bottom:16px"></div>');
664
+ card.append(el('<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"><strong>'+esc(site)+'</strong></div>'));
665
+
666
+ // group keys by category, in CATEGORIES order; skip empty groups
667
+ CATEGORIES.forEach(cat => {
668
+ const inCat = keys.filter(k => k.category === cat.key);
669
+ if (!inCat.length) return;
670
+ card.append(el('<div class="cat-head">'+esc(cat.label)+'</div>'));
671
+ const table = el('<table><tbody></tbody></table>');
672
+ const tbody = table.querySelector('tbody');
673
+ inCat.forEach(k => tbody.append(credRow(site, k.name, k.category)));
674
+ card.append(table);
675
+ });
676
+
677
+ // add-key row (category inferred server-side from the key name)
678
+ const add = el('<div class="addrow" style="margin-top:12px"><input placeholder="key (e.g. username)" /><input placeholder="value" type="password" />'+catSelect()+'<button class="btn">Add</button></div>');
679
+ const [keyIn, valIn] = add.querySelectorAll('input');
680
+ const catSel = add.querySelector('select');
681
+ add.querySelector('button').onclick = async () => {
682
+ if (!keyIn.value) return;
683
+ await getJSON('/api/creds/' + encodeURIComponent(site), { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ key: keyIn.value, value: valIn.value, category: catSel.value || undefined }) });
684
+ renderCreds();
685
+ };
686
+ card.append(add);
687
+ return card;
688
+ }
689
+
690
+ function catSelect(selected) {
691
+ let o = '<option value="">auto</option>';
692
+ CATEGORIES.forEach(c => { o += '<option value="'+c.key+'"'+(c.key===selected?' selected':'')+'>'+esc(c.label)+'</option>'; });
693
+ return '<select>'+o+'</select>';
694
+ }
695
+
696
+ function credRow(site, key, category) {
697
+ const tr = el('<tr><td style="width:30%"><code>'+esc(key)+'</code></td><td><code class="val">••••••</code></td><td style="width:280px;text-align:right"></td></tr>');
698
+ const valCell = tr.children[1].querySelector('code');
699
+ const actions = tr.children[2];
700
+
701
+ let shown = false, editing = false, revealed = '';
702
+ const reveal = el('<button class="btn">Reveal</button>');
703
+ const edit = el('<button class="btn" style="margin-left:6px">Edit</button>');
704
+ const del = el('<button class="btn danger" style="margin-left:6px">Delete</button>');
705
+
706
+ async function fetchValue() {
707
+ if (revealed === '') { const r = await getJSON('/api/creds/'+encodeURIComponent(site)+'/'+encodeURIComponent(key)); revealed = r.value; }
708
+ return revealed;
709
+ }
710
+ reveal.onclick = async () => {
711
+ if (editing) return;
712
+ if (shown) { valCell.textContent = '••••••'; reveal.textContent = 'Reveal'; shown = false; return; }
713
+ valCell.textContent = await fetchValue(); reveal.textContent = 'Hide'; shown = true;
714
+ };
715
+ edit.onclick = async () => {
716
+ if (editing) return; editing = true;
717
+ const cur = await fetchValue();
718
+ const input = el('<input class="inline" />'); input.value = cur;
719
+ valCell.replaceWith(input); input.focus();
720
+ edit.textContent = 'Save';
721
+ edit.onclick = async () => {
722
+ await getJSON('/api/creds/'+encodeURIComponent(site), { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ key, value: input.value }) });
723
+ renderCreds(); // re-render to reset row state cleanly
724
+ };
725
+ // allow Enter to save
726
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') edit.onclick(); });
727
+ };
728
+ del.onclick = async () => {
729
+ if (!confirm('Delete ' + key + ' for ' + site + '?')) return;
730
+ await fetch('/api/creds/'+encodeURIComponent(site)+'/'+encodeURIComponent(key), { method:'DELETE' });
731
+ renderCreds();
732
+ };
733
+
734
+ // category selector — change persists immediately
735
+ const sel = el(catSelect(category));
736
+ sel.querySelector('option[value=""]').remove(); // an existing key always has a concrete category
737
+ sel.value = category;
738
+ sel.onchange = async () => {
739
+ await getJSON('/api/creds/'+encodeURIComponent(site)+'/'+encodeURIComponent(key)+'/category', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ category: sel.value }) });
740
+ };
741
+
742
+ actions.append(sel, reveal, edit, del);
743
+ return tr;
744
+ }
745
+
746
+ function addSiteCard() {
747
+ const card = el('<div class="detail"></div>');
748
+ card.append(el('<div style="margin-bottom:8px"><strong>Add credential for a new site</strong></div>'));
749
+ const row = el('<div class="addrow"><input placeholder="site (e.g. www.saucedemo.com)" style="width:220px" /><input placeholder="key" /><input placeholder="value" type="password" />'+catSelect()+'<button class="btn">Save</button></div>');
750
+ const [siteIn, keyIn, valIn] = row.querySelectorAll('input');
751
+ const catSel = row.querySelector('select');
752
+ row.querySelector('button').onclick = async () => {
753
+ if (!siteIn.value || !keyIn.value) return;
754
+ await getJSON('/api/creds/' + encodeURIComponent(siteIn.value), { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ key: keyIn.value, value: valIn.value, category: catSel.value || undefined }) });
755
+ renderCreds();
756
+ };
757
+ card.append(row);
758
+ return card;
759
+ }
760
+
761
+ // ---------- RECORDINGS ----------
762
+ let replayPoll = null;
763
+ let currentOpenId = null;
764
+ let winSession = null; // which recording owns the driven window right now
765
+ let rowEls = {}; // sessionId → its list row (for in-place refresh, no flicker)
766
+ let detailCtx = null; // { r, headBox, stepsBox, logsBox, subTab } for the open detail
767
+ let lastLogT = 0;
768
+
769
+ // Sessions tab = list-PRIMARY (full width). Selecting a session swaps in a breadcrumb +
770
+ // full-width detail; the "Sessions" crumb returns to the list. openId (a session id) opens
771
+ // straight into that session's detail (used after New session / a soft re-render on that view).
772
+ async function renderRecordings(openId) {
773
+ clearInterval(replayPoll); replayPoll = null;
774
+ main.style.gridTemplateColumns = '1fr';
775
+ const recs = await getJSON('/api/recordings');
776
+ try { winSession = (await getJSON('/api/recordings/window')).session; } catch { winSession = null; }
777
+ main.innerHTML = '';
778
+ rowEls = {}; detailCtx = null;
779
+ // If asked to open a specific session (and it exists), go straight to its detail view.
780
+ if (openId && recs.some(r => r.sessionId === openId)) { openDetail(recs.find(r => r.sessionId === openId)); startEvents(); return; }
781
+
782
+ const wrap = el('<div class="listwrap fade"></div>');
783
+ const selected = new Set();
784
+ // Toolbar (FIXED height — its contents toggle, they never insert into the list, so the
785
+ // list NEVER reflows/shifts when you select a row). Left: count / "N selected". Right:
786
+ // Delete-selected (shown only when a selection exists) · Clear all · + New session.
787
+ const bar = el('<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;min-height:32px">'
788
+ + '<span class="muted bar-count" style="font-size:13px"></span>'
789
+ + '<select class="bar-origin" style="padding:2px 8px;font-size:12px;background:var(--bg-sunken);color:var(--fg);border:1px solid var(--border);border-radius:6px">'
790
+ + '<option value="all">All sources</option><option value="extension">Extension</option><option value="agent">Agent</option><option value="manual">Manual</option>'
791
+ + '</select><span style="flex:1"></span>'
792
+ + '<button class="btn danger bar-delsel" style="padding:2px 10px;display:none"></button>'
793
+ + (recs.length ? '<button class="btn danger bar-clear" style="padding:2px 10px">Clear all</button>' : '')
794
+ + '<button class="btn bar-new" style="padding:2px 10px">+ New session</button></div>');
795
+ wrap.append(bar);
796
+ // Origin filter — 'agent'/'extension' match exactly; everything else (incl. legacy null) is 'manual'.
797
+ const originOf = (r) => (r.origin === 'agent' || r.origin === 'extension') ? r.origin : 'manual';
798
+ const originSel = bar.querySelector('.bar-origin');
799
+ originSel.value = recordingsOriginFilter;
800
+ originSel.onchange = () => { recordingsOriginFilter = originSel.value; renderRecordings(); };
801
+ const shown = recordingsOriginFilter === 'all' ? recs : recs.filter(r => originOf(r) === recordingsOriginFilter);
802
+ const setCount = () => { bar.querySelector('.bar-count').textContent = selected.size
803
+ ? selected.size+' selected' : shown.length+' session'+(shown.length===1?'':'s'); };
804
+ const syncBulk = () => {
805
+ const b = bar.querySelector('.bar-delsel');
806
+ b.style.display = selected.size ? '' : 'none';
807
+ b.textContent = 'Delete selected ('+selected.size+')';
808
+ setCount();
809
+ };
810
+ setCount();
811
+ bar.querySelector('.bar-delsel').onclick = async () => {
812
+ if (!confirm('Delete '+selected.size+' session(s)?')) return;
813
+ for (const id of selected) await fetch('/api/recordings/'+encodeURIComponent(id), { method:'DELETE' });
814
+ renderRecordings();
815
+ };
816
+ const clearBtn = bar.querySelector('.bar-clear');
817
+ if (clearBtn) clearBtn.onclick = async () => {
818
+ if (prompt('Delete ALL '+recs.length+' sessions (steps, videos, reviews)? This cannot be undone. Type "delete all" to confirm:') !== 'delete all') return;
819
+ for (const rr of recs) await fetch('/api/recordings/'+encodeURIComponent(rr.sessionId), { method:'DELETE' });
820
+ renderRecordings();
821
+ };
822
+ bar.querySelector('.bar-new').onclick = () => openNewSessionDialog();
823
+
824
+ const list = el('<div class="list"></div>');
825
+ shown.forEach(r => {
826
+ const row = el('<div class="srow"><input type="checkbox" style="width:auto" aria-label="select" /><div class="nm"></div><div class="col site"></div><div class="col steps r"></div><div class="col vid r"></div><div class="col date r"></div><button class="btn danger" title="delete" style="padding:2px 8px">✕</button></div>');
827
+ rowEls[r.sessionId] = row;
828
+ fillRow(row, r);
829
+ row.onclick = () => openDetail(r);
830
+ rowKeyboard(row, () => row.click());
831
+ const cb = row.querySelector('input[type=checkbox]');
832
+ cb.onclick = (e) => { e.stopPropagation(); if (cb.checked) selected.add(r.sessionId); else selected.delete(r.sessionId); syncBulk(); };
833
+ row.querySelector('button.danger').onclick = async (e) => {
834
+ e.stopPropagation();
835
+ if (!confirm('Delete session '+r.sessionId+'?')) return;
836
+ await fetch('/api/recordings/'+encodeURIComponent(r.sessionId), { method:'DELETE' });
837
+ renderRecordings();
838
+ };
839
+ list.append(row);
840
+ });
841
+ if (!shown.length) list.append(el(recs.length
842
+ ? '<div class="empty">No <strong>'+esc(recordingsOriginFilter)+'</strong> sessions. Change the source filter above.</div>'
843
+ : '<div class="empty">No sessions yet. Click <strong>+ New session</strong> to record one.</div>'));
844
+ wrap.append(list);
845
+ main.append(wrap);
846
+ startEvents();
847
+ }
848
+
849
+ // Drill into ONE session: breadcrumb + full-width detail (list hidden). "Sessions" returns.
850
+ function openDetail(r) {
851
+ main.style.gridTemplateColumns = '1fr';
852
+ main.innerHTML = '';
853
+ const wrap = el('<div class="listwrap fade"></div>');
854
+ const crumbs = el('<div class="crumbs"><a tabindex="0">Sessions</a><span class="sep">/</span><span class="cur"></span></div>');
855
+ crumbs.querySelector('.cur').textContent = r.sessionId;
856
+ const back = crumbs.querySelector('a');
857
+ back.onclick = () => renderRecordings();
858
+ back.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renderRecordings(); } });
859
+ const detail = el('<div class="detail"></div>');
860
+ wrap.append(crumbs, detail);
861
+ main.append(wrap);
862
+ showRecording(r, detail);
863
+ }
864
+ function originLabel(origin) {
865
+ return origin === 'agent' ? 'Agent' : origin === 'extension' ? 'Extension' : 'Manual';
866
+ }
867
+ function originTag(origin) {
868
+ const o = origin === 'agent' || origin === 'extension' ? origin : 'manual';
869
+ return '<span class="badge origin-'+o+'">'+originLabel(o)+'</span>';
870
+ }
871
+ // Capture-review badge from the stored verdict (webnav dev review). Verified (green) = zero
872
+ // gaps, graph-ready; needs-fix (amber, with gap count) = review found capture gaps; nothing =
873
+ // never reviewed. Titled with the verdict reason so hovering explains WHY.
874
+ // Three states, all shown (so failed captures are VISIBLE, not just absent-of-green):
875
+ // approved → ✓ Verified (green) · reviewed-but-not-approved → ⚠ Failed N gaps (red) ·
876
+ // never reviewed → Unverified (grey). Only APPROVED sessions build the graph (approval gate).
877
+ function reviewBadge(review) {
878
+ if (!review) return '<span class="badge unrev" title="not reviewed yet — run a review before building a graph from it">Unverified</span>';
879
+ if (review.approved) return '<span class="badge ok" title="'+esc(review.reason||'all on-screen actions captured')+'">✓ Verified</span>';
880
+ const n = review.gaps || 0;
881
+ return '<span class="badge fail" title="'+esc(review.reason||'capture gaps found — not used to build the graph')+'">⚠ Failed'+(n?' · '+n+' gap'+(n===1?'':'s'):'')+'</span>';
882
+ }
883
+ function fillRow(row, r) {
884
+ // name column: recording dot (if active) + name + Agent/Manual + Verified badges, on ONE line.
885
+ row.querySelector('.nm').innerHTML = (r.active ? '<span style="color:var(--rec)" class="pulse">●</span>' : '')
886
+ + '<span class="t">'+esc(r.sessionId)+'</span>'+originTag(r.origin)+reviewBadge(r.review);
887
+ const site = row.querySelector('.col.site'); if (site) site.textContent = r.site || '—';
888
+ const steps = row.querySelector('.col.steps'); if (steps) steps.textContent = r.steps+' step'+(r.steps===1?'':'s');
889
+ const vid = row.querySelector('.col.vid'); if (vid) vid.innerHTML = r.videoCount ? '\\uD83C\\uDFA5 '+r.videoCount : ''; // 🎥 N
890
+ const date = row.querySelector('.col.date'); if (date) date.textContent = new Date(r.startedAt).toLocaleDateString();
891
+ }
892
+
893
+ // SOFT refresh (no flicker): update rows + the open detail's header IN PLACE.
894
+ // Only a changed recordings SET (add/remove) does a full re-render.
895
+ async function softRefresh(kind) {
896
+ if (tab !== 'recordings' || replayPoll) return;
897
+ let recs;
898
+ try {
899
+ recs = await getJSON('/api/recordings');
900
+ winSession = (await getJSON('/api/recordings/window')).session;
901
+ } catch { return; }
902
+ // DETAIL open: the list isn't rendered (rowEls empty) — just refresh the open header below,
903
+ // don't run the list-set diff (it would spuriously full-re-render every tick).
904
+ if (!detailCtx) {
905
+ const ids = recs.map(r => r.sessionId).sort().join('|');
906
+ const anyChecked = [...document.querySelectorAll('.list input[type=checkbox]')].some(c => c.checked);
907
+ if (ids !== Object.keys(rowEls).sort().join('|')) { if (!anyChecked) renderRecordings(); return; }
908
+ recs.forEach(r => { const row = rowEls[r.sessionId]; if (row) fillRow(row, r); });
909
+ }
910
+ if (detailCtx) {
911
+ const fresh = recs.find(r => r.sessionId === detailCtx.r.sessionId);
912
+ if (fresh) {
913
+ const stateChanged = fresh.active !== detailCtx.r.active || (winSession === fresh.sessionId) !== detailCtx.hasWindow;
914
+ detailCtx.r = fresh;
915
+ if (stateChanged) buildHead(detailCtx); // recording→stopped, window gained/lost → repaint header NOW
916
+ // GROUND TRUTH over inference (advisor): any sessions/step event while the Ledger
917
+ // tab is open → just refetch both panes. One cheap call each; retires the whole
918
+ // "steps blank until re-click" class instead of guessing when the count moved.
919
+ if (detailCtx.subTab === 'ledger') { loadSteps(detailCtx); loadLedger(detailCtx); }
920
+ if (detailCtx.subTab === 'review') loadReview(detailCtx); // review start/finish emits 'sessions'
921
+ }
922
+ }
923
+ }
924
+
925
+ // Realtime: the server pushes 'sessions' / 'step' / 'replay' / 'log' over SSE.
926
+ let es = null;
927
+ function startEvents() {
928
+ if (es) return;
929
+ es = new EventSource('/api/events');
930
+ es.onmessage = (m) => {
931
+ if (tab === 'profiles') { if (m.data !== 'log') renderProfiles(); return; }
932
+ if (tab !== 'recordings') return;
933
+ if (m.data === 'log') { appendLogLive(); return; }
934
+ if (m.data === 'replay') return; // replay view drives itself
935
+ softRefresh(m.data);
936
+ };
937
+ }
938
+
939
+ // New session as a native <dialog> MODAL (impeccable: use <dialog>, not an absolute div; it
940
+ // escapes the stacking context + gives a backdrop + Esc-to-close for free). Collect name/url/
941
+ // profile → open the window → close → drill STRAIGHT into that session's detail page.
942
+ function openNewSessionDialog() {
943
+ document.getElementById('newdlg')?.remove();
944
+ const d = new Date(), p2 = (x) => String(x).padStart(2, '0');
945
+ // default name (editable): s-MMDDHHMMSS — SHORT on purpose (playwright-cli daemon socket
946
+ // path embeds it; macOS caps socket paths ~104 chars — a long name → listen EINVAL).
947
+ const defName = 's-' + p2(d.getMonth()+1) + p2(d.getDate()) + p2(d.getHours()) + p2(d.getMinutes()) + p2(d.getSeconds());
948
+ const dlg = el('<dialog id="newdlg" class="dlg">'
949
+ + '<form method="dialog" class="dlgform">'
950
+ + '<h2>New session</h2>'
951
+ + '<label>Session name<input name="sess" autocomplete="off" /></label>'
952
+ + '<label>Start URL <span class="muted">(optional)</span><input name="url" placeholder="blank window — navigate yourself" autocomplete="off" /></label>'
953
+ + '<label>Profile <span class="muted">(logged-in state reused; blank = throwaway)</span><input name="prof" autocomplete="off" /></label>'
954
+ + '<div class="dlgmsg muted"></div>'
955
+ + '<div class="dlgbtns"><button type="button" class="btn" value="cancel">Cancel</button><button type="button" class="btn primary" value="go">Open window &amp; record</button></div>'
956
+ + '</form></dialog>');
957
+ document.body.append(dlg);
958
+ const sessIn = dlg.querySelector('[name=sess]'), urlIn = dlg.querySelector('[name=url]'), profIn = dlg.querySelector('[name=prof]');
959
+ sessIn.value = defName; profIn.value = 'default';
960
+ const msg = dlg.querySelector('.dlgmsg');
961
+ const close = () => { dlg.close(); dlg.remove(); };
962
+ dlg.querySelector('[value=cancel]').onclick = close;
963
+ dlg.addEventListener('cancel', close); // Esc
964
+ const go = async () => {
965
+ if (!sessIn.value.trim()) { msg.textContent = 'session name required'; sessIn.focus(); return; }
966
+ const profile = profIn.value.trim(); // blank → throwaway (persistent:false)
967
+ msg.textContent = 'opening window…';
968
+ const r = await fetch('/api/recordings/open', { method:'POST', headers:{'content-type':'application/json'},
969
+ body: JSON.stringify({ url: urlIn.value || 'about:blank', session: sessIn.value.trim(), persistent: !!profile, profile: profile || undefined }) });
970
+ if (!r.ok) { msg.textContent = (await r.json()).error || 'failed to open'; return; }
971
+ const id = sessIn.value.trim();
972
+ close();
973
+ // drill STRAIGHT into the new session's detail page (its window is opening + recording).
974
+ setTimeout(() => renderRecordings(id), 400);
975
+ };
976
+ dlg.querySelector('[value=go]').onclick = go;
977
+ sessIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
978
+ dlg.showModal();
979
+ sessIn.focus(); sessIn.select();
980
+ }
981
+
982
+ // The detail header: state + action buttons. Rebuilt IN PLACE on state changes.
983
+ function buildHead(ctx) {
984
+ const r = ctx.r;
985
+ const hasWindow = winSession === r.sessionId;
986
+ ctx.hasWindow = hasWindow;
987
+ const recState = r.active ? '<span class="pulse" style="color:var(--rec);font-weight:600">● recording…</span>'
988
+ : hasWindow ? '<span class="muted">🪟 window open (armed)</span>'
989
+ : winSession ? '<span class="muted">window busy: '+esc(winSession)+'</span>' : '';
990
+ ctx.headBox.innerHTML = '';
991
+ const profBadge = r.hasProfile ? ' <span title="runs under this saved-login profile" style="border:1px solid var(--ok);color:var(--ok);border-radius:4px;padding:0 5px;font-size:10px">\uD83D\uDD10 '+esc(r.profile)+'</span>' : '';
992
+ const head = el('<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><strong>'+esc(r.sessionId)+'</strong>'+originTag(r.origin)+'<span class="muted">'+esc(r.site||'')+'</span>'+profBadge+'<span class="hstate">'+recState+'</span><span style="flex:1"></span></div>');
993
+ const btn = (t, danger) => el('<button class="btn'+(danger?' danger':'')+'">'+t+'</button>');
994
+ const repB = btn('▶ Replay'), repXB = btn('▶ Replay exact'), anB = btn('Analyse → draft'), delB = btn('Delete', true);
995
+ repB.title = 'Replays the cleaned-up route. Finds each element again even if the page changed. Best for repeatable automation.';
996
+ repXB.title = 'Replays exactly what was done, event by event, nothing skipped. Best for exact reruns and for checking the recording caught everything.';
997
+ // Open window and Record are SEPARATE intents here (live feedback): the window
998
+ // opens ARMED; Record activates once the window exists.
999
+ const openB = btn(hasWindow ? '🪟 window open' : (r.hasProfile ? '\\uD83D\\uDD10 Open (' + r.profile + ')' : 'Open window'));
1000
+ // Open is disabled whenever ANY driven window exists — this session's (already
1001
+ // open, #2) or another's (busy). Only openable when no window is live.
1002
+ openB.disabled = !!winSession;
1003
+ if (winSession && !hasWindow) openB.title = 'window is busy with '+winSession;
1004
+ if (hasWindow) openB.title = 'this session already has a window open';
1005
+ openB.onclick = async () => {
1006
+ openB.disabled = true; openB.textContent = 'opening…';
1007
+ // REOPEN is always persistent: reuse this session's saved profile so a prior
1008
+ // Cloudflare/2FA login carries over (live bug: reopen sent persistent:false →
1009
+ // fresh throwaway profile → forced re-login). A first-ever open with no profile
1010
+ // yet still creates one under the same session, so the next reopen has a login.
1011
+ const res = await fetch('/api/recordings/open', { method:'POST', headers:{'content-type':'application/json'},
1012
+ body: JSON.stringify({ url: r.startUrl || (r.site ? 'https://'+r.site : 'about:blank'), session: r.sessionId, persistent: true, armedOnly: true, profile: r.profile || 'default' }) });
1013
+ // ALWAYS restore the button first (a failed open changes nothing server-side, so
1014
+ // softRefresh wouldn't rebuild the header → the button stayed 'opening…' forever).
1015
+ openB.disabled = false; openB.textContent = r.hasProfile ? '🔐 Open (' + r.profile + ')' : 'Open window';
1016
+ if (!res.ok) { toast((await res.json()).error); }
1017
+ softRefresh('sessions');
1018
+ };
1019
+ const recB = btn(r.active ? '■ Stop' : '⏺ Record');
1020
+ // Record is active ONLY when this session's window is open (#3). Stop stays
1021
+ // enabled while recording (always allow stopping). Greyed when no window.
1022
+ recB.disabled = !hasWindow && !r.active;
1023
+ if (!hasWindow && !r.active) recB.title = 'open a window first';
1024
+ if (r.active) recB.style.borderColor = 'var(--rec)';
1025
+ recB.onclick = async () => {
1026
+ // OPTIMISTIC: flip the header immediately; the server confirms via SSE.
1027
+ const starting = !r.active;
1028
+ head.querySelector('.hstate').innerHTML = starting
1029
+ ? '<span class="pulse" style="color:var(--rec);font-weight:600">● recording…</span>'
1030
+ : '<span class="muted">🪟 window open (armed)</span>';
1031
+ recB.disabled = true;
1032
+ await fetch('/api/recordings/'+encodeURIComponent(r.sessionId)+'/'+(r.active?'stop':'record'), { method:'POST' });
1033
+ softRefresh('sessions');
1034
+ };
1035
+ delB.onclick = async () => { if (confirm('Delete session '+r.sessionId+'?')) { await fetch('/api/recordings/'+encodeURIComponent(r.sessionId), { method:'DELETE' }); renderRecordings(); } };
1036
+ anB.onclick = async () => {
1037
+ const d = await getJSON('/api/recordings/'+encodeURIComponent(r.sessionId)+'/draft');
1038
+ ctx.stepsBox.innerHTML = ''; setSubTab(ctx, 'ledger');
1039
+ ctx.stepsBox.append(el('<pre>'+esc(JSON.stringify(d, null, 2))+'</pre>'));
1040
+ };
1041
+ // Two replay modes: 'steps' (the cleaned-up route) and 'ledger' (event-by-event, exact).
1042
+ const startReplay = async (mode) => {
1043
+ const res = await fetch('/api/recordings/'+encodeURIComponent(r.sessionId)+'/replay',
1044
+ { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ mode: mode }) });
1045
+ if (!res.ok) { toast((await res.json()).error); return; }
1046
+ setSubTab(ctx, 'ledger');
1047
+ pollReplay(ctx.stepsBox, r.sessionId);
1048
+ };
1049
+ repB.onclick = () => startReplay('steps');
1050
+ repXB.onclick = () => startReplay('ledger');
1051
+ head.append(openB, recB, repB, repXB, anB, delB);
1052
+ ctx.headBox.append(head);
1053
+ }
1054
+
1055
+ // Refetch the session's steps and re-render the table. This is the single source
1056
+ // of step rendering for live updates (SSE 'step'/'sessions') AND tab switches —
1057
+ // it was CALLED in both places but never DEFINED, so every live refresh threw
1058
+ // 'loadSteps is not defined' and steps only appeared on a hard reload (live #1).
1059
+ async function loadSteps(ctx) {
1060
+ let steps = [];
1061
+ try { steps = await getJSON('/api/recordings/'+encodeURIComponent(ctx.r.sessionId)+'/steps'); } catch { return; }
1062
+ ctx.stepsBox.innerHTML = '';
1063
+ ctx.stepsBox.append(stepTable(steps.map(x => ({ ...x, status: '' })), ctx.r.sessionId));
1064
+ }
1065
+
1066
+ // --- Ledger sub-tab: the RAW event record with each event's fate. This is the
1067
+ // deterministic capture-coverage view (events → steps); drops are assembly losses.
1068
+ async function loadLedger(ctx) {
1069
+ let d = null;
1070
+ try { d = await getJSON('/api/recordings/'+encodeURIComponent(ctx.r.sessionId)+'/events'); } catch { return; }
1071
+ ctx.rawBox.innerHTML = '';
1072
+ if (!d || !d.events || !d.events.length) {
1073
+ ctx.rawBox.append(el('<div class="muted" style="margin:8px 0">No ledger — this session was recorded before the ledger existed. Steps replay still works.</div>'));
1074
+ return;
1075
+ }
1076
+ const c = d.coverage;
1077
+ ctx.rawBox.append(el('<div style="margin:8px 0">'+c.total+' events \\u2192 '+c.captured+' steps'
1078
+ + (c.dropped.length ? ' \\u00B7 <span style="color:var(--rec);font-weight:600">'+c.dropped.length+' dropped</span>' : ' \\u00B7 all captured')+'</div>'));
1079
+ const rows = d.events.map(e => {
1080
+ const desc = e.descriptor || {};
1081
+ const label = desc.name || desc.ariaLabel || desc.leafText || desc.placeholder || (e.kind === 'navigate' ? (desc.url || '') : '');
1082
+ // fate uses the same positive-state color the Steps table's ✓ and the Review ok badge use (var(--ok));
1083
+ // a drop reason is flagged in var(--rec) like the "dropped" summary; a null disposition is muted.
1084
+ const fate = !e.disposition ? '<span class="muted">unprocessed</span>'
1085
+ : e.disposition.indexOf('step:') === 0 ? '<span style="color:var(--ok);font-weight:600">step '+esc(e.disposition.slice(5))+'</span>'
1086
+ : '<span style="color:var(--rec)">'+esc(e.disposition.replace('dropped:',''))+'</span>';
1087
+ const t = e.t ? new Date(e.t).toLocaleTimeString() : '';
1088
+ return '<tr><td class="muted">'+e.seq+'</td><td>'+esc(t)+'</td><td>'+esc(e.source)+'</td><td>'+esc(e.kind)+'</td><td>'+esc(String(label))+'</td><td>'+fate+'</td></tr>';
1089
+ }).join('');
1090
+ ctx.rawBox.append(el('<table><tr><th>#</th><th>time</th><th>src</th><th>kind</th><th>label</th><th>fate</th></tr>'+rows+'</table>'));
1091
+ }
1092
+
1093
+ function setSubTab(ctx, name) {
1094
+ ctx.subTab = name;
1095
+ ctx.tabsBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.sub === name));
1096
+ ctx.logsBox.style.display = name === 'logs' ? '' : 'none';
1097
+ ctx.videosBox.style.display = name === 'videos' ? '' : 'none';
1098
+ ctx.reviewBox.style.display = name === 'review' ? '' : 'none';
1099
+ ctx.ledgerBox.style.display = name === 'ledger' ? 'flex' : 'none';
1100
+ if (name === 'logs') loadLogs(ctx);
1101
+ if (name === 'videos') loadVideos(ctx);
1102
+ if (name === 'review') loadReview(ctx);
1103
+ // Ledger tab = two panes, refreshed together: Raw (loadLedger) + Webnav (loadSteps).
1104
+ if (name === 'ledger') { loadSteps(ctx); loadLedger(ctx); }
1105
+ }
1106
+
1107
+ // Minimal markdown → HTML for the review report (esc() runs FIRST, so this only
1108
+ // ever wraps already-escaped text — no XSS surface). Headings, bold, italics,
1109
+ // inline/fenced code, bullet & numbered lists, pipe tables, paragraphs. BT = backtick
1110
+ // (kept out of the source literal — this whole file lives inside a template string).
1111
+ const BT = String.fromCharCode(96);
1112
+ function mdToHtml(md) {
1113
+ let src = esc(md).replace(/\\r/g, '');
1114
+ const codeBlocks = [];
1115
+ src = src.replace(new RegExp(BT+BT+BT+'([^]*?)'+BT+BT+BT, 'g'), (_, c) => {
1116
+ codeBlocks.push('<pre>' + c.replace(/^\\w*\\n/, '') + '</pre>');
1117
+ return '@@CB' + (codeBlocks.length - 1) + '@@';
1118
+ });
1119
+ src = src.replace(new RegExp(BT+'([^'+BT+']+)'+BT, 'g'), '<code class="val">$1</code>');
1120
+ src = src.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
1121
+ src = src.replace(/(^|\\s)\\*([^*\\n]+)\\*(?=\\s|$)/g, '$1<em>$2</em>');
1122
+ const out = [];
1123
+ let list = null;
1124
+ let table = null; // { rows: [{cells, header}] } while inside a pipe-table
1125
+ const closeList = () => { if (list) { out.push('</' + list + '>'); list = null; } };
1126
+ const closeTable = () => {
1127
+ if (!table) return;
1128
+ out.push('<table>' + table.rows.map(row => {
1129
+ const tag = row.header ? 'th' : 'td';
1130
+ return '<tr>' + row.cells.map(c => '<' + tag + '>' + c + '</' + tag + '>').join('') + '</tr>';
1131
+ }).join('') + '</table>');
1132
+ table = null;
1133
+ };
1134
+ const splitCells = row => row.replace(/^\\s*\\||\\|\\s*$/g, '').split('|').map(c => c.trim());
1135
+ for (const line of src.split('\\n')) {
1136
+ const h = /^(#{1,4})\\s+(.*)$/.exec(line);
1137
+ const li = /^\\s*[-*]\\s+(.*)$/.exec(line);
1138
+ const ol = /^\\s*\\d+[.)]\\s+(.*)$/.exec(line);
1139
+ const row = /^\\s*\\|(.+)\\|\\s*$/.exec(line);
1140
+ const isSep = row && splitCells(line).every(c => /^:?-+:?$/.test(c));
1141
+ if (row && isSep) { /* separator row — swallow, previous row was the header */ }
1142
+ else if (row) {
1143
+ closeList();
1144
+ if (!table) table = { rows: [] };
1145
+ table.rows.push({ cells: splitCells(line), header: table.rows.length === 0 });
1146
+ }
1147
+ else if (h) { closeTable(); closeList(); out.push('<h' + (h[1].length + 2) + ' style="margin:14px 0 4px">' + h[2] + '</h' + (h[1].length + 2) + '>'); }
1148
+ else if (li) { closeTable(); if (list !== 'ul') { closeList(); out.push('<ul style="margin:4px 0 8px 18px">'); list = 'ul'; } out.push('<li>' + li[1] + '</li>'); }
1149
+ else if (ol) { closeTable(); if (list !== 'ol') { closeList(); out.push('<ol style="margin:4px 0 8px 18px">'); list = 'ol'; } out.push('<li>' + ol[1] + '</li>'); }
1150
+ else if (!line.trim()) { closeTable(); closeList(); out.push('<div style="height:8px"></div>'); }
1151
+ else { closeTable(); closeList(); out.push('<div>' + line + '</div>'); }
1152
+ }
1153
+ closeTable();
1154
+ closeList();
1155
+ return out.join('').replace(/@@CB(\\d+)@@/g, (_, i) => codeBlocks[Number(i)]);
1156
+ }
1157
+
1158
+ // --- Review sub-tab: headless-Claude audit of captured steps vs the video frames ---
1159
+ async function loadReview(ctx) {
1160
+ const r = ctx.r;
1161
+ ctx.reviewBox.innerHTML = '';
1162
+ const cfg = await getJSON('/api/review-config').catch(() => ({ model: 'sonnet', instructions: '' }));
1163
+ let state = null;
1164
+ try { state = await getJSON('/api/recordings/'+encodeURIComponent(r.sessionId)+'/review'); } catch { state = null; }
1165
+
1166
+ // controls: model picker + editable agent instructions + run
1167
+ const bar = el('<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px"><button class="btn">Run review</button><select><option value="sonnet">sonnet</option><option value="opus">opus</option><option value="haiku">haiku</option></select><button class="btn" data-k="instr">Instructions</button><span class="muted" style="font-size:12px">compares the VIDEO frames against the captured steps to find capture gaps · runs on your claude login · 1–3 min</span></div>');
1168
+ const runB = bar.querySelector('button');
1169
+ const modelSel = bar.querySelector('select');
1170
+ modelSel.value = ['sonnet','opus','haiku'].includes(cfg.model) ? cfg.model : 'sonnet';
1171
+ const instrWrap = el('<div style="display:none;margin-bottom:10px"><div class="cat-head">Agent instructions (editable — saved as default for future runs)</div><textarea style="width:100%;min-height:180px;background:var(--bg-sunken);border:1px solid var(--border);color:var(--fg);border-radius:6px;padding:8px;font:12px ui-monospace,monospace"></textarea></div>');
1172
+ instrWrap.querySelector('textarea').value = cfg.instructions || '';
1173
+ bar.querySelector('[data-k=instr]').onclick = () => { instrWrap.style.display = instrWrap.style.display === 'none' ? '' : 'none'; };
1174
+ runB.onclick = async () => {
1175
+ const res = await fetch('/api/recordings/'+encodeURIComponent(r.sessionId)+'/review', {
1176
+ method:'POST', headers:{'content-type':'application/json'},
1177
+ body: JSON.stringify({ model: modelSel.value, instructions: instrWrap.querySelector('textarea').value }) });
1178
+ if (!res.ok) { toast((await res.json()).error); return; }
1179
+ loadReview(ctx); // re-render into the running state — progress visible HERE (and in Logs)
1180
+ };
1181
+ ctx.reviewBox.append(bar, instrWrap);
1182
+
1183
+ if (state && state.running) {
1184
+ runB.disabled = true;
1185
+ ctx.reviewBox.append(el('<div class="pulse" style="color:var(--accent);margin:8px 0">\u23F3 review running\u2026 (progress also streams in Logs; the report will appear here)</div>'));
1186
+ }
1187
+ if (state && state.report) {
1188
+ const when = state.at ? new Date(state.at).toLocaleString() : '';
1189
+ // VERDICT-FIRST: a clear outcome banner (the answer at a glance), then the gap list, then the
1190
+ // long prose report tucked behind an expander (it read as a wall of text before).
1191
+ const v = state.verdict;
1192
+ if (v) {
1193
+ const ok = v.approved, n = v.gaps || 0;
1194
+ const cls = ok ? 'ok' : 'fail';
1195
+ const head = ok ? '✓ Verified — capture is complete' : ('⚠ Failed — '+n+' capture gap'+(n===1?'':'s'));
1196
+ const sub = ok ? 'This session is APPROVED and eligible to build the graph.'
1197
+ : 'NOT approved — this session is excluded from graph building until it passes.';
1198
+ ctx.reviewBox.append(el('<div class="verdict '+cls+'"><div class="vh">'+esc(head)+'</div>'
1199
+ + '<div class="vs">'+esc(sub)+'</div><div class="vm">reviewed '+esc(when)+(v.reason?' · '+esc(v.reason):'')+'</div></div>'));
1200
+ } else {
1201
+ ctx.reviewBox.append(el('<div class="muted" style="font-size:12px;margin:6px 0">last run: '+esc(when)+'</div>'));
1202
+ }
1203
+ // full prose report behind a <details> expander (collapsed by default)
1204
+ const rep = el('<details class="reprep"><summary>Full review report</summary><div class="repbody">'+mdToHtml(state.report)+'</div></details>');
1205
+ ctx.reviewBox.append(rep);
1206
+ } else if (!state || !state.running) {
1207
+ ctx.reviewBox.append(el('<div class="empty">no review yet — run one above</div>'));
1208
+ }
1209
+ }
1210
+
1211
+ async function showRecording(r, detail) {
1212
+ currentOpenId = r.sessionId;
1213
+ clearInterval(replayPoll); replayPoll = null;
1214
+ const steps = await getJSON('/api/recordings/'+encodeURIComponent(r.sessionId)+'/steps');
1215
+ detail.innerHTML = '';
1216
+ const headBox = el('<div style="margin-bottom:10px"></div>');
1217
+ const tabsBar = el('<nav style="padding:0;border-bottom:1px solid var(--border);margin-bottom:10px"><button data-sub="ledger" class="active">Ledger</button><button data-sub="videos">Session videos</button><button data-sub="review">Review</button><button data-sub="logs">Logs</button></nav>');
1218
+ // Ledger tab = one flex row, two panes: left Raw (event ledger), right Webnav (assembled steps).
1219
+ const rawPane = el('<div style="flex:1 1 0;min-width:0"></div>');
1220
+ const webnavPane = el('<div style="flex:1 1 0;min-width:0"></div>');
1221
+ const rawBox = el('<div></div>');
1222
+ const webnavBox = el('<div></div>');
1223
+ rawPane.append(el('<div class="cat-head">Raw</div>'), rawBox);
1224
+ webnavPane.append(el('<div class="cat-head">Webnav</div>'), webnavBox);
1225
+ const ledgerBox = el('<div style="display:flex;gap:14px;align-items:flex-start"></div>');
1226
+ ledgerBox.append(rawPane, webnavPane);
1227
+ const logsBox = el('<div style="display:none"></div>');
1228
+ const videosBox = el('<div style="display:none"></div>');
1229
+ const reviewBox = el('<div style="display:none"></div>');
1230
+ const ctx = { r, headBox, tabsBar, stepsBox: webnavBox, logsBox, videosBox, reviewBox, ledgerBox, rawBox, subTab: 'ledger', hasWindow: winSession === r.sessionId };
1231
+ detailCtx = ctx;
1232
+ tabsBar.querySelectorAll('button').forEach(b => { b.onclick = () => setSubTab(ctx, b.dataset.sub); });
1233
+ buildHead(ctx);
1234
+ webnavBox.append(stepTable(steps.map(x => ({ ...x, status: '' }))));
1235
+ loadLedger(ctx); // Raw pane has no initial data yet (unlike Webnav, prefilled above) — fetch it
1236
+ detail.append(headBox, tabsBar, ledgerBox, logsBox, videosBox, reviewBox);
1237
+ }
1238
+
1239
+ // --- Logs sub-tab: continuous stream + freshness ping ---
1240
+ async function loadLogs(ctx) {
1241
+ const data = await getJSON('/api/logs');
1242
+ ctx.logsBox.innerHTML = '<div class="muted" id="logping" style="font-size:11px;margin-bottom:6px"></div><pre id="logstream" style="max-height:55vh"></pre>';
1243
+ const pre = ctx.logsBox.querySelector('#logstream');
1244
+ pre.textContent = data.lines.map(l => new Date(l.t).toLocaleTimeString()+' '+l.line).join('\\n');
1245
+ pre.scrollTop = pre.scrollHeight;
1246
+ lastLogT = data.lines.length ? data.lines[data.lines.length-1].t : data.now;
1247
+ tickLogPing(ctx);
1248
+ clearInterval(ctx.logTicker); ctx.logTicker = setInterval(() => tickLogPing(ctx), 1000);
1249
+ }
1250
+ function tickLogPing(ctx) {
1251
+ const elp = ctx.logsBox.querySelector('#logping');
1252
+ if (!elp) { clearInterval(ctx.logTicker); return; }
1253
+ elp.textContent = lastLogT ? ('last update ' + Math.max(0, Math.round((Date.now()-lastLogT)/1000)) + 's ago · live') : 'no logs yet · live';
1254
+ }
1255
+ async function appendLogLive() {
1256
+ if (!detailCtx || detailCtx.subTab !== 'logs') { lastLogT = Date.now(); return; }
1257
+ const data = await getJSON('/api/logs');
1258
+ const pre = detailCtx.logsBox.querySelector('#logstream');
1259
+ if (pre) { pre.textContent = data.lines.map(l => new Date(l.t).toLocaleTimeString()+' '+l.line).join('\\n'); pre.scrollTop = pre.scrollHeight; }
1260
+ lastLogT = data.lines.length ? data.lines[data.lines.length-1].t : Date.now();
1261
+ }
1262
+
1263
+ // --- Session videos sub-tab: takes over time ---
1264
+ async function loadVideos(ctx) {
1265
+ const r = ctx.r;
1266
+ ctx.videosBox.innerHTML = '';
1267
+ let vids = [];
1268
+ try { vids = await getJSON('/api/recordings/'+encodeURIComponent(r.sessionId)+'/videos'); } catch {}
1269
+ if (!vids.length) { ctx.videosBox.append(el('<div class="empty">no video takes yet — each Record→Stop span saves one</div>')); return; }
1270
+ vids.forEach(v => {
1271
+ const m = v.match(/take-(\\d+)\\.webm/);
1272
+ const when = m ? new Date(Number(m[1])).toLocaleString() : v;
1273
+ const wrap = el('<div style="margin-bottom:12px"><div class="cat-head">'+esc(when)+'</div></div>');
1274
+ const vid = el('<video controls preload="metadata" style="max-width:100%;border:1px solid var(--border);border-radius:6px" src="/recordings-media/'+encodeURIComponent(r.sessionId)+'/'+encodeURIComponent(v)+'"></video>');
1275
+ // Screencast webm is written live → no duration in the header → the browser
1276
+ // reports Infinity and the scrubber is dead. Standard fix: seek to a huge
1277
+ // time once; the browser scans the file, learns the real duration, and the
1278
+ // slider works. Rewind to 0 when it settles.
1279
+ vid.addEventListener('loadedmetadata', () => {
1280
+ if (vid.duration !== Infinity) return;
1281
+ vid.currentTime = 1e7;
1282
+ const fix = () => { vid.removeEventListener('timeupdate', fix); vid.currentTime = 0; };
1283
+ vid.addEventListener('timeupdate', fix);
1284
+ });
1285
+ wrap.append(vid);
1286
+ ctx.videosBox.append(wrap);
1287
+ });
1288
+ }
1289
+
1290
+
1291
+ function stepTable(steps, session) {
1292
+ const t = el('<table><tbody></tbody></table>'); const tb = t.querySelector('tbody');
1293
+ const ICON = { ok: '✓', fail: '✗', running: '▶', jumped: '↪', skipped: '⊘', pending: '·', '': '' };
1294
+ const KIND = { input: ['input', 'var(--accent)'], click: ['click', 'var(--muted)'], navigate: ['nav', 'var(--ok)'], jump: ['jump', 'var(--ok)'], observe: ['page', 'var(--muted)'] };
1295
+ const pathOf = (u) => { try { const x = new URL(u); return x.host + x.pathname; } catch { return u || ''; } };
1296
+ steps.forEach(s => {
1297
+ const color = s.status==='ok'?'var(--ok)':s.status==='fail'?'var(--danger)':'var(--muted)';
1298
+ const [kLabel, kColor] = KIND[s.kind] || [s.kind || '', 'var(--muted)'];
1299
+ const kindChip = kLabel ? '<span style="display:inline-block;white-space:nowrap;border:1px solid '+kColor+';color:'+kColor+';border-radius:4px;padding:0 5px;font-size:10px;text-transform:uppercase">'+esc(kLabel)+'</span>' : '';
1300
+ const val = (s.kind === 'input' && s.value !== undefined && s.value !== null)
1301
+ ? ' <code class="val" style="color:var(--warn)">= "'+esc(String(s.value))+'"</code>' : '';
1302
+ const dest = s.kind === 'navigate' || s.kind === 'jump'
1303
+ ? '<div class="muted" style="font-size:11px">'+esc(pathOf(s.fromUrl))+' → '+esc(pathOf(s.toUrl))+'</div>'
1304
+ : '<div class="muted" style="font-size:11px">'+esc(pathOf(s.fromUrl || s.toUrl))+'</div>';
1305
+ const when = s.capturedAt ? new Date(s.capturedAt).toLocaleTimeString() : '';
1306
+ const shot = s.shot && session ? '<img src="/replays/'+encodeURIComponent(session)+'/'+encodeURIComponent(s.shot)+'" style="height:44px;border-radius:4px;border:1px solid var(--border)" />' : '';
1307
+ const note = s.note ? ' <span class="muted">('+esc(s.note)+')</span>' : '';
1308
+ tb.append(el('<tr><td style="width:22px;color:'+color+'">'+(ICON[s.status]||'')+'</td><td style="width:62px">'+kindChip+'</td><td><div>'+esc(s.label||('step '+s.seq))+val+note+'</div>'+dest+'</td><td class="muted" style="width:90px;font-size:11px;white-space:nowrap">'+esc(when)+'</td><td style="text-align:right">'+shot+'</td></tr>'));
1309
+ });
1310
+ return t;
1311
+ }
1312
+ function pollReplay(box, session) {
1313
+ clearInterval(replayPoll);
1314
+ const controls = el('<div style="display:flex;gap:8px;margin:10px 0"><button class="btn">Pause</button><button class="btn">Next</button><button class="btn">Resume</button><button class="btn danger">Abort</button></div>');
1315
+ const [pauseB, nextB, resumeB, abortB] = controls.querySelectorAll('button');
1316
+ const ctl = a => body => fetch('/api/replay/control', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(Object.assign({ action: a }, body||{})) });
1317
+ pauseB.onclick = () => ctl('pause')(); nextB.onclick = () => ctl('next')(); resumeB.onclick = () => ctl('resume')(); abortB.onclick = () => ctl('abort')();
1318
+ const prompt = el('<div></div>');
1319
+ replayPoll = setInterval(async () => {
1320
+ const st = await getJSON('/api/replay/status');
1321
+ if (!st || st.running === false) { clearInterval(replayPoll); }
1322
+ box.innerHTML = ''; box.append(controls, prompt, stepTable(st.steps || [], session));
1323
+ prompt.innerHTML = '';
1324
+ if (st.waiting === 'value') {
1325
+ const p = el('<div class="addrow" style="margin:8px 0"><span class="muted">value for \\u201C'+esc(st.waitingLabel||'')+'\\u201D: </span><input type="password" /><button class="btn">Use once</button><button class="btn">Use &amp; save</button></div>');
1326
+ const inp = p.querySelector('input'); const [once, save] = p.querySelectorAll('button');
1327
+ once.onclick = () => ctl('supply')({ value: inp.value, save: false });
1328
+ save.onclick = () => ctl('supply')({ value: inp.value, save: true });
1329
+ prompt.append(p);
1330
+ } else if (st.waiting === 'confirm') {
1331
+ const p = el('<div class="addrow" style="margin:8px 0"><span style="color:var(--danger)">\\u26A0 \\u201C'+esc(st.waitingLabel||'')+'\\u201D looks like a commit (order/pay/delete). Fire it?</span> <button class="btn danger">Fire</button><button class="btn">Skip</button></div>');
1332
+ const [fire, skip] = p.querySelectorAll('button');
1333
+ fire.onclick = () => ctl('confirm')({ fire: true }); skip.onclick = () => ctl('confirm')({ fire: false });
1334
+ prompt.append(p);
1335
+ }
1336
+ if (st.done) clearInterval(replayPoll);
1337
+ }, 700);
1338
+ }
1339
+
1340
+ render();
1341
+ </script>
1342
+ </body>
1343
+ </html>`;