forked from
anil.recoil.org/monopam-myspace
My aggregated monorepo of OCaml code, automaintained
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 "&"
145 | '<' -> Buffer.add_string buf "<"
146 | '>' -> Buffer.add_string buf ">"
147 | '"' -> Buffer.add_string buf """
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\">▶ 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\">▶ 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">×</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)