jevhooks.git / web / src / view.rs
view.rsannotatedview.rssource327 lines · 17.0 KB · raw

The page as a pure function: data in, HTML out. Nothing here fetches, reads the clock or knows a visitor, so the whole page is tested as a string, natively.

The look is jev-ui's, the same as lmjtfy.fun's and whiskers.lmjtfy.fun's: the shell, the nav, the windows, the band. What is here is jevhooks': the words and the four pictures.

7use jev_ui::components::{Link, Nav, band, copy_line, footer, headline, nav, pixel_icon, sheet, skip_link, window};
8use jev_ui::preview::{Origin, Picture, Preview};
9use jev_ui::shell::{Assets, Head, document};
10use maud::{Markup, PreEscaped, html};
12use crate::hooks::{self, Phase, State};
13use crate::rules;
14use crate::content::{
15    CARD_ALT, CARD_HEIGHT, CARD_PATH, CARD_WIDTH, CHAPTER_DAEMON, CHAPTER_HOOKS, CHAPTER_QUESTIONS, DESCRIPTION, GUIDE, HOOK, JEV_DOCS, LMJTFY, NAME, SHOTS,
16    BY_HAND, RUN_IT, Shot, TITLE, TRY_IT, VERSION, install,
17};
18
19const CSS: &str = include_str!("page.css");

The bar down the side of a Discord embed: the page's pink, as lmjtfy's.

22const THEME: &str = "#f386a1";

The hook as a still picture, for the browser tab.

25pub fn favicon() -> String {
26    let mut svg = String::from("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 12 12\" shape-rendering=\"crispEdges\">");
27    for (y, row) in HOOK.iter().enumerate() {
28        for (x, cell) in row.bytes().enumerate() {
29            if cell == b'#' {
30                svg.push_str(&format!("<rect x=\"{x}\" y=\"{y}\" width=\"1\" height=\"1\" fill=\"#1e1e1e\"/>"));
31            }
32        }
33    }
34    svg.push_str("</svg>");
35    svg
36}
38fn preview(origin: &Origin) -> Preview<'_> {
39    Preview {
40        origin,
41        site_name: NAME,
42        title: TITLE,
43        description: DESCRIPTION,
44        page: "/",
45        picture: Picture { path: CARD_PATH, query: None, width: CARD_WIDTH, height: CARD_HEIGHT, alt: CARD_ALT },
46        video: None,
47        theme_color: THEME,
48    }
49}

One feature: a window with the sentence and the picture of it happening.

52fn feature(shot: &Shot, eager: bool) -> Markup {
53    window(
54        Some(&HOOK),
55        html! { h2 { (shot.title) } },
56        None,
57        true,
58        html! {
59            p { (shot.says) }
60            figure .shot {
61                img src=(shot.asset.href()) alt=(shot.alt) width=(shot.width) height=(shot.height) loading=(if eager { "eager" } else { "lazy" }) decoding="async";
62            }
63        },
64    )
65}

The chip that says what is done with an event.

68fn chip(state: State) -> Markup {
69    html! { span class={ "chip " (state.class()) } { (state.word()) } }
70}

Every hook point and what jevhooks does there, grouped by where in a session it is raised.

