The few facts that are configuration, each in one place.
Where the site is. The Worker reads SITE_ORIGIN from its variables first (so a staging
copy previews itself); this is the answer when that is not set.
5pub const SITE_ORIGIN: &str = "https://whiskers.lmjtfy.fun";
The address git clone is given on the page: the code host's, which serves the repository
with its submodule (~/lmjtfy, src/clone.rs).
9pub const CLONE_URL: &str = "https://code.lmjtfy.fun/whiskers.git";
Where the release files are served: the code host's, never this Worker (~/lmjtfy builds the
contract; the files may be absent until it ships). A file's address is this, then its name.
13pub const RELEASES_URL: &str = "https://code.lmjtfy.fun/whiskers/latest/";
Where an icon comes from. Each has its own directory under web/third-party/.
shuqikhor's pixel-icons (MIT), unchanged: pixel-icons/.
19 Shuqikhor,
Drawn for this site in the pack's style, because neither pack has the thing: pixel-icons-extra/.
21 Drawn,
An icon's file: pixel art as SVG, or a SerenityOS PNG.
A pixel icon: the one list of every icon the page may draw, and where each file is. A test holds it to the three directories (every file used, every use a file).
Drawn here: a green Android head.
48 Android,
Drawn here: a chef's hat, for mise install.
70macro_rules! svg { 71 ($origin:ident, $dir:literal, $name:literal) => { 72 (Source::$origin, $name, Art::Svg(include_str!(concat!("../../third-party/", $dir, "/", $name, ".svg")))) 73 }; 74} 75 76impl Pic { 77 pub const ALL: [Pic; 17] = [ 78 Pic::Android, Pic::Arm, Pic::SoundHigh, Pic::Earth, Pic::Network, Pic::Wifi, Pic::Claude, Pic::Key, Pic::ChefHat, 79 Pic::Lock, Pic::Calendar, Pic::AppleLogo, Pic::Microphone, Pic::Picture, Pic::Magnifier, Pic::Clock, Pic::Book, 80 ];
Where it comes from, its file name without the extension, and the file's exact contents.
83 pub const fn art(self) -> (Source, &'static str, Art) { 84 match self { 85 Pic::Android => svg!(Drawn, "pixel-icons-extra", "android"), 86 Pic::Arm => svg!(Drawn, "pixel-icons-extra", "arm"), 87 Pic::Network => (Source::Serenity, "network", Art::Png(include_bytes!("../../third-party/serenity-icons/network.png"))), 88 Pic::SoundHigh => svg!(Shuqikhor, "pixel-icons", "sound-high"), 89 Pic::Earth => svg!(Shuqikhor, "pixel-icons", "earth"), 90 Pic::Wifi => svg!(Shuqikhor, "pixel-icons", "wifi"), 91 Pic::Claude => svg!(Shuqikhor, "pixel-icons", "claude"), 92 Pic::Key => svg!(Shuqikhor, "pixel-icons", "key"), 93 Pic::ChefHat => svg!(Drawn, "pixel-icons-extra", "chef-hat"), 94 Pic::Lock => svg!(Shuqikhor, "pixel-icons", "lock"), 95 Pic::Calendar => svg!(Shuqikhor, "pixel-icons", "calendar"), 96 Pic::AppleLogo => svg!(Shuqikhor, "pixel-icons", "apple-logo"), 97 Pic::Microphone => svg!(Shuqikhor, "pixel-icons", "microphone"), 98 Pic::Picture => svg!(Shuqikhor, "pixel-icons", "picture"), 99 Pic::Magnifier => svg!(Shuqikhor, "pixel-icons", "magnifier"), 100 Pic::Clock => svg!(Shuqikhor, "pixel-icons", "clock"), 101 Pic::Book => svg!(Shuqikhor, "pixel-icons", "book"), 102 } 103 } 104}
One downloadable file: its name (also its label in the address), what the menu calls it, its approximate size (measured from release 2026.10.12), a line on what it is, and its icons (side by side).
The icon of the "Download for Android" button.
117pub const APP_BUTTON_ICON: Pic = Pic::Android;
Every Android build the page offers, in one table: the page draws its rows from it and a test holds that each is linked once.
121pub const APP_DOWNLOADS: [Download; 3] = [ 122 Download { file: "arm64-lite.apk", label: "arm64 lite", size: "85 MB", what: "The smallest, without the offline voice. For most phones and tablets.", icons: &[Pic::Arm] }, 123 Download { file: "arm64.apk", label: "arm64 with voice", size: "176 MB", what: "With the offline voice (about 90 MB more).", icons: &[Pic::Arm, Pic::SoundHigh] }, 124 Download { file: "universal.apk", label: "universal", size: "208 MB", what: "Also runs on 32-bit ARM devices and on emulators.", icons: &[Pic::Earth] }, 125];
The server, whiskersd: one static file for any x86-64 Linux, and its checksums.
The address of a release file.
Where the page's screenshots are served from: worker/public/screens/, made by tools/screens
from the Android screenshot tests' goldens, so they are always the app's current look.
144pub const SCREENS_DIR: &str = "/screens/";
The longest side of a screenshot as served (tools/screens shrinks to fit, never enlarges).
147pub const SCREEN_FIT: u32 = 1000;
One golden of android/app/screenshots/, shown on the page. width and height are the
golden's own size (a test holds them to the file); the picture is its top keep rows, served at most SCREEN_FIT
on its longest side.
The golden's name without .png.
How many rows from the top are shown: the golden's height, or fewer when the rest of the
screen is empty black and only makes the writing smaller.
160 pub keep: u32,
The size it is served at.
171 pub fn served(&self) -> (u32, u32) { 172 let longest = self.width.max(self.keep); 173 if longest <= SCREEN_FIT { 174 return (self.width, self.keep); 175 } 176 let scale = |side: u32| (u64::from(side) * u64::from(SCREEN_FIT)).div_ceil(u64::from(longest)) as u32; 177 (scale(self.width).max(1), scale(self.keep).max(1)) 178 } 179}
181pub const SHOT_MEMORIES: Shot = Shot { name: "menu-memories", width: 2056, height: 1285, keep: 1285, alt: "A grid of square memory cards on a black screen, each with a pixel picture such as a rainbow, a rocket, a star, a whale and a pumpkin, under day headings: Today, Yesterday with the word Halloween, Thanksgiving and July 15." }; 182pub const SHOT_SCROLLER: Shot = Shot { name: "menu-memories-scroller", width: 1078, height: 2422, keep: 2422, alt: "The same grid on a phone, three cards to a row, with a thin scroll bar down the right edge: a pumpkin marks Halloween on the bar, and the handle is held on Thanksgiving with that name shown beside it." }; 183pub const SHOT_STARS: Shot = Shot { name: "menu-card-pictures", width: 2056, height: 1285, keep: 1285, alt: "An open memory card showing two pictures side by side, one green and one blue, each with a small star in its corner and the second star lit yellow, above the words \u{201c}Ada has a green dinosaur called Rex\u{201d}, a green check button and a red trash can." }; 184pub const SHOT_UNDO: Shot = Shot { name: "menu-undo-toast-card", width: 2056, height: 1285, keep: 1285, alt: "An open card with a pixel pumpkin and the words \u{201c}Ada loves pumpkins\u{201d}, a green check and a red trash can, and at the bottom of the screen a small yellow-edged button with an arrow and the word Undo." }; 185pub const SHOT_HEARTS: Shot = Shot { name: "menu-time", width: 2056, height: 1285, keep: 1285, alt: "A page titled Time left with three pixel hearts, two red and one empty, and the words \u{201c}About 20 minutes left\u{201d}, \u{201c}Today you can play for 30 minutes\u{201d}, \u{201c}The hearts fill up again tomorrow morning\u{201d} and \u{201c}Quiet time starts in about 2 and a half hours\u{201d}." }; 186pub const SHOT_CAPTIONS: Shot = Shot { name: "speaking", width: 2056, height: 1285, keep: 1285, alt: "The grey pixel cat in the middle of a black screen with its mouth open, and under it the caption \u{201c}A bunny can hop really far! Did you know that?\u{201d} in a round, widely spaced typeface, the spoken words bright and the rest grey, with round buttons for the microphone, the camera, pictures and goodnight along the bottom." }; 187pub const SHOT_FIRST_RUN: Shot = Shot { name: "first-run-child", width: 2056, height: 1285, keep: 520, alt: "A screen titled Welcome to Whiskers with a card called About your child: a box for the name, a row of ages from 3 to 12, a Continue button, and a note that a PIN for the grown-ups\u{2019} side comes next." }; 188pub const SHOT_OPENS_ON: Shot = Shot { name: "parents-opens-on-parents", width: 2056, height: 1285, keep: 1285, alt: "The grown-ups\u{2019} side with cards for About your child (a name and an age picked), This device opens on (Whiskers or The parents\u{2019} side, the second chosen), the service address and the start of the Backup card." }; 189pub const SHOT_BACKUP: Shot = Shot { name: "backup-done", width: 1212, height: 341, keep: 341, alt: "A Backup card that says \u{201c}Backed up.\u{201d} in green, the file name of a zip with its date and a size of 4.6 MB, a note that it is in the Downloads folder, and an OK button." }; 190pub const SHOT_FORGET: Shot = Shot { name: "parents-removed-by-her", width: 2056, height: 1285, keep: 440, alt: "A list titled Removed by her with two memories, one about being afraid of the dark and one about a toy fox, each with a Restore button and a Forget button." }; 191 192pub const SHOT_CHARACTER_ENTRY: Shot = Shot { name: "menu-panel", width: 2056, height: 1285, keep: 1285, alt: "The child\u{2019}s side panel open over the grey cat, with three big picture buttons: Memories, Character (showing a small grey cat) and Time left with three hearts, two red and one empty." }; 193pub const SHOT_CHARACTER: Shot = Shot { name: "chooser-panda", width: 2056, height: 1285, keep: 1285, alt: "A pixel panda in the middle of a black screen with a blue arrow button on each side of it, and under it the name of its voice, Jennifer, between two more arrow buttons; a green check at the bottom left keeps the choice and a grey X at the bottom right leaves it." }; 194pub const SHOT_VOICE: Shot = Shot { name: "chooser-voice-speaking", width: 2056, height: 1285, keep: 1285, alt: "The same screen with a pixel fox, and under it the caption \u{201c}Hi Ada, this voice is called Jennifer\u{201d}, the voice\u{2019}s name dimmer than the rest, between the voice arrows, with the green check and the grey X below." }; 195pub const SHOT_MOODS: Shot = Shot { name: "characters-at-a-glance-2", width: 981, height: 1068, keep: 1068, alt: "Six rows of pixel friends on black, a fox, a white rabbit, an owl, a unicorn, a shiba dog and a sloth, each resting, speaking with its mouth open, spinning dizzy and lying asleep with z's rising beside it." }; 196pub const SHOT_PHONE_CHOOSER: Shot = Shot { name: "chooser-pixel", width: 1078, height: 2422, keep: 2422, alt: "The character screen on a phone: the pixel fox in the upper middle with an arrow button on each side, and near the bottom the caption \u{201c}Hi Ada, this voice is called Jennifer\u{201d} between the voice arrows, above the green check and the grey X." };
Every screenshot the page shows, in the order it shows them.
199pub const SHOTS: [Shot; 15] = [SHOT_MEMORIES, SHOT_SCROLLER, SHOT_STARS, SHOT_UNDO, SHOT_HEARTS, SHOT_CAPTIONS, SHOT_CHARACTER_ENTRY, SHOT_CHARACTER, SHOT_VOICE, SHOT_MOODS, SHOT_PHONE_CHOOSER, SHOT_FIRST_RUN, SHOT_OPENS_ON, SHOT_BACKUP, SHOT_FORGET];
201pub const NAME: &str = "Whiskers"; 202pub const TITLE: &str = "Whiskers, a talking cat for a young child"; 203pub const DESCRIPTION: &str = "A talking cat companion for a young child. Parents see every conversation and set the limits, and Jev checks every message before the child sees or hears it. A personal project, not a product.";
The link preview's files, served from worker/public/preview/ (generated by
web/tools/preview) at these paths. The size is the picture's and the video's alike.
212#[cfg(test)] 213mod tests { 214 use super::*; 215 use jev_ui::preview::{Kind, Origin, Picture, Preview, Video, missing}; 216 217 #[test] 218 fn the_icons_are_exactly_the_vendored_and_drawn_files() { 219 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../third-party"); 220 let mut listed: Vec<String> = Vec::new(); 221 for p in Pic::ALL { 222 let (origin, name, art) = p.art(); 223 let ext = if matches!(art, Art::Svg(_)) { "svg" } else { "png" }; 224 let file = format!("{}/{name}.{ext}", origin.directory()); 225 let on_disk = std::fs::read(root.join(&file)).unwrap_or_else(|_| panic!("{file} is missing")); 226 match art { 227 Art::Svg(text) => { 228 assert_eq!(on_disk, text.as_bytes(), "{file}"); 229 assert!(text.contains("fill=\"#"), "{file}"); 230 } 231 Art::Png(bytes) => assert_eq!(on_disk, bytes, "{file}"), 232 } 233 listed.push(file); 234 } 235 let mut unique = listed.clone(); 236 unique.sort(); 237 unique.dedup(); 238 assert_eq!(unique.len(), listed.len(), "an icon listed twice"); 239 let mut found: Vec<String> = Vec::new(); 240 for origin in [Source::Shuqikhor, Source::Drawn, Source::Serenity] { 241 for e in std::fs::read_dir(root.join(origin.directory())).unwrap() { 242 let n = e.unwrap().file_name().into_string().unwrap(); 243 if n.ends_with(".svg") || n.ends_with(".png") { 244 found.push(format!("{}/{n}", origin.directory())); 245 } 246 } 247 } 248 found.sort(); 249 assert_eq!(found, unique, "a vendored file nothing uses, or a use with no file"); 250 } 251 252 #[test] 253 fn every_download_icon_is_in_the_list() { 254 for d in APP_DOWNLOADS.iter().chain([&SERVER_DOWNLOAD]) { 255 assert!(!d.icons.is_empty(), "{}", d.file); 256 for i in d.icons { 257 assert!(Pic::ALL.contains(i)); 258 } 259 } 260 assert!(Pic::ALL.contains(&APP_BUTTON_ICON)); 261 } 262 263 #[test] 264 fn every_screenshot_is_a_golden_of_the_size_declared() { 265 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/app/screenshots"); 266 let mut seen = Vec::new(); 267 for s in SHOTS { 268 assert!(!seen.contains(&s.name), "{} listed twice", s.name); 269 seen.push(s.name); 270 let head = std::fs::read(dir.join(format!("{}.png", s.name))).unwrap_or_else(|_| panic!("no golden {}", s.name)); 271 assert_eq!(&head[..8], b"\x89PNG\r\n\x1a\n", "{}", s.name); 272 let size = |at: usize| u32::from_be_bytes(head[at..at + 4].try_into().unwrap()); 273 assert_eq!((size(16), size(20)), (s.width, s.height), "{} is not the size declared", s.name); 274 assert!(s.alt.len() > 60 && s.alt.ends_with('.'), "{} needs a description of what it shows", s.name); 275 assert!(s.keep <= s.height && s.served().0.max(s.served().1) <= SCREEN_FIT); 276 } 277 } 278 279 #[test] 280 fn the_configured_card_is_buildable() { 281 let origin = Origin::parse(SITE_ORIGIN).unwrap(); 282 let p = Preview { 283 origin: &origin, 284 site_name: NAME, 285 title: TITLE, 286 description: DESCRIPTION, 287 page: "/", 288 picture: Picture { path: PREVIEW_IMAGE, query: None, width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, alt: "x" }, 289 video: Some(Video { path: PREVIEW_VIDEO, width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }), 290 theme_color: "#000000", 291 }; 292 assert!(missing(&p.html().unwrap(), Kind::Video).is_empty()); 293 } 294}