A fork of mtelver's day10 project
1(** Common HTML layout components *)
2
3let head ~title =
4 Printf.sprintf {|<!DOCTYPE html>
5<html lang="en">
6<head>
7 <meta charset="UTF-8">
8 <meta name="viewport" content="width=device-width, initial-scale=1.0">
9 <title>%s - OHC</title>
10 <link rel="preconnect" href="https://fonts.googleapis.com">
11 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12 <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
13 <style>
14 :root {
15 --bg-deep: #0a0c0f;
16 --bg: #0f1114;
17 --bg-panel: #141820;
18 --bg-inset: #0c0e12;
19 --text: #c8d3e0;
20 --text-bright: #e8f0f8;
21 --text-dim: #5a6a7a;
22 --phosphor: #00ff9d;
23 --phosphor-dim: #00cc7d;
24 --phosphor-glow: rgba(0, 255, 157, 0.15);
25 --amber: #ffb020;
26 --amber-dim: #cc8800;
27 --amber-glow: rgba(255, 176, 32, 0.15);
28 --error: #ff4060;
29 --error-dim: #cc2040;
30 --error-glow: rgba(255, 64, 96, 0.15);
31 --border: #2a3040;
32 --border-highlight: #3a4560;
33 --scan-line: rgba(255, 255, 255, 0.02);
34 }
35
36 @keyframes fadeInUp {
37 from { opacity: 0; transform: translateY(12px); }
38 to { opacity: 1; transform: translateY(0); }
39 }
40 @keyframes pulse-glow {
41 0%%, 100%% { box-shadow: 0 0 4px currentColor, 0 0 8px currentColor; }
42 50%% { box-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
43 }
44 @keyframes scan {
45 0%% { background-position: 0 0; }
46 100%% { background-position: 0 4px; }
47 }
48 @keyframes blink {
49 0%%, 50%%, 100%% { opacity: 1; }
50 25%%, 75%% { opacity: 0.7; }
51 }
52
53 * { box-sizing: border-box; margin: 0; padding: 0; }
54
55 body {
56 font-family: 'IBM Plex Mono', 'SF Mono', Consolas, monospace;
57 background: var(--bg-deep);
58 color: var(--text);
59 line-height: 1.7;
60 font-size: 14px;
61 min-height: 100vh;
62 position: relative;
63 }
64
65 /* CRT scan-line overlay */
66 body::before {
67 content: '';
68 position: fixed;
69 top: 0;
70 left: 0;
71 right: 0;
72 bottom: 0;
73 background: repeating-linear-gradient(
74 0deg,
75 transparent,
76 transparent 2px,
77 var(--scan-line) 2px,
78 var(--scan-line) 4px
79 );
80 pointer-events: none;
81 z-index: 9999;
82 animation: scan 0.5s linear infinite;
83 }
84
85 /* Subtle vignette */
86 body::after {
87 content: '';
88 position: fixed;
89 top: 0;
90 left: 0;
91 right: 0;
92 bottom: 0;
93 background: radial-gradient(ellipse at center, transparent 0%%, rgba(0,0,0,0.3) 100%%);
94 pointer-events: none;
95 z-index: 9998;
96 }
97
98 .container {
99 max-width: 1280px;
100 margin: 0 auto;
101 padding: 1.5rem 2rem;
102 }
103
104 /* Navigation - instrument panel header */
105 nav {
106 background: linear-gradient(180deg, var(--bg-panel) 0%%, var(--bg) 100%%);
107 border-bottom: 2px solid var(--border);
108 padding: 0;
109 position: relative;
110 }
111 nav::before {
112 content: '';
113 position: absolute;
114 bottom: -2px;
115 left: 0;
116 right: 0;
117 height: 1px;
118 background: linear-gradient(90deg,
119 transparent 0%%,
120 var(--phosphor-dim) 20%%,
121 var(--phosphor) 50%%,
122 var(--phosphor-dim) 80%%,
123 transparent 100%%
124 );
125 opacity: 0.5;
126 }
127 nav .container {
128 display: flex;
129 align-items: center;
130 gap: 3rem;
131 padding: 1rem 2rem;
132 }
133 nav a {
134 color: var(--text-dim);
135 text-decoration: none;
136 text-transform: uppercase;
137 font-size: 11px;
138 letter-spacing: 0.15em;
139 font-weight: 500;
140 padding: 0.5rem 0;
141 position: relative;
142 transition: color 0.2s ease;
143 }
144 nav a:hover {
145 color: var(--phosphor);
146 }
147 nav a::after {
148 content: '';
149 position: absolute;
150 bottom: 0;
151 left: 0;
152 right: 0;
153 height: 2px;
154 background: var(--phosphor);
155 transform: scaleX(0);
156 transition: transform 0.2s ease;
157 }
158 nav a:hover::after {
159 transform: scaleX(1);
160 }
161 nav .brand {
162 font-family: 'Space Grotesk', sans-serif;
163 font-weight: 700;
164 font-size: 1.1rem;
165 letter-spacing: 0.05em;
166 color: var(--text-bright);
167 display: flex;
168 align-items: center;
169 gap: 0.75rem;
170 }
171 nav .brand::before {
172 content: '';
173 width: 8px;
174 height: 8px;
175 background: var(--phosphor);
176 border-radius: 50%%;
177 box-shadow: 0 0 8px var(--phosphor), 0 0 16px var(--phosphor);
178 animation: pulse-glow 2s ease-in-out infinite;
179 }
180
181 /* Typography */
182 h1, h2, h3 {
183 font-family: 'Space Grotesk', sans-serif;
184 font-weight: 500;
185 letter-spacing: -0.01em;
186 color: var(--text-bright);
187 margin-bottom: 1.25rem;
188 }
189 h1 {
190 font-size: 1.75rem;
191 display: flex;
192 align-items: center;
193 gap: 1rem;
194 animation: fadeInUp 0.4s ease-out;
195 }
196 h1::before {
197 content: '//';
198 color: var(--phosphor-dim);
199 font-family: 'IBM Plex Mono', monospace;
200 font-weight: 400;
201 }
202 h2 {
203 font-size: 0.9rem;
204 text-transform: uppercase;
205 letter-spacing: 0.1em;
206 color: var(--text-dim);
207 border-bottom: 1px solid var(--border);
208 padding-bottom: 0.75rem;
209 margin-bottom: 1.25rem;
210 }
211 h3 {
212 font-size: 0.85rem;
213 text-transform: uppercase;
214 letter-spacing: 0.08em;
215 color: var(--text-dim);
216 }
217
218 /* Cards - instrument panels */
219 .card {
220 background: var(--bg-panel);
221 border: 1px solid var(--border);
222 border-radius: 2px;
223 padding: 1.5rem;
224 margin-bottom: 1.5rem;
225 position: relative;
226 animation: fadeInUp 0.5s ease-out backwards;
227 }
228 .card::before {
229 content: '';
230 position: absolute;
231 top: 0;
232 left: 0;
233 right: 0;
234 height: 3px;
235 background: linear-gradient(90deg, var(--phosphor-dim) 0%%, transparent 100%%);
236 opacity: 0.3;
237 }
238 .card:nth-child(1) { animation-delay: 0.05s; }
239 .card:nth-child(2) { animation-delay: 0.1s; }
240 .card:nth-child(3) { animation-delay: 0.15s; }
241 .card:nth-child(4) { animation-delay: 0.2s; }
242
243 /* Stats grid - LED readout style */
244 .grid {
245 display: grid;
246 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
247 gap: 1.25rem;
248 }
249 .stat {
250 background: var(--bg-inset);
251 border: 1px solid var(--border);
252 border-radius: 2px;
253 padding: 1.25rem;
254 text-align: center;
255 position: relative;
256 }
257 .stat::before {
258 content: '';
259 position: absolute;
260 top: 6px;
261 right: 6px;
262 width: 6px;
263 height: 6px;
264 background: var(--phosphor);
265 border-radius: 50%%;
266 box-shadow: 0 0 4px var(--phosphor);
267 animation: blink 3s ease-in-out infinite;
268 }
269 .stat-value {
270 font-family: 'Space Grotesk', sans-serif;
271 font-size: 2.25rem;
272 font-weight: 700;
273 color: var(--phosphor);
274 text-shadow: 0 0 20px var(--phosphor-glow);
275 line-height: 1.1;
276 margin-bottom: 0.5rem;
277 }
278 .stat-label {
279 color: var(--text-dim);
280 font-size: 10px;
281 text-transform: uppercase;
282 letter-spacing: 0.15em;
283 }
284
285 /* Badges - LED indicators */
286 .badge {
287 display: inline-flex;
288 align-items: center;
289 gap: 0.4rem;
290 padding: 0.3rem 0.6rem;
291 border-radius: 2px;
292 font-size: 10px;
293 font-weight: 600;
294 text-transform: uppercase;
295 letter-spacing: 0.1em;
296 }
297 .badge::before {
298 content: '';
299 width: 6px;
300 height: 6px;
301 border-radius: 50%%;
302 flex-shrink: 0;
303 }
304 .badge-success {
305 background: var(--phosphor-glow);
306 color: var(--phosphor);
307 border: 1px solid var(--phosphor-dim);
308 }
309 .badge-success::before {
310 background: var(--phosphor);
311 box-shadow: 0 0 6px var(--phosphor);
312 }
313 .badge-error {
314 background: var(--error-glow);
315 color: var(--error);
316 border: 1px solid var(--error-dim);
317 }
318 .badge-error::before {
319 background: var(--error);
320 box-shadow: 0 0 6px var(--error);
321 }
322 .badge-warning {
323 background: var(--amber-glow);
324 color: var(--amber);
325 border: 1px solid var(--amber-dim);
326 }
327 .badge-warning::before {
328 background: var(--amber);
329 box-shadow: 0 0 6px var(--amber);
330 }
331
332 /* Tables - data terminal */
333 table {
334 width: 100%%;
335 border-collapse: collapse;
336 }
337 th, td {
338 padding: 0.875rem 1rem;
339 text-align: left;
340 border-bottom: 1px solid var(--border);
341 }
342 th {
343 color: var(--text-dim);
344 font-weight: 500;
345 font-size: 10px;
346 text-transform: uppercase;
347 letter-spacing: 0.12em;
348 background: var(--bg-inset);
349 }
350 tr:hover td {
351 background: rgba(0, 255, 157, 0.03);
352 }
353 td { font-size: 13px; }
354
355 /* Links */
356 a {
357 color: var(--phosphor);
358 text-decoration: none;
359 transition: all 0.15s ease;
360 }
361 a:hover {
362 color: var(--text-bright);
363 text-shadow: 0 0 8px var(--phosphor-glow);
364 }
365
366 /* Code/Pre - terminal output */
367 pre {
368 background: var(--bg-deep);
369 border: 1px solid var(--border);
370 padding: 1.25rem;
371 border-radius: 2px;
372 overflow-x: auto;
373 font-size: 12px;
374 line-height: 1.8;
375 color: var(--text);
376 }
377 pre::before {
378 content: '$ OUTPUT';
379 display: block;
380 color: var(--text-dim);
381 font-size: 9px;
382 letter-spacing: 0.15em;
383 margin-bottom: 1rem;
384 padding-bottom: 0.75rem;
385 border-bottom: 1px solid var(--border);
386 }
387
388 /* Search input - data entry */
389 input[type="search"] {
390 width: 100%%;
391 padding: 0.875rem 1rem;
392 background: var(--bg-inset);
393 border: 1px solid var(--border);
394 border-radius: 2px;
395 color: var(--text-bright);
396 font-family: inherit;
397 font-size: 13px;
398 margin-bottom: 1.25rem;
399 transition: all 0.2s ease;
400 }
401 input[type="search"]::placeholder {
402 color: var(--text-dim);
403 text-transform: uppercase;
404 font-size: 10px;
405 letter-spacing: 0.1em;
406 }
407 input[type="search"]:focus {
408 outline: none;
409 border-color: var(--phosphor-dim);
410 box-shadow: 0 0 0 3px var(--phosphor-glow), inset 0 0 20px var(--phosphor-glow);
411 }
412
413 /* Lists */
414 ul {
415 list-style: none;
416 padding: 0;
417 }
418 ul li {
419 padding: 0.5rem 0;
420 border-bottom: 1px solid var(--border);
421 display: flex;
422 align-items: center;
423 gap: 0.75rem;
424 }
425 ul li::before {
426 content: '>';
427 color: var(--phosphor-dim);
428 font-weight: 600;
429 }
430 ul li:last-child {
431 border-bottom: none;
432 }
433
434 /* Utility */
435 p { margin-bottom: 1rem; }
436 strong { color: var(--text-bright); font-weight: 600; }
437 </style>
438</head>
439<body>
440|} title
441
442let nav () = {|
443<nav>
444 <div class="container">
445 <a href="/" class="brand">OHC</a>
446 <a href="/">Dashboard</a>
447 <a href="/packages">Packages</a>
448 <a href="/runs">Run History</a>
449 </div>
450</nav>
451|}
452
453let footer () = {|
454</body>
455</html>
456|}
457
458let page ~title ~content =
459 head ~title ^ nav () ^
460 {|<main class="container">|} ^ content ^ {|</main>|} ^
461 footer ()
462
463let badge status =
464 match status with
465 | `Success -> {|<span class="badge badge-success">success</span>|}
466 | `Failed -> {|<span class="badge badge-error">failed</span>|}
467 | `Skipped -> {|<span class="badge badge-warning">skipped</span>|}
468
469let stat ~value ~label =
470 Printf.sprintf {|<div class="stat"><div class="stat-value">%s</div><div class="stat-label">%s</div></div>|} value label