73fn map() -> Markup {
74    let hooks = hooks::all();
75    html! {
76        div #hooks .stack {
77            (window(Some(&HOOK), html! { h2 { "Every hook point, and what is done there" } }, None, true, html! {
78                p {
79                    "Claude Code raises " (hooks.len()) " kinds of event that a plugin can answer. jevhooks is built at "
80                    (hooks::count(State::Judged) + hooks::count(State::Heard)) " of them. This is all of them, so you can see what is built, what is only an idea, and what nobody has thought about yet."
81                }
82                ul .legend {
83                    @let (events, ideas) = hooks::ideas();
84                    @for state in State::ALL {
85                        li {
86                            (chip(state))
87                            @if state == State::Idea {
88                                span { (ideas) " for " (events) " of the events. " (state.means()) }
89                            } @else {
90                                span { (hooks::count(state)) " events. " (state.means()) }
91                            }
92                        }
93                    }
94                }
95            }))
96            @for phase in Phase::ALL {
97                (window(Some(&HOOK), html! { h3 { (phase.title()) } }, None, true, html! {
98                    ul .hooks {
99                        @for hook in hooks.iter().filter(|hook| hook.phase == phase) {
100                            li {
101                                div .hook-name { code { (hook.name()) } (chip(hook.state())) }
102                                @if !hook.today.is_empty() { p { (hook.today) } }
103                                @if !hook.ideas.is_empty() {
104                                    p .ideas-title { (chip(State::Idea)) " written down for it:" }
105                                    ul .ideas { @for idea in hook.ideas { li { (idea) } } }
106                                }
107                            }
108                        }
109                    }
110                }))
111            }
112            (window(Some(&HOOK), html! { h3 { "Two more, outside that list" } }, None, true, html! {
113                p { "The plugin also steps in at two points of Claude Code's newer plugin interface, to choose a model. Both are built, and off unless you switch them on." }
114                ul .hooks {
115                    @for (name, does) in hooks::BEYOND {
116                        li { div .hook-name { code { (name) } (chip(State::Judged)) } p { (does) } }
117                    }
118                }
119            }))
120        }
121    }
122}

The rules that turn Jev's answers into what happens, drawn from the networks that run.

125fn rulebook() -> Markup {
126    html! {
127        div #rules .stack {
128            (window(Some(&HOOK), html! { h2 { "The rules, drawn from the rules that run" } }, None, true, html! {
129                p {
130                    "Jev answers with numbers. What happens next is decided by a few rules, in order: of the rules that hold, the first decides. "
131                    "They are compiled into one small network, and these pictures are drawn from that network, not beside it, so they cannot show a rule the plugin does not have."
132                }
133                p {
134                    "Read one left to right: what can be known, the tests on it, where a rule's tests meet, and the rules. "
135                    "Black is what a rule that holds stands on, and dashed is a test that failed. A pink rule holds; more than one can, and the numbered ones are those that decided, in order. "
136                    "The framed facts are Jev's, and are asked for together, in one request. Open a case to see how it went."
137                }
138            }))
139            @for judgment in rules::judgments() {
140                (window(Some(&HOOK), html! { h3 { (judgment.title) } }, None, true, html! {
141                    p { (judgment.says) }
142                    (judgment.cases)
143                }))
144            }
145        }
146    }
147}

The page. origin is where the site is served, for the link preview's absolute addresses.

150pub fn page(origin: &Origin) -> Markup {
151    // The tags cannot be built wrong (`jev_ui::preview`), and these are constants a test builds, so
152    // a failure here is a programming error the tests catch, not a visitor's.
153    let meta = preview(origin).html().expect("the preview's constants are valid (the_page_carries_a_whole_link_preview)");
154    let head = Head { title: TITLE, meta: PreEscaped(meta), style: CSS, assets: Assets::Linked, datastar: false, icon: Some("/favicon.svg"), extra: html! {} };
155    let links = [Link { href: "#hooks", label: "Hooks" }, Link { href: "#rules", label: "Rules" }, Link { href: "#install", label: "Install" }, Link { href: GUIDE, label: "The guide" }, Link { href: LMJTFY, label: "lmjtfy" }];
156    let body = html! {
157        (skip_link("main", "Skip to the page"))
158        (nav(&Nav { label: "Site", brand: Link { href: "/", label: NAME }, middle: html! {}, links: &links }))
159        main #main {
160            (sheet(html! {
161                (headline(Some("A Claude Code plugin"), "Jev judges what Claude Code is about to do."))
162                p .lede {
163                    "Claude Code runs shell commands for you and decides for itself when it has finished. jevhooks stops at "
164                    "those moments to ask a second opinion of something that does not write, cannot be talked round, and "
165                    "costs a few thousandths of a cent to ask."
166                }
167                p {
168                    "That something is " a href=(JEV_DOCS) { "Jev" } ", TypeSafe AI's System One model. It writes no text. It is "
169                    "handed a situation and a question whose answers are already written down, and says how likely each "
170                    "answer is, in about a quarter of a second."
171                }
172            }))
173            (sheet(html! {
174                div .stack {
175                    @for (index, shot) in SHOTS.iter().enumerate() { (feature(shot, index == 0)) }
176                }
177                p .small { "Every picture is a real session, taken by a script in the repository. Nothing is drawn by hand." }
178            }))
179            (band(html! {
180                p .promise { "Jev being slow, absent, unsure or broken never blocks anything. Then whatever would have happened without the plugin happens." }
181            }))
182            (sheet(html! { div .below-band { (map()) } }))
183            (sheet(html! { (rulebook()) }))
184            (sheet(html! { div .stack {
185                (window(Some(&HOOK), html! { h2 { "How it works" } }, None, true, html! {
186                    ol .steps {
187                        li { "A small piece of the plugin hears every event Claude Code raises. A command that plainly only reads goes no further: no question, no cost. (" a href=(CHAPTER_HOOKS) { "the hooks" } ")" }
188                        li { "Anything worth judging goes to one daemon for the whole machine, which keeps the connection to Jev warm and the key in one place. (" a href=(CHAPTER_DAEMON) { "the daemon" } ")" }
189                        li { "The daemon asks Jev three things in one request: which of eight kinds of act this is, how hard it would be to put back, and how much of the machine it takes. (" a href=(CHAPTER_QUESTIONS) { "the questions" } ")" }
190                        li { "Rules written in code, where a test can hold them still, turn the answers into allow, ask, wait or no opinion. Your own hooks still run, and the stricter answer wins." }
191                    }
192                }))
193                (window(Some(&HOOK), html! { h2 #install { "Install it" } }, None, true, html! {
194                    p { "Version " (VERSION) ", for Linux on x86_64 and arm64: the plugin with its daemon already built, so it needs no Rust. With " a href="https://mise.jdx.dev" { "mise" } ":" }
195                    (copy_line("install-line", html! { "Install" }, &install()))
196                    (copy_line("run-line", html! { "Run" }, RUN_IT))
197                    p .small {
198                        "The long word in the first line is the public half of the key every release is signed with. mise checks the download against it and refuses anything else. "
199                        "Without mise, the same files and their SHA-256 digests are " a href=(BY_HAND) { "here" } ": unpack one and give its folder to " code { "claude --plugin-dir" } "."
200                    }
201                    p .small { "To judge anything the daemon needs a TypeSafe key in " code { "TYPESAFE_API_KEY" } ". Without one it runs, asks nothing, and changes nothing." }
202                }))
203                (window(Some(&HOOK), html! { h2 { "Try it" } }, None, true, html! {
204                    p { "Not ready to install? With Claude Code installed, this only runs the plugin's tests against a stand-in for the daemon, to show it working. It installs nothing, and needs no Rust, no Jev key and no account." }
205                    (copy_line("try", html! { "Run" }, TRY_IT))
206                    p .small { "The whole repository is written as " a href=(GUIDE) { "a guide in chapters" } ": every folder says what it is and why." }
207                }))
208            } }))
209        }
210        (footer(
211            &HOOK,
212            html! { "jevhooks is a personal project, not a product. Jev is TypeSafe AI's; this plugin is not theirs." },
213            Some(html! { a href=(GUIDE) { "Read the code" } }),
214        ))
215        // An icon nobody sees, so the shared sheet's `.icon` rule is exercised on every page the same way.
216        span hidden { (pixel_icon(&HOOK, "")) }
217    };
218    document(&head, &[], body)
219}
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use jev_ui::preview::{Kind, missing};
225
226    fn rendered() -> String {
227        page(&Origin::parse("https://hooks.lmjtfy.fun").unwrap()).into_string()
228    }

A link pasted into Discord unfurls only when every tag it reads is there; the shared crate knows the list, and the page is checked against it as rendered.

232    #[test]
233    fn the_page_carries_a_whole_link_preview() {
234        let html = rendered();
235        assert_eq!(missing(&html, Kind::Still), Vec::<String>::new());
236        assert!(html.contains("https://hooks.lmjtfy.fun/preview/card.png"));
237    }

Each feature is shown, not only described: all four pictures, each with words for someone who cannot see it and a declared size so the page does not jump.

241    #[test]
242    fn every_feature_has_its_picture() {
243        let html = rendered();
244        for shot in SHOTS {
245            assert!(html.contains(&shot.asset.href()), "{}", shot.asset.path);
246            assert!(html.contains(shot.title), "{}", shot.title);
247            assert!(!shot.alt.is_empty());
248        }
249        assert_eq!(html.matches("<img ").count(), SHOTS.len());
250        assert_eq!(html.matches("loading=\"eager\"").count(), 1, "only the first picture is fetched at once");
251    }

The policy forbids inline script (headers::policy), so the page must not have any: one would be silently dropped by the browser.

255    #[test]
256    fn the_page_has_no_inline_script() {
257        let html = rendered();
258        for (at, _) in html.match_indices("<script") {
259            let tag = &html[at..at + html[at..].find('>').unwrap()];
260            assert!(tag.contains("src="), "an inline script: {tag}");
261        }
262    }

Windows that follow one another sit in a .stack, which is what puts space between them: two windows placed straight into a sheet touch (seen on the first deploy, where "How it works" ran into "Try it" and into the band above).

267    #[test]
268    fn no_window_touches_the_next() {
269        let html = rendered();
270        // The pictures, the map (its head, a window a phase, the two beyond), the rules (their
271        // head and a window a judgment), then how and try.
272        assert_eq!(html.matches("class=\"win").count(), SHOTS.len() + 1 + Phase::ALL.len() + 1 + 1 + rules::judgments().len() + 3);
273        assert_eq!(html.matches("class=\"stack").count(), 4, "the pictures, the map, the rules, and the last three windows");
274        // Every window is inside one of the two: a window opens only after a stack has.
275        let first_window = html.find("class=\"win").unwrap();
276        assert!(html.find("class=\"stack").unwrap() < first_window);
277        let below = html.find("class=\"below-band").unwrap();
278        assert!(html[below..].find("class=\"stack").unwrap() < html[below..].find("class=\"win").unwrap(), "what follows the band is in a stack");
279    }

The map names every event, each once as a heading of its own row, and says of each what is done there in one of the four words.

283    #[test]
284    fn the_map_shows_every_event_with_its_state() {
285        let html = rendered();
286        for hook in hooks::all() {
287            let row = format!("<code>{}</code><span class=\"chip {}\">", hook.name(), hook.state().class());
288            assert_eq!(html.matches(&row).count(), 1, "{}", hook.name());
289        }
290        assert!(html.contains("jevhooks is built at 7 of them"), "the count is the plugin's own");
291    }

The page is the plugin's promise in public, so it has to make it. The rules are on the page as drawings of the networks, one per worked case, and each case can be opened without script.

296    #[test]
297    fn the_rules_are_drawn_on_the_page() {
298        let html = rendered();
299        assert_eq!(html.matches("<div class=\"rete\">").count(), 10);
300        assert_eq!(html.matches("<details class=\"case\"").count(), 10);
301        assert_eq!(html.matches("<details class=\"case\" open").count(), 3, "the first case of each judgment is open");
302        assert!(html.contains("href=\"#rules\""));
303    }

The page says how to install the release: the signed route with the key that is in the repository, the exact version, and the files for someone without mise.

307    #[test]
308    fn the_page_says_how_to_install() {
309        let html = rendered();
310        let key = crate::content::release_key();
311        assert!(key.starts_with("RW") && key.len() == 56, "a minisign public key line: {key}");
312        assert!(html.contains(&format!("pubkey={key},allow_unlogged=true]@{VERSION}")));
313        assert!(html.contains("mise where packslip:code.lmjtfy.fun/jevhooks"));
314        assert!(html.contains(BY_HAND));
315    }
317    #[test]
318    fn the_page_makes_the_promise() {
319        assert!(rendered().contains("never blocks anything"));
320    }
321
322    #[test]
323    fn the_favicon_is_the_hook() {
324        let ink = HOOK.iter().map(|row| row.bytes().filter(|&cell| cell == b'#').count()).sum::<usize>();
325        assert_eq!(favicon().matches("<rect").count(), ink);
326    }
327}