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    gloss,
16    tools::{self, Setting},
17    transcript::{Block, Folio, GlossPanel, ImageSource, Known, Panel, PanelKind, Speech, Usage},
18};
19
20/// Copyright notices for the embedded fonts, emitted into every folio. The SIL
21/// OFL requires each copy of the font to carry its copyright and license; the
22/// woff2 metadata does for most faces, but this notice covers every artifact
23/// uniformly. Full license texts are vendored under src/fonts/licenses.
24const FONT_NOTICES: [(&str, &str); 3] = [
25    ("Junicode", "Copyright 2025 Peter S. Baker"),
26    ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
27    (
28        "UnifrakturCook",
29        "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
30    ),
31];
32
33/// The font attribution as an HTML comment for the top of every folio.
34fn font_notice() -> String {
35    let mut notice = String::from(
36        "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
37    );
38    for (family, copyright) in FONT_NOTICES {
39        notice.push_str(&format!("\n     {family}: {copyright}"));
40    }
41    notice.push_str("\n-->");
42    notice
43}
44
45/// The `@font-face` blocks: the vendored woff2 files as data URIs, encoded by
46/// `build.rs` so a render inlines a constant rather than base64'ing megabytes.
47/// The fonts are inlined at all so a folio stays self-contained (see the
48/// rendering invariants in CLAUDE.md); `just fonts` vendors them from upstream.
49///
50/// Two blocks, because the faces are almost the whole of a short folio: the cut
51/// ones carry what a transcript sets and are a fifth the bytes, the whole ones
52/// carry everything upstream shipped. [`Scribe::folio`] picks per folio.
53const CUT_FACES: &str = include_str!(concat!(env!("OUT_DIR"), "/font-faces-cut.css"));
54const WHOLE_FACES: &str = include_str!(concat!(env!("OUT_DIR"), "/font-faces-whole.css"));
55
56include!(concat!(env!("OUT_DIR"), "/dropped.rs"));
57
58/// How a folio reaches its reader, which decides whether it can gain a message
59/// under them. `serve` re-reads the session and re-renders on every load, so a
60/// served folio grows as the session is written; a written one is a fixed
61/// artifact, and the file it was rendered from could be a year old.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum Delivery {
64    /// Written to a file or published as a gist: the reader holds a snapshot.
65    #[default]
66    Static,
67    /// Served by `serve`, which re-renders the session on every page load.
68    Served,
69}
70
71/// Which cut of the embedded faces a folio should carry.
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
73pub enum Fonts {
74    /// The cut faces, unless the folio's own text reaches a character they
75    /// dropped, in which case the whole ones. Never renders a character worse
76    /// than upstream would, and is a fifth the bytes for a folio that stays
77    /// inside what a transcript usually sets.
78    #[default]
79    Fitted,
80    /// The whole faces, whatever this folio happens to set.
81    Whole,
82}
83
84/// The characters in `text` that an embedded face carries whole but not cut,
85/// tallied by how often each occurs.
86///
87/// This is the *regression* the cut introduced, not the faces' coverage: a
88/// character no face ever carried (an emoji, a CJK ideograph) is absent, since
89/// it falls back to the reader's own fonts either way and always did.
90pub fn beyond_cut(text: &str) -> BTreeMap<char, usize> {
91    let mut tally = BTreeMap::new();
92    let bytes = text.as_bytes();
93    let mut index = 0;
94
95    while index < bytes.len() {
96        // Nothing below U+0080 is ever dropped (the tests hold that), and a
97        // folio is overwhelmingly ASCII: hundreds of kilobytes of base64 font
98        // before a word of transcript. So skip to the next lead byte rather
99        // than decoding every character. Everything skipped is single-byte, so
100        // the index stays on a character boundary.
101        let Some(offset) = bytes[index..].iter().position(|byte| *byte >= 0x80) else {
102            break;
103        };
104        index += offset;
105        let character = text[index..]
106            .chars()
107            .next()
108            .expect("index lands on a character boundary");
109        if dropped(character) {
110            *tally.entry(character).or_insert(0) += 1;
111        }
112        index += character.len_utf8();
113    }
114    tally
115}
116
117fn dropped(character: char) -> bool {
118    let codepoint = character as u32;
119    DROPPED
120        .binary_search_by(|(low, high)| {
121            if codepoint < *low {
122                Ordering::Greater
123            } else if codepoint > *high {
124                Ordering::Less
125            } else {
126                Ordering::Equal
127            }
128        })
129        .is_ok()
130}
131
132/// The generation metadata recorded in every folio's plaque.
133pub struct Colophon {
134    pub generated: Timestamp,
135    pub tool: &'static str,
136    pub version: &'static str,
137    pub home: &'static str,
138}
139
140/// What a render cost: how long the scribe took, and how large the folio came
141/// out. Neither can be known while the markup is being written, so the plaque
142/// carries a placeholder for each and [`inscribe`] fills them in afterwards.
143pub struct Labour {
144    pub took: Duration,
145    pub bytes: usize,
146}
147
148/// The placeholders the plaque carries until the render's own cost is known.
149/// Comments, so an uninscribed folio still reads correctly, and so no transcript
150/// can forge one: raw HTML in a transcript is escaped, and only the scribe's own
151/// markup reaches the document unescaped.
152const TOOK_MARK: &str = "<!--folio:took-->";
153const SIZE_MARK: &str = "<!--folio:size-->";
154
155/// The lights a folio can be read by, which are also the controls that choose
156/// between them, and the turn of the ring that hands the choice back to the
157/// reader's system. Inline SVG, so they take the folio's own pigments.
158const SUN: &str = include_str!("luminary/sun.svg");
159const CANDLE: &str = include_str!("luminary/candle.svg");
160const SYSTEM: &str = include_str!("luminary/system.svg");
161
162/// Fills a finished folio's own cost into its plaque.
163///
164/// The numbers displace the placeholders they replace, so the size a folio
165/// states is the markup it was measured from rather than the document it ends
166/// up as. The difference is a few dozen bytes against a folio of megabytes, and
167/// the human-readable figure rounds it away.
168pub fn inscribe(markup: String, labour: &Labour) -> String {
169    markup
170        .replace(TOOK_MARK, &elapsed(labour.took))
171        .replace(SIZE_MARK, &size(labour.bytes))
172}
173
174/// A duration in the coarsest unit that still says something: whole
175/// milliseconds under a second, then seconds to one decimal place.
176pub fn elapsed(took: Duration) -> String {
177    let seconds = took.as_secs_f64();
178    if seconds < 1.0 {
179        format!("{:.0} ms", seconds * 1_000.0)
180    } else {
181        format!("{seconds:.1} s")
182    }
183}
184
185/// A byte count as a size a reader can hold in mind, in the decimal units file
186/// sizes are quoted in.
187pub fn size(bytes: usize) -> String {
188    match bytes {
189        ..1_000 => format!("{bytes} B"),
190        1_000..1_000_000 => format!("{:.0} kB", bytes as f64 / 1_000.0),
191        _ => format!("{:.1} MB", bytes as f64 / 1_000_000.0),
192    }
193}
194
195/// Renders folios, carrying the decisions a render depends on: how markdown
196/// becomes HTML, how code gets highlighted, which zone timestamps read in,
197/// which cut of the embedded faces to carry, and how the folio will reach its
198/// reader.
199pub struct Scribe<'a> {
200    options: Options<'a>,
201    /// The same options with hard breaks, for text a program printed.
202    printed: Options<'a>,
203    plugins: Plugins<'a>,
204    timezone: TimeZone,
205    fonts: Fonts,
206    delivery: Delivery,
207}
208
209impl<'a> Scribe<'a> {
210    pub fn new(
211        highlighter: &'a SyntectAdapter,
212        timezone: TimeZone,
213        fonts: Fonts,
214        delivery: Delivery,
215    ) -> Self {
216        let mut options = Options::default();
217        options.extension.strikethrough = true;
218        options.extension.table = true;
219        options.extension.tasklist = true;
220        options.extension.autolink = true;
221        options.extension.footnotes = true;
222        options.render.github_pre_lang = true;
223
224        // The same reading, with the source's own line breaks kept. See
225        // `markdown_printed`.
226        let mut printed = options.clone();
227        printed.render.hardbreaks = true;
228
229        let mut plugins = Plugins::default();
230        plugins.render.codefence_syntax_highlighter = Some(highlighter);
231
232        Self {
233            options,
234            printed,
235            plugins,
236            timezone,
237            fonts,
238            delivery,
239        }
240    }
241
242    pub(crate) fn markdown(&self, source: &str) -> Markup {
243        PreEscaped(markdown_to_html_with_plugins(
244            source,
245            &self.options,
246            &self.plugins,
247        ))
248    }
249
250    /// Markdown for text a *program* printed rather than composed: a hook's
251    /// output is the case, and it is genuinely between the two readings.
252    ///
253    /// Markdown folds a single newline into a space, which is right for prose
254    /// someone wrapped by hand and wrong for a list of things a script emitted
255    /// one to a line: `M  CLAUDE.md\nM  src/gloss.rs` came out as one run of
256    /// filenames. Keeping the breaks costs a hand-wrapped paragraph its reflow,
257    /// so it goes slightly ragged, and that is much the smaller loss. Setting
258    /// such output as preformatted text instead is worse than either: it wraps
259    /// twice, once where the source wrapped and again at the box, *and* throws
260    /// away the headings and lists these mostly carry.
261    pub(crate) fn markdown_printed(&self, source: &str) -> Markup {
262        PreEscaped(markdown_to_html_with_plugins(
263            source,
264            &self.printed,
265            &self.plugins,
266        ))
267    }
268
269    fn zoned(&self, timestamp: Timestamp) -> Zoned {
270        timestamp.to_zoned(self.timezone.clone())
271    }
272
273    /// Sets a folio, and reports which characters (if any) drove it onto the
274    /// whole faces. An empty tally means the cut faces served it.
275    pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> (Markup, BTreeMap<char, usize>) {
276        let title = format!("folio {}", folio.session_id());
277        let panels = folio.panels();
278        let source = folio.source.display().to_string();
279        // A panel's cost is dominated by highlighting its tool bodies, where a
280        // syntax's regexes compile the first time that language is met, so the
281        // panels are set concurrently and several languages compile at once
282        // rather than each waiting on the last. Collected in order, so the
283        // folio reads as the session ran.
284        //
285        // Each panel is also weighed against the cut faces as it is set, which
286        // rides along on the threads already running rather than costing a
287        // second pass over the finished markup.
288        let (rendered_panels, reaches): (Vec<Markup>, Vec<BTreeMap<char, usize>>) = panels
289            .par_iter()
290            .map(|panel| {
291                let markup = self.panel(panel);
292                let reach = beyond_cut(&markup.0);
293                (markup, reach)
294            })
295            .unzip();
296
297        // The panels are the transcript; the source path is the only other
298        // place a folio sets text it didn't choose. The rest of the chrome is
299        // this crate's own markup, which the tests hold inside the cut faces.
300        let mut reached = beyond_cut(&source);
301        for reach in reaches {
302            for (character, count) in reach {
303                *reached.entry(character).or_insert(0) += count;
304            }
305        }
306        let faces = match self.fonts {
307            Fonts::Whole => WHOLE_FACES,
308            Fonts::Fitted if !reached.is_empty() => WHOLE_FACES,
309            Fonts::Fitted => CUT_FACES,
310        };
311
312        let left_border = margin_strip(border_seed(folio.session_id(), "left"));
313        let right_border = margin_strip(border_seed(folio.session_id(), "right"));
314        let document = html! {
315            (DOCTYPE)
316            (PreEscaped(font_notice()))
317            html lang="en" {
318                head {
319                    meta charset="utf-8";
320                    meta name="viewport" content="width=device-width, initial-scale=1";
321                    title { (title) }
322                    style {
323                        (PreEscaped(faces))
324                        (PreEscaped(include_str!("illumination.css")))
325                    }
326                    // The folio's own behaviour: theme, search, copy, the
327                    // navigation dock, and the minimap. It sits in <head> so the
328                    // stored theme applies before the body paints, avoiding a
329                    // flash of the wrong scheme, which is also why it stays a
330                    // classic script rather than a module (a module is deferred,
331                    // and would paint first). The core is inlined ahead of the
332                    // shell, in the same script, so the shell closes over it:
333                    // two files because one is pure and testable without a
334                    // browser, one script because a folio is one file.
335                    script {
336                        (PreEscaped(include_str!("illumination.core.js")))
337                        (PreEscaped(include_str!("illumination.shell.js")))
338                    }
339                }
340                // The session the folio was set from, so the app script can key
341                // what it remembers about this folio to this folio: a fold's
342                // own key is a turn number and a position within it, which name
343                // a different marginalia in every session.
344                body data-folio=(folio.session_id()) {
345                    // Illuminated borders down each outer margin: a per-session
346                    // strip of vine sections with drolleries seated among them,
347                    // tiled by the stylesheet. Purely decorative, so hidden from
348                    // assistive tech.
349                    div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
350                    div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
351                    // The folio's plaque: title, facts, and colophon, tucked
352                    // into the top corner so the reading column is pure
353                    // transcript. A pure-CSS hover/focus disclosure (no script):
354                    // the panel shows while the seal is hovered or focused, and
355                    // a click focuses the seal to hold it open.
356                    div .plaque {
357                        button .plaque__seal type="button" aria-label="folio details" title="folio details" { "❦" }
358                        div .plaque__panel {
359                            h1 .plaque__title { (title) }
360                            dl .plaque__facts {
361                                dt { "source" } dd { code { (source) } }
362                                dt { "turns" } dd { (panels.len()) }
363                                @if let Some(first) = panels.first() {
364                                    dt { "opened" } dd { (self.stamp(first.timestamp())) }
365                                }
366                                // The session's flux: how big the conversation
367                                // ever got, against all the output. The input
368                                // is the largest single turn's rather than a
369                                // sum, since every turn is sent the whole
370                                // conversation.
371                                @if let (Some(input), Some(output)) = (folio.largest_input(), folio.output()) {
372                                    dt { "tokens" } dd title=(folio_flux(input, output)) { (tally(input, output)) }
373                                }
374                            }
375                            // The render's own cost is stated here rather than
376                            // among the facts above, which are the session's.
377                            // Both figures arrive after the markup exists, as
378                            // placeholders `inscribe` fills in.
379                            p .plaque__colophon {
380                                "Written by " a href=(colophon.home) { (colophon.tool) } " " (colophon.version)
381                                " on " (self.stamp(colophon.generated)) ", taking "
382                                (PreEscaped(TOOK_MARK)) " to set " (PreEscaped(SIZE_MARK)) "."
383                            }
384                            p .plaque__colophon {
385                                "Set in Junicode, Fira Code, and UnifrakturCook, under the "
386                                a href="https://openfontlicense.org" { "SIL Open Font License" } "."
387                            }
388                        }
389                    }
390                    // The reading rail: the key, and stacked under it the search,
391                    // the dock, and the minimap, all of which answer to it.
392                    // Standing them in one column is what says they are tied
393                    // together, without a word of explanation.
394                    div .rail {
395                        // The folio's key leads the rail, because everything
396                        // under it answers to it: which kinds are in play, and,
397                        // since each chip carries its own kind's pigment, what
398                        // every edge in the margin means. It is a control rather
399                        // than a legend alone, and the *only* one of its sort:
400                        // the search and the dock both read it, so a reader says
401                        // once what they are looking through rather than once per
402                        // panel that looks. A column per side of the exchange, in
403                        // the order `PanelKind::EVERY` declares, so no list of
404                        // kinds is restated in the markup.
405                        div .key role="group" aria-label="kinds of message to show" {
406                            @for kind in PanelKind::EVERY {
407                                button .key__chip type="button"
408                                    data-scope=(kind.label()) data-side=(kind.side().label())
409                                    aria-pressed="true" { (kind.label()) }
410                            }
411                        }
412                        // Highlights matches and steps through them, wired by
413                        // the app script. Looks only at what the key leaves in.
414                        // Two rows: the field takes a whole one, and the count
415                        // and step arrows share the next. Sharing a single row
416                        // left the field a fraction of the rail's width, which
417                        // was both the reason the rail had to be wide and the
418                        // reason the placeholder was cut off in it.
419                        div .search role="search" {
420                            input .search__input type="search" placeholder="search folio" aria-label="search folio";
421                            div .search__bar {
422                                span .search__count aria-live="polite" {}
423                                button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
424                                button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
425                            }
426                        }
427                    // A dock: jump between messages, leap to either end
428                    // and follow new ones (tail -f), and fold every tool call open
429                    // or shut. Wired by the app script. The nav grid is three
430                    // columns of up/down arrows: the middle steps between every
431                    // message, flanked by a column per side of the exchange, so
432                    // the cool arrows seek what reached the model (the reader's
433                    // own words, their commands, skills, and hooks) and the warm
434                    // ones seek what it produced (its replies, reasoning, and
435                    // tool calls). The dock therefore steps along the same axis
436                    // the palette is pitched on and the search box is grouped by.
437                    nav .dock aria-label="folio navigation" {
438                        div .dock__nav {
439                            button .dock__btn .dock__btn--entered type="button" data-nav="prev" data-side="entered" aria-label="previous message that reached the model" title="previous message that reached the model" { "▲" }
440                            button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
441                            button .dock__btn .dock__btn--model type="button" data-nav="prev" data-side="model" aria-label="previous message the model produced" title="previous message the model produced" { "▲" }
442                            button .dock__btn .dock__btn--entered type="button" data-nav="next" data-side="entered" aria-label="next message that reached the model" title="next message that reached the model" { "▼" }
443                            button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
444                            button .dock__btn .dock__btn--model type="button" data-nav="next" data-side="model" aria-label="next message the model produced" title="next message the model produced" { "▼" }
445                        }
446                        // Jump to the first or last message, and, where the
447                        // session can still grow, a follow toggle that re-pins
448                        // the newest message's start on every reload until the
449                        // reader scrolls away. Only a served folio is re-read
450                        // as the session is written, so only it offers the
451                        // toggle: the control's presence is what tells the app
452                        // script this folio can follow.
453                        div .dock__leap {
454                            button .dock__btn type="button" data-nav="top" aria-label="jump to top" title="jump to top" { "⤒" }
455                            button .dock__btn type="button" data-nav="end" aria-label="jump to end" title="jump to end" { "⤓" }
456                            @if self.delivery == Delivery::Served {
457                                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" { "⇊" }
458                            }
459                        }
460                        div .dock__fold {
461                            button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
462                            button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
463                        }
464                        }
465                        // The folio seen edge-on, under the controls that steer
466                        // it: a band per panel in that panel's own pigment,
467                        // sized to the share of the document the panel takes,
468                        // with the reader's own view of the leaf drawn over
469                        // them. A drag along it scrubs the folio, landing on
470                        // whichever panel the key leaves in play, so it steps
471                        // through the same kinds the dock above it does, and the
472                        // wheel zooms the map alone, so a stretch of a
473                        // thousand-panel folio can be opened up and picked
474                        // through without leaving the place being read.
475                        //
476                        // The bands are recovered from the panels themselves
477                        // rather than written out here, since what a band states
478                        // is the share of the document its panel takes, which
479                        // only the browser knows and which changes every time a
480                        // fold opens. The empty track is what tells the app
481                        // script to draw them, exactly as the follow button's
482                        // presence tells it a folio can be followed.
483                        //
484                        // Hidden from assistive tech: every band is a second way
485                        // to a panel the dock already steps to and the panel's
486                        // own number already links to, and a stop per panel in
487                        // the tab order would bury both.
488                        div .minimap aria-hidden="true" {
489                            div .minimap__track title="drag to scrub, scroll to zoom" {
490                                div .minimap__view {}
491                            }
492                        }
493                    }
494                    // Presentation controls, opposite the navigation dock: the
495                    // lights the folio can be read by, which are also the
496                    // controls that choose between them. There is no separate
497                    // toggle to label, because the reader presses the light they
498                    // want: the sun for day, the candle for after dark.
499                    //
500                    // Which of them is *lit* is the scheme's to say, not the
501                    // press's: by day the sun burns and the candle stands
502                    // smoking, after dark the moon hangs and the candle is lit.
503                    // That is one set of `light-dark()` pigments (see the
504                    // stylesheet), so a folio still reads either way with no
505                    // second set of rules and nothing rendered for one scheme
506                    // alone. What the press changes is which scheme is in force.
507                    //
508                    // Each carries its own radiance, the light it throws across
509                    // the leaf, so the glow comes from whichever is burning.
510                    // Drawn rather than lettered, so each needs the name it used
511                    // to spell out: a figure says nothing to a reader who cannot
512                    // see it.
513                    div .controls {
514                        div .luminaries role="group" aria-label="colour theme" {
515                            button .luminary type="button" data-theme-choice="light"
516                                aria-label="read by daylight" title="read by daylight" {
517                                span .luminary__radiance .luminary__radiance--day {}
518                                (PreEscaped(SUN))
519                            }
520                            button .luminary type="button" data-theme-choice="dark"
521                                aria-label="read by candlelight" title="read by candlelight" {
522                                span .luminary__radiance .luminary__radiance--night {}
523                                (PreEscaped(CANDLE))
524                            }
525                            // Only of use once a light has been chosen, so the
526                            // stylesheet shows it only then, keyed off the
527                            // `data-theme` the choice sets on the document.
528                            button .theme-reset type="button" data-theme-choice="system"
529                                aria-pressed="true" aria-label="follow the system"
530                                title="follow the system" { (PreEscaped(SYSTEM)) }
531                        }
532                    }
533                    main .folio {
534                        // The only text in the reading column this crate wrote
535                        // rather than set from the session: a session file is
536                        // not everything the model was told, and a reader who
537                        // takes it for one misreads it.
538                        //
539                        // It carries no `.turn` class, which is what every
540                        // part of the app script keys on, so the dock never
541                        // steps to it, the minimap draws no band for it, the
542                        // key cannot set it aside, and the search never counts
543                        // it as a hit. It is the folio's own voice, not one of
544                        // the session's panels.
545                        aside .caveat {
546                            span .caveat__lead { "Caveat lector." }
547                            " A session file is not everything the model was told. The system "
548                            "prompt, the tool descriptions, and the " code { "CLAUDE.md" } " and "
549                            "rule files loaded when a session starts are sent with every request "
550                            "but never recorded, so they can't appear here. What the harness "
551                            em { "did" } " write down (hook output, a skill's instructions, a file "
552                            "pulled into context mid-session) is here."
553                        }
554                        @for panel in &rendered_panels {
555                            (panel)
556                        }
557                    }
558                }
559            }
560        };
561        (document, reached)
562    }
563
564    fn stamp(&self, timestamp: Timestamp) -> String {
565        self.zoned(timestamp)
566            .strftime("%Y-%m-%d %H:%M:%S %Z")
567            .to_string()
568    }
569
570    fn panel(&self, panel: &Panel) -> Markup {
571        match panel {
572            Panel::Speech(speech) => self.speech(speech),
573            Panel::Gloss(gloss) => self.gloss(gloss),
574        }
575    }
576
577    fn speech(&self, panel: &Speech) -> Markup {
578        let kind = panel.kind();
579        // A speaker's leading paragraph opens with a rubricated versal (a
580        // dropped blackletter initial the stylesheet draws): tag the block that
581        // carries it so only that one is decorated, and not any prose that
582        // resumes after a tool call. Tool and thinking panels carry no versal.
583        let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
584            .then(|| panel.blocks.iter().position(Block::is_visible_text))
585            .flatten();
586        html! {
587            article id={ "turn-" (panel.turn_number) }
588                class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
589                data-side=(kind.side().label())
590                data-turn=(panel.turn_number)
591                data-sidechain[panel.is_sidechain] {
592                header .turn__meta {
593                    span .turn__role { (kind.label()) }
594                    @if let Some(model) = &panel.model {
595                        span .turn__model {
596                            (model)
597                            @if let Some(effort) = &panel.effort {
598                                " " span .turn__effort { "(" (effort) ")" }
599                            }
600                        }
601                    }
602                    @if let Some(usage) = &panel.usage {
603                        span .turn__usage title=(turn_flux(usage)) {
604                            (tally(usage.uncached_input(), usage.output_tokens))
605                        }
606                    }
607                    time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
608                    a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
609                }
610                @for (index, block) in panel.blocks.iter().enumerate() {
611                    (self.block(block, Some(index) == opening))
612                }
613            }
614        }
615    }
616
617    /// A note the harness wrote into the session: a panel of its own, since it
618    /// happened at a point in the conversation rather than inside anyone's
619    /// turn, but with no speaker, no model, and no cost of its own to state.
620    /// What it says is folded away behind its summary line, the way a tool
621    /// call's subject is: it is context a reader reaches for, not prose they
622    /// read through.
623    fn gloss(&self, panel: &GlossPanel) -> Markup {
624        let kind = PanelKind::Gloss(panel.gloss.kind);
625        html! {
626            article id={ "turn-" (panel.turn_number) }
627                class="turn turn--gloss" data-kind=(kind.label())
628                data-side=(kind.side().label())
629                data-turn=(panel.turn_number)
630                data-sidechain[panel.is_sidechain] {
631                header .turn__meta {
632                    span .turn__role { (kind.label()) }
633                    time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
634                    a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
635                }
636                (marginalia(
637                    "marginalia--gloss",
638                    None,
639                    gloss::setting(self, &panel.gloss),
640                    Outcome::Fine,
641                ))
642            }
643        }
644    }
645
646    pub(crate) fn block(&self, block: &Block, versal: bool) -> Markup {
647        let known = match block {
648            Block::Known(known) => known,
649            Block::Unknown(value) => return unknown(value),
650        };
651        match known {
652            Known::Text { text } => {
653                html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
654            }
655            // Redacted thinking arrives as an empty string with only a
656            // signature: the reasoning happened but wasn't recorded. Mark it
657            // rather than dropping it to nothing (which leaves a bare turn).
658            Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
659                p .block.block--redacted { "reasoning redacted" }
660            },
661            Known::Thinking { thinking } => html! {
662                section .block.block--thinking {
663                    (self.markdown(thinking))
664                }
665            },
666            Known::ToolUse { name, input, .. } => self.tool_call(name, input),
667            // A result sits in the panel holding the call it answers, so the box
668            // above it already names the tool and its subject and the line has
669            // no need to say either again. What it shows instead is the first
670            // thing that came back, which is the one thing the call cannot show.
671            Known::ToolResult {
672                content,
673                is_error,
674                answers,
675                ..
676            } => marginalia(
677                "marginalia--result",
678                None,
679                Setting::new()
680                    .maybe_gist(tools::hint(answers.as_ref(), content, *is_error))
681                    .body(tools::result(self, answers.as_ref(), content, *is_error)),
682                if *is_error {
683                    Outcome::Failed
684                } else {
685                    Outcome::Fine
686                },
687            ),
688            Known::Image { source } => image(source),
689        }
690    }
691
692    /// A tool call: its summary line says what the call is, and its fold holds
693    /// the subject. A call the line already states in full (a read of a named
694    /// file, a query) has no subject left to hold, so it is set as one flat line
695    /// with nothing to open.
696    fn tool_call(&self, name: &str, input: &Value) -> Markup {
697        marginalia(
698            "marginalia--use",
699            Some(name),
700            tools::call(self, name, input),
701            Outcome::Fine,
702        )
703    }
704
705    /// A fenced code block run through the markdown path so it picks up syntax
706    /// highlighting. The fence is grown past the longest backtick run in the
707    /// source, so a body that itself contains backticks can't break out of it.
708    pub(crate) fn code_block(&self, lang: &str, code: &str) -> Markup {
709        // A file's own trailing newline is a fact about the file, not a line of
710        // it, so it isn't set as one: an empty line before the fold's bottom
711        // edge reads as content that isn't there.
712        let code = code.trim_end();
713        let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
714        self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
715    }
716}
717
718/// Whether a fold reports a failure, which the stylesheet marks and the summary
719/// line names.
720///
721/// Success is deliberately unmarked. A result sits in the panel holding the call
722/// it answers, so the box below the call is already what says it answered, and a
723/// word saying so on every one of them is the noise a reader reads past. A
724/// failure is the exception, so it is the one that gets named; marking only the
725/// exception is what makes the mark worth anything.
726#[derive(Clone, Copy, PartialEq, Eq)]
727enum Outcome {
728    Fine,
729    Failed,
730}
731
732impl Outcome {
733    fn word(self) -> Option<&'static str> {
734        match self {
735            Outcome::Fine => None,
736            Outcome::Failed => Some("error"),
737        }
738    }
739
740    fn failed(self) -> bool {
741        self == Outcome::Failed
742    }
743}
744
745/// A summary line and the fold under it: the shape a tool call and a harness
746/// note share. The line carries the labelling (what is being shown, a gist of
747/// its subject, and any qualifier), and the fold carries the subject itself. A
748/// setting whose line already states the whole of it has nothing left to hold,
749/// so it is set as one flat line with nothing to open.
750///
751/// The subject belongs in the setting's *gist* and never in a note: the gist is
752/// the one part of the line that can shrink, and a note holding a path drives
753/// the line out of the fold entirely.
754fn marginalia(variant: &str, label: Option<&str>, setting: Setting, outcome: Outcome) -> Markup {
755    let head = html! {
756        @if let Some(word) = outcome.word() {
757            span .marginalia__outcome { (word) }
758        }
759        @if let Some(label) = label {
760            span .marginalia__tool { (label) }
761        }
762        @if let Some(gist) = &setting.gist {
763            @match &setting.href {
764                Some(href) => a .marginalia__gist href=(href) { (gist) },
765                None => span .marginalia__gist { (gist) },
766            }
767        }
768        @for note in &setting.notes {
769            span .marginalia__note { (note) }
770        }
771    };
772    let failed = outcome.failed();
773    match &setting.body {
774        Some(body) => html! {
775            details class={ "marginalia " (variant) } data-error[failed] {
776                summary .marginalia__head { (head) }
777                (body)
778            }
779        },
780        None => html! {
781            div class={ "marginalia " (variant) " marginalia--flat" } data-error[failed] {
782                div .marginalia__head { (head) }
783            }
784        },
785    }
786}
787
788/// One border cell's coordinate box: the composed strip is one cell wide, and
789/// each vine or drollery is authored to sit in this 90x210 space.
790const CELL_WIDTH: u32 = 90;
791const CELL_HEIGHT: u32 = 210;
792
793/// Cells the border is stitched from before the strip repeats down a long
794/// folio. Long enough that a full bestiary's worth of drolleries appears before
795/// the strip recurs, at the cost of a larger data URI.
796const STRIP_CELLS: usize = 48;
797
798/// The vine cell, the border's default section.
799const VINE_CELL: &str = include_str!("drolleries/vine.svg");
800
801/// A vine stub that eases the border into a drollery: baked above each creature
802/// and mirrored below, its stroke fading to transparent (via the `vinefade`
803/// gradient) as it nears the beast, so the vine dissolves in and coalesces back
804/// rather than stopping dead at a gap.
805const TRAIL: &str = include_str!("drolleries/trail.svg");
806
807/// The fade the trail's stroke draws with: opaque vine gold at the seam,
808/// transparent by the time it reaches the creature. `userSpaceOnUse` resolves it
809/// in each trail's own (possibly mirrored) coordinate space, so one definition
810/// serves every drollery and its mirror.
811const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
812     x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
813     <stop offset=\"0\" stop-color=\"#c1912f\"/>\
814     <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
815
816/// The bestiary a border seats between vines, each paired with the `(dx, dy)`
817/// nudge that centres it in its cell: `dx` on the vine's centreline (x=45), and
818/// `dy` in the gap between the fading trail above and its mirror below (the
819/// creatures are drawn low in the 90x210 box, so most lift toward the gap's
820/// centre at y=105). Several carry a tail or ear that pulls the bounding box off
821/// centre, so the nudges are measured, not zero. Each is line-and-pigment art
822/// authored in that cell (see CLAUDE.md); a background-image SVG can't reach the
823/// palette variables, so the colours are baked to read on either parchment.
824const DROLLERIES: [(&str, i32, i32); 10] = [
825    (include_str!("drolleries/snail.svg"), 0, -18),
826    (include_str!("drolleries/budgie.svg"), -7, -11),
827    (include_str!("drolleries/cockatiel.svg"), -8, 2),
828    (include_str!("drolleries/cardinal.svg"), -6, -3),
829    (include_str!("drolleries/fish.svg"), -8, 0),
830    (include_str!("drolleries/butterfly.svg"), 0, -23),
831    (include_str!("drolleries/frog.svg"), 0, -32),
832    (include_str!("drolleries/cat.svg"), -5, -23),
833    (include_str!("drolleries/hare.svg"), 0, -14),
834    (include_str!("drolleries/stag.svg"), -2, -21),
835];
836
837/// A small deterministic PRNG (SplitMix64) so a border's section layout is
838/// stable for a seed but varies cell to cell.
839struct Rng(u64);
840
841impl Rng {
842    fn next(&mut self) -> u64 {
843        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
844        let mut z = self.0;
845        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
846        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
847        z ^ (z >> 31)
848    }
849}
850
851/// A stable seed for one border of a folio: the session id folded with a
852/// per-side salt (FNV-1a), so the two borders differ but each is reproducible.
853/// FNV-1a keeps the mapping self-contained rather than leaning on stdlib hash
854/// output, whose value isn't guaranteed across Rust releases.
855fn border_seed(session_id: &str, salt: &str) -> u64 {
856    session_id
857        .bytes()
858        .chain(salt.bytes())
859        .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
860            (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
861        })
862}
863
864/// A section of a border: a plain vine cell, or a drollery with the offset that
865/// centres it.
866#[derive(Clone, Copy)]
867enum BorderCell {
868    Vine,
869    Drollery {
870        svg: &'static str,
871        dx: i32,
872        dy: i32,
873        flip: bool,
874    },
875}
876
877/// The whole bestiary in a fresh random order (Fisher-Yates). Drawing drolleries
878/// from a bag that refills when drained means every creature appears before any
879/// repeats, so a border cycles through all of them rather than a fixed few.
880fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
881    let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
882    for i in (1..bag.len()).rev() {
883        bag.swap(i, rng.next() as usize % (i + 1));
884    }
885    bag
886}
887
888/// One border as a sequence of cells: mostly vine, with drolleries seated at
889/// intervals. The seam cells stay vine so the strip tiles cleanly, and no two
890/// drolleries sit adjacent so each creature reads on its own.
891fn border_cells(seed: u64) -> Vec<BorderCell> {
892    let mut rng = Rng(seed);
893    let mut bag = shuffled_bag(&mut rng);
894    let mut cells = Vec::with_capacity(STRIP_CELLS);
895    let mut previous_was_drollery = false;
896    for index in 0..STRIP_CELLS {
897        let seam = index == 0 || index == STRIP_CELLS - 1;
898        let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
899        if drollery {
900            if bag.is_empty() {
901                bag = shuffled_bag(&mut rng);
902            }
903            let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
904            let flip = rng.next().is_multiple_of(2);
905            cells.push(BorderCell::Drollery { svg, dx, dy, flip });
906        } else {
907            cells.push(BorderCell::Vine);
908        }
909        previous_was_drollery = drollery;
910    }
911    cells
912}
913
914/// A tiling border strip for one side of a folio as a base64 SVG data URI. The
915/// renderer sets it as a `background-image`; the stylesheet repeats it to fill
916/// the leaf, however tall (see the border invariants in CLAUDE.md).
917fn margin_strip(seed: u64) -> String {
918    let height = STRIP_CELLS as u32 * CELL_HEIGHT;
919    let mut inner = String::new();
920    for (index, cell) in border_cells(seed).iter().enumerate() {
921        let y = index as u32 * CELL_HEIGHT;
922        // Frame a drollery with the fading trail above and its vertical mirror
923        // below, nudging the creature to centre it between them; vine cells
924        // stand alone.
925        let content = match cell {
926            BorderCell::Vine => VINE_CELL.to_string(),
927            BorderCell::Drollery { svg, dx, dy, flip } => {
928                // Mirror a flipped creature about the cell's centreline (x=45)
929                // so it stays seated on the vine; the trail frames it either way.
930                let place = if *flip {
931                    format!("translate(90,0) scale(-1,1) translate({dx},{dy})")
932                } else {
933                    format!("translate({dx},{dy})")
934                };
935                format!(
936                    "{TRAIL}<g transform=\"{place}\">{svg}</g>\
937                     <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
938                )
939            }
940        };
941        inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
942    }
943    let svg = format!(
944        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
945         height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
946    );
947    format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
948}
949
950/// Token flux at a glance: what went in, then what came out.
951fn tally(input: u64, output: u64) -> String {
952    format!("↑ {} ↓ {}", compact(input), compact(output))
953}
954
955/// A turn's figures unrounded, for the title a reader hovers. Its input is what
956/// the turn added, so it stands beside the turn's own output rather than
957/// restating the whole conversation the request re-sent.
958fn turn_flux(usage: &Usage) -> String {
959    format!(
960        "{} input this turn · {} output this turn",
961        separated(usage.uncached_input()),
962        separated(usage.output_tokens),
963    )
964}
965
966/// The folio's figures unrounded. Its input is the largest single turn's, not a
967/// sum, so the title says which.
968fn folio_flux(largest_input: u64, output: u64) -> String {
969    format!(
970        "{} input at its largest · {} output in all",
971        separated(largest_input),
972        separated(output),
973    )
974}
975
976/// A count grouped in thousands, so an exact figure stays readable.
977fn separated(tokens: u64) -> String {
978    let digits = tokens.to_string();
979    let mut grouped = String::new();
980    for (index, digit) in digits.chars().enumerate() {
981        if index > 0 && (digits.len() - index).is_multiple_of(3) {
982            grouped.push(',');
983        }
984        grouped.push(digit);
985    }
986    grouped
987}
988
989/// A token count short enough to sit in a line of chrome: exact below a
990/// thousand, then one decimal place per magnitude, with a bare `.0` trimmed.
991fn compact(tokens: u64) -> String {
992    // A magnitude ends where rounding would carry into the next one: 999,950
993    // would otherwise print as `1000k`, which reads as having crossed the
994    // boundary without switching suffix.
995    let (scaled, suffix) = match tokens {
996        ..1_000 => return tokens.to_string(),
997        1_000..999_950 => (tokens as f64 / 1_000.0, "k"),
998        _ => (tokens as f64 / 1_000_000.0, "M"),
999    };
1000    let rounded = format!("{scaled:.1}");
1001    format!("{}{suffix}", rounded.strip_suffix(".0").unwrap_or(&rounded))
1002}
1003
1004fn longest_backtick_run(source: &str) -> usize {
1005    let mut longest = 0;
1006    let mut run = 0;
1007    for byte in source.bytes() {
1008        if byte == b'`' {
1009            run += 1;
1010            longest = longest.max(run);
1011        } else {
1012            run = 0;
1013        }
1014    }
1015    longest
1016}
1017
1018pub(crate) fn json(value: &Value) -> Markup {
1019    let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
1020    html! { pre { code { (pretty) } } }
1021}
1022
1023fn unknown(value: &Value) -> Markup {
1024    let label = value
1025        .get("type")
1026        .and_then(Value::as_str)
1027        .unwrap_or("unrecognized");
1028    html! {
1029        details .block.block--unknown {
1030            summary { (label) }
1031            (json(value))
1032        }
1033    }
1034}
1035
1036fn image(source: &ImageSource) -> Markup {
1037    let src = format!("data:{};base64,{}", source.media_type, source.data);
1038    html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[test]
1046    fn token_counts_shorten_to_one_decimal_place_per_magnitude() {
1047        assert_eq!(compact(0), "0");
1048        assert_eq!(compact(847), "847");
1049        assert_eq!(compact(999), "999");
1050        assert_eq!(compact(1_000), "1k");
1051        assert_eq!(compact(47_612), "47.6k");
1052        assert_eq!(compact(999_400), "999.4k");
1053        assert_eq!(compact(7_643_000), "7.6M");
1054    }
1055
1056    #[test]
1057    fn a_count_that_rounds_up_a_magnitude_takes_the_next_suffix() {
1058        assert_eq!(compact(999_949), "999.9k");
1059        assert_eq!(compact(999_950), "1M");
1060        assert_eq!(compact(1_000_000), "1M");
1061    }
1062
1063    #[test]
1064    fn a_turn_counts_only_the_input_it_added() {
1065        // The cached prefix is the conversation the request re-sent, so it is
1066        // no part of what this turn contributed.
1067        let usage = Usage {
1068            input_tokens: 3,
1069            output_tokens: 214,
1070            cache_creation_input_tokens: 32_400,
1071            cache_read_input_tokens: 15_200,
1072        };
1073
1074        assert_eq!(
1075            tally(usage.uncached_input(), usage.output_tokens),
1076            "↑ 32.4k ↓ 214"
1077        );
1078        assert_eq!(
1079            turn_flux(&usage),
1080            "32,403 input this turn · 214 output this turn"
1081        );
1082    }
1083
1084    #[test]
1085    fn a_folio_names_its_input_as_the_largest_rather_than_a_sum() {
1086        assert_eq!(
1087            folio_flux(60_867, 48_923),
1088            "60,867 input at its largest · 48,923 output in all"
1089        );
1090    }
1091
1092    #[test]
1093    fn exact_counts_group_in_thousands() {
1094        assert_eq!(separated(7), "7");
1095        assert_eq!(separated(942), "942");
1096        assert_eq!(separated(1_206), "1,206");
1097        assert_eq!(separated(47_603), "47,603");
1098        assert_eq!(separated(7_643_812), "7,643,812");
1099    }
1100
1101    #[test]
1102    fn a_render_reads_in_milliseconds_until_it_takes_a_second() {
1103        assert_eq!(elapsed(Duration::from_micros(412_400)), "412 ms");
1104        assert_eq!(elapsed(Duration::from_millis(999)), "999 ms");
1105        assert_eq!(elapsed(Duration::from_millis(1_000)), "1.0 s");
1106        assert_eq!(elapsed(Duration::from_millis(3_260)), "3.3 s");
1107    }
1108
1109    #[test]
1110    fn a_folio_is_sized_in_the_units_files_are_quoted_in() {
1111        assert_eq!(size(742), "742 B");
1112        assert_eq!(size(6_140), "6 kB");
1113        assert_eq!(size(812_600), "813 kB");
1114        assert_eq!(size(2_947_312), "2.9 MB");
1115    }
1116
1117    #[test]
1118    fn a_folio_states_the_render_it_came_out_of() {
1119        let markup = format!("<p>taking {TOOK_MARK} to set {SIZE_MARK}.</p>");
1120        let labour = Labour {
1121            took: Duration::from_millis(412),
1122            bytes: 2_947_312,
1123        };
1124
1125        assert_eq!(
1126            inscribe(markup, &labour),
1127            "<p>taking 412 ms to set 2.9 MB.</p>"
1128        );
1129    }
1130
1131    #[test]
1132    fn border_strip_is_stable_per_seed() {
1133        let seed = border_seed("3f9c-a17b-session", "left");
1134        assert_eq!(margin_strip(seed), margin_strip(seed));
1135    }
1136
1137    #[test]
1138    fn the_two_borders_of_a_folio_differ() {
1139        let session = "3f9c-a17b-session";
1140        assert_ne!(
1141            margin_strip(border_seed(session, "left")),
1142            margin_strip(border_seed(session, "right"))
1143        );
1144    }
1145
1146    fn is_drollery(cell: &BorderCell) -> bool {
1147        matches!(cell, BorderCell::Drollery { .. })
1148    }
1149
1150    #[test]
1151    fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
1152        // Seams stay vine so the strip tiles cleanly, drolleries never sit
1153        // adjacent, and vine dominates. Exercise many seeds for confidence.
1154        let mut total_drolleries = 0;
1155        for salt in [
1156            "one", "two", "three", "four", "five", "six", "seven", "eight",
1157        ] {
1158            let cells = border_cells(border_seed("a-session", salt));
1159            assert_eq!(cells.len(), STRIP_CELLS);
1160            assert!(matches!(cells[0], BorderCell::Vine));
1161            assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
1162            let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
1163            assert!(
1164                drolleries < STRIP_CELLS / 2,
1165                "too many drolleries: {drolleries}"
1166            );
1167            let adjacent = cells
1168                .windows(2)
1169                .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
1170            assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
1171            total_drolleries += drolleries;
1172        }
1173        // A border of pure vine would defeat the point; the generator must seat
1174        // creatures.
1175        assert!(total_drolleries > 0, "generator produced no drolleries");
1176    }
1177
1178    #[test]
1179    fn drolleries_face_both_ways_across_a_border() {
1180        // Each seated creature is mirrored or not at random, so a full strip
1181        // shows both facings rather than one consistent direction.
1182        let cells = border_cells(border_seed("flip-variety-session", "left"));
1183        let flips: Vec<bool> = cells
1184            .iter()
1185            .filter_map(|cell| match cell {
1186                BorderCell::Drollery { flip, .. } => Some(*flip),
1187                BorderCell::Vine => None,
1188            })
1189            .collect();
1190        assert!(flips.iter().any(|&flip| flip), "no creature was flipped");
1191        assert!(
1192            flips.iter().any(|&flip| !flip),
1193            "no creature kept its facing"
1194        );
1195    }
1196
1197    #[test]
1198    fn a_border_cycles_through_the_whole_bestiary() {
1199        // The shuffled bag draws every creature before repeating any, so a
1200        // border with a full bag's worth of drolleries shows all of them, and a
1201        // shorter one still never repeats before the bag drains.
1202        let cells = border_cells(border_seed("variety-session", "left"));
1203        let used: std::collections::HashSet<&str> = cells
1204            .iter()
1205            .filter_map(|cell| match cell {
1206                BorderCell::Drollery { svg, .. } => Some(*svg),
1207                BorderCell::Vine => None,
1208            })
1209            .collect();
1210        let count = cells.iter().filter(|cell| is_drollery(cell)).count();
1211        let expected = count.min(DROLLERIES.len());
1212        assert_eq!(
1213            used.len(),
1214            expected,
1215            "creatures repeated before the bag drained"
1216        );
1217    }
1218}