My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1846 lines 53 kB view raw
1(** Scrollycode Extension for odoc 2 3 Provides scroll-driven code tutorials with three visual themes: 4 - warm: Earthy, bookish aesthetic (Fraunces + Source Serif) 5 - dark: Cinematic terminal aesthetic (JetBrains Mono + Outfit) 6 - notebook: Clean editorial aesthetic (Newsreader + DM Sans) 7 8 Authoring format uses @scrolly.<theme> custom tags with an ordered 9 list inside, where each list item is a tutorial step containing 10 a bold title, prose paragraphs, and a code block. *) 11 12module Comment = Odoc_model.Comment 13module Location_ = Odoc_model.Location_ 14module Block = Odoc_document.Types.Block 15module Inline = Odoc_document.Types.Inline 16 17(** {1 Step Extraction} *) 18 19(** A single tutorial step extracted from the ordered list structure *) 20type step = { 21 title : string; 22 prose : string; 23 code : string; 24 focus : int list; (** 1-based line numbers to highlight *) 25} 26 27(** Extract plain text from inline elements *) 28let rec text_of_inline (el : Comment.inline_element Location_.with_location) = 29 match el.Location_.value with 30 | `Space -> " " 31 | `Word w -> w 32 | `Code_span c -> "`" ^ c ^ "`" 33 | `Math_span m -> m 34 | `Raw_markup (_, r) -> r 35 | `Styled (_, content) -> text_of_inlines content 36 | `Reference (_, content) -> text_of_link_content content 37 | `Link (_, content) -> text_of_link_content content 38 39and text_of_inlines content = 40 String.concat "" (List.map text_of_inline content) 41 42and text_of_link_content content = 43 String.concat "" (List.map text_of_non_link content) 44 45and text_of_non_link 46 (el : Comment.non_link_inline_element Location_.with_location) = 47 match el.Location_.value with 48 | `Space -> " " 49 | `Word w -> w 50 | `Code_span c -> "`" ^ c ^ "`" 51 | `Math_span m -> m 52 | `Raw_markup (_, r) -> r 53 | `Styled (_, content) -> text_of_link_content content 54 55let text_of_paragraph (p : Comment.paragraph) = 56 String.concat "" (List.map text_of_inline p) 57 58(** Extract title, prose, code and focus lines from a single list item *) 59let extract_step 60 (item : Comment.nestable_block_element Location_.with_location list) : step 61 = 62 let title = ref "" in 63 let prose_parts = ref [] in 64 let code = ref "" in 65 let focus = ref [] in 66 List.iter 67 (fun (el : Comment.nestable_block_element Location_.with_location) -> 68 match el.Location_.value with 69 | `Paragraph p -> ( 70 let text = text_of_paragraph p in 71 (* Check if the paragraph starts with bold text — that's the title *) 72 match p with 73 | first :: _ 74 when (match first.Location_.value with 75 | `Styled (`Bold, _) -> true 76 | _ -> false) -> 77 if !title = "" then title := text 78 else prose_parts := text :: !prose_parts 79 | _ -> prose_parts := text :: !prose_parts) 80 | `Code_block { content = code_content; _ } -> 81 let code_text = code_content.Location_.value in 82 (* Check for focus annotation in the code: lines starting with >>> *) 83 let lines = String.split_on_char '\n' code_text in 84 let focused_lines = ref [] in 85 let clean_lines = 86 List.mapi 87 (fun i line -> 88 if 89 String.length line >= 4 90 && String.sub line 0 4 = "(* >" 91 then ( 92 focused_lines := (i + 1) :: !focused_lines; 93 (* Remove the focus marker *) 94 let rest = String.sub line 4 (String.length line - 4) in 95 let rest = 96 if 97 String.length rest >= 4 98 && String.sub rest (String.length rest - 4) 4 = "< *)" 99 then String.sub rest 0 (String.length rest - 4) 100 else rest 101 in 102 String.trim rest) 103 else line) 104 lines 105 in 106 code := String.concat "\n" clean_lines; 107 focus := List.rev !focused_lines 108 | `Verbatim v -> prose_parts := v :: !prose_parts 109 | _ -> ()) 110 item; 111 { 112 title = !title; 113 prose = String.concat "\n\n" (List.rev !prose_parts); 114 code = !code; 115 focus = !focus; 116 } 117 118(** Extract all steps from the tag content (expects an ordered list) *) 119let extract_steps 120 (content : 121 Comment.nestable_block_element Location_.with_location list) : 122 string * step list = 123 (* First element might be a paragraph with the tutorial title *) 124 let tutorial_title = ref "Tutorial" in 125 let steps = ref [] in 126 List.iter 127 (fun (el : Comment.nestable_block_element Location_.with_location) -> 128 match el.Location_.value with 129 | `Paragraph p -> 130 let text = text_of_paragraph p in 131 if !steps = [] then tutorial_title := text 132 | `List (`Ordered, items) -> 133 steps := List.map extract_step items 134 | _ -> ()) 135 content; 136 (!tutorial_title, !steps) 137 138(** {1 HTML Escaping} *) 139 140let html_escape s = 141 let buf = Buffer.create (String.length s) in 142 String.iter 143 (function 144 | '&' -> Buffer.add_string buf "&amp;" 145 | '<' -> Buffer.add_string buf "&lt;" 146 | '>' -> Buffer.add_string buf "&gt;" 147 | '"' -> Buffer.add_string buf "&quot;" 148 | c -> Buffer.add_char buf c) 149 s; 150 Buffer.contents buf 151 152(** {1 Diff Computation} *) 153 154type diff_line = 155 | Same of string 156 | Added of string 157 | Removed of string 158 159(** Simple LCS-based line diff between two code strings *) 160let diff_lines old_code new_code = 161 let old_lines = String.split_on_char '\n' old_code |> Array.of_list in 162 let new_lines = String.split_on_char '\n' new_code |> Array.of_list in 163 let n = Array.length old_lines in 164 let m = Array.length new_lines in 165 let dp = Array.make_matrix (n + 1) (m + 1) 0 in 166 for i = 1 to n do 167 for j = 1 to m do 168 if old_lines.(i-1) = new_lines.(j-1) then 169 dp.(i).(j) <- dp.(i-1).(j-1) + 1 170 else 171 dp.(i).(j) <- max dp.(i-1).(j) dp.(i).(j-1) 172 done 173 done; 174 let result = ref [] in 175 let i = ref n and j = ref m in 176 while !i > 0 || !j > 0 do 177 if !i > 0 && !j > 0 && old_lines.(!i-1) = new_lines.(!j-1) then begin 178 result := Same old_lines.(!i-1) :: !result; 179 decr i; decr j 180 end else if !j > 0 && (!i = 0 || dp.(!i).(!j-1) >= dp.(!i-1).(!j)) then begin 181 result := Added new_lines.(!j-1) :: !result; 182 decr j 183 end else begin 184 result := Removed old_lines.(!i-1) :: !result; 185 decr i 186 end 187 done; 188 !result 189 190(** {1 OCaml Syntax Highlighting} 191 192 A simple lexer-based highlighter for OCaml code. Produces HTML spans 193 with classes for keywords, types, strings, comments, operators. *) 194 195let ocaml_keywords = 196 [ 197 "let"; "in"; "if"; "then"; "else"; "match"; "with"; "fun"; "function"; 198 "type"; "module"; "struct"; "sig"; "end"; "open"; "include"; "val"; 199 "rec"; "and"; "of"; "when"; "as"; "begin"; "do"; "done"; "for"; "to"; 200 "while"; "downto"; "try"; "exception"; "raise"; "mutable"; "ref"; 201 "true"; "false"; "assert"; "failwith"; "not"; 202 ] 203 204let ocaml_types = 205 [ 206 "int"; "float"; "string"; "bool"; "unit"; "list"; "option"; "array"; 207 "char"; "bytes"; "result"; "exn"; "ref"; 208 ] 209 210(** Tokenize and highlight OCaml code into HTML *) 211let highlight_ocaml code = 212 let len = String.length code in 213 let buf = Buffer.create (len * 2) in 214 let i = ref 0 in 215 let peek () = if !i < len then Some code.[!i] else None in 216 let advance () = incr i in 217 let current () = code.[!i] in 218 while !i < len do 219 match current () with 220 (* Comments *) 221 | '(' when !i + 1 < len && code.[!i + 1] = '*' -> 222 Buffer.add_string buf "<span class=\"hl-comment\">"; 223 Buffer.add_string buf "(*"; 224 i := !i + 2; 225 let depth = ref 1 in 226 while !depth > 0 && !i < len do 227 if !i + 1 < len && code.[!i] = '(' && code.[!i + 1] = '*' then ( 228 Buffer.add_string buf "(*"; 229 i := !i + 2; 230 incr depth) 231 else if !i + 1 < len && code.[!i] = '*' && code.[!i + 1] = ')' then ( 232 Buffer.add_string buf "*)"; 233 i := !i + 2; 234 decr depth) 235 else ( 236 Buffer.add_string buf (html_escape (String.make 1 code.[!i])); 237 advance ()) 238 done; 239 Buffer.add_string buf "</span>" 240 (* Strings *) 241 | '"' -> 242 Buffer.add_string buf "<span class=\"hl-string\">"; 243 Buffer.add_char buf '"'; 244 advance (); 245 while !i < len && current () <> '"' do 246 if current () = '\\' && !i + 1 < len then ( 247 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 248 advance (); 249 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 250 advance ()) 251 else ( 252 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 253 advance ()) 254 done; 255 if !i < len then ( 256 Buffer.add_char buf '"'; 257 advance ()); 258 Buffer.add_string buf "</span>" 259 (* Char literals *) 260 | '\'' when !i + 2 < len && code.[!i + 2] = '\'' -> 261 Buffer.add_string buf "<span class=\"hl-string\">"; 262 Buffer.add_char buf '\''; 263 advance (); 264 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 265 advance (); 266 Buffer.add_char buf '\''; 267 advance (); 268 Buffer.add_string buf "</span>" 269 (* Numbers *) 270 | '0' .. '9' -> 271 Buffer.add_string buf "<span class=\"hl-number\">"; 272 while 273 !i < len 274 && 275 match current () with 276 | '0' .. '9' | '.' | '_' | 'x' | 'o' | 'b' | 'a' .. 'f' 277 | 'A' .. 'F' -> 278 true 279 | _ -> false 280 do 281 Buffer.add_char buf (current ()); 282 advance () 283 done; 284 Buffer.add_string buf "</span>" 285 (* Identifiers and keywords *) 286 | 'a' .. 'z' | '_' -> 287 let start = !i in 288 while 289 !i < len 290 && 291 match current () with 292 | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true 293 | _ -> false 294 do 295 advance () 296 done; 297 let word = String.sub code start (!i - start) in 298 if List.mem word ocaml_keywords then 299 Buffer.add_string buf 300 (Printf.sprintf "<span class=\"hl-keyword\">%s</span>" 301 (html_escape word)) 302 else if List.mem word ocaml_types then 303 Buffer.add_string buf 304 (Printf.sprintf "<span class=\"hl-type\">%s</span>" 305 (html_escape word)) 306 else Buffer.add_string buf (html_escape word) 307 (* Module/constructor names (capitalized identifiers) *) 308 | 'A' .. 'Z' -> 309 let start = !i in 310 while 311 !i < len 312 && 313 match current () with 314 | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' | '\'' -> true 315 | _ -> false 316 do 317 advance () 318 done; 319 let word = String.sub code start (!i - start) in 320 Buffer.add_string buf 321 (Printf.sprintf "<span class=\"hl-module\">%s</span>" 322 (html_escape word)) 323 (* Operators *) 324 | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' | '~' 325 | '!' | '?' | '%' | '&' -> 326 Buffer.add_string buf "<span class=\"hl-operator\">"; 327 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 328 advance (); 329 (* Consume multi-char operators *) 330 while 331 !i < len 332 && 333 match current () with 334 | '|' | '-' | '+' | '*' | '/' | '=' | '<' | '>' | '@' | '^' 335 | '~' | '!' | '?' | '%' | '&' -> 336 true 337 | _ -> false 338 do 339 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 340 advance () 341 done; 342 Buffer.add_string buf "</span>" 343 (* Punctuation *) 344 | ':' | ';' | '.' | ',' | '[' | ']' | '{' | '}' | '(' | ')' -> 345 Buffer.add_string buf 346 (Printf.sprintf "<span class=\"hl-punct\">%s</span>" 347 (html_escape (String.make 1 (current ())))); 348 advance () 349 (* Arrow special case: -> *) 350 | ' ' | '\t' | '\n' | '\r' -> 351 Buffer.add_char buf (current ()); 352 advance () 353 | _ -> 354 let _ = peek () in 355 Buffer.add_string buf (html_escape (String.make 1 (current ()))); 356 advance () 357 done; 358 Buffer.contents buf 359 360(** Render a diff as HTML with colored lines *) 361let render_diff_html diff = 362 let buf = Buffer.create 1024 in 363 List.iter (fun line -> 364 match line with 365 | Same s -> 366 Buffer.add_string buf 367 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-same\">%s</div>\n" 368 (highlight_ocaml s)) 369 | Added s -> 370 Buffer.add_string buf 371 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-added\">%s</div>\n" 372 (highlight_ocaml s)) 373 | Removed s -> 374 Buffer.add_string buf 375 (Printf.sprintf "<div class=\"sc-diff-line sc-diff-removed\">%s</div>\n" 376 (highlight_ocaml s))) 377 diff; 378 Buffer.contents buf 379 380(** {1 Shared JavaScript} 381 382 The scrollycode runtime handles IntersectionObserver-based step 383 detection and line-level transition animations. *) 384 385let shared_js = 386 {| 387(function() { 388 'use strict'; 389 390 function initScrollycode(container) { 391 var steps = container.querySelectorAll('.sc-step'); 392 var codeBody = container.querySelector('.sc-code-body'); 393 var stepBadge = container.querySelector('.sc-step-badge'); 394 var pips = container.querySelectorAll('.sc-pip'); 395 var currentStep = -1; 396 397 function parseLines(el) { 398 if (!el) return []; 399 var items = el.querySelectorAll('.sc-line'); 400 return Array.from(items).map(function(line) { 401 return { id: line.dataset.id, html: line.innerHTML, focused: line.classList.contains('sc-focused') }; 402 }); 403 } 404 405 function renderStep(index) { 406 if (index === currentStep || index < 0 || index >= steps.length) return; 407 408 var stepEl = steps[index]; 409 var codeSlot = stepEl.querySelector('.sc-code-slot'); 410 var newLines = parseLines(codeSlot); 411 var oldLines = parseLines(codeBody); 412 var oldById = {}; 413 oldLines.forEach(function(l) { oldById[l.id] = l; }); 414 var newById = {}; 415 newLines.forEach(function(l) { newById[l.id] = l; }); 416 417 // Determine exiting lines 418 var exiting = oldLines.filter(function(l) { return !newById[l.id]; }); 419 420 // Animate exit 421 exiting.forEach(function(l, i) { 422 var el = codeBody.querySelector('[data-id="' + l.id + '"]'); 423 if (el) { 424 el.style.animationDelay = (i * 30) + 'ms'; 425 el.classList.add('sc-exiting'); 426 } 427 }); 428 429 var exitTime = exiting.length > 0 ? 200 + exiting.length * 30 : 0; 430 431 setTimeout(function() { 432 // Rebuild DOM 433 codeBody.innerHTML = ''; 434 var firstNew = null; 435 newLines.forEach(function(l, i) { 436 var div = document.createElement('div'); 437 var isNew = !oldById[l.id]; 438 div.className = 'sc-line' + (l.focused ? ' sc-focused' : '') + (isNew ? ' sc-entering' : ''); 439 div.dataset.id = l.id; 440 div.innerHTML = '<span class="sc-line-number">' + (i + 1) + '</span>' + l.html; 441 if (isNew) { 442 div.style.animationDelay = (i * 25) + 'ms'; 443 if (!firstNew) firstNew = div; 444 } 445 codeBody.appendChild(div); 446 }); 447 448 // Scroll to first new line, with some context above 449 if (firstNew) { 450 var lineH = firstNew.offsetHeight || 24; 451 var scrollTarget = firstNew.offsetTop - lineH * 2; 452 codeBody.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); 453 } 454 455 // Update badge and pips 456 if (stepBadge) stepBadge.textContent = (index + 1) + ' / ' + steps.length; 457 pips.forEach(function(pip, i) { 458 pip.classList.toggle('sc-active', i === index); 459 }); 460 }, exitTime); 461 462 currentStep = index; 463 } 464 465 // Set up IntersectionObserver 466 var observer = new IntersectionObserver(function(entries) { 467 entries.forEach(function(entry) { 468 if (entry.isIntersecting) { 469 var idx = parseInt(entry.target.dataset.stepIndex, 10); 470 renderStep(idx); 471 } 472 }); 473 }, { 474 rootMargin: '-30% 0px -30% 0px', 475 threshold: 0 476 }); 477 478 steps.forEach(function(step) { observer.observe(step); }); 479 480 // Initialize first step 481 renderStep(0); 482 483 // Playground overlay 484 var overlay = document.getElementById('sc-playground-overlay'); 485 var closeBtn = overlay ? overlay.querySelector('.sc-playground-close') : null; 486 487 if (overlay && closeBtn) { 488 // Close button 489 closeBtn.addEventListener('click', function() { 490 overlay.classList.remove('sc-open'); 491 }); 492 493 // ESC key closes 494 document.addEventListener('keydown', function(e) { 495 if (e.key === 'Escape') overlay.classList.remove('sc-open'); 496 }); 497 498 // Click outside closes 499 overlay.addEventListener('click', function(e) { 500 if (e.target === overlay) overlay.classList.remove('sc-open'); 501 }); 502 } 503 504 // Try it buttons 505 container.querySelectorAll('.sc-playground-btn').forEach(function(btn) { 506 btn.addEventListener('click', function() { 507 var stepIndex = parseInt(btn.dataset.step, 10); 508 // Collect code from all steps up to and including this one 509 var allCode = []; 510 for (var si = 0; si <= stepIndex; si++) { 511 var slot = steps[si].querySelector('.sc-code-slot'); 512 if (slot) { 513 var lines = slot.querySelectorAll('.sc-line'); 514 var code = Array.from(lines).map(function(l) { 515 return l.textContent.replace(/^\d+/, ''); 516 }).join('\n'); 517 allCode.push(code); 518 } 519 } 520 var fullCode = allCode.join('\n\n'); 521 522 var editor = document.getElementById('sc-playground-x-ocaml'); 523 if (editor) { 524 editor.textContent = fullCode; 525 // Trigger re-initialization if x-ocaml supports it 526 if (editor.setSource) editor.setSource(fullCode); 527 } 528 529 if (overlay) overlay.classList.add('sc-open'); 530 }); 531 }); 532 } 533 534 // Initialize all scrollycode containers on the page 535 document.addEventListener('DOMContentLoaded', function() { 536 document.querySelectorAll('.sc-container').forEach(initScrollycode); 537 }); 538})(); 539|} 540 541(** {1 Theme: Warm Workshop} 542 543 Earthy, bookish. Cream background, burnt sienna accents. 544 Fraunces display + Source Serif 4 body. 545 Dark navy code panel with warm syntax highlighting. *) 546 547let warm_css = 548 {| 549.sc-container.sc-warm { 550 --sc-bg: #f5f0e6; 551 --sc-text: #2c2416; 552 --sc-text-dim: #8a7c6a; 553 --sc-accent: #c25832; 554 --sc-accent-soft: rgba(194, 88, 50, 0.08); 555 --sc-code-bg: #1a1a2e; 556 --sc-code-text: #d4d0c8; 557 --sc-code-gutter: #3a3a52; 558 --sc-border: rgba(44, 36, 22, 0.1); 559 --sc-focus-bg: rgba(194, 88, 50, 0.06); 560 --sc-panel-radius: 12px; 561 font-family: 'Source Serif 4', Georgia, serif; 562} 563 564.sc-container.sc-warm .sc-hero { 565 background: var(--sc-bg); 566 text-align: center; 567 padding: 5rem 2rem 3rem; 568 border-bottom: 1px solid var(--sc-border); 569} 570 571.sc-container.sc-warm .sc-hero h1 { 572 font-family: 'Fraunces', serif; 573 font-size: clamp(2.2rem, 5vw, 3.4rem); 574 font-weight: 800; 575 font-style: italic; 576 color: var(--sc-text); 577 letter-spacing: -0.03em; 578 line-height: 1.1; 579 margin-bottom: 0.75rem; 580} 581 582.sc-container.sc-warm .sc-hero p { 583 color: var(--sc-text-dim); 584 font-size: 1.05rem; 585 max-width: 48ch; 586 margin: 0 auto; 587 line-height: 1.6; 588} 589 590.sc-container.sc-warm .sc-tutorial { 591 display: flex; 592 gap: 0; 593 background: var(--sc-bg); 594 position: relative; 595} 596 597.sc-container.sc-warm .sc-steps-col { 598 flex: 1; 599 min-width: 0; 600 padding: 2rem 2.5rem 50vh 2.5rem; 601} 602 603.sc-container.sc-warm .sc-code-col { 604 width: 52%; 605 flex-shrink: 0; 606} 607 608.sc-container.sc-warm .sc-step { 609 min-height: 70vh; 610 display: flex; 611 flex-direction: column; 612 justify-content: center; 613 padding: 2rem 0; 614} 615 616.sc-container.sc-warm .sc-step-number { 617 font-family: 'Source Code Pro', monospace; 618 font-size: 0.7rem; 619 font-weight: 600; 620 letter-spacing: 0.1em; 621 color: var(--sc-accent); 622 text-transform: uppercase; 623 margin-bottom: 0.5rem; 624} 625 626.sc-container.sc-warm .sc-step h2 { 627 font-family: 'Fraunces', serif; 628 font-size: 1.5rem; 629 font-weight: 700; 630 color: var(--sc-text); 631 letter-spacing: -0.02em; 632 margin-bottom: 0.75rem; 633 line-height: 1.25; 634} 635 636.sc-container.sc-warm .sc-step p { 637 color: var(--sc-text-dim); 638 font-size: 0.95rem; 639 line-height: 1.7; 640 max-width: 44ch; 641} 642 643.sc-container.sc-warm .sc-code-panel { 644 position: sticky; 645 top: 10vh; 646 height: 80vh; 647 margin: 0 2rem 0 0; 648 background: var(--sc-code-bg); 649 border-radius: var(--sc-panel-radius); 650 overflow: hidden; 651 display: flex; 652 flex-direction: column; 653 box-shadow: 0 20px 60px rgba(26, 26, 46, 0.3), 0 0 0 1px rgba(255,255,255,0.03) inset; 654} 655 656.sc-container.sc-warm .sc-code-header { 657 display: flex; 658 align-items: center; 659 padding: 0.85rem 1.25rem; 660 background: rgba(255,255,255,0.03); 661 border-bottom: 1px solid rgba(255,255,255,0.06); 662 gap: 0.6rem; 663} 664 665.sc-container.sc-warm .sc-dots { 666 display: flex; 667 gap: 6px; 668} 669 670.sc-container.sc-warm .sc-dots span { 671 width: 10px; 672 height: 10px; 673 border-radius: 50%; 674} 675 676.sc-container.sc-warm .sc-dots span:nth-child(1) { background: #ff5f57; } 677.sc-container.sc-warm .sc-dots span:nth-child(2) { background: #ffbd2e; } 678.sc-container.sc-warm .sc-dots span:nth-child(3) { background: #28c840; } 679 680.sc-container.sc-warm .sc-filename { 681 font-family: 'Source Code Pro', monospace; 682 font-size: 0.72rem; 683 color: rgba(255,255,255,0.35); 684 letter-spacing: 0.04em; 685 flex: 1; 686 text-align: center; 687} 688 689.sc-container.sc-warm .sc-step-badge { 690 font-family: 'Source Code Pro', monospace; 691 font-size: 0.65rem; 692 color: rgba(255,255,255,0.25); 693 letter-spacing: 0.06em; 694} 695 696.sc-container.sc-warm .sc-code-body { 697 flex: 1; 698 overflow-y: auto; 699 padding: 1.25rem 0; 700 font-family: 'Source Code Pro', monospace; 701 font-size: 0.82rem; 702 line-height: 1.7; 703 color: var(--sc-code-text); 704} 705 706.sc-container.sc-warm .sc-line { 707 padding: 0 1.25rem; 708 white-space: pre; 709 transition: opacity 0.3s ease; 710 opacity: 0.35; 711} 712 713.sc-container.sc-warm .sc-line.sc-focused { 714 opacity: 1; 715 background: rgba(194, 88, 50, 0.06); 716} 717 718.sc-container.sc-warm .sc-line-number { 719 display: inline-block; 720 width: 3ch; 721 text-align: right; 722 margin-right: 1.5ch; 723 color: var(--sc-code-gutter); 724 user-select: none; 725} 726 727/* Syntax highlighting */ 728.sc-container.sc-warm .hl-keyword { color: #f0a6a0; font-weight: 500; } 729.sc-container.sc-warm .hl-type { color: #8ec8e8; } 730.sc-container.sc-warm .hl-string { color: #b8d89a; } 731.sc-container.sc-warm .hl-comment { color: #6a6a82; font-style: italic; } 732.sc-container.sc-warm .hl-number { color: #ddb97a; } 733.sc-container.sc-warm .hl-module { color: #e8c87a; } 734.sc-container.sc-warm .hl-operator { color: #c8a8d8; } 735.sc-container.sc-warm .hl-punct { color: #7a7a92; } 736 737/* Progress pips */ 738.sc-container.sc-warm .sc-progress { 739 position: fixed; 740 left: 1.5rem; 741 top: 50%; 742 transform: translateY(-50%); 743 display: flex; 744 flex-direction: column; 745 gap: 8px; 746 z-index: 100; 747} 748 749.sc-container.sc-warm .sc-pip { 750 width: 6px; 751 height: 6px; 752 border-radius: 50%; 753 background: var(--sc-border); 754 transition: all 0.3s ease; 755} 756 757.sc-container.sc-warm .sc-pip.sc-active { 758 background: var(--sc-accent); 759 box-shadow: 0 0 8px rgba(194, 88, 50, 0.4); 760 transform: scale(1.4); 761} 762 763/* Animations */ 764@keyframes sc-line-exit { 765 0% { opacity: 1; transform: translateX(0); } 766 100% { opacity: 0; transform: translateX(-30px); } 767} 768 769@keyframes sc-line-enter { 770 0% { opacity: 0; transform: translateX(30px); } 771 100% { opacity: 1; transform: translateX(0); } 772} 773 774.sc-container.sc-warm .sc-line.sc-exiting { 775 animation: sc-line-exit 0.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; 776} 777 778.sc-container.sc-warm .sc-line.sc-entering { 779 animation: sc-line-enter 0.25s cubic-bezier(0.22, 1, 0.36, 1) both; 780} 781 782/* Hidden code slot */ 783.sc-code-slot { display: none; } 784 785/* Mobile responsive */ 786@media (max-width: 700px) { 787 .sc-container.sc-warm { padding: 0 1rem; } 788 .sc-container.sc-warm .sc-desktop { display: none !important; } 789 .sc-container.sc-warm .sc-mobile { display: block !important; } 790 .sc-container.sc-warm .sc-progress { display: none; } 791 .sc-container.sc-warm .sc-hero h1 { font-size: 2rem; } 792} 793@media (min-width: 701px) { 794 .sc-container.sc-warm .sc-mobile { display: none !important; } 795} 796.sc-container.sc-warm .sc-mobile-step { 797 margin: 1.5rem 0; 798 padding: 1.5rem; 799 border-radius: 12px; 800 background: rgba(255,255,255,0.5); 801} 802.sc-container.sc-warm .sc-mobile-step-num { 803 font-family: 'Fraunces', serif; 804 font-size: 0.75rem; 805 text-transform: uppercase; 806 letter-spacing: 0.15em; 807 color: #a0785a; 808 margin-bottom: 0.5rem; 809} 810.sc-container.sc-warm .sc-mobile-step h2 { 811 font-family: 'Fraunces', serif; 812 font-size: 1.3rem; 813 color: #3a2e28; 814 margin: 0 0 0.75rem; 815} 816.sc-container.sc-warm .sc-mobile-step p { 817 font-family: 'Source Serif 4', serif; 818 font-size: 1rem; 819 color: #5a4a3a; 820 line-height: 1.6; 821 margin: 0 0 1rem; 822} 823.sc-container.sc-warm .sc-diff-block { 824 background: #1e1b2e; 825 border-radius: 8px; 826 padding: 0.75rem; 827 overflow-x: auto; 828 font-family: 'Source Code Pro', monospace; 829 font-size: 0.8rem; 830 line-height: 1.5; 831} 832.sc-container.sc-warm .sc-diff-line { padding: 1px 0.5rem; white-space: pre; } 833.sc-container.sc-warm .sc-diff-added { background: rgba(80, 200, 80, 0.15); border-left: 3px solid #4caf50; } 834.sc-container.sc-warm .sc-diff-removed { background: rgba(255, 80, 80, 0.12); border-left: 3px solid #ef5350; text-decoration: line-through; opacity: 0.7; } 835.sc-container.sc-warm .sc-diff-same { opacity: 0.5; } 836 837/* Playground overlay */ 838.sc-playground-overlay { 839 display: none; 840 position: fixed; 841 inset: 0; 842 z-index: 10000; 843 background: rgba(0,0,0,0.6); 844 backdrop-filter: blur(4px); 845 align-items: center; 846 justify-content: center; 847} 848.sc-playground-overlay.sc-open { 849 display: flex; 850} 851.sc-playground-container { 852 width: 90vw; 853 max-width: 900px; 854 height: 80vh; 855 background: #1e1b2e; 856 border-radius: 12px; 857 display: flex; 858 flex-direction: column; 859 overflow: hidden; 860 box-shadow: 0 25px 80px rgba(0,0,0,0.5); 861} 862.sc-playground-header { 863 display: flex; 864 align-items: center; 865 justify-content: space-between; 866 padding: 0.75rem 1rem; 867 background: rgba(255,255,255,0.05); 868 border-bottom: 1px solid rgba(255,255,255,0.1); 869} 870.sc-playground-title { 871 font-family: 'Fraunces', serif; 872 font-size: 0.9rem; 873 color: rgba(255,255,255,0.8); 874} 875.sc-playground-close { 876 background: none; 877 border: none; 878 color: rgba(255,255,255,0.5); 879 font-size: 1.5rem; 880 cursor: pointer; 881 padding: 0 0.5rem; 882 line-height: 1; 883} 884.sc-playground-close:hover { color: #fff; } 885.sc-playground-editor { 886 flex: 1; 887 overflow: auto; 888} 889.sc-playground-editor x-ocaml { 890 display: block; 891 height: 100%; 892} 893.sc-container.sc-warm .sc-playground-btn { 894 display: inline-block; 895 margin-top: 0.75rem; 896 padding: 0.4rem 1rem; 897 border: 1px solid rgba(160,120,90,0.3); 898 border-radius: 6px; 899 background: transparent; 900 color: #a0785a; 901 font-family: 'Source Serif 4', serif; 902 font-size: 0.85rem; 903 cursor: pointer; 904 transition: all 0.2s; 905} 906.sc-container.sc-warm .sc-playground-btn:hover { 907 background: rgba(160,120,90,0.1); 908 border-color: #a0785a; 909} 910|} 911 912(** {1 Theme: Dark Terminal} 913 914 Cinematic dark theme. Near-black background, phosphor green and amber. 915 JetBrains Mono + Outfit geometric sans. 916 Code panel is hero-sized, prose is a narrow overlay strip. *) 917 918let dark_css = 919 {| 920.sc-container.sc-dark { 921 --sc-bg: #0a0a0f; 922 --sc-text: #e8e6f0; 923 --sc-text-dim: #6e6b80; 924 --sc-accent: #4ade80; 925 --sc-accent-alt: #fbbf24; 926 --sc-code-bg: #0f0f18; 927 --sc-code-text: #c8c5d8; 928 --sc-code-gutter: #2a2a3e; 929 --sc-border: rgba(255, 255, 255, 0.06); 930 --sc-panel-radius: 0; 931 font-family: 'Outfit', sans-serif; 932 background: var(--sc-bg); 933 color: var(--sc-text); 934} 935 936.sc-container.sc-dark .sc-hero { 937 background: var(--sc-bg); 938 text-align: left; 939 padding: 8rem 4rem 4rem; 940 max-width: 800px; 941 position: relative; 942} 943 944.sc-container.sc-dark .sc-hero::before { 945 content: ''; 946 position: absolute; 947 top: 0; 948 left: 0; 949 right: 0; 950 bottom: 0; 951 background: radial-gradient(ellipse at 20% 50%, rgba(74, 222, 128, 0.04) 0%, transparent 60%); 952 pointer-events: none; 953} 954 955.sc-container.sc-dark .sc-hero h1 { 956 font-family: 'Outfit', sans-serif; 957 font-size: clamp(2.8rem, 6vw, 4.5rem); 958 font-weight: 800; 959 color: var(--sc-text); 960 letter-spacing: -0.04em; 961 line-height: 1.0; 962 margin-bottom: 1.25rem; 963} 964 965.sc-container.sc-dark .sc-hero h1 em { 966 font-style: normal; 967 color: var(--sc-accent); 968} 969 970.sc-container.sc-dark .sc-hero p { 971 color: var(--sc-text-dim); 972 font-size: 1.1rem; 973 max-width: 50ch; 974 line-height: 1.6; 975 font-weight: 300; 976} 977 978.sc-container.sc-dark .sc-tutorial { 979 display: flex; 980 gap: 0; 981 position: relative; 982} 983 984.sc-container.sc-dark .sc-steps-col { 985 width: 38%; 986 flex-shrink: 0; 987 padding: 2rem 2.5rem 50vh 4rem; 988 border-right: 1px solid var(--sc-border); 989} 990 991.sc-container.sc-dark .sc-code-col { 992 flex: 1; 993 min-width: 0; 994} 995 996.sc-container.sc-dark .sc-step { 997 min-height: 70vh; 998 display: flex; 999 flex-direction: column; 1000 justify-content: center; 1001 padding: 2rem 0; 1002} 1003 1004.sc-container.sc-dark .sc-step-number { 1005 font-family: 'JetBrains Mono', monospace; 1006 font-size: 0.65rem; 1007 font-weight: 700; 1008 letter-spacing: 0.15em; 1009 color: var(--sc-accent); 1010 text-transform: uppercase; 1011 margin-bottom: 0.75rem; 1012 display: flex; 1013 align-items: center; 1014 gap: 0.75rem; 1015} 1016 1017.sc-container.sc-dark .sc-step-number::after { 1018 content: ''; 1019 flex: 1; 1020 height: 1px; 1021 background: var(--sc-border); 1022} 1023 1024.sc-container.sc-dark .sc-step h2 { 1025 font-family: 'Outfit', sans-serif; 1026 font-size: 1.4rem; 1027 font-weight: 700; 1028 color: var(--sc-text); 1029 letter-spacing: -0.02em; 1030 margin-bottom: 0.75rem; 1031 line-height: 1.2; 1032} 1033 1034.sc-container.sc-dark .sc-step p { 1035 color: var(--sc-text-dim); 1036 font-size: 0.9rem; 1037 line-height: 1.7; 1038 max-width: 40ch; 1039 font-weight: 300; 1040} 1041 1042.sc-container.sc-dark .sc-code-panel { 1043 position: sticky; 1044 top: 0; 1045 height: 100vh; 1046 background: var(--sc-code-bg); 1047 display: flex; 1048 flex-direction: column; 1049 border-left: 1px solid var(--sc-border); 1050} 1051 1052.sc-container.sc-dark .sc-code-header { 1053 display: flex; 1054 align-items: center; 1055 padding: 1rem 1.5rem; 1056 border-bottom: 1px solid var(--sc-border); 1057 gap: 1rem; 1058} 1059 1060.sc-container.sc-dark .sc-dots { 1061 display: flex; 1062 gap: 6px; 1063} 1064 1065.sc-container.sc-dark .sc-dots span { 1066 width: 8px; 1067 height: 8px; 1068 border-radius: 50%; 1069 background: var(--sc-code-gutter); 1070} 1071 1072.sc-container.sc-dark .sc-filename { 1073 font-family: 'JetBrains Mono', monospace; 1074 font-size: 0.7rem; 1075 color: var(--sc-text-dim); 1076 letter-spacing: 0.04em; 1077 flex: 1; 1078} 1079 1080.sc-container.sc-dark .sc-step-badge { 1081 font-family: 'JetBrains Mono', monospace; 1082 font-size: 0.6rem; 1083 color: var(--sc-accent); 1084 letter-spacing: 0.08em; 1085 background: rgba(74, 222, 128, 0.08); 1086 padding: 0.25em 0.75em; 1087 border-radius: 3px; 1088} 1089 1090.sc-container.sc-dark .sc-code-body { 1091 flex: 1; 1092 overflow-y: auto; 1093 padding: 1.5rem 0; 1094 font-family: 'JetBrains Mono', monospace; 1095 font-size: 0.8rem; 1096 line-height: 1.75; 1097 color: var(--sc-code-text); 1098} 1099 1100.sc-container.sc-dark .sc-line { 1101 padding: 0 1.5rem; 1102 white-space: pre; 1103 transition: opacity 0.3s ease, background 0.3s ease; 1104 opacity: 0.25; 1105} 1106 1107.sc-container.sc-dark .sc-line.sc-focused { 1108 opacity: 1; 1109 background: rgba(74, 222, 128, 0.04); 1110 border-left: 2px solid var(--sc-accent); 1111 padding-left: calc(1.5rem - 2px); 1112} 1113 1114.sc-container.sc-dark .sc-line-number { 1115 display: inline-block; 1116 width: 3ch; 1117 text-align: right; 1118 margin-right: 2ch; 1119 color: var(--sc-code-gutter); 1120 user-select: none; 1121} 1122 1123/* Syntax highlighting — neon palette */ 1124.sc-container.sc-dark .hl-keyword { color: #ff7eb3; font-weight: 500; } 1125.sc-container.sc-dark .hl-type { color: #7dd3fc; } 1126.sc-container.sc-dark .hl-string { color: #4ade80; } 1127.sc-container.sc-dark .hl-comment { color: #4a4a62; font-style: italic; } 1128.sc-container.sc-dark .hl-number { color: #fbbf24; } 1129.sc-container.sc-dark .hl-module { color: #c4b5fd; } 1130.sc-container.sc-dark .hl-operator { color: #67e8f9; } 1131.sc-container.sc-dark .hl-punct { color: #4a4a62; } 1132 1133/* Progress pips */ 1134.sc-container.sc-dark .sc-progress { 1135 position: fixed; 1136 right: 1.5rem; 1137 top: 50%; 1138 transform: translateY(-50%); 1139 display: flex; 1140 flex-direction: column; 1141 gap: 10px; 1142 z-index: 100; 1143} 1144 1145.sc-container.sc-dark .sc-pip { 1146 width: 3px; 1147 height: 20px; 1148 border-radius: 2px; 1149 background: var(--sc-border); 1150 transition: all 0.3s ease; 1151} 1152 1153.sc-container.sc-dark .sc-pip.sc-active { 1154 background: var(--sc-accent); 1155 box-shadow: 0 0 12px rgba(74, 222, 128, 0.5); 1156 height: 30px; 1157} 1158 1159/* Animations */ 1160.sc-container.sc-dark .sc-line.sc-exiting { 1161 animation: sc-line-exit 0.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; 1162} 1163 1164.sc-container.sc-dark .sc-line.sc-entering { 1165 animation: sc-line-enter 0.25s cubic-bezier(0.22, 1, 0.36, 1) both; 1166} 1167 1168.sc-code-slot { display: none; } 1169 1170/* Mobile responsive */ 1171@media (max-width: 700px) { 1172 .sc-container.sc-dark { padding: 0 1rem; } 1173 .sc-container.sc-dark .sc-desktop { display: none !important; } 1174 .sc-container.sc-dark .sc-mobile { display: block !important; } 1175 .sc-container.sc-dark .sc-progress { display: none; } 1176 .sc-container.sc-dark .sc-hero h1 { font-size: 2rem; } 1177} 1178@media (min-width: 701px) { 1179 .sc-container.sc-dark .sc-mobile { display: none !important; } 1180} 1181.sc-container.sc-dark .sc-mobile-step { 1182 margin: 1.5rem 0; 1183 padding: 1.5rem; 1184 border-radius: 10px; 1185 background: rgba(255,255,255,0.04); 1186 border: 1px solid rgba(255,255,255,0.08); 1187} 1188.sc-container.sc-dark .sc-mobile-step-num { 1189 font-family: 'Outfit', sans-serif; 1190 font-size: 0.7rem; 1191 text-transform: uppercase; 1192 letter-spacing: 0.2em; 1193 color: #00d4aa; 1194 margin-bottom: 0.5rem; 1195} 1196.sc-container.sc-dark .sc-mobile-step h2 { 1197 font-family: 'Outfit', sans-serif; 1198 font-size: 1.3rem; 1199 color: #e8e6e3; 1200 margin: 0 0 0.75rem; 1201} 1202.sc-container.sc-dark .sc-mobile-step p { 1203 font-family: 'Outfit', sans-serif; 1204 font-size: 0.95rem; 1205 color: rgba(232,230,227,0.7); 1206 line-height: 1.6; 1207 margin: 0 0 1rem; 1208} 1209.sc-container.sc-dark .sc-diff-block { 1210 background: #0d1117; 1211 border-radius: 8px; 1212 padding: 0.75rem; 1213 overflow-x: auto; 1214 font-family: 'JetBrains Mono', monospace; 1215 font-size: 0.8rem; 1216 line-height: 1.5; 1217 border: 1px solid rgba(0,212,170,0.15); 1218} 1219.sc-container.sc-dark .sc-diff-line { padding: 1px 0.5rem; white-space: pre; } 1220.sc-container.sc-dark .sc-diff-added { background: rgba(0, 212, 170, 0.12); border-left: 3px solid #00d4aa; } 1221.sc-container.sc-dark .sc-diff-removed { background: rgba(255, 80, 80, 0.1); border-left: 3px solid #ff6b6b; text-decoration: line-through; opacity: 0.6; } 1222.sc-container.sc-dark .sc-diff-same { opacity: 0.4; } 1223 1224/* Playground */ 1225.sc-container.sc-dark .sc-playground-btn { 1226 display: inline-block; 1227 margin-top: 0.75rem; 1228 padding: 0.4rem 1rem; 1229 border: 1px solid rgba(0,212,170,0.3); 1230 border-radius: 6px; 1231 background: transparent; 1232 color: #00d4aa; 1233 font-family: 'Outfit', sans-serif; 1234 font-size: 0.85rem; 1235 cursor: pointer; 1236 transition: all 0.2s; 1237} 1238.sc-container.sc-dark .sc-playground-btn:hover { 1239 background: rgba(0,212,170,0.1); 1240 border-color: #00d4aa; 1241} 1242|} 1243 1244(** {1 Theme: Notebook} 1245 1246 Clean editorial. Soft white, blue-violet accent. 1247 Newsreader display + DM Sans body. 1248 Vertical layout with code blocks inline but sticky. *) 1249 1250let notebook_css = 1251 {| 1252.sc-container.sc-notebook { 1253 --sc-bg: #fafbfe; 1254 --sc-text: #1a1a2e; 1255 --sc-text-dim: #64648a; 1256 --sc-accent: #6366f1; 1257 --sc-accent-soft: rgba(99, 102, 241, 0.06); 1258 --sc-code-bg: #1e1e32; 1259 --sc-code-text: #d1d0e0; 1260 --sc-code-gutter: #3a3a52; 1261 --sc-border: rgba(99, 102, 241, 0.08); 1262 --sc-panel-radius: 16px; 1263 font-family: 'DM Sans', sans-serif; 1264} 1265 1266.sc-container.sc-notebook .sc-hero { 1267 background: var(--sc-bg); 1268 text-align: left; 1269 padding: 6rem 0 3rem; 1270 max-width: 640px; 1271 margin: 0 auto; 1272 border-bottom: 2px solid var(--sc-accent); 1273 position: relative; 1274} 1275 1276.sc-container.sc-notebook .sc-hero::after { 1277 content: ''; 1278 position: absolute; 1279 bottom: -2px; 1280 left: 0; 1281 width: 120px; 1282 height: 2px; 1283 background: var(--sc-accent); 1284 box-shadow: 0 0 16px rgba(99, 102, 241, 0.4); 1285} 1286 1287.sc-container.sc-notebook .sc-hero h1 { 1288 font-family: 'Newsreader', serif; 1289 font-size: clamp(2rem, 4vw, 2.8rem); 1290 font-weight: 600; 1291 color: var(--sc-text); 1292 letter-spacing: -0.02em; 1293 line-height: 1.15; 1294 margin-bottom: 0.75rem; 1295} 1296 1297.sc-container.sc-notebook .sc-hero p { 1298 color: var(--sc-text-dim); 1299 font-size: 1rem; 1300 max-width: 52ch; 1301 line-height: 1.6; 1302 font-weight: 400; 1303} 1304 1305.sc-container.sc-notebook .sc-tutorial { 1306 display: flex; 1307 gap: 0; 1308 background: var(--sc-bg); 1309 max-width: 1200px; 1310 margin: 0 auto; 1311 position: relative; 1312} 1313 1314.sc-container.sc-notebook .sc-steps-col { 1315 flex: 1; 1316 min-width: 0; 1317 padding: 2rem 3rem 50vh 0; 1318 max-width: 420px; 1319} 1320 1321.sc-container.sc-notebook .sc-code-col { 1322 flex: 1; 1323 min-width: 0; 1324} 1325 1326.sc-container.sc-notebook .sc-step { 1327 min-height: 60vh; 1328 display: flex; 1329 flex-direction: column; 1330 justify-content: center; 1331 padding: 1.5rem 0; 1332 position: relative; 1333} 1334 1335.sc-container.sc-notebook .sc-step::before { 1336 content: ''; 1337 position: absolute; 1338 left: -1.5rem; 1339 top: 50%; 1340 transform: translateY(-50%); 1341 width: 3px; 1342 height: 0; 1343 background: var(--sc-accent); 1344 border-radius: 2px; 1345 transition: height 0.4s cubic-bezier(0.22, 1, 0.36, 1); 1346} 1347 1348.sc-container.sc-notebook .sc-step-number { 1349 font-family: 'DM Sans', sans-serif; 1350 font-size: 0.68rem; 1351 font-weight: 700; 1352 letter-spacing: 0.12em; 1353 color: var(--sc-accent); 1354 text-transform: uppercase; 1355 margin-bottom: 0.5rem; 1356 display: flex; 1357 align-items: center; 1358 gap: 0.5rem; 1359} 1360 1361.sc-container.sc-notebook .sc-step h2 { 1362 font-family: 'Newsreader', serif; 1363 font-size: 1.3rem; 1364 font-weight: 600; 1365 color: var(--sc-text); 1366 letter-spacing: -0.01em; 1367 margin-bottom: 0.6rem; 1368 line-height: 1.3; 1369} 1370 1371.sc-container.sc-notebook .sc-step p { 1372 color: var(--sc-text-dim); 1373 font-size: 0.88rem; 1374 line-height: 1.7; 1375 max-width: 42ch; 1376} 1377 1378.sc-container.sc-notebook .sc-code-panel { 1379 position: sticky; 1380 top: 8vh; 1381 height: 84vh; 1382 margin: 0 0 0 2rem; 1383 background: var(--sc-code-bg); 1384 border-radius: var(--sc-panel-radius); 1385 overflow: hidden; 1386 display: flex; 1387 flex-direction: column; 1388 box-shadow: 1389 0 24px 80px rgba(30, 30, 50, 0.15), 1390 0 0 0 1px rgba(99, 102, 241, 0.08); 1391} 1392 1393.sc-container.sc-notebook .sc-code-header { 1394 display: flex; 1395 align-items: center; 1396 padding: 0.75rem 1.25rem; 1397 background: rgba(99, 102, 241, 0.04); 1398 border-bottom: 1px solid rgba(255,255,255,0.04); 1399 gap: 0.75rem; 1400} 1401 1402.sc-container.sc-notebook .sc-dots { 1403 display: flex; 1404 gap: 5px; 1405} 1406 1407.sc-container.sc-notebook .sc-dots span { 1408 width: 9px; 1409 height: 9px; 1410 border-radius: 50%; 1411 background: rgba(255,255,255,0.08); 1412} 1413 1414.sc-container.sc-notebook .sc-filename { 1415 font-family: 'DM Mono', monospace; 1416 font-size: 0.7rem; 1417 color: rgba(255,255,255,0.3); 1418 letter-spacing: 0.04em; 1419 flex: 1; 1420 text-align: center; 1421} 1422 1423.sc-container.sc-notebook .sc-step-badge { 1424 font-family: 'DM Mono', monospace; 1425 font-size: 0.6rem; 1426 color: var(--sc-accent); 1427 letter-spacing: 0.06em; 1428} 1429 1430.sc-container.sc-notebook .sc-code-body { 1431 flex: 1; 1432 overflow-y: auto; 1433 padding: 1.25rem 0; 1434 font-family: 'DM Mono', 'Source Code Pro', monospace; 1435 font-size: 0.78rem; 1436 line-height: 1.75; 1437 color: var(--sc-code-text); 1438} 1439 1440.sc-container.sc-notebook .sc-line { 1441 padding: 0 1.25rem; 1442 white-space: pre; 1443 transition: opacity 0.3s ease; 1444 opacity: 0.3; 1445} 1446 1447.sc-container.sc-notebook .sc-line.sc-focused { 1448 opacity: 1; 1449 background: rgba(99, 102, 241, 0.05); 1450} 1451 1452.sc-container.sc-notebook .sc-line-number { 1453 display: inline-block; 1454 width: 3ch; 1455 text-align: right; 1456 margin-right: 1.5ch; 1457 color: var(--sc-code-gutter); 1458 user-select: none; 1459} 1460 1461/* Syntax highlighting — cool tones */ 1462.sc-container.sc-notebook .hl-keyword { color: #a78bfa; font-weight: 500; } 1463.sc-container.sc-notebook .hl-type { color: #67e8f9; } 1464.sc-container.sc-notebook .hl-string { color: #86efac; } 1465.sc-container.sc-notebook .hl-comment { color: #4a4a62; font-style: italic; } 1466.sc-container.sc-notebook .hl-number { color: #fde68a; } 1467.sc-container.sc-notebook .hl-module { color: #f9a8d4; } 1468.sc-container.sc-notebook .hl-operator { color: #93c5fd; } 1469.sc-container.sc-notebook .hl-punct { color: #4a4a62; } 1470 1471/* Progress pips */ 1472.sc-container.sc-notebook .sc-progress { 1473 position: fixed; 1474 left: 2rem; 1475 top: 50%; 1476 transform: translateY(-50%); 1477 display: flex; 1478 flex-direction: column; 1479 gap: 6px; 1480 z-index: 100; 1481} 1482 1483.sc-container.sc-notebook .sc-pip { 1484 width: 8px; 1485 height: 8px; 1486 border-radius: 3px; 1487 background: var(--sc-border); 1488 transition: all 0.3s ease; 1489} 1490 1491.sc-container.sc-notebook .sc-pip.sc-active { 1492 background: var(--sc-accent); 1493 box-shadow: 0 0 10px rgba(99, 102, 241, 0.4); 1494 border-radius: 2px; 1495 width: 8px; 1496 height: 16px; 1497} 1498 1499/* Animations */ 1500.sc-container.sc-notebook .sc-line.sc-exiting { 1501 animation: sc-line-exit 0.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; 1502} 1503 1504.sc-container.sc-notebook .sc-line.sc-entering { 1505 animation: sc-line-enter 0.25s cubic-bezier(0.22, 1, 0.36, 1) both; 1506} 1507 1508.sc-code-slot { display: none; } 1509 1510/* Mobile responsive */ 1511@media (max-width: 700px) { 1512 .sc-container.sc-notebook { padding: 0 1rem; } 1513 .sc-container.sc-notebook .sc-desktop { display: none !important; } 1514 .sc-container.sc-notebook .sc-mobile { display: block !important; } 1515 .sc-container.sc-notebook .sc-progress { display: none; } 1516 .sc-container.sc-notebook .sc-hero h1 { font-size: 2rem; } 1517} 1518@media (min-width: 701px) { 1519 .sc-container.sc-notebook .sc-mobile { display: none !important; } 1520} 1521.sc-container.sc-notebook .sc-mobile-step { 1522 margin: 1.5rem 0; 1523 padding: 1.5rem; 1524 border-radius: 6px; 1525 background: #ffffff; 1526 border: 1px solid #e0ddd8; 1527} 1528.sc-container.sc-notebook .sc-mobile-step-num { 1529 font-family: 'DM Sans', sans-serif; 1530 font-size: 0.7rem; 1531 text-transform: uppercase; 1532 letter-spacing: 0.15em; 1533 color: #0066cc; 1534 font-weight: 600; 1535 margin-bottom: 0.5rem; 1536} 1537.sc-container.sc-notebook .sc-mobile-step h2 { 1538 font-family: 'Newsreader', serif; 1539 font-size: 1.3rem; 1540 color: #1a1a1a; 1541 margin: 0 0 0.75rem; 1542} 1543.sc-container.sc-notebook .sc-mobile-step p { 1544 font-family: 'DM Sans', sans-serif; 1545 font-size: 0.95rem; 1546 color: #4a4a4a; 1547 line-height: 1.6; 1548 margin: 0 0 1rem; 1549} 1550.sc-container.sc-notebook .sc-diff-block { 1551 background: #282c34; 1552 border-radius: 6px; 1553 padding: 0.75rem; 1554 overflow-x: auto; 1555 font-family: 'IBM Plex Mono', monospace; 1556 font-size: 0.8rem; 1557 line-height: 1.5; 1558 border: 1px solid #e0ddd8; 1559} 1560.sc-container.sc-notebook .sc-diff-line { padding: 1px 0.5rem; white-space: pre; } 1561.sc-container.sc-notebook .sc-diff-added { background: rgba(0, 102, 204, 0.12); border-left: 3px solid #0066cc; } 1562.sc-container.sc-notebook .sc-diff-removed { background: rgba(220, 50, 50, 0.1); border-left: 3px solid #dc3232; text-decoration: line-through; opacity: 0.6; } 1563.sc-container.sc-notebook .sc-diff-same { opacity: 0.4; } 1564 1565/* Playground */ 1566.sc-container.sc-notebook .sc-playground-btn { 1567 display: inline-block; 1568 margin-top: 0.75rem; 1569 padding: 0.4rem 1rem; 1570 border: 1px solid rgba(0,102,204,0.3); 1571 border-radius: 6px; 1572 background: transparent; 1573 color: #0066cc; 1574 font-family: 'DM Sans', sans-serif; 1575 font-size: 0.85rem; 1576 cursor: pointer; 1577 transition: all 0.2s; 1578} 1579.sc-container.sc-notebook .sc-playground-btn:hover { 1580 background: rgba(0,102,204,0.1); 1581 border-color: #0066cc; 1582} 1583|} 1584 1585(** {1 CSS to hide odoc chrome} 1586 1587 When a scrollycode block is rendered, we want it to take over 1588 the page. This CSS hides the odoc navigation, breadcrumbs, etc. *) 1589 1590let chrome_override_css = 1591 {| 1592/* Override odoc page chrome for scrollycode pages */ 1593.odoc-nav, .odoc-tocs, .odoc-search { display: none !important; } 1594.odoc-preamble > h1, .odoc-preamble > h2, .odoc-preamble > h3 { display: none !important; } 1595.at-tags > li > .at-tag { display: none !important; } 1596.odoc-preamble, .odoc-content { 1597 max-width: none !important; 1598 padding: 0 !important; 1599 margin: 0 !important; 1600 display: block !important; 1601} 1602.at-tags { 1603 list-style: none !important; 1604 padding: 0 !important; 1605 margin: 0 !important; 1606} 1607.at-tags > li { 1608 display: block !important; 1609 margin: 0 !important; 1610 padding: 0 !important; 1611} 1612body.odoc, .odoc { 1613 padding: 0 !important; 1614 margin: 0 !important; 1615 max-width: none !important; 1616 background: inherit; 1617} 1618|} 1619 1620(** {1 Google Fonts links} *) 1621 1622let warm_fonts = 1623 {|<link rel="preconnect" href="https://fonts.googleapis.com"> 1624<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 1625<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Source+Code+Pro:ital,wght@0,300..900;1,300..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,300..900;1,8..60,300..900&display=swap" rel="stylesheet">|} 1626 1627let dark_fonts = 1628 {|<link rel="preconnect" href="https://fonts.googleapis.com"> 1629<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 1630<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300..800&family=Outfit:wght@300..900&display=swap" rel="stylesheet">|} 1631 1632let notebook_fonts = 1633 {|<link rel="preconnect" href="https://fonts.googleapis.com"> 1634<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 1635<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:ital,opsz,wght@0,9..40,300..900;1,9..40,300..900&family=Newsreader:ital,opsz,wght@0,6..72,300..800;1,6..72,300..800&display=swap" rel="stylesheet">|} 1636 1637(** {1 HTML Generation} *) 1638 1639(** Generate the code lines HTML for a step's code slot *) 1640let generate_code_lines code focus = 1641 let lines = String.split_on_char '\n' code in 1642 let buf = Buffer.create 1024 in 1643 List.iteri 1644 (fun i line -> 1645 let line_num = i + 1 in 1646 let focused = focus = [] || List.mem line_num focus in 1647 let highlighted = highlight_ocaml line in 1648 Buffer.add_string buf 1649 (Printf.sprintf 1650 "<div class=\"sc-line%s\" data-id=\"L%d\">%s</div>\n" 1651 (if focused then " sc-focused" else "") 1652 line_num highlighted)) 1653 lines; 1654 Buffer.contents buf 1655 1656(** Generate the mobile stacked layout with diffs between steps *) 1657let generate_mobile_html steps = 1658 let buf = Buffer.create 8192 in 1659 Buffer.add_string buf "<div class=\"sc-mobile\">\n"; 1660 let prev_code = ref None in 1661 List.iteri (fun i step -> 1662 Buffer.add_string buf 1663 (Printf.sprintf " <div class=\"sc-mobile-step\">\n"); 1664 Buffer.add_string buf 1665 (Printf.sprintf " <div class=\"sc-mobile-step-num\">Step %02d</div>\n" (i + 1)); 1666 if step.title <> "" then 1667 Buffer.add_string buf 1668 (Printf.sprintf " <h2>%s</h2>\n" (html_escape step.title)); 1669 if step.prose <> "" then 1670 Buffer.add_string buf 1671 (Printf.sprintf " <p>%s</p>\n" (html_escape step.prose)); 1672 (* Diff block *) 1673 Buffer.add_string buf " <div class=\"sc-diff-block\">\n"; 1674 let diff = match !prev_code with 1675 | None -> 1676 List.map (fun l -> Added l) (String.split_on_char '\n' step.code) 1677 | Some prev -> 1678 diff_lines prev step.code 1679 in 1680 Buffer.add_string buf (render_diff_html diff); 1681 Buffer.add_string buf " </div>\n"; 1682 Buffer.add_string buf 1683 (Printf.sprintf " <button class=\"sc-playground-btn\" data-step=\"%d\">&#9654; Try it</button>\n" i); 1684 Buffer.add_string buf " </div>\n"; 1685 prev_code := Some step.code) 1686 steps; 1687 Buffer.add_string buf "</div>\n"; 1688 Buffer.contents buf 1689 1690(** Generate the full scrollycode HTML for a given theme *) 1691let generate_html ~theme ~title ~filename steps = 1692 let theme_class, fonts, css = 1693 match theme with 1694 | "warm" -> ("sc-warm", warm_fonts, warm_css) 1695 | "dark" -> ("sc-dark", dark_fonts, dark_css) 1696 | "notebook" -> ("sc-notebook", notebook_fonts, notebook_css) 1697 | _ -> ("sc-warm", warm_fonts, warm_css) 1698 in 1699 let buf = Buffer.create 16384 in 1700 1701 (* Fonts *) 1702 Buffer.add_string buf fonts; 1703 Buffer.add_char buf '\n'; 1704 1705 (* CSS *) 1706 Buffer.add_string buf "<style>\n"; 1707 Buffer.add_string buf chrome_override_css; 1708 Buffer.add_string buf css; 1709 Buffer.add_string buf "</style>\n"; 1710 1711 (* Container *) 1712 Buffer.add_string buf 1713 (Printf.sprintf "<div class=\"sc-container %s\">\n" theme_class); 1714 1715 (* Hero *) 1716 Buffer.add_string buf "<div class=\"sc-hero\">\n"; 1717 Buffer.add_string buf 1718 (Printf.sprintf " <h1>%s</h1>\n" (html_escape title)); 1719 Buffer.add_string buf "</div>\n"; 1720 1721 (* Progress pips *) 1722 Buffer.add_string buf "<nav class=\"sc-progress\">\n"; 1723 List.iteri 1724 (fun i _step -> 1725 Buffer.add_string buf 1726 (Printf.sprintf " <div class=\"sc-pip%s\"></div>\n" 1727 (if i = 0 then " sc-active" else ""))) 1728 steps; 1729 Buffer.add_string buf "</nav>\n"; 1730 1731 (* Desktop layout *) 1732 Buffer.add_string buf "<div class=\"sc-desktop\">\n"; 1733 Buffer.add_string buf "<div class=\"sc-tutorial\">\n"; 1734 1735 (* Steps column *) 1736 Buffer.add_string buf " <div class=\"sc-steps-col\">\n"; 1737 List.iteri 1738 (fun i step -> 1739 Buffer.add_string buf 1740 (Printf.sprintf 1741 " <div class=\"sc-step\" data-step-index=\"%d\">\n" i); 1742 Buffer.add_string buf 1743 (Printf.sprintf 1744 " <div class=\"sc-step-number\">Step %02d</div>\n" (i + 1)); 1745 if step.title <> "" then 1746 Buffer.add_string buf 1747 (Printf.sprintf " <h2>%s</h2>\n" (html_escape step.title)); 1748 if step.prose <> "" then 1749 Buffer.add_string buf 1750 (Printf.sprintf " <p>%s</p>\n" (html_escape step.prose)); 1751 (* Hidden code slot for JS to read *) 1752 Buffer.add_string buf " <div class=\"sc-code-slot\">\n"; 1753 Buffer.add_string buf (generate_code_lines step.code step.focus); 1754 Buffer.add_string buf " </div>\n"; 1755 Buffer.add_string buf 1756 (Printf.sprintf " <button class=\"sc-playground-btn\" data-step=\"%d\">&#9654; Try it</button>\n" i); 1757 Buffer.add_string buf " </div>\n") 1758 steps; 1759 Buffer.add_string buf " </div>\n"; 1760 1761 (* Code column *) 1762 Buffer.add_string buf " <div class=\"sc-code-col\">\n"; 1763 Buffer.add_string buf " <div class=\"sc-code-panel\">\n"; 1764 Buffer.add_string buf " <div class=\"sc-code-header\">\n"; 1765 Buffer.add_string buf 1766 " <div class=\"sc-dots\"><span></span><span></span><span></span></div>\n"; 1767 Buffer.add_string buf 1768 (Printf.sprintf " <span class=\"sc-filename\">%s</span>\n" 1769 (html_escape filename)); 1770 Buffer.add_string buf 1771 (Printf.sprintf 1772 " <span class=\"sc-step-badge\">1 / %d</span>\n" 1773 (List.length steps)); 1774 Buffer.add_string buf " </div>\n"; 1775 Buffer.add_string buf " <div class=\"sc-code-body\">\n"; 1776 (* Initial code from first step *) 1777 (match steps with 1778 | first :: _ -> Buffer.add_string buf (generate_code_lines first.code first.focus) 1779 | [] -> ()); 1780 Buffer.add_string buf " </div>\n"; 1781 Buffer.add_string buf " </div>\n"; 1782 Buffer.add_string buf " </div>\n"; 1783 1784 Buffer.add_string buf "</div>\n"; 1785 Buffer.add_string buf "</div>\n"; 1786 1787 (* Mobile stacked layout *) 1788 Buffer.add_string buf (generate_mobile_html steps); 1789 1790 (* Playground overlay *) 1791 Buffer.add_string buf {|<div id="sc-playground-overlay" class="sc-playground-overlay"> 1792 <div class="sc-playground-container"> 1793 <div class="sc-playground-header"> 1794 <span class="sc-playground-title">Playground</span> 1795 <button class="sc-playground-close">&times;</button> 1796 </div> 1797 <div class="sc-playground-editor"> 1798 <x-ocaml id="sc-playground-x-ocaml" run-on="click"></x-ocaml> 1799 </div> 1800 </div> 1801</div> 1802|}; 1803 1804 (* JavaScript *) 1805 Buffer.add_string buf "<script>\n"; 1806 Buffer.add_string buf shared_js; 1807 Buffer.add_string buf "</script>\n"; 1808 1809 (* x-ocaml for playground *) 1810 Buffer.add_string buf {|<script src="/_x-ocaml/x-ocaml.js" src-worker="/_x-ocaml/worker.js" backend="jtw"></script> 1811|}; 1812 1813 Buffer.contents buf 1814 1815(** {1 Extension Registration} *) 1816 1817module Scrolly : Odoc_extension_api.Extension = struct 1818 let prefix = "scrolly" 1819 1820 let to_document ~tag content = 1821 (* Extract theme from tag: scrolly.warm, scrolly.dark, scrolly.notebook *) 1822 let theme = 1823 match String.index_opt tag '.' with 1824 | None -> "warm" 1825 | Some i -> String.sub tag (i + 1) (String.length tag - i - 1) 1826 in 1827 let tutorial_title, steps = extract_steps content in 1828 let filename = 1829 match theme with 1830 | "dark" -> "main.ml" 1831 | "notebook" -> "test.ml" 1832 | _ -> "parser.ml" 1833 in 1834 let html = generate_html ~theme ~title:tutorial_title ~filename steps in 1835 let block : Block.t = 1836 [ 1837 { 1838 Odoc_document.Types.Block.attr = [ "scrollycode" ]; 1839 desc = Raw_markup ("html", html); 1840 }; 1841 ] 1842 in 1843 { Odoc_extension_api.content = block; overrides = []; resources = []; assets = [] } 1844end 1845 1846let () = Odoc_extension_api.Registry.register (module Scrolly)