jevhooks.git / web / src / content.rs
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}