Skip to main content

claude_scriptorium/
render.rs

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