1//! What the page says and shows, as data: the words a link preview carries, the four pictures, and 2//! the addresses it links to. No markup here; `view` draws it. 3 4use aldebaran_assets::{Asset, Bundle}; 5use jev_ui::components::Icon; 6 7pub const NAME: &str = "jevhooks"; 8pub const TITLE: &str = "jevhooks: Jev judges what Claude Code is about to do"; 9pub const DESCRIPTION: &str = "A Claude Code plugin. Before a shell command runs, Jev says what kind of act it is, how hard it \ 10 would be to undo and how much memory it needs. Ordinary work runs without a prompt, risky work is put to you, and \ 11 heavy commands wait their turn."; 12 13/// Where the site is when nothing says otherwise (`wrangler dev`). The deploy passes the real one. 14pub const SITE_ORIGIN: &str = "http://localhost:8790"; 15 16/// The guide: every folder of the repository as a chapter, on the code host. 17pub const GUIDE: &str = "https://code.lmjtfy.fun/jevhooks.git/"; 18pub const CHAPTER_HOOKS: &str = "https://code.lmjtfy.fun/jevhooks.git/plugin/hooks/"; 19pub const CHAPTER_QUESTIONS: &str = "https://code.lmjtfy.fun/jevhooks.git/crates/jevhooks-rules/src/"; 20pub const CHAPTER_DAEMON: &str = "https://code.lmjtfy.fun/jevhooks.git/crates/jevhooks-daemon/"; 21pub const JEV_DOCS: &str = "https://docs.typesafe.ai"; 22pub const LMJTFY: &str = "https://lmjtfy.fun"; 23 24/// What a stranger types to see it work, with no Rust, no key and no account. 25pub const TRY_IT: &str = "git clone --recurse-submodules https://lmjtfy.fun/jevhooks.git && cd jevhooks && claude plugin test plugin"; 26 27/// The release the page tells people to install: the workspace's version (`tools/check-versions.sh` 28/// holds this to the root `Cargo.toml`). 29pub const VERSION: &str = "0.1.0"; 30 31/// The public half of the key releases are signed with: the second line of `tools/release.pub`, 32/// the one copy of it. Whoever installs gives it to mise, which then accepts only what it signed. 33pub fn release_key() -> &'static str { 34 include_str!("../../tools/release.pub").lines().nth(1).unwrap_or_default().trim() 35} 36 37/// The name releases are signed under, which is where they are served. 38pub const PROJECT: &str = "code.lmjtfy.fun/jevhooks"; 39 40/// Installs the plugin, daemon built in, with mise checking the signature. 41pub fn install() -> String { 42 format!("mise use -g 'packslip:{PROJECT}[pubkey={},allow_unlogged=true]@{VERSION}'", release_key()) 43} 44 45/// Starts a session with the installed plugin loaded. 46pub const RUN_IT: &str = "claude --plugin-dir \"$(mise where packslip:code.lmjtfy.fun/jevhooks)\""; 47 48/// The same files for someone without mise: the archive for this machine and the digests to check it by. 49pub const BY_HAND: &str = "https://code.lmjtfy.fun/jevhooks/latest/"; 50 51/// The link preview's card: drawn by `src/bin/card.rs`, made a PNG by `tools/build-web.sh`. 52pub const CARD_PATH: &str = "/preview/card.png"; 53pub const CARD_WIDTH: u32 = 1200; 54pub const CARD_HEIGHT: u32 = 630; 55pub const CARD_ALT: &str = "A window on a dotted background that says: Jev judges what Claude Code is about to do"; 56 57/// A hook, 12 by 12, `#` for ink: the title bars' and the tab's icon. 58pub const HOOK: Icon = [ 59 "............", "....####....", "...##..##...", "...##..##...", ".......##...", ".......##...", ".......##...", "..##...##...", 60 "..##...##...", "...#####....", "............", "............", 61]; 62 63/// One picture of the real terminal, and what it shows. 64#[derive(Clone, Copy)] 65pub struct Shot { 66 pub asset: Asset, 67 /// The window's title. 68 pub title: &'static str, 69 /// For someone who cannot see the picture: what is on the screen. 70 pub alt: &'static str, 71 /// One or two plain sentences beside it. 72 pub says: &'static str, 73 /// The picture's own size, so the page does not jump as it loads. 74 pub width: u32, 75 pub height: u32, 76} 77 78const SVG: &str = "image/svg+xml"; 79 80// The pictures are the guide's own: `mise run screenshots` takes them from a real session and writes 81// them beside the chapters that show them. They are embedded from there, so the site cannot show a 82// different picture from the guide. 83pub const BAND: Shot = Shot { 84 asset: Asset { path: "/shots/band.svg", content_type: SVG, body: include_bytes!("../../plugin/hooks/band.svg") }, 85 title: "Ordinary work just runs", 86 alt: "Claude Code's terminal after a command ran with no prompt. Above the prompt, a line reads: command: Jev: builds or tests; undone by one command; allowed.", 87 says: "Reading, building and editing run without a prompt. A band above the prompt says what Jev made of the last \ 88 command, how long it took and what the session's questions have cost.", 89 width: 1048, 90 height: 268, 91}; 92 93pub const ASK: Shot = Shot { 94 asset: Asset { path: "/shots/ask.svg", content_type: SVG, body: include_bytes!("../../plugin/hooks/ask.svg") }, 95 title: "Anything hard to undo is put to you", 96 alt: "Claude Code's permission dialog for git reset --hard and git clean. It carries Jev's reason: discards or rewrites version-control state; hard to undo.", 97 says: "A command that deletes, rewrites history, changes the machine, sends something elsewhere or runs code nobody \ 98 has read stops at a prompt, with Jev's reason in it. You decide.", 99 width: 1048, 100 height: 436, 101}; 102 103pub const WAITING: Shot = Shot { 104 asset: Asset { path: "/shots/waiting.svg", content_type: SVG, body: include_bytes!("../../crates/jevhooks-daemon/src/waiting.svg") }, 105 title: "Heavy commands wait their turn", 106 alt: "Claude Code's terminal while cargo build --release waits. The band reads: waiting for memory: needs 3.0 GB, 1.0 GB to spare.", 107 says: "Every session on the machine shares one ledger of the memory already promised to commands. Five agents \ 108 starting five builds in the same second get two builds now and three in a queue, where they used to get a \ 109 dead machine.", 110 width: 1048, 111 height: 268, 112}; 113 114pub const MODEL: Shot = Shot { 115 asset: Asset { path: "/shots/model.svg", content_type: SVG, body: include_bytes!("../../crates/jevhooks-daemon/src/model.svg") }, 116 title: "A prompt goes to the cheapest model that will do", 117 alt: "Claude Code's terminal after the prompt: What branch am I on? The band reads: prompt: Jev: a lookup; haiku.", 118 says: "Switch it on and Jev scores what each prompt takes, on four levels from a lookup to open-ended work. The turn \ 119 goes to the cheapest of haiku, sonnet, opus and fable that is 75% likely to be enough.", 120 width: 1048, 121 height: 218, 122}; 123 124/// In the order the page shows them. 125pub const SHOTS: [Shot; 4] = [BAND, ASK, WAITING, MODEL]; 126 127/// Every file the site serves from itself besides the shared look's. 128pub static FILES: Bundle = Bundle::new(&[BAND.asset, ASK.asset, WAITING.asset, MODEL.asset]); 129 130#[cfg(test)] 131mod tests { 132 use super::*; 133 134 /// A picture's declared size is what keeps the page from jumping as it loads, so it has to be 135 /// the picture's real one: read back from the SVG the screenshots task wrote. 136 #[test] 137 fn each_picture_declares_its_real_size() { 138 for shot in SHOTS { 139 let svg = std::str::from_utf8(shot.asset.body).unwrap(); 140 let number = |name: &str| -> f64 { 141 let at = svg.find(&format!("{name}=\"")).unwrap_or_else(|| panic!("{}: no {name}", shot.asset.path)) + name.len() + 2; 142 svg[at..].split('"').next().unwrap().parse().unwrap() 143 }; 144 assert_eq!((number("width").round() as u32, number("height").round() as u32), (shot.width, shot.height), "{}", shot.asset.path); 145 } 146 } 147 148 /// The screenshots task drops the rows Claude Code draws about the account of whoever ran it. 149 /// This is the last place one could get out, so it is checked here too. 150 #[test] 151 fn no_picture_shows_the_account_of_whoever_took_it() { 152 for shot in SHOTS { 153 let svg = std::str::from_utf8(shot.asset.body).unwrap(); 154 for private in ["weekly limit", "usage-credits", "/home/", "/Users/"] { 155 assert!(!svg.contains(private), "{} contains {private:?}", shot.asset.path); 156 } 157 } 158 } 159 160 /// Link previews cut long text; ours must arrive whole (`jev_ui::preview` refuses longer). 161 #[test] 162 fn the_preview_words_fit() { 163 assert!(TITLE.chars().count() <= 70, "{}", TITLE.chars().count()); 164 assert!(DESCRIPTION.chars().count() <= 300, "{}", DESCRIPTION.chars().count()); 165 } 166}