A fork of mtelver's day10 project
at main 470 lines 12 kB view raw
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