report-shell.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Skill Analysis Report</title>
  7. <style>
  8. :root {
  9. --bg: #0f1b2d;
  10. --panel: #16263d;
  11. --panel-2: #1d3250;
  12. --ink: #e9eef6;
  13. --ink-dim: #9fb0c7;
  14. --line: #294366;
  15. --accent: #b66d46;
  16. --accent-ink: #f4d9c8;
  17. --critical: #e05656;
  18. --high: #e0904a;
  19. --medium: #d8c24a;
  20. --low: #5aa0d0;
  21. --ok: #4caf72;
  22. }
  23. * { box-sizing: border-box; }
  24. body {
  25. margin: 0;
  26. background: var(--bg);
  27. color: var(--ink);
  28. font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  29. }
  30. .wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 80px; }
  31. header h1 { font-size: 22px; margin: 0 0 4px; }
  32. header .meta { color: var(--ink-dim); font-size: 13px; }
  33. header .meta b { color: var(--ink); font-weight: 600; }
  34. .banner {
  35. display: none;
  36. background: #3a1414;
  37. border: 1px solid var(--critical);
  38. color: #ffd9d9;
  39. padding: 14px 16px;
  40. border-radius: 8px;
  41. margin: 16px 0;
  42. white-space: pre-wrap;
  43. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  44. font-size: 13px;
  45. }
  46. .banner.show { display: block; }
  47. .overview {
  48. background: var(--panel);
  49. border: 1px solid var(--line);
  50. border-radius: 10px;
  51. padding: 18px;
  52. margin: 18px 0;
  53. }
  54. .grade {
  55. font-size: 34px;
  56. font-weight: 800;
  57. margin: 0 0 8px;
  58. text-transform: capitalize;
  59. }
  60. .grade.g-excellent { color: var(--ok); }
  61. .grade.g-good { color: var(--low); }
  62. .grade.g-fair { color: var(--medium); }
  63. .grade.g-poor { color: var(--critical); }
  64. .verdict { font-size: 16px; font-weight: 600; margin: 0 0 14px; }
  65. .summary { color: var(--ink-dim); margin: 0 0 14px; }
  66. .counts { display: flex; flex-wrap: wrap; gap: 10px; }
  67. .pill {
  68. display: inline-flex;
  69. align-items: center;
  70. gap: 8px;
  71. padding: 6px 12px;
  72. border-radius: 999px;
  73. background: var(--panel-2);
  74. border: 1px solid var(--line);
  75. font-size: 13px;
  76. }
  77. .pill .dot { width: 10px; height: 10px; border-radius: 50%; }
  78. .pill .n { font-weight: 700; }
  79. .dot.critical { background: var(--critical); }
  80. .dot.high { background: var(--high); }
  81. .dot.medium { background: var(--medium); }
  82. .dot.low { background: var(--low); }
  83. /* Generic synthesis/agent-block panel */
  84. .block {
  85. background: var(--panel);
  86. border: 1px solid var(--line);
  87. border-radius: 10px;
  88. padding: 18px;
  89. margin: 18px 0;
  90. }
  91. .block > h2 {
  92. font-size: 13px;
  93. text-transform: uppercase;
  94. letter-spacing: 0.06em;
  95. color: var(--ink-dim);
  96. margin: 0 0 12px;
  97. }
  98. .block .mono, .block code {
  99. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  100. font-size: 13px;
  101. background: var(--panel-2);
  102. padding: 1px 5px;
  103. border-radius: 4px;
  104. }
  105. .kv { margin: 0; display: grid; grid-template-columns: 150px 1fr; gap: 6px 14px; }
  106. .kv dt { color: var(--ink-dim); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
  107. .kv dd { margin: 0; }
  108. /* Themes */
  109. .theme { padding: 12px 0; border-top: 1px solid var(--line); }
  110. .theme:first-of-type { border-top: none; padding-top: 0; }
  111. .theme .t-head { display: flex; align-items: center; gap: 10px; }
  112. .theme .t-title { font-weight: 600; flex: 1 1 auto; min-width: 0; }
  113. .theme .t-cause { color: var(--ink-dim); margin-top: 4px; }
  114. .theme .t-action { margin-top: 4px; }
  115. .theme .t-findings { margin-top: 8px; padding-left: 12px; border-left: 2px solid var(--line); }
  116. .theme .t-finding { font-size: 13px; color: var(--ink-dim); padding: 2px 0; }
  117. /* Strengths */
  118. .strength-list { margin: 0; padding-left: 20px; }
  119. .strength-list li { padding: 2px 0; }
  120. /* Recommendations */
  121. .rec { padding: 8px 0; border-top: 1px solid var(--line); }
  122. .rec:first-of-type { border-top: none; padding-top: 0; }
  123. .rec .rank { font-weight: 700; color: var(--accent-ink); margin-right: 8px; }
  124. .rec .resolves { color: var(--ink-dim); font-size: 12px; margin-left: 8px; }
  125. /* Experience journeys */
  126. .block .journey { padding: 8px 0; border-top: 1px solid var(--line); }
  127. .block .journey:first-of-type { border-top: none; padding-top: 0; }
  128. .block .journey .j-name { font-weight: 600; }
  129. .block .journey .j-steps { color: var(--ink-dim); margin-top: 2px; }
  130. .toolbar {
  131. display: flex;
  132. align-items: center;
  133. gap: 12px;
  134. margin: 18px 0 10px;
  135. flex-wrap: wrap;
  136. }
  137. .toolbar .sel-count { color: var(--ink-dim); font-size: 13px; }
  138. button {
  139. font: inherit;
  140. cursor: pointer;
  141. border-radius: 8px;
  142. border: 1px solid var(--line);
  143. background: var(--panel-2);
  144. color: var(--ink);
  145. padding: 8px 14px;
  146. }
  147. button.primary {
  148. background: var(--accent);
  149. border-color: var(--accent);
  150. color: #1a0e07;
  151. font-weight: 600;
  152. }
  153. button:disabled { opacity: 0.5; cursor: default; }
  154. button.link {
  155. background: none;
  156. border: none;
  157. color: var(--accent-ink);
  158. padding: 4px 6px;
  159. font-size: 13px;
  160. }
  161. button.small { padding: 5px 10px; font-size: 13px; flex: 0 0 auto; }
  162. .no-findings {
  163. background: var(--panel);
  164. border: 1px dashed var(--line);
  165. border-radius: 10px;
  166. padding: 28px;
  167. text-align: center;
  168. color: var(--ink-dim);
  169. }
  170. .no-findings .big { font-size: 18px; color: var(--ok); margin-bottom: 6px; }
  171. .group { margin: 18px 0; }
  172. .group > h2 {
  173. font-size: 13px;
  174. text-transform: uppercase;
  175. letter-spacing: 0.06em;
  176. color: var(--ink-dim);
  177. margin: 0 0 8px;
  178. display: flex;
  179. align-items: center;
  180. gap: 8px;
  181. }
  182. .finding {
  183. background: var(--panel);
  184. border: 1px solid var(--line);
  185. border-left: 4px solid var(--line);
  186. border-radius: 8px;
  187. margin: 8px 0;
  188. overflow: hidden;
  189. }
  190. .finding.sev-critical { border-left-color: var(--critical); }
  191. .finding.sev-high { border-left-color: var(--high); }
  192. .finding.sev-medium { border-left-color: var(--medium); }
  193. .finding.sev-low { border-left-color: var(--low); }
  194. .finding .row {
  195. display: flex;
  196. align-items: center;
  197. gap: 12px;
  198. padding: 12px 14px;
  199. }
  200. .finding .row .chk { width: 16px; height: 16px; flex: 0 0 auto; cursor: pointer; }
  201. .finding .row .head { flex: 1 1 auto; cursor: pointer; min-width: 0; }
  202. .finding .row .title { font-weight: 600; }
  203. .finding .row .sub { color: var(--ink-dim); font-size: 12px; margin-top: 2px; }
  204. .finding .tag {
  205. flex: 0 0 auto;
  206. font-size: 11px;
  207. text-transform: uppercase;
  208. letter-spacing: 0.04em;
  209. padding: 3px 8px;
  210. border-radius: 6px;
  211. background: var(--panel-2);
  212. border: 1px solid var(--line);
  213. color: var(--ink-dim);
  214. }
  215. .finding .caret { flex: 0 0 auto; color: var(--ink-dim); transition: transform 0.15s; cursor: pointer; }
  216. .finding.open .caret { transform: rotate(90deg); }
  217. .finding .body {
  218. display: none;
  219. padding: 0 14px 14px 42px;
  220. border-top: 1px solid var(--line);
  221. }
  222. .finding.open .body { display: block; }
  223. .finding .body dl { margin: 12px 0 0; display: grid; grid-template-columns: 130px 1fr; gap: 6px 14px; }
  224. .finding .body dt { color: var(--ink-dim); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
  225. .finding .body dd { margin: 0; }
  226. .finding .body code, .finding .body .mono {
  227. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  228. font-size: 13px;
  229. background: var(--panel-2);
  230. padding: 1px 5px;
  231. border-radius: 4px;
  232. }
  233. .toast {
  234. position: fixed;
  235. left: 50%;
  236. bottom: 28px;
  237. transform: translateX(-50%);
  238. background: var(--ok);
  239. color: #06160c;
  240. padding: 10px 18px;
  241. border-radius: 8px;
  242. font-weight: 600;
  243. opacity: 0;
  244. transition: opacity 0.2s;
  245. pointer-events: none;
  246. }
  247. .toast.show { opacity: 1; }
  248. .fallback-area { margin-top: 12px; display: none; }
  249. .fallback-area.show { display: block; }
  250. .fallback-area textarea {
  251. width: 100%;
  252. min-height: 160px;
  253. background: var(--panel-2);
  254. color: var(--ink);
  255. border: 1px solid var(--line);
  256. border-radius: 8px;
  257. padding: 10px;
  258. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  259. font-size: 13px;
  260. }
  261. </style>
  262. </head>
  263. <body>
  264. <div class="wrap">
  265. <header>
  266. <h1>Skill Analysis Report</h1>
  267. <div class="meta">
  268. <span>Subject: <b id="m-subject">—</b></span> &nbsp;·&nbsp;
  269. <span>Generated: <b id="m-generated">—</b></span> &nbsp;·&nbsp;
  270. <span>Schema: <b id="m-schema">—</b></span>
  271. </div>
  272. </header>
  273. <div id="parse-banner" class="banner"></div>
  274. <section id="overview" class="overview" hidden>
  275. <div id="grade" class="grade" hidden></div>
  276. <p id="verdict" class="verdict"></p>
  277. <p id="summary-text" class="summary" hidden></p>
  278. <div id="counts" class="counts"></div>
  279. </section>
  280. <!-- Synthesis layer (themes, strengths, recommendations). Hidden when absent. -->
  281. <section id="themes" class="block" hidden></section>
  282. <section id="strengths" class="block" hidden></section>
  283. <section id="recommendations" class="block" hidden></section>
  284. <!-- Experience: journeys plus headless (experience). Hidden when absent. -->
  285. <section id="experience" class="block" hidden></section>
  286. <div id="toolbar" class="toolbar" hidden>
  287. <button id="btn-copy" class="primary" disabled>Copy selected as paste-back prompt</button>
  288. <span id="sel-count" class="sel-count">0 selected</span>
  289. <button id="btn-select-all" class="link">Select all</button>
  290. <button id="btn-clear" class="link">Clear</button>
  291. <button id="btn-expand-all" class="link">Expand all</button>
  292. <button id="btn-collapse-all" class="link">Collapse all</button>
  293. </div>
  294. <div id="fallback" class="fallback-area">
  295. <p class="sel-count">Clipboard was unavailable. Copy the text below manually:</p>
  296. <textarea id="fallback-text" readonly></textarea>
  297. </div>
  298. <div id="findings-root"></div>
  299. </div>
  300. <div id="toast" class="toast">Copied</div>
  301. <!-- scripts/render_report.py replaces the contents of this island per run.
  302. The placeholder below is intentionally unusable: the shell refuses to
  303. render it, so a failed injection can never look like real findings. -->
  304. <script type="application/json" id="report-data">
  305. {
  306. "schema_version": 2,
  307. "subject": "__PLACEHOLDER__",
  308. "generated": "",
  309. "verdict": "",
  310. "findings": []
  311. }
  312. </script>
  313. <script>
  314. (function () {
  315. "use strict";
  316. var SEVERITIES = ["critical", "high", "medium", "low"];
  317. var SEV_LABEL = { critical: "Critical", high: "High", medium: "Medium", low: "Low" };
  318. var GRADES = ["excellent", "good", "fair", "poor"];
  319. var PLACEHOLDER_SUBJECT = "__PLACEHOLDER__";
  320. var els = {
  321. banner: document.getElementById("parse-banner"),
  322. overview: document.getElementById("overview"),
  323. grade: document.getElementById("grade"),
  324. verdict: document.getElementById("verdict"),
  325. summaryText: document.getElementById("summary-text"),
  326. counts: document.getElementById("counts"),
  327. themes: document.getElementById("themes"),
  328. strengths: document.getElementById("strengths"),
  329. recommendations: document.getElementById("recommendations"),
  330. experience: document.getElementById("experience"),
  331. toolbar: document.getElementById("toolbar"),
  332. root: document.getElementById("findings-root"),
  333. subject: document.getElementById("m-subject"),
  334. generated: document.getElementById("m-generated"),
  335. schema: document.getElementById("m-schema"),
  336. selCount: document.getElementById("sel-count"),
  337. btnCopy: document.getElementById("btn-copy"),
  338. btnSelectAll: document.getElementById("btn-select-all"),
  339. btnClear: document.getElementById("btn-clear"),
  340. btnExpandAll: document.getElementById("btn-expand-all"),
  341. btnCollapseAll: document.getElementById("btn-collapse-all"),
  342. fallback: document.getElementById("fallback"),
  343. fallbackText: document.getElementById("fallback-text"),
  344. toast: document.getElementById("toast")
  345. };
  346. var selected = Object.create(null);
  347. var findings = [];
  348. var findingsById = Object.create(null);
  349. var subjectPath = "";
  350. var standards = null;
  351. function showBanner(message) {
  352. els.banner.textContent = message;
  353. els.banner.classList.add("show");
  354. }
  355. function esc(value) {
  356. var s = value == null ? "" : String(value);
  357. return s.replace(/[&<>"']/g, function (c) {
  358. return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
  359. });
  360. }
  361. // Normalize an arbitrary parsed object against schema_version 2, supplying
  362. // defaults so a partial or future island still renders. Unknown fields are
  363. // ignored, not fatal. Severity counts are always derived from the findings
  364. // array, never read from the island, so they cannot disagree with it. The
  365. // synthesis blocks (grade, summary, themes, strengths, recommendations) and
  366. // the experience block are OPTIONAL: each normalizes to an empty value that
  367. // renders nothing rather than an empty panel or an error.
  368. function normalize(raw) {
  369. var obj = raw && typeof raw === "object" ? raw : {};
  370. var rawFindings = Array.isArray(obj.findings) ? obj.findings : [];
  371. var norm = {
  372. schema_version: typeof obj.schema_version === "number" ? obj.schema_version : 2,
  373. subject: obj.subject != null ? String(obj.subject) : "(unspecified)",
  374. generated: obj.generated != null ? String(obj.generated) : "(unspecified)",
  375. verdict: obj.verdict != null ? String(obj.verdict) : "(no verdict supplied)",
  376. grade: GRADES.indexOf(String(obj.grade || "").toLowerCase()) >= 0
  377. ? String(obj.grade).toLowerCase() : "",
  378. summary: typeof obj.summary === "string" ? obj.summary : "",
  379. standards: (obj.standards && typeof obj.standards === "object") ? {
  380. canon: obj.standards.canon != null ? String(obj.standards.canon) : "",
  381. principles: obj.standards.principles != null ? String(obj.standards.principles) : "",
  382. scripts: obj.standards.scripts != null ? String(obj.standards.scripts) : ""
  383. } : null,
  384. themes: normalizeThemes(obj.themes),
  385. strengths: normalizeStrengths(obj.strengths),
  386. recommendations: normalizeRecommendations(obj.recommendations),
  387. experience: normalizeExperience(obj.experience),
  388. counts: { critical: 0, high: 0, medium: 0, low: 0 },
  389. findings: []
  390. };
  391. rawFindings.forEach(function (f, i) {
  392. if (!f || typeof f !== "object") { return; }
  393. var sev = SEVERITIES.indexOf(f.severity) >= 0 ? f.severity : "low";
  394. norm.findings.push({
  395. id: f.id != null ? String(f.id) : "finding-" + (i + 1),
  396. lens: f.lens != null ? String(f.lens) : "(unknown)",
  397. severity: sev,
  398. title: f.title != null ? String(f.title) : "(untitled finding)",
  399. location: f.location != null ? String(f.location) : "",
  400. evidence: f.evidence != null ? String(f.evidence) : "",
  401. recommendation: f.recommendation != null ? String(f.recommendation) : "",
  402. proposed_smallest: f.proposed_smallest != null ? String(f.proposed_smallest) : "",
  403. predicted_delta: f.predicted_delta != null ? String(f.predicted_delta) : ""
  404. });
  405. norm.counts[sev] += 1;
  406. });
  407. return norm;
  408. }
  409. function normalizeThemes(raw) {
  410. if (!Array.isArray(raw)) { return []; }
  411. var list = [];
  412. raw.forEach(function (t) {
  413. if (!t || typeof t !== "object") { return; }
  414. var ids = [];
  415. if (Array.isArray(t.finding_ids)) {
  416. t.finding_ids.forEach(function (id) { if (id != null) { ids.push(String(id)); } });
  417. }
  418. var title = t.title != null ? String(t.title) : "";
  419. if (!title && !ids.length) { return; }
  420. list.push({
  421. title: title || "(untitled theme)",
  422. root_cause: t.root_cause != null ? String(t.root_cause) : "",
  423. action: t.action != null ? String(t.action) : "",
  424. finding_ids: ids
  425. });
  426. });
  427. return list;
  428. }
  429. function normalizeStrengths(raw) {
  430. if (!Array.isArray(raw)) { return []; }
  431. var list = [];
  432. raw.forEach(function (s) {
  433. if (typeof s === "string" && s) { list.push(s); }
  434. else if (s && typeof s === "object" && s.title) {
  435. list.push(String(s.title) + (s.detail ? " — " + String(s.detail) : ""));
  436. }
  437. });
  438. return list;
  439. }
  440. function normalizeRecommendations(raw) {
  441. if (!Array.isArray(raw)) { return []; }
  442. var list = [];
  443. raw.forEach(function (r, i) {
  444. if (!r || typeof r !== "object") { return; }
  445. var action = r.action != null ? String(r.action) : "";
  446. if (!action) { return; }
  447. var resolves = "";
  448. if (Array.isArray(r.resolves)) { resolves = r.resolves.map(String).join(", "); }
  449. else if (typeof r.resolves === "number") { resolves = r.resolves + " findings"; }
  450. else if (r.resolves != null) { resolves = String(r.resolves); }
  451. list.push({
  452. rank: typeof r.rank === "number" ? r.rank : i + 1,
  453. action: action,
  454. resolves: resolves
  455. });
  456. });
  457. list.sort(function (a, b) { return a.rank - b.rank; });
  458. return list;
  459. }
  460. // Optional experience block: journeys plus a headless note. Returns null when
  461. // neither is usable.
  462. function normalizeExperience(raw) {
  463. if (!raw || typeof raw !== "object") { return null; }
  464. var journeys = [];
  465. if (Array.isArray(raw.journeys)) {
  466. raw.journeys.forEach(function (j) {
  467. if (!j || typeof j !== "object") { return; }
  468. var name = j.name != null ? String(j.name) : "";
  469. var steps = j.steps != null ? String(j.steps) : "";
  470. if (!name && !steps) { return; }
  471. journeys.push({ name: name, steps: steps });
  472. });
  473. }
  474. var headless = raw.headless != null ? String(raw.headless) : "";
  475. if (!journeys.length && !headless) { return null; }
  476. return { journeys: journeys, headless: headless };
  477. }
  478. function renderOverview(data) {
  479. els.subject.textContent = data.subject;
  480. els.generated.textContent = data.generated;
  481. els.schema.textContent = String(data.schema_version);
  482. els.verdict.textContent = data.verdict;
  483. if (data.grade) {
  484. els.grade.textContent = data.grade;
  485. els.grade.className = "grade g-" + data.grade;
  486. els.grade.hidden = false;
  487. }
  488. if (data.summary) {
  489. els.summaryText.textContent = data.summary;
  490. els.summaryText.hidden = false;
  491. }
  492. els.counts.innerHTML = "";
  493. SEVERITIES.forEach(function (s) {
  494. var pill = document.createElement("span");
  495. pill.className = "pill";
  496. pill.innerHTML =
  497. '<span class="dot ' + s + '"></span>' +
  498. '<span class="lbl">' + SEV_LABEL[s] + '</span>' +
  499. '<span class="n">' + data.counts[s] + "</span>";
  500. els.counts.appendChild(pill);
  501. });
  502. els.overview.hidden = false;
  503. }
  504. // Every copied fix prompt opens by anchoring the fixing session to the same
  505. // standards that produced the findings, so the fix is held to the bar too.
  506. function standardsPreamble() {
  507. if (!standards || !standards.canon) { return []; }
  508. var bar = standards.canon + (standards.principles ? " and " + standards.principles : "");
  509. var lines = [
  510. "Hold " + bar + " as the bar for every line you change — a fix that adds ceremony is a new finding, not a fix."
  511. ];
  512. if (standards.scripts) {
  513. lines.push("If the fix adds or changes scripts, follow " + standards.scripts + ".");
  514. }
  515. lines.push("");
  516. return lines;
  517. }
  518. function composeThemePrompt(theme, resolved) {
  519. var lines = standardsPreamble();
  520. lines.push("Fix the following theme in " + subjectPath + ": " + theme.title);
  521. lines.push("");
  522. if (theme.root_cause) { lines.push("Root cause: " + theme.root_cause); }
  523. if (theme.action) { lines.push("Fix: " + theme.action); }
  524. if (resolved.length) {
  525. lines.push("");
  526. lines.push("Findings to address:");
  527. resolved.forEach(function (f, i) {
  528. lines.push((i + 1) + ". " + f.title);
  529. if (f.location) { lines.push(" Location: " + f.location); }
  530. if (f.evidence) { lines.push(" Evidence: " + f.evidence); }
  531. if (f.recommendation) { lines.push(" Recommendation: " + f.recommendation); }
  532. });
  533. }
  534. return lines.join("\n") + "\n";
  535. }
  536. function renderThemes(themes) {
  537. if (!themes.length) { els.themes.hidden = true; return; }
  538. els.themes.innerHTML = "<h2>Themes</h2>";
  539. themes.forEach(function (t) {
  540. var resolved = t.finding_ids
  541. .map(function (id) { return findingsById[id]; })
  542. .filter(function (f) { return !!f; });
  543. var items = resolved.map(function (f) {
  544. return '<div class="t-finding"><span class="mono">' + esc(f.id) + "</span> " +
  545. esc(f.title) +
  546. (f.location ? ' · <span class="mono">' + esc(f.location) + "</span>" : "") +
  547. "</div>";
  548. }).join("");
  549. var node = document.createElement("div");
  550. node.className = "theme";
  551. node.innerHTML =
  552. '<div class="t-head"><span class="t-title">' + esc(t.title) + "</span>" +
  553. '<button class="small t-fix">Fix This Theme</button></div>' +
  554. (t.root_cause ? '<div class="t-cause">Root cause: ' + esc(t.root_cause) + "</div>" : "") +
  555. (t.action ? '<div class="t-action"><b>Fix:</b> ' + esc(t.action) + "</div>" : "") +
  556. (items ? '<div class="t-findings">' + items + "</div>" : "");
  557. node.querySelector(".t-fix").addEventListener("click", function () {
  558. copyText(composeThemePrompt(t, resolved));
  559. });
  560. els.themes.appendChild(node);
  561. });
  562. els.themes.hidden = false;
  563. }
  564. function renderStrengths(list) {
  565. if (!list.length) { els.strengths.hidden = true; return; }
  566. els.strengths.innerHTML =
  567. "<h2>Strengths</h2><ul class=\"strength-list\">" +
  568. list.map(function (s) { return "<li>" + esc(s) + "</li>"; }).join("") +
  569. "</ul>";
  570. els.strengths.hidden = false;
  571. }
  572. function renderRecommendations(recs) {
  573. if (!recs.length) { els.recommendations.hidden = true; return; }
  574. var html = "<h2>Recommendations</h2>";
  575. recs.forEach(function (r) {
  576. html += '<div class="rec"><span class="rank">#' + esc(String(r.rank)) + "</span>" +
  577. esc(r.action) +
  578. (r.resolves ? '<span class="resolves">resolves: ' + esc(r.resolves) + "</span>" : "") +
  579. "</div>";
  580. });
  581. els.recommendations.innerHTML = html;
  582. els.recommendations.hidden = false;
  583. }
  584. function renderExperience(exp) {
  585. if (!exp) { els.experience.hidden = true; return; }
  586. var html = "<h2>Experience</h2>";
  587. if (exp.journeys.length) {
  588. html += exp.journeys.map(function (j) {
  589. return '<div class="journey">' +
  590. '<div class="j-name">' + esc(j.name || "(unnamed journey)") + "</div>" +
  591. (j.steps ? '<div class="j-steps">' + esc(j.steps) + "</div>" : "") +
  592. "</div>";
  593. }).join("");
  594. }
  595. if (exp.headless) {
  596. html += '<dl class="kv" style="margin-top:12px"><dt>Headless</dt><dd>' +
  597. esc(exp.headless) + "</dd></dl>";
  598. }
  599. els.experience.innerHTML = html;
  600. els.experience.hidden = false;
  601. }
  602. function renderNoFindings() {
  603. els.root.innerHTML =
  604. '<div class="no-findings">' +
  605. '<div class="big">No findings</div>' +
  606. "<div>The scanners returned a clean pass for this subject.</div>" +
  607. "</div>";
  608. }
  609. function findingNode(f) {
  610. var node = document.createElement("div");
  611. node.className = "finding sev-" + f.severity;
  612. node.setAttribute("data-id", f.id);
  613. var sub =
  614. esc(f.lens) +
  615. (f.location ? ' · <span class="mono">' + esc(f.location) + "</span>" : "");
  616. var rows =
  617. "<dt>Lens</dt><dd>" + esc(f.lens) + "</dd>" +
  618. (f.location ? "<dt>Location</dt><dd><code>" + esc(f.location) + "</code></dd>" : "") +
  619. (f.evidence ? "<dt>Evidence</dt><dd>" + esc(f.evidence) + "</dd>" : "") +
  620. (f.recommendation ? "<dt>Recommendation</dt><dd>" + esc(f.recommendation) + "</dd>" : "") +
  621. (f.proposed_smallest ? "<dt>Proposed smallest</dt><dd>" + esc(f.proposed_smallest) + "</dd>" : "") +
  622. (f.predicted_delta ? "<dt>Predicted delta</dt><dd>" + esc(f.predicted_delta) + "</dd>" : "");
  623. node.innerHTML =
  624. '<div class="row">' +
  625. '<input type="checkbox" class="chk" aria-label="Select finding">' +
  626. '<div class="head">' +
  627. '<div class="title">' + esc(f.title) + "</div>" +
  628. '<div class="sub">' + sub + "</div>" +
  629. "</div>" +
  630. '<span class="tag">' + SEV_LABEL[f.severity] + "</span>" +
  631. '<span class="caret">▸</span>' +
  632. "</div>" +
  633. '<div class="body"><dl>' + rows + "</dl></div>";
  634. var chk = node.querySelector(".chk");
  635. chk.checked = !!selected[f.id];
  636. chk.addEventListener("change", function () {
  637. if (chk.checked) { selected[f.id] = true; } else { delete selected[f.id]; }
  638. updateSelection();
  639. });
  640. var head = node.querySelector(".head");
  641. var caret = node.querySelector(".caret");
  642. function toggle() { node.classList.toggle("open"); }
  643. head.addEventListener("click", toggle);
  644. caret.addEventListener("click", toggle);
  645. return node;
  646. }
  647. function renderFindings(list) {
  648. els.root.innerHTML = "";
  649. if (list.length === 0) {
  650. renderNoFindings();
  651. els.toolbar.hidden = true;
  652. return;
  653. }
  654. els.toolbar.hidden = false;
  655. SEVERITIES.forEach(function (sev) {
  656. var group = list.filter(function (f) { return f.severity === sev; });
  657. if (group.length === 0) { return; }
  658. var wrap = document.createElement("div");
  659. wrap.className = "group";
  660. var h = document.createElement("h2");
  661. h.innerHTML = '<span class="dot ' + sev + '"></span>' + SEV_LABEL[sev] + " (" + group.length + ")";
  662. wrap.appendChild(h);
  663. group.forEach(function (f) { wrap.appendChild(findingNode(f)); });
  664. els.root.appendChild(wrap);
  665. });
  666. }
  667. function updateSelection() {
  668. var n = Object.keys(selected).length;
  669. els.selCount.textContent = n + " selected";
  670. els.btnCopy.disabled = n === 0;
  671. }
  672. function composePrompt() {
  673. var picked = findings.filter(function (f) { return selected[f.id]; });
  674. if (picked.length === 0) { return ""; }
  675. var lines = standardsPreamble();
  676. lines.push("Fix the following issues in " + subjectPath + ":");
  677. lines.push("");
  678. picked.forEach(function (f, i) {
  679. lines.push((i + 1) + ". " + f.title);
  680. if (f.location) { lines.push(" Location: " + f.location); }
  681. if (f.evidence) { lines.push(" Evidence: " + f.evidence); }
  682. if (f.recommendation) { lines.push(" Recommendation: " + f.recommendation); }
  683. if (f.proposed_smallest) { lines.push(" Proposed smallest: " + f.proposed_smallest); }
  684. lines.push("");
  685. });
  686. return lines.join("\n").replace(/\n+$/, "\n");
  687. }
  688. function showToast(text) {
  689. els.toast.textContent = text;
  690. els.toast.classList.add("show");
  691. setTimeout(function () { els.toast.classList.remove("show"); }, 1600);
  692. }
  693. function fallbackCopy(text) {
  694. els.fallbackText.value = text;
  695. els.fallback.classList.add("show");
  696. els.fallbackText.focus();
  697. els.fallbackText.select();
  698. try {
  699. var ok = document.execCommand && document.execCommand("copy");
  700. if (ok) {
  701. showToast("Copied");
  702. return;
  703. }
  704. } catch (e) { /* fall through to manual */ }
  705. showToast("Copy the text shown below");
  706. }
  707. function copyText(text) {
  708. if (!text) { return; }
  709. if (navigator.clipboard && navigator.clipboard.writeText) {
  710. navigator.clipboard.writeText(text).then(
  711. function () { showToast("Copied"); },
  712. function () { fallbackCopy(text); }
  713. );
  714. } else {
  715. fallbackCopy(text);
  716. }
  717. }
  718. function doCopy() {
  719. copyText(composePrompt());
  720. }
  721. function wireToolbar() {
  722. els.btnCopy.addEventListener("click", doCopy);
  723. els.btnSelectAll.addEventListener("click", function () {
  724. findings.forEach(function (f) { selected[f.id] = true; });
  725. document.querySelectorAll(".finding .chk").forEach(function (c) { c.checked = true; });
  726. updateSelection();
  727. });
  728. els.btnClear.addEventListener("click", function () {
  729. selected = Object.create(null);
  730. document.querySelectorAll(".finding .chk").forEach(function (c) { c.checked = false; });
  731. els.fallback.classList.remove("show");
  732. updateSelection();
  733. });
  734. els.btnExpandAll.addEventListener("click", function () {
  735. document.querySelectorAll(".finding").forEach(function (n) { n.classList.add("open"); });
  736. });
  737. els.btnCollapseAll.addEventListener("click", function () {
  738. document.querySelectorAll(".finding").forEach(function (n) { n.classList.remove("open"); });
  739. });
  740. }
  741. function init() {
  742. var island = document.getElementById("report-data");
  743. var parsed;
  744. try {
  745. if (!island) { throw new Error("report-data island element not found"); }
  746. parsed = JSON.parse(island.textContent);
  747. } catch (err) {
  748. showBanner(
  749. "Could not parse the report data island.\n\n" +
  750. "Error: " + (err && err.message ? err.message : String(err)) + "\n\n" +
  751. "The findings could not be rendered. The JSON inside the " +
  752. 'report-data island (the application/json script tag) is malformed.'
  753. );
  754. return;
  755. }
  756. var data = normalize(parsed);
  757. if (data.subject === PLACEHOLDER_SUBJECT) {
  758. els.subject.textContent = data.subject;
  759. showBanner(
  760. "This is the unfilled report shell.\n\n" +
  761. "The report-data island still carries the placeholder subject, so " +
  762. "there are no findings here. Generate a real report with " +
  763. "scripts/render_report.py."
  764. );
  765. return;
  766. }
  767. findings = data.findings;
  768. subjectPath = data.subject;
  769. standards = data.standards;
  770. findingsById = Object.create(null);
  771. findings.forEach(function (f) { findingsById[f.id] = f; });
  772. renderOverview(data);
  773. renderThemes(data.themes);
  774. renderStrengths(data.strengths);
  775. renderRecommendations(data.recommendations);
  776. renderExperience(data.experience);
  777. renderFindings(findings);
  778. wireToolbar();
  779. updateSelection();
  780. }
  781. if (document.readyState === "loading") {
  782. document.addEventListener("DOMContentLoaded", init);
  783. } else {
  784. init();
  785. }
  786. })();
  787. </script>
  788. </body>
  789. </html>