hematite/agent/
html_template.rs1pub fn he(s: &str) -> String {
6 s.replace('&', "&")
7 .replace('<', "<")
8 .replace('>', ">")
9 .replace('"', """)
10}
11
12const CSS: &str = r#":root{--bg:#000;--fg:#fff;--dim:#6b6b6b;--line:#1a1a1a;--line-2:#262626}
13*{box-sizing:border-box;margin:0;padding:0}
14html{scrollbar-width:thin;scrollbar-color:#2a2a2a #000}
15::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#000}::-webkit-scrollbar-thumb{background:#222;border-radius:999px;border:2px solid #000}::-webkit-scrollbar-thumb:hover{background:#333}
16body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background:var(--bg);color:var(--fg);padding:2.5rem 1.5rem;min-height:100vh}
17.wrap{max-width:900px;margin:0 auto}
18header{background:#0a0a0a;border:1px solid var(--line-2);border-radius:18px;padding:2rem 2.25rem;margin-bottom:1rem}
19h1{font-size:1.35rem;font-weight:600;letter-spacing:-0.025em;color:var(--fg);margin-bottom:.6rem}
20.meta{font-size:.775rem;color:var(--dim);margin-bottom:1.25rem;display:flex;flex-wrap:wrap;gap:.4rem 1.5rem;letter-spacing:-0.005em}
21.score-row{display:flex;align-items:center;gap:1rem;flex-wrap:wrap}
22.grade{font-size:2rem;font-weight:800;width:3rem;height:3rem;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:-0.02em}
23.gA{background:#14532d;color:#4ade80}.gB{background:#166534;color:#86efac}.gC{background:#78350f;color:#fbbf24}.gD{background:#7c2d12;color:#fb923c}.gF{background:#7f1d1d;color:#f87171}
24.score-info h2{font-size:1rem;font-weight:600;letter-spacing:-0.02em;color:var(--fg)}.score-info p{color:#a3a3a3;font-size:.85rem;margin-top:.2rem;letter-spacing:-0.005em}
25section{background:#0a0a0a;border:1px solid var(--line-2);border-radius:18px;padding:2rem 2.25rem;margin-bottom:1rem}
26section>h2{font-size:.85rem;font-weight:600;letter-spacing:-0.01em;color:#d4d4d4;margin-bottom:1.25rem;padding-bottom:.75rem;border-bottom:1px solid var(--line)}
27.recipe{padding:1rem 1.25rem;border-left:3px solid var(--line-2);border-radius:0 10px 10px 0;margin-bottom:.75rem;background:#111}
28.recipe:last-child{margin-bottom:0}
29.sev-action{border-left-color:#dc2626}.sev-investigate{border-left-color:#d97706}.sev-monitor{border-left-color:#3b82f6}
30.recipe h3{font-size:.875rem;font-weight:600;letter-spacing:-0.015em;margin-bottom:.7rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;color:var(--fg)}
31.badge{font-size:.65rem;font-weight:700;padding:.2rem .5rem;border-radius:5px;letter-spacing:.02em}
32.b-action{background:#7f1d1d;color:#f87171}.b-investigate{background:#78350f;color:#fbbf24}.b-monitor{background:#1e3a5f;color:#93c5fd}
33.recipe ol{padding-left:1.2rem;color:#d4d4d4}
34.recipe li{margin-bottom:.4rem;line-height:1.6;font-size:.85rem}
35.dig-deeper{font-size:.75rem;color:#4b4b4b;margin-top:.7rem}
36.dig-deeper code{background:var(--line);padding:.1rem .3rem;border-radius:3px;font-size:.75rem;color:#6b6b6b}
37.healthy{color:#4ade80;font-weight:500;font-size:.875rem;padding:.4rem 0;letter-spacing:-0.01em}
38details{border:1px solid var(--line);border-radius:10px;margin-bottom:.6rem;overflow:hidden}
39details:last-child{margin-bottom:0}
40summary{cursor:pointer;font-weight:500;font-size:.8rem;color:#a3a3a3;padding:.7rem 1rem;background:#111;list-style:none;user-select:none;letter-spacing:-0.005em;transition:color 150ms ease,background 150ms ease}
41summary::-webkit-details-marker{display:none}
42summary::before{content:'▶ ';font-size:.6rem;color:var(--dim)}
43details[open] summary::before{content:'▼ '}
44summary:hover{background:#161616;color:var(--fg)}
45pre{font-family:'Cascadia Code','JetBrains Mono','Fira Code',Consolas,monospace;font-size:.75rem;background:#000;color:#a3a3a3;padding:1.25rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word;line-height:1.6;margin:0;border-top:1px solid var(--line)}
46footer{text-align:center;color:var(--dim);font-size:.725rem;margin-top:1.5rem;padding-top:1rem;letter-spacing:-0.005em}
47@media(max-width:640px){body{padding:1.5rem .75rem}header,section{padding:1.5rem;border-radius:14px}}
48.copy-btn{display:inline-flex;align-items:center;gap:8px;margin-top:1.25rem;padding:9px 18px;border-radius:999px;font-family:inherit;font-size:.8rem;font-weight:500;letter-spacing:-0.005em;cursor:pointer;background:transparent;color:#d4d4d4;border:1px solid var(--line-2);transition:border-color 160ms ease,color 160ms ease,background 160ms ease}
49.copy-btn:hover{border-color:var(--fg);color:var(--fg)}
50.copy-btn.copied{border-color:#4ade80;color:#4ade80}
51p{line-height:1.6;color:#d4d4d4;font-size:.9rem;letter-spacing:-0.005em}
52p+p{margin-top:.75rem}
53h2{font-size:1.1rem;font-weight:600;letter-spacing:-0.02em;margin-bottom:.75rem}
54h3{font-size:.95rem;font-weight:600;letter-spacing:-0.015em;margin-bottom:.5rem}
55ul,ol{padding-left:1.25rem;color:#d4d4d4}
56li{margin-bottom:.4rem;line-height:1.6;font-size:.875rem}
57a{color:#d4d4d4;text-decoration:none;border-bottom:1px solid var(--line-2);transition:border-color 150ms ease,color 150ms ease}
58a:hover{color:var(--fg);border-bottom-color:var(--fg)}
59.grade-intro{font-size:.9rem;color:#d4d4d4;margin-top:.85rem;line-height:1.55;letter-spacing:-0.005em}"#;
60
61const COPY_SCRIPT: &str = r#"
64function copyReport() {
65 var btn = document.getElementById('copyBtn');
66 if (!btn) return;
67 var orig = btn.innerHTML;
68 var lines = [];
69 var h1 = document.querySelector('h1'); if (h1) lines.push(h1.innerText);
70 var sh2 = document.querySelector('.score-info h2'); if (sh2) lines.push(sh2.innerText);
71 var sp = document.querySelector('.score-info p'); if (sp) { lines.push(sp.innerText); lines.push(''); }
72 document.querySelectorAll('.recipe').forEach(function(r) {
73 var h = r.querySelector('h3'); if (h) lines.push(h.innerText);
74 r.querySelectorAll('li').forEach(function(li) { lines.push('- ' + li.innerText); });
75 lines.push('');
76 });
77 var dets = document.querySelectorAll('details');
78 if (dets.length) {
79 lines.push('--- Diagnostic Data ---');
80 dets.forEach(function(d) {
81 var s = d.querySelector('summary'); if (s) lines.push('\n[' + s.innerText.trim() + ']');
82 var pre = d.querySelector('pre'); if (pre) lines.push(pre.innerText.trim());
83 });
84 } else {
85 document.querySelectorAll('section').forEach(function(sec) {
86 var sh = sec.querySelector('h2'); if (sh) lines.push('\n--- ' + sh.innerText + ' ---');
87 lines.push(sec.innerText.replace(sh ? sh.innerText : '', '').trim());
88 });
89 }
90 navigator.clipboard.writeText(lines.join('\n')).then(function() {
91 btn.textContent = 'Copied!';
92 btn.classList.add('copied');
93 setTimeout(function() { btn.innerHTML = orig; btn.classList.remove('copied'); }, 2000);
94 });
95}
96"#;
97
98pub const COPY_BUTTON_HTML: &str = r#"<button class="copy-btn" id="copyBtn" onclick="copyReport()">
99 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
100 Copy report for AI
101</button>"#;
102
103pub fn markdown_to_html(md: &str) -> String {
107 let mut out = String::new();
108 let mut in_code_block = false;
109 let mut code_buf = String::new();
110 let mut list_items: Vec<String> = Vec::new();
111
112 let flush_list = |items: &mut Vec<String>, out: &mut String| {
113 if !items.is_empty() {
114 out.push_str("<ul>\n");
115 for item in items.iter() {
116 out.push_str(&format!("<li>{}</li>\n", item));
117 }
118 out.push_str("</ul>\n");
119 items.clear();
120 }
121 };
122
123 for line in md.lines() {
124 if line.starts_with("```") {
126 if in_code_block {
127 out.push_str(&format!("<pre>{}</pre>\n", he(code_buf.trim_end())));
128 code_buf.clear();
129 in_code_block = false;
130 } else {
131 flush_list(&mut list_items, &mut out);
132 in_code_block = true;
133 }
134 continue;
135 }
136 if in_code_block {
137 code_buf.push_str(line);
138 code_buf.push('\n');
139 continue;
140 }
141
142 if let Some(rest) = line.strip_prefix("### ") {
144 flush_list(&mut list_items, &mut out);
145 out.push_str(&format!("<h3>{}</h3>\n", inline_md(rest)));
146 continue;
147 }
148 if let Some(rest) = line.strip_prefix("## ") {
149 flush_list(&mut list_items, &mut out);
150 out.push_str(&format!("<h2>{}</h2>\n", inline_md(rest)));
151 continue;
152 }
153 if let Some(rest) = line.strip_prefix("# ") {
154 flush_list(&mut list_items, &mut out);
155 out.push_str(&format!("<h2>{}</h2>\n", inline_md(rest)));
156 continue;
157 }
158
159 if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
161 list_items.push(inline_md(rest));
162 continue;
163 }
164 if let Some(rest) = line
165 .strip_prefix(" - ")
166 .or_else(|| line.strip_prefix(" * "))
167 {
168 list_items.push(inline_md(rest));
169 continue;
170 }
171
172 if line.trim().is_empty() {
174 flush_list(&mut list_items, &mut out);
175 out.push('\n');
176 continue;
177 }
178
179 flush_list(&mut list_items, &mut out);
181 out.push_str(&format!("<p>{}</p>\n", inline_md(line)));
182 }
183
184 flush_list(&mut list_items, &mut out);
185 if in_code_block && !code_buf.is_empty() {
186 out.push_str(&format!("<pre>{}</pre>\n", he(code_buf.trim_end())));
187 }
188
189 out
191}
192
193fn inline_md(s: &str) -> String {
195 let mut result = he(s);
196 result = replace_pairs(&result, "**", "<strong>", "</strong>");
198 result = replace_pairs(&result, "__", "<strong>", "</strong>");
200 result = replace_pairs(&result, "*", "<em>", "</em>");
202 result = replace_pairs(&result, "`", "<code>", "</code>");
204 result = linkify(&result);
206 result
207}
208
209fn replace_pairs(s: &str, delim: &str, open: &str, close: &str) -> String {
210 let mut out = String::new();
211 let mut rest = s;
212 let mut open_tag = true;
213 while let Some(pos) = rest.find(delim) {
214 out.push_str(&rest[..pos]);
215 out.push_str(if open_tag { open } else { close });
216 rest = &rest[pos + delim.len()..];
217 open_tag = !open_tag;
218 }
219 out.push_str(rest);
220 out
221}
222
223fn linkify(s: &str) -> String {
224 let mut out = String::new();
226 let mut rest = s;
227 while let Some(pos) = rest.find("http") {
228 let pre = &rest[..pos];
229 if pre.ends_with("href=\"") || pre.ends_with("href='") {
231 out.push_str(&rest[..pos + 4]);
232 rest = &rest[pos + 4..];
233 continue;
234 }
235 out.push_str(pre);
236 let url_start = &rest[pos..];
237 let end = url_start
238 .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
239 .unwrap_or(url_start.len());
240 let url = &url_start[..end];
241 out.push_str(&format!(
242 "<a href=\"{}\" target=\"_blank\">{}</a>",
243 url, url
244 ));
245 rest = &url_start[end..];
246 }
247 out.push_str(rest);
248 out
249}
250
251pub fn build_html_shell(title: &str, version: &str, content_html: &str) -> String {
255 format!(
256 r#"<!DOCTYPE html>
257<html lang="en">
258<head>
259<meta charset="utf-8">
260<meta name="viewport" content="width=device-width,initial-scale=1">
261<title>{title}</title>
262<style>{css}</style>
263</head>
264<body>
265<div class="wrap">
266{content}
267</div>
268<footer>Generated by Hematite v{version}</footer>
269<script>{script}</script>
270</body>
271</html>"#,
272 title = he(title),
273 version = he(version),
274 css = CSS,
275 content = content_html,
276 script = COPY_SCRIPT,
277 )
278}