jevhooks.git / web / src / view.rs
view.rsannotatedview.rssource327 lines · 17.0 KB · raw
1//! The page as a pure function: data in, HTML out. Nothing here fetches, reads the clock or knows a
2//! visitor, so the whole page is tested as a string, natively.
3//!
4//! The look is `jev-ui`'s, the same as lmjtfy.fun's and whiskers.lmjtfy.fun's: the shell, the nav,
5//! the windows, the band. What is here is jevhooks': the words and the four pictures.
6
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};
11
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");
20
21/// The bar down the side of a Discord embed: the page's pink, as lmjtfy's.
22const THEME: &str = "#f386a1";
23
24/// 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}
37
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}
50
51/// 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}
66
67/// 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}
71
72/// 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}
123
124/// 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}
148
149/// 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}
220
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    }
229
230    /// A link pasted into Discord unfurls only when every tag it reads is there; the shared crate
231    /// 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    }
238
239    /// Each feature is shown, not only described: all four pictures, each with words for someone
240    /// 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    }
252
253    /// The policy forbids inline script (`headers::policy`), so the page must not have any: one
254    /// 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    }
263
264    /// Windows that follow one another sit in a `.stack`, which is what puts space between
265    /// them: two windows placed straight into a sheet touch (seen on the first deploy, where
266    /// "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    }
280
281    /// The map names every event, each once as a heading of its own row, and says of each what
282    /// 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    }
292
293    /// The page is the plugin's promise in public, so it has to make it.
294    /// The rules are on the page as drawings of the networks, one per worked case, and each case
295    /// 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    }
304
305    /// The page says how to install the release: the signed route with the key that is in the
306    /// 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    }
316
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}