Skip to main content

claude_scriptorium/
render.rs

1//! Turning a parsed folio into a self-contained HTML document.
2
3use std::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::{
14    tools,
15    transcript::{Block, Folio, ImageSource, Known, Panel, PanelKind, Usage},
16};
17
18/// One embedded web font: the family and posture the stylesheet asks for, the
19/// weight range the variable file covers, and the woff2 bytes themselves.
20struct Face {
21    family: &'static str,
22    style: &'static str,
23    weight: &'static str,
24    woff2: &'static [u8],
25}
26
27/// The fonts are inlined so a folio stays self-contained (see the rendering
28/// invariants in CLAUDE.md). `just fonts` vendors these from upstream.
29///
30/// Junicode 2 (serif body, roman + italic) and Fira Code (monospace) are
31/// variable, so one file per posture spans every weight; UnifrakturCook is the
32/// single-weight blackletter used for headings and dropped versals.
33const FACES: [Face; 4] = [
34    Face {
35        family: "Junicode",
36        style: "normal",
37        weight: "300 700",
38        woff2: include_bytes!("fonts/JunicodeVF-Roman.woff2"),
39    },
40    Face {
41        family: "Junicode",
42        style: "italic",
43        weight: "300 700",
44        woff2: include_bytes!("fonts/JunicodeVF-Italic.woff2"),
45    },
46    Face {
47        family: "UnifrakturCook",
48        style: "normal",
49        weight: "400",
50        woff2: include_bytes!("fonts/UnifrakturCook.woff2"),
51    },
52    Face {
53        family: "Fira Code",
54        style: "normal",
55        weight: "300 700",
56        woff2: include_bytes!("fonts/FiraCode-VF.woff2"),
57    },
58];
59
60/// Copyright notices for the embedded fonts, emitted into every folio. The SIL
61/// OFL requires each copy of the font to carry its copyright and license; the
62/// woff2 metadata does for most faces, but this notice covers every artifact
63/// uniformly. Full license texts are vendored under src/fonts/licenses.
64const FONT_NOTICES: [(&str, &str); 3] = [
65    ("Junicode", "Copyright 2025 Peter S. Baker"),
66    ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
67    (
68        "UnifrakturCook",
69        "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
70    ),
71];
72
73/// The font attribution as an HTML comment for the top of every folio.
74fn font_notice() -> String {
75    let mut notice = String::from(
76        "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
77    );
78    for (family, copyright) in FONT_NOTICES {
79        notice.push_str(&format!("\n     {family}: {copyright}"));
80    }
81    notice.push_str("\n-->");
82    notice
83}
84
85/// The `@font-face` block, built once: base64 is deterministic and the bytes
86/// are compile-time constants, so encoding on every render would be wasted work
87/// on the `serve` hot path.
88static FONT_FACES: LazyLock<String> = LazyLock::new(|| {
89    FACES
90        .iter()
91        .map(|face| {
92            let data = STANDARD.encode(face.woff2);
93            format!(
94                "@font-face{{font-family:\"{}\";font-style:{};font-weight:{};font-display:swap;\
95                 src:url(data:font/woff2;base64,{}) format(\"woff2\")}}",
96                face.family, face.style, face.weight, data,
97            )
98        })
99        .collect()
100});
101
102/// The generation metadata recorded in every folio's plaque.
103pub struct Colophon {
104    pub generated: Timestamp,
105    pub tool: &'static str,
106    pub version: &'static str,
107    pub home: &'static str,
108}
109
110/// Renders folios, carrying the decisions a render depends on: how markdown
111/// becomes HTML, how code gets highlighted, and which zone timestamps read in.
112pub struct Scribe<'a> {
113    options: Options<'a>,
114    plugins: Plugins<'a>,
115    timezone: TimeZone,
116}
117
118impl<'a> Scribe<'a> {
119    pub fn new(highlighter: &'a SyntectAdapter, timezone: TimeZone) -> Self {
120        let mut options = Options::default();
121        options.extension.strikethrough = true;
122        options.extension.table = true;
123        options.extension.tasklist = true;
124        options.extension.autolink = true;
125        options.extension.footnotes = true;
126        options.render.github_pre_lang = true;
127
128        let mut plugins = Plugins::default();
129        plugins.render.codefence_syntax_highlighter = Some(highlighter);
130
131        Self {
132            options,
133            plugins,
134            timezone,
135        }
136    }
137
138    pub(crate) fn markdown(&self, source: &str) -> Markup {
139        PreEscaped(markdown_to_html_with_plugins(
140            source,
141            &self.options,
142            &self.plugins,
143        ))
144    }
145
146    fn zoned(&self, timestamp: Timestamp) -> Zoned {
147        timestamp.to_zoned(self.timezone.clone())
148    }
149
150    pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> Markup {
151        let title = format!("folio {}", folio.session_id());
152        let panels = folio.panels();
153        let left_border = margin_strip(border_seed(folio.session_id(), "left"));
154        let right_border = margin_strip(border_seed(folio.session_id(), "right"));
155        html! {
156            (DOCTYPE)
157            (PreEscaped(font_notice()))
158            html lang="en" {
159                head {
160                    meta charset="utf-8";
161                    meta name="viewport" content="width=device-width, initial-scale=1";
162                    title { (title) }
163                    style {
164                        (PreEscaped(FONT_FACES.as_str()))
165                        (PreEscaped(include_str!("illumination.css")))
166                    }
167                    // The folio's own behaviour (theme, and more to come). It
168                    // sits in <head> so the stored theme applies before the
169                    // body paints, avoiding a flash of the wrong scheme.
170                    script { (PreEscaped(include_str!("illumination.js"))) }
171                }
172                body {
173                    // Illuminated borders down each outer margin: a per-session
174                    // strip of vine sections with drolleries seated among them,
175                    // tiled by the stylesheet. Purely decorative, so hidden from
176                    // assistive tech.
177                    div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
178                    div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
179                    // The folio's plaque: title, facts, and colophon, tucked
180                    // into the top corner so the reading column is pure
181                    // transcript. A pure-CSS hover/focus disclosure (no script):
182                    // the panel shows while the seal is hovered or focused, and
183                    // a click focuses the seal to hold it open.
184                    div .plaque {
185                        button .plaque__seal type="button" aria-label="folio details" title="folio details" { "❦" }
186                        div .plaque__panel {
187                            h1 .plaque__title { (title) }
188                            dl .plaque__facts {
189                                dt { "source" } dd { code { (folio.source.display().to_string()) } }
190                                dt { "turns" } dd { (panels.len()) }
191                                @if let Some(first) = panels.first() {
192                                    dt { "opened" } dd { (self.stamp(first.timestamp)) }
193                                }
194                                // The session's flux: how big the conversation
195                                // ever got, against all the output. The input
196                                // is the largest single turn's rather than a
197                                // sum, since every turn is sent the whole
198                                // conversation.
199                                @if let (Some(input), Some(output)) = (folio.largest_input(), folio.output()) {
200                                    dt { "tokens" } dd title=(folio_flux(input, output)) { (tally(input, output)) }
201                                }
202                            }
203                            p .plaque__colophon {
204                                "Written by " a href=(colophon.home) { (colophon.tool) } " " (colophon.version)
205                                " on " (self.stamp(colophon.generated)) "."
206                            }
207                            p .plaque__colophon {
208                                "Set in Junicode, Fira Code, and UnifrakturCook, under the "
209                                a href="https://openfontlicense.org" { "SIL Open Font License" } "."
210                            }
211                        }
212                    }
213                    // A fixed search widget: highlights matches and steps
214                    // through them, wired by the app script.
215                    div .search role="search" {
216                        div .search__bar {
217                            input .search__input type="search" placeholder="search folio" aria-label="search folio";
218                            span .search__count aria-live="polite" {}
219                            button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
220                            button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
221                        }
222                        // Restrict the search to chosen kinds of message, so a
223                        // query need not wade through tool output or reasoning.
224                        div .search__scopes role="group" aria-label="search in" {
225                            button .search__scope.search__scope--user type="button" data-scope="user" aria-pressed="true" { "user" }
226                            button .search__scope.search__scope--assistant type="button" data-scope="assistant" aria-pressed="true" { "assistant" }
227                            button .search__scope type="button" data-scope="tool" aria-pressed="true" { "tool" }
228                            button .search__scope type="button" data-scope="thinking" aria-pressed="true" { "thinking" }
229                        }
230                    }
231                    // A fixed dock: jump between messages, leap to either end
232                    // and follow new ones (tail -f), and fold every tool call open
233                    // or shut. Wired by the app script. The nav grid is three
234                    // columns of up/down arrows: the middle steps between all
235                    // messages, flanked by a user (blue) and an assistant
236                    // (orange) column that seek only that speaker.
237                    nav .dock aria-label="folio navigation" {
238                        div .dock__nav {
239                            button .dock__btn .dock__btn--user type="button" data-nav="prev" data-role="user" aria-label="previous user message" title="previous user message" { "▲" }
240                            button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
241                            button .dock__btn .dock__btn--assistant type="button" data-nav="prev" data-role="assistant" aria-label="previous assistant message" title="previous assistant message" { "▲" }
242                            button .dock__btn .dock__btn--user type="button" data-nav="next" data-role="user" aria-label="next user message" title="next user message" { "▼" }
243                            button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
244                            button .dock__btn .dock__btn--assistant type="button" data-nav="next" data-role="assistant" aria-label="next assistant message" title="next assistant message" { "▼" }
245                        }
246                        // Jump to the first or last message, and a follow toggle
247                        // that re-pins the newest message's start on every
248                        // reload until the reader scrolls away.
249                        div .dock__leap {
250                            button .dock__btn type="button" data-nav="top" aria-label="jump to top" title="jump to top" { "⤒" }
251                            button .dock__btn type="button" data-nav="end" aria-label="jump to end" title="jump to end" { "⤓" }
252                            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" { "⇊" }
253                        }
254                        div .dock__fold {
255                            button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
256                            button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
257                        }
258                    }
259                    // Presentation controls, opposite the navigation dock:
260                    // light / dark / system, wired by the app script and
261                    // defaulting to the reader's system preference.
262                    div .controls {
263                        div .theme-toggle role="group" aria-label="colour theme" {
264                            button type="button" data-theme-choice="light" { "light" }
265                            button type="button" data-theme-choice="system" aria-pressed="true" { "system" }
266                            button type="button" data-theme-choice="dark" { "dark" }
267                        }
268                    }
269                    main .folio {
270                        @for panel in &panels {
271                            (self.panel(panel))
272                        }
273                    }
274                }
275            }
276        }
277    }
278
279    fn stamp(&self, timestamp: Timestamp) -> String {
280        self.zoned(timestamp)
281            .strftime("%Y-%m-%d %H:%M:%S %Z")
282            .to_string()
283    }
284
285    fn panel(&self, panel: &Panel) -> Markup {
286        let kind = panel.kind();
287        // A speaker's leading paragraph opens with a rubricated versal (a
288        // dropped blackletter initial the stylesheet draws): tag the block that
289        // carries it so only that one is decorated, and not any prose that
290        // resumes after a tool call. Tool and thinking panels carry no versal.
291        let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
292            .then(|| panel.blocks.iter().position(Block::is_visible_text))
293            .flatten();
294        html! {
295            article id={ "turn-" (panel.turn_number) }
296                class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
297                data-turn=(panel.turn_number)
298                data-sidechain[panel.is_sidechain] data-meta[panel.is_meta] {
299                header .turn__meta {
300                    span .turn__role { (kind.label()) }
301                    @if let Some(model) = &panel.model {
302                        span .turn__model {
303                            (model)
304                            @if let Some(effort) = &panel.effort {
305                                " " span .turn__effort { "(" (effort) ")" }
306                            }
307                        }
308                    }
309                    @if let Some(usage) = &panel.usage {
310                        span .turn__usage title=(turn_flux(usage)) {
311                            (tally(usage.uncached_input(), usage.output_tokens))
312                        }
313                    }
314                    time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
315                    a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
316                }
317                @for (index, block) in panel.blocks.iter().enumerate() {
318                    (self.block(block, Some(index) == opening))
319                }
320            }
321        }
322    }
323
324    pub(crate) fn block(&self, block: &Block, versal: bool) -> Markup {
325        let known = match block {
326            Block::Known(known) => known,
327            Block::Unknown(value) => return unknown(value),
328        };
329        match known {
330            Known::Text { text } => {
331                html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
332            }
333            // Redacted thinking arrives as an empty string with only a
334            // signature: the reasoning happened but wasn't recorded. Mark it
335            // rather than dropping it to nothing (which leaves a bare turn).
336            Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
337                p .block.block--redacted { "reasoning redacted" }
338            },
339            Known::Thinking { thinking } => html! {
340                section .block.block--thinking {
341                    (self.markdown(thinking))
342                }
343            },
344            Known::ToolUse { name, input, .. } => self.tool_call(name, input),
345            Known::ToolResult {
346                content,
347                is_error,
348                answers,
349                ..
350            } => html! {
351                details .marginalia.marginalia--result data-error[*is_error] {
352                    summary .marginalia__head { @if *is_error { "error" } @else { "result" } }
353                    (tools::result(self, answers.as_ref(), content, *is_error))
354                }
355            },
356            Known::Image { source } => image(source),
357        }
358    }
359
360    /// A tool call: its summary line says what the call is, and its fold holds
361    /// the subject. A call the line already states in full (a read of a named
362    /// file, a query) has no subject left to hold, so it is set as one flat line
363    /// with nothing to open.
364    fn tool_call(&self, name: &str, input: &Value) -> Markup {
365        let setting = tools::call(self, name, input);
366        let head = html! {
367            span .marginalia__tool { (name) }
368            @if let Some(gist) = &setting.gist {
369                @match &setting.href {
370                    Some(href) => a .marginalia__gist href=(href) { (gist) },
371                    None => span .marginalia__gist { (gist) },
372                }
373            }
374            @for note in &setting.notes {
375                span .marginalia__note { (note) }
376            }
377        };
378        match &setting.body {
379            Some(body) => html! {
380                details .marginalia.marginalia--use {
381                    summary .marginalia__head { (head) }
382                    (body)
383                }
384            },
385            None => html! {
386                div .marginalia.marginalia--use.marginalia--flat {
387                    div .marginalia__head { (head) }
388                }
389            },
390        }
391    }
392
393    /// A fenced code block run through the markdown path so it picks up syntax
394    /// highlighting. The fence is grown past the longest backtick run in the
395    /// source, so a body that itself contains backticks can't break out of it.
396    pub(crate) fn code_block(&self, lang: &str, code: &str) -> Markup {
397        // A file's own trailing newline is a fact about the file, not a line of
398        // it, so it isn't set as one: an empty line before the fold's bottom
399        // edge reads as content that isn't there.
400        let code = code.trim_end();
401        let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
402        self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
403    }
404}
405
406/// One border cell's coordinate box: the composed strip is one cell wide, and
407/// each vine or drollery is authored to sit in this 90x210 space.
408const CELL_WIDTH: u32 = 90;
409const CELL_HEIGHT: u32 = 210;
410
411/// Cells the border is stitched from before the strip repeats down a long
412/// folio. Long enough that a full bestiary's worth of drolleries appears before
413/// the strip recurs, at the cost of a larger data URI.
414const STRIP_CELLS: usize = 48;
415
416/// The vine cell, the border's default section.
417const VINE_CELL: &str = include_str!("drolleries/vine.svg");
418
419/// A vine stub that eases the border into a drollery: baked above each creature
420/// and mirrored below, its stroke fading to transparent (via the `vinefade`
421/// gradient) as it nears the beast, so the vine dissolves in and coalesces back
422/// rather than stopping dead at a gap.
423const TRAIL: &str = include_str!("drolleries/trail.svg");
424
425/// The fade the trail's stroke draws with: opaque vine gold at the seam,
426/// transparent by the time it reaches the creature. `userSpaceOnUse` resolves it
427/// in each trail's own (possibly mirrored) coordinate space, so one definition
428/// serves every drollery and its mirror.
429const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
430     x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
431     <stop offset=\"0\" stop-color=\"#c1912f\"/>\
432     <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
433
434/// The bestiary a border seats between vines, each paired with the `(dx, dy)`
435/// nudge that centres it in its cell: `dx` on the vine's centreline (x=45), and
436/// `dy` in the gap between the fading trail above and its mirror below (the
437/// creatures are drawn low in the 90x210 box, so most lift toward the gap's
438/// centre at y=105). Several carry a tail or ear that pulls the bounding box off
439/// centre, so the nudges are measured, not zero. Each is line-and-pigment art
440/// authored in that cell (see CLAUDE.md); a background-image SVG can't reach the
441/// palette variables, so the colours are baked to read on either parchment.
442const DROLLERIES: [(&str, i32, i32); 10] = [
443    (include_str!("drolleries/snail.svg"), 0, -18),
444    (include_str!("drolleries/budgie.svg"), -7, -11),
445    (include_str!("drolleries/cockatiel.svg"), -8, 2),
446    (include_str!("drolleries/cardinal.svg"), -6, -3),
447    (include_str!("drolleries/fish.svg"), -8, 0),
448    (include_str!("drolleries/butterfly.svg"), 0, -23),
449    (include_str!("drolleries/frog.svg"), 0, -32),
450    (include_str!("drolleries/cat.svg"), -5, -23),
451    (include_str!("drolleries/hare.svg"), 0, -14),
452    (include_str!("drolleries/stag.svg"), -2, -21),
453];
454
455/// A small deterministic PRNG (SplitMix64) so a border's section layout is
456/// stable for a seed but varies cell to cell.
457struct Rng(u64);
458
459impl Rng {
460    fn next(&mut self) -> u64 {
461        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
462        let mut z = self.0;
463        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
464        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
465        z ^ (z >> 31)
466    }
467}
468
469/// A stable seed for one border of a folio: the session id folded with a
470/// per-side salt (FNV-1a), so the two borders differ but each is reproducible.
471/// FNV-1a keeps the mapping self-contained rather than leaning on stdlib hash
472/// output, whose value isn't guaranteed across Rust releases.
473fn border_seed(session_id: &str, salt: &str) -> u64 {
474    session_id
475        .bytes()
476        .chain(salt.bytes())
477        .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
478            (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
479        })
480}
481
482/// A section of a border: a plain vine cell, or a drollery with the offset that
483/// centres it.
484#[derive(Clone, Copy)]
485enum BorderCell {
486    Vine,
487    Drollery {
488        svg: &'static str,
489        dx: i32,
490        dy: i32,
491        flip: bool,
492    },
493}
494
495/// The whole bestiary in a fresh random order (Fisher-Yates). Drawing drolleries
496/// from a bag that refills when drained means every creature appears before any
497/// repeats, so a border cycles through all of them rather than a fixed few.
498fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
499    let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
500    for i in (1..bag.len()).rev() {
501        bag.swap(i, rng.next() as usize % (i + 1));
502    }
503    bag
504}
505
506/// One border as a sequence of cells: mostly vine, with drolleries seated at
507/// intervals. The seam cells stay vine so the strip tiles cleanly, and no two
508/// drolleries sit adjacent so each creature reads on its own.
509fn border_cells(seed: u64) -> Vec<BorderCell> {
510    let mut rng = Rng(seed);
511    let mut bag = shuffled_bag(&mut rng);
512    let mut cells = Vec::with_capacity(STRIP_CELLS);
513    let mut previous_was_drollery = false;
514    for index in 0..STRIP_CELLS {
515        let seam = index == 0 || index == STRIP_CELLS - 1;
516        let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
517        if drollery {
518            if bag.is_empty() {
519                bag = shuffled_bag(&mut rng);
520            }
521            let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
522            let flip = rng.next().is_multiple_of(2);
523            cells.push(BorderCell::Drollery { svg, dx, dy, flip });
524        } else {
525            cells.push(BorderCell::Vine);
526        }
527        previous_was_drollery = drollery;
528    }
529    cells
530}
531
532/// A tiling border strip for one side of a folio as a base64 SVG data URI. The
533/// renderer sets it as a `background-image`; the stylesheet repeats it to fill
534/// the leaf, however tall (see the border invariants in CLAUDE.md).
535fn margin_strip(seed: u64) -> String {
536    let height = STRIP_CELLS as u32 * CELL_HEIGHT;
537    let mut inner = String::new();
538    for (index, cell) in border_cells(seed).iter().enumerate() {
539        let y = index as u32 * CELL_HEIGHT;
540        // Frame a drollery with the fading trail above and its vertical mirror
541        // below, nudging the creature to centre it between them; vine cells
542        // stand alone.
543        let content = match cell {
544            BorderCell::Vine => VINE_CELL.to_string(),
545            BorderCell::Drollery { svg, dx, dy, flip } => {
546                // Mirror a flipped creature about the cell's centreline (x=45)
547                // so it stays seated on the vine; the trail frames it either way.
548                let place = if *flip {
549                    format!("translate(90,0) scale(-1,1) translate({dx},{dy})")
550                } else {
551                    format!("translate({dx},{dy})")
552                };
553                format!(
554                    "{TRAIL}<g transform=\"{place}\">{svg}</g>\
555                     <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
556                )
557            }
558        };
559        inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
560    }
561    let svg = format!(
562        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
563         height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
564    );
565    format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
566}
567
568/// Token flux at a glance: what went in, then what came out.
569fn tally(input: u64, output: u64) -> String {
570    format!("↑ {} ↓ {}", compact(input), compact(output))
571}
572
573/// A turn's figures unrounded, for the title a reader hovers. Its input is what
574/// the turn added, so it stands beside the turn's own output rather than
575/// restating the whole conversation the request re-sent.
576fn turn_flux(usage: &Usage) -> String {
577    format!(
578        "{} input this turn · {} output this turn",
579        separated(usage.uncached_input()),
580        separated(usage.output_tokens),
581    )
582}
583
584/// The folio's figures unrounded. Its input is the largest single turn's, not a
585/// sum, so the title says which.
586fn folio_flux(largest_input: u64, output: u64) -> String {
587    format!(
588        "{} input at its largest · {} output in all",
589        separated(largest_input),
590        separated(output),
591    )
592}
593
594/// A count grouped in thousands, so an exact figure stays readable.
595fn separated(tokens: u64) -> String {
596    let digits = tokens.to_string();
597    let mut grouped = String::new();
598    for (index, digit) in digits.chars().enumerate() {
599        if index > 0 && (digits.len() - index).is_multiple_of(3) {
600            grouped.push(',');
601        }
602        grouped.push(digit);
603    }
604    grouped
605}
606
607/// A token count short enough to sit in a line of chrome: exact below a
608/// thousand, then one decimal place per magnitude, with a bare `.0` trimmed.
609fn compact(tokens: u64) -> String {
610    // A magnitude ends where rounding would carry into the next one: 999,950
611    // would otherwise print as `1000k`, which reads as having crossed the
612    // boundary without switching suffix.
613    let (scaled, suffix) = match tokens {
614        ..1_000 => return tokens.to_string(),
615        1_000..999_950 => (tokens as f64 / 1_000.0, "k"),
616        _ => (tokens as f64 / 1_000_000.0, "M"),
617    };
618    let rounded = format!("{scaled:.1}");
619    format!("{}{suffix}", rounded.strip_suffix(".0").unwrap_or(&rounded))
620}
621
622fn longest_backtick_run(source: &str) -> usize {
623    let mut longest = 0;
624    let mut run = 0;
625    for byte in source.bytes() {
626        if byte == b'`' {
627            run += 1;
628            longest = longest.max(run);
629        } else {
630            run = 0;
631        }
632    }
633    longest
634}
635
636pub(crate) fn json(value: &Value) -> Markup {
637    let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
638    html! { pre { code { (pretty) } } }
639}
640
641fn unknown(value: &Value) -> Markup {
642    let label = value
643        .get("type")
644        .and_then(Value::as_str)
645        .unwrap_or("unrecognized");
646    html! {
647        details .block.block--unknown {
648            summary { (label) }
649            (json(value))
650        }
651    }
652}
653
654fn image(source: &ImageSource) -> Markup {
655    let src = format!("data:{};base64,{}", source.media_type, source.data);
656    html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn token_counts_shorten_to_one_decimal_place_per_magnitude() {
665        assert_eq!(compact(0), "0");
666        assert_eq!(compact(847), "847");
667        assert_eq!(compact(999), "999");
668        assert_eq!(compact(1_000), "1k");
669        assert_eq!(compact(47_612), "47.6k");
670        assert_eq!(compact(999_400), "999.4k");
671        assert_eq!(compact(7_643_000), "7.6M");
672    }
673
674    #[test]
675    fn a_count_that_rounds_up_a_magnitude_takes_the_next_suffix() {
676        assert_eq!(compact(999_949), "999.9k");
677        assert_eq!(compact(999_950), "1M");
678        assert_eq!(compact(1_000_000), "1M");
679    }
680
681    #[test]
682    fn a_turn_counts_only_the_input_it_added() {
683        // The cached prefix is the conversation the request re-sent, so it is
684        // no part of what this turn contributed.
685        let usage = Usage {
686            input_tokens: 3,
687            output_tokens: 214,
688            cache_creation_input_tokens: 32_400,
689            cache_read_input_tokens: 15_200,
690        };
691
692        assert_eq!(
693            tally(usage.uncached_input(), usage.output_tokens),
694            "↑ 32.4k ↓ 214"
695        );
696        assert_eq!(
697            turn_flux(&usage),
698            "32,403 input this turn · 214 output this turn"
699        );
700    }
701
702    #[test]
703    fn a_folio_names_its_input_as_the_largest_rather_than_a_sum() {
704        assert_eq!(
705            folio_flux(60_867, 48_923),
706            "60,867 input at its largest · 48,923 output in all"
707        );
708    }
709
710    #[test]
711    fn exact_counts_group_in_thousands() {
712        assert_eq!(separated(7), "7");
713        assert_eq!(separated(942), "942");
714        assert_eq!(separated(1_206), "1,206");
715        assert_eq!(separated(47_603), "47,603");
716        assert_eq!(separated(7_643_812), "7,643,812");
717    }
718
719    #[test]
720    fn border_strip_is_stable_per_seed() {
721        let seed = border_seed("3f9c-a17b-session", "left");
722        assert_eq!(margin_strip(seed), margin_strip(seed));
723    }
724
725    #[test]
726    fn the_two_borders_of_a_folio_differ() {
727        let session = "3f9c-a17b-session";
728        assert_ne!(
729            margin_strip(border_seed(session, "left")),
730            margin_strip(border_seed(session, "right"))
731        );
732    }
733
734    fn is_drollery(cell: &BorderCell) -> bool {
735        matches!(cell, BorderCell::Drollery { .. })
736    }
737
738    #[test]
739    fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
740        // Seams stay vine so the strip tiles cleanly, drolleries never sit
741        // adjacent, and vine dominates. Exercise many seeds for confidence.
742        let mut total_drolleries = 0;
743        for salt in [
744            "one", "two", "three", "four", "five", "six", "seven", "eight",
745        ] {
746            let cells = border_cells(border_seed("a-session", salt));
747            assert_eq!(cells.len(), STRIP_CELLS);
748            assert!(matches!(cells[0], BorderCell::Vine));
749            assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
750            let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
751            assert!(
752                drolleries < STRIP_CELLS / 2,
753                "too many drolleries: {drolleries}"
754            );
755            let adjacent = cells
756                .windows(2)
757                .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
758            assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
759            total_drolleries += drolleries;
760        }
761        // A border of pure vine would defeat the point; the generator must seat
762        // creatures.
763        assert!(total_drolleries > 0, "generator produced no drolleries");
764    }
765
766    #[test]
767    fn drolleries_face_both_ways_across_a_border() {
768        // Each seated creature is mirrored or not at random, so a full strip
769        // shows both facings rather than one consistent direction.
770        let cells = border_cells(border_seed("flip-variety-session", "left"));
771        let flips: Vec<bool> = cells
772            .iter()
773            .filter_map(|cell| match cell {
774                BorderCell::Drollery { flip, .. } => Some(*flip),
775                BorderCell::Vine => None,
776            })
777            .collect();
778        assert!(flips.iter().any(|&flip| flip), "no creature was flipped");
779        assert!(
780            flips.iter().any(|&flip| !flip),
781            "no creature kept its facing"
782        );
783    }
784
785    #[test]
786    fn a_border_cycles_through_the_whole_bestiary() {
787        // The shuffled bag draws every creature before repeating any, so a
788        // border with a full bag's worth of drolleries shows all of them, and a
789        // shorter one still never repeats before the bag drains.
790        let cells = border_cells(border_seed("variety-session", "left"));
791        let used: std::collections::HashSet<&str> = cells
792            .iter()
793            .filter_map(|cell| match cell {
794                BorderCell::Drollery { svg, .. } => Some(*svg),
795                BorderCell::Vine => None,
796            })
797            .collect();
798        let count = cells.iter().filter(|cell| is_drollery(cell)).count();
799        let expected = count.min(DROLLERIES.len());
800        assert_eq!(
801            used.len(),
802            expected,
803            "creatures repeated before the bag drained"
804        );
805    }
806}