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}