1use std::{path::Path, sync::LazyLock};
4
5use base64::{Engine, engine::general_purpose::STANDARD};
6use comrak::{
7 Options, markdown_to_html_with_plugins, options::Plugins, plugins::syntect::SyntectAdapter,
8};
9use jiff::{Timestamp, Zoned, tz::TimeZone};
10use maud::{DOCTYPE, Markup, PreEscaped, html};
11use serde_json::Value;
12
13use crate::transcript::{Block, Folio, ImageSource, Known, Panel, PanelKind, ToolResultContent};
14
15struct Face {
18 family: &'static str,
19 style: &'static str,
20 weight: &'static str,
21 woff2: &'static [u8],
22}
23
24const FACES: [Face; 4] = [
31 Face {
32 family: "Junicode",
33 style: "normal",
34 weight: "300 700",
35 woff2: include_bytes!("fonts/JunicodeVF-Roman.woff2"),
36 },
37 Face {
38 family: "Junicode",
39 style: "italic",
40 weight: "300 700",
41 woff2: include_bytes!("fonts/JunicodeVF-Italic.woff2"),
42 },
43 Face {
44 family: "UnifrakturCook",
45 style: "normal",
46 weight: "400",
47 woff2: include_bytes!("fonts/UnifrakturCook.woff2"),
48 },
49 Face {
50 family: "Fira Code",
51 style: "normal",
52 weight: "300 700",
53 woff2: include_bytes!("fonts/FiraCode-VF.woff2"),
54 },
55];
56
57const FONT_NOTICES: [(&str, &str); 3] = [
62 ("Junicode", "Copyright 2025 Peter S. Baker"),
63 ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
64 (
65 "UnifrakturCook",
66 "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
67 ),
68];
69
70fn font_notice() -> String {
72 let mut notice = String::from(
73 "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
74 );
75 for (family, copyright) in FONT_NOTICES {
76 notice.push_str(&format!("\n {family}: {copyright}"));
77 }
78 notice.push_str("\n-->");
79 notice
80}
81
82static FONT_FACES: LazyLock<String> = LazyLock::new(|| {
86 FACES
87 .iter()
88 .map(|face| {
89 let data = STANDARD.encode(face.woff2);
90 format!(
91 "@font-face{{font-family:\"{}\";font-style:{};font-weight:{};font-display:swap;\
92 src:url(data:font/woff2;base64,{}) format(\"woff2\")}}",
93 face.family, face.style, face.weight, data,
94 )
95 })
96 .collect()
97});
98
99pub struct Colophon {
101 pub generated: Timestamp,
102 pub tool: &'static str,
103 pub version: &'static str,
104}
105
106pub struct Scribe<'a> {
109 options: Options<'a>,
110 plugins: Plugins<'a>,
111 timezone: TimeZone,
112}
113
114impl<'a> Scribe<'a> {
115 pub fn new(highlighter: &'a SyntectAdapter, timezone: TimeZone) -> Self {
116 let mut options = Options::default();
117 options.extension.strikethrough = true;
118 options.extension.table = true;
119 options.extension.tasklist = true;
120 options.extension.autolink = true;
121 options.extension.footnotes = true;
122 options.render.github_pre_lang = true;
123
124 let mut plugins = Plugins::default();
125 plugins.render.codefence_syntax_highlighter = Some(highlighter);
126
127 Self {
128 options,
129 plugins,
130 timezone,
131 }
132 }
133
134 fn markdown(&self, source: &str) -> Markup {
135 PreEscaped(markdown_to_html_with_plugins(
136 source,
137 &self.options,
138 &self.plugins,
139 ))
140 }
141
142 fn zoned(&self, timestamp: Timestamp) -> Zoned {
143 timestamp.to_zoned(self.timezone.clone())
144 }
145
146 pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> Markup {
147 let title = format!("folio {}", folio.session_id());
148 let panels = folio.panels();
149 let left_border = margin_strip(border_seed(folio.session_id(), "left"));
150 let right_border = margin_strip(border_seed(folio.session_id(), "right"));
151 html! {
152 (DOCTYPE)
153 (PreEscaped(font_notice()))
154 html lang="en" {
155 head {
156 meta charset="utf-8";
157 meta name="viewport" content="width=device-width, initial-scale=1";
158 title { (title) }
159 style {
160 (PreEscaped(FONT_FACES.as_str()))
161 (PreEscaped(include_str!("illumination.css")))
162 }
163 script { (PreEscaped(include_str!("illumination.js"))) }
167 }
168 body {
169 div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
174 div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
175 div .plaque {
181 button .plaque__seal type="button" aria-label="folio details" title="folio details" { "❦" }
182 div .plaque__panel {
183 h1 .plaque__title { (title) }
184 dl .plaque__facts {
185 dt { "source" } dd { code { (folio.source.display().to_string()) } }
186 dt { "turns" } dd { (panels.len()) }
187 @if let Some(first) = panels.first() {
188 dt { "opened" } dd { (self.stamp(first.timestamp)) }
189 }
190 }
191 p .plaque__colophon {
192 "Written by " (colophon.tool) " " (colophon.version)
193 " on " (self.stamp(colophon.generated)) "."
194 }
195 p .plaque__colophon {
196 "Set in Junicode, Fira Code, and UnifrakturCook, under the "
197 a href="https://openfontlicense.org" { "SIL Open Font License" } "."
198 }
199 }
200 }
201 div .search role="search" {
204 div .search__bar {
205 input .search__input type="search" placeholder="search folio" aria-label="search folio";
206 span .search__count aria-live="polite" {}
207 button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
208 button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
209 }
210 div .search__scopes role="group" aria-label="search in" {
213 button .search__scope.search__scope--user type="button" data-scope="user" aria-pressed="true" { "user" }
214 button .search__scope.search__scope--assistant type="button" data-scope="assistant" aria-pressed="true" { "assistant" }
215 button .search__scope type="button" data-scope="tool" aria-pressed="true" { "tool" }
216 button .search__scope type="button" data-scope="thinking" aria-pressed="true" { "thinking" }
217 }
218 }
219 nav .dock aria-label="folio navigation" {
226 div .dock__nav {
227 button .dock__btn .dock__btn--user type="button" data-nav="prev" data-role="user" aria-label="previous user message" title="previous user message" { "▲" }
228 button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
229 button .dock__btn .dock__btn--assistant type="button" data-nav="prev" data-role="assistant" aria-label="previous assistant message" title="previous assistant message" { "▲" }
230 button .dock__btn .dock__btn--user type="button" data-nav="next" data-role="user" aria-label="next user message" title="next user message" { "▼" }
231 button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
232 button .dock__btn .dock__btn--assistant type="button" data-nav="next" data-role="assistant" aria-label="next assistant message" title="next assistant message" { "▼" }
233 }
234 div .dock__tail {
238 button .dock__btn type="button" data-nav="end" aria-label="jump to end" title="jump to end" { "⤓" }
239 button .dock__btn .dock__btn--tail type="button" data-tail="toggle" aria-pressed="false" aria-label="follow new messages" title="follow new messages, like tail -f" { "⇊" }
240 }
241 div .dock__fold {
242 button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
243 button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
244 }
245 }
246 div .controls {
250 div .theme-toggle role="group" aria-label="colour theme" {
251 button type="button" data-theme-choice="light" { "light" }
252 button type="button" data-theme-choice="system" aria-pressed="true" { "system" }
253 button type="button" data-theme-choice="dark" { "dark" }
254 }
255 }
256 main .folio {
257 @for panel in &panels {
258 (self.panel(panel))
259 }
260 }
261 }
262 }
263 }
264 }
265
266 fn stamp(&self, timestamp: Timestamp) -> String {
267 self.zoned(timestamp)
268 .strftime("%Y-%m-%d %H:%M:%S %Z")
269 .to_string()
270 }
271
272 fn panel(&self, panel: &Panel) -> Markup {
273 let kind = panel.kind();
274 let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
279 .then(|| panel.blocks.iter().position(Block::is_visible_text))
280 .flatten();
281 html! {
282 article id={ "turn-" (panel.turn_number) }
283 class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
284 data-turn=(panel.turn_number)
285 data-sidechain[panel.is_sidechain] data-meta[panel.is_meta] {
286 header .turn__meta {
287 span .turn__role { (kind.label()) }
288 @if let Some(model) = &panel.model {
289 span .turn__model { (model) }
290 }
291 time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
292 a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
293 }
294 @for (index, block) in panel.blocks.iter().enumerate() {
295 (self.block(block, Some(index) == opening))
296 }
297 }
298 }
299 }
300
301 fn block(&self, block: &Block, versal: bool) -> Markup {
302 let known = match block {
303 Block::Known(known) => known,
304 Block::Unknown(value) => return unknown(value),
305 };
306 match known {
307 Known::Text { text } => {
308 html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
309 }
310 Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
314 p .block.block--redacted { "reasoning redacted" }
315 },
316 Known::Thinking { thinking } => html! {
317 section .block.block--thinking {
318 (self.markdown(thinking))
319 }
320 },
321 Known::ToolUse { name, input } => html! {
322 details .marginalia.marginalia--use {
323 summary {
324 span .marginalia__tool { (name) }
325 @if let Some(gist) = gist(input) {
326 span .marginalia__gist { (gist) }
327 }
328 }
329 (self.tool_body(name, input))
330 }
331 },
332 Known::ToolResult { content, is_error } => html! {
333 details .marginalia.marginalia--result data-error[*is_error] {
334 summary { @if *is_error { "error" } @else { "result" } }
335 @match content {
336 ToolResultContent::Text(text) => pre .marginalia__body { code { (text) } },
337 ToolResultContent::Blocks(blocks) => @for block in blocks { (self.block(block, false)) },
338 }
339 }
340 },
341 Known::Image { source } => image(source),
342 }
343 }
344
345 fn tool_body(&self, name: &str, input: &Value) -> Markup {
350 let special = match name {
351 "Bash" => self.bash_body(input),
352 "Write" => self.write_body(input),
353 "Edit" => self.edit_body(input),
354 "TodoWrite" => self.todo_body(input),
355 _ => None,
356 };
357 special.unwrap_or_else(|| json(input))
358 }
359
360 fn bash_body(&self, input: &Value) -> Option<Markup> {
361 let command = input.get("command")?.as_str()?;
362 let description = input.get("description").and_then(Value::as_str);
363 Some(html! {
364 div .tool.tool--bash {
365 @if let Some(description) = description {
366 p .tool__caption { (description) }
367 }
368 (self.code_block("bash", command))
369 }
370 })
371 }
372
373 fn write_body(&self, input: &Value) -> Option<Markup> {
374 let path = input.get("file_path")?.as_str()?;
375 let content = input.get("content")?.as_str()?;
376 Some(html! {
377 div .tool.tool--write {
378 (self.code_block(lang_for_path(path), content))
379 }
380 })
381 }
382
383 fn edit_body(&self, input: &Value) -> Option<Markup> {
384 let old = input.get("old_string")?.as_str()?;
385 let new = input.get("new_string")?.as_str()?;
386 let replace_all = input
387 .get("replace_all")
388 .and_then(Value::as_bool)
389 .unwrap_or(false);
390 Some(html! {
391 div .tool.tool--edit {
392 @if replace_all {
393 p .tool__caption { "replace all occurrences" }
394 }
395 (self.code_block("diff", &unified_diff(old, new)))
396 }
397 })
398 }
399
400 fn todo_body(&self, input: &Value) -> Option<Markup> {
401 let todos = input.get("todos")?.as_array()?;
402 Some(html! {
403 ul .tool.tool--todos {
404 @for todo in todos {
405 @let content = todo.get("content").and_then(Value::as_str).unwrap_or("");
406 @let status = todo.get("status").and_then(Value::as_str).unwrap_or("pending");
407 li .tool__todo data-status=(status) { (content) }
408 }
409 }
410 })
411 }
412
413 fn code_block(&self, lang: &str, code: &str) -> Markup {
417 let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
418 self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
419 }
420}
421
422const CELL_WIDTH: u32 = 90;
425const CELL_HEIGHT: u32 = 210;
426
427const STRIP_CELLS: usize = 48;
431
432const VINE_CELL: &str = include_str!("drolleries/vine.svg");
434
435const TRAIL: &str = include_str!("drolleries/trail.svg");
440
441const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
446 x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
447 <stop offset=\"0\" stop-color=\"#c1912f\"/>\
448 <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
449
450const DROLLERIES: [(&str, i32, i32); 10] = [
459 (include_str!("drolleries/snail.svg"), 0, -18),
460 (include_str!("drolleries/budgie.svg"), -7, -11),
461 (include_str!("drolleries/cockatiel.svg"), -8, 2),
462 (include_str!("drolleries/cardinal.svg"), -6, -3),
463 (include_str!("drolleries/fish.svg"), -8, 0),
464 (include_str!("drolleries/butterfly.svg"), 0, -23),
465 (include_str!("drolleries/frog.svg"), 0, -32),
466 (include_str!("drolleries/cat.svg"), -5, -23),
467 (include_str!("drolleries/hare.svg"), 0, -14),
468 (include_str!("drolleries/stag.svg"), -2, -21),
469];
470
471struct Rng(u64);
474
475impl Rng {
476 fn next(&mut self) -> u64 {
477 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
478 let mut z = self.0;
479 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
480 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
481 z ^ (z >> 31)
482 }
483}
484
485fn border_seed(session_id: &str, salt: &str) -> u64 {
490 session_id
491 .bytes()
492 .chain(salt.bytes())
493 .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
494 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
495 })
496}
497
498#[derive(Clone, Copy)]
501enum BorderCell {
502 Vine,
503 Drollery {
504 svg: &'static str,
505 dx: i32,
506 dy: i32,
507 flip: bool,
508 },
509}
510
511fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
515 let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
516 for i in (1..bag.len()).rev() {
517 bag.swap(i, rng.next() as usize % (i + 1));
518 }
519 bag
520}
521
522fn border_cells(seed: u64) -> Vec<BorderCell> {
526 let mut rng = Rng(seed);
527 let mut bag = shuffled_bag(&mut rng);
528 let mut cells = Vec::with_capacity(STRIP_CELLS);
529 let mut previous_was_drollery = false;
530 for index in 0..STRIP_CELLS {
531 let seam = index == 0 || index == STRIP_CELLS - 1;
532 let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
533 if drollery {
534 if bag.is_empty() {
535 bag = shuffled_bag(&mut rng);
536 }
537 let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
538 let flip = rng.next().is_multiple_of(2);
539 cells.push(BorderCell::Drollery { svg, dx, dy, flip });
540 } else {
541 cells.push(BorderCell::Vine);
542 }
543 previous_was_drollery = drollery;
544 }
545 cells
546}
547
548fn margin_strip(seed: u64) -> String {
552 let height = STRIP_CELLS as u32 * CELL_HEIGHT;
553 let mut inner = String::new();
554 for (index, cell) in border_cells(seed).iter().enumerate() {
555 let y = index as u32 * CELL_HEIGHT;
556 let content = match cell {
560 BorderCell::Vine => VINE_CELL.to_string(),
561 BorderCell::Drollery { svg, dx, dy, flip } => {
562 let place = if *flip {
565 format!("translate(90,0) scale(-1,1) translate({dx},{dy})")
566 } else {
567 format!("translate({dx},{dy})")
568 };
569 format!(
570 "{TRAIL}<g transform=\"{place}\">{svg}</g>\
571 <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
572 )
573 }
574 };
575 inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
576 }
577 let svg = format!(
578 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
579 height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
580 );
581 format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
582}
583
584fn gist(input: &Value) -> Option<&str> {
587 ["command", "file_path", "pattern", "path", "url", "prompt"]
588 .iter()
589 .find_map(|field| input.get(field)?.as_str())
590}
591
592fn lang_for_path(path: &str) -> &str {
596 Path::new(path)
597 .extension()
598 .and_then(|extension| extension.to_str())
599 .unwrap_or("")
600}
601
602fn unified_diff(old: &str, new: &str) -> String {
606 let mut diff = String::new();
607 for line in old.lines() {
608 diff.push('-');
609 diff.push_str(line);
610 diff.push('\n');
611 }
612 for line in new.lines() {
613 diff.push('+');
614 diff.push_str(line);
615 diff.push('\n');
616 }
617 diff
618}
619
620fn longest_backtick_run(source: &str) -> usize {
621 let mut longest = 0;
622 let mut run = 0;
623 for byte in source.bytes() {
624 if byte == b'`' {
625 run += 1;
626 longest = longest.max(run);
627 } else {
628 run = 0;
629 }
630 }
631 longest
632}
633
634fn json(value: &Value) -> Markup {
635 let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
636 html! { pre .marginalia__body { code { (pretty) } } }
637}
638
639fn unknown(value: &Value) -> Markup {
640 let label = value
641 .get("type")
642 .and_then(Value::as_str)
643 .unwrap_or("unrecognized");
644 html! {
645 details .block.block--unknown {
646 summary { (label) }
647 (json(value))
648 }
649 }
650}
651
652fn image(source: &ImageSource) -> Markup {
653 let src = format!("data:{};base64,{}", source.media_type, source.data);
654 html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 #[test]
662 fn border_strip_is_stable_per_seed() {
663 let seed = border_seed("3f9c-a17b-session", "left");
664 assert_eq!(margin_strip(seed), margin_strip(seed));
665 }
666
667 #[test]
668 fn the_two_borders_of_a_folio_differ() {
669 let session = "3f9c-a17b-session";
670 assert_ne!(
671 margin_strip(border_seed(session, "left")),
672 margin_strip(border_seed(session, "right"))
673 );
674 }
675
676 fn is_drollery(cell: &BorderCell) -> bool {
677 matches!(cell, BorderCell::Drollery { .. })
678 }
679
680 #[test]
681 fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
682 let mut total_drolleries = 0;
685 for salt in [
686 "one", "two", "three", "four", "five", "six", "seven", "eight",
687 ] {
688 let cells = border_cells(border_seed("a-session", salt));
689 assert_eq!(cells.len(), STRIP_CELLS);
690 assert!(matches!(cells[0], BorderCell::Vine));
691 assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
692 let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
693 assert!(
694 drolleries < STRIP_CELLS / 2,
695 "too many drolleries: {drolleries}"
696 );
697 let adjacent = cells
698 .windows(2)
699 .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
700 assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
701 total_drolleries += drolleries;
702 }
703 assert!(total_drolleries > 0, "generator produced no drolleries");
706 }
707
708 #[test]
709 fn drolleries_face_both_ways_across_a_border() {
710 let cells = border_cells(border_seed("flip-variety-session", "left"));
713 let flips: Vec<bool> = cells
714 .iter()
715 .filter_map(|cell| match cell {
716 BorderCell::Drollery { flip, .. } => Some(*flip),
717 BorderCell::Vine => None,
718 })
719 .collect();
720 assert!(flips.iter().any(|&flip| flip), "no creature was flipped");
721 assert!(
722 flips.iter().any(|&flip| !flip),
723 "no creature kept its facing"
724 );
725 }
726
727 #[test]
728 fn a_border_cycles_through_the_whole_bestiary() {
729 let cells = border_cells(border_seed("variety-session", "left"));
733 let used: std::collections::HashSet<&str> = cells
734 .iter()
735 .filter_map(|cell| match cell {
736 BorderCell::Drollery { svg, .. } => Some(*svg),
737 BorderCell::Vine => None,
738 })
739 .collect();
740 let count = cells.iter().filter(|cell| is_drollery(cell)).count();
741 let expected = count.min(DROLLERIES.len());
742 assert_eq!(
743 used.len(),
744 expected,
745 "creatures repeated before the bag drained"
746 );
747 }
748}