1//! The few facts that are configuration, each in one place. 2 3/// Where the site is. The Worker reads `SITE_ORIGIN` from its variables first (so a staging 4/// copy previews itself); this is the answer when that is not set. 5pub const SITE_ORIGIN: &str = "https://whiskers.lmjtfy.fun"; 6 7/// The address `git clone` is given on the page: the code host's, which serves the repository 8/// with its submodule (`~/lmjtfy`, `src/clone.rs`). 9pub const CLONE_URL: &str = "https://code.lmjtfy.fun/whiskers.git"; 10 11/// Where the release files are served: the code host's, never this Worker (`~/lmjtfy` builds the 12/// 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/"; 14 15/// Where an icon comes from. Each has its own directory under `web/third-party/`. 16#[derive(Clone, Copy, Debug, PartialEq, Eq)] 17pub enum Source { 18 /// shuqikhor's pixel-icons (MIT), unchanged: `pixel-icons/`. 19 Shuqikhor, 20 /// Drawn for this site in the pack's style, because neither pack has the thing: `pixel-icons-extra/`. 21 Drawn, 22 /// SerenityOS's icons (BSD-2-Clause), unchanged: `serenity-icons/`. 23 Serenity, 24} 25 26impl Source { 27 pub const fn directory(self) -> &'static str { 28 match self { 29 Source::Shuqikhor => "pixel-icons", 30 Source::Drawn => "pixel-icons-extra", 31 Source::Serenity => "serenity-icons", 32 } 33 } 34} 35 36/// An icon's file: pixel art as SVG, or a SerenityOS PNG. 37#[derive(Clone, Copy, Debug, PartialEq, Eq)] 38pub enum Art { 39 Svg(&'static str), 40 Png(&'static [u8]), 41} 42 43/// A pixel icon: the one list of every icon the page may draw, and where each file is. A test holds 44/// it to the three directories (every file used, every use a file). 45#[derive(Clone, Copy, Debug, PartialEq, Eq)] 46pub enum Pic { 47 /// Drawn here: a green Android head. 48 Android, 49 /// Drawn here: the lowercase `arm` wordmark. 50 Arm, 51 SoundHigh, 52 Earth, 53 /// SerenityOS's two computers on a network. 54 Network, 55 Wifi, 56 Claude, 57 Key, 58 /// Drawn here: a chef's hat, for `mise install`. 59 ChefHat, 60 Lock, 61 Calendar, 62 AppleLogo, 63 Microphone, 64 Picture, 65 Magnifier, 66 Clock, 67 Book, 68} 69 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 ]; 81 82 /// 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} 105 106/// One downloadable file: its name (also its label in the address), what the menu calls it, its 107/// approximate size (measured from release 2026.10.12), a line on what it is, and its icons (side by side). 108pub struct Download { 109 pub file: &'static str, 110 pub label: &'static str, 111 pub size: &'static str, 112 pub what: &'static str, 113 pub icons: &'static [Pic], 114} 115 116/// The icon of the "Download for Android" button. 117pub const APP_BUTTON_ICON: Pic = Pic::Android; 118 119/// Every Android build the page offers, in one table: the page draws its rows from it and a test 120/// 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]; 126 127/// The server, `whiskersd`: one static file for any x86-64 Linux, and its checksums. 128pub const SERVER_DOWNLOAD: Download = Download { 129 file: "x86_64-linux.tar.gz", 130 label: "whiskersd (x86-64 Linux)", 131 size: "3.4 MB", 132 what: "One static file for any x86-64 Linux.", 133 icons: &[Pic::Network], 134}; 135pub const CHECKSUMS_FILE: &str = "SHA256SUMS"; 136 137/// The address of a release file. 138pub fn release_url(file: &str) -> String { 139 format!("{RELEASES_URL}{file}") 140} 141 142/// Where the page's screenshots are served from: `worker/public/screens/`, made by `tools/screens` 143/// from the Android screenshot tests' goldens, so they are always the app's current look. 144pub const SCREENS_DIR: &str = "/screens/"; 145 146/// The longest side of a screenshot as served (`tools/screens` shrinks to fit, never enlarges). 147pub const SCREEN_FIT: u32 = 1000; 148 149/// One golden of `android/app/screenshots/`, shown on the page. `width` and `height` are the 150/// golden's own size (a test holds them to the file); the picture is its top `keep` rows, served at most `SCREEN_FIT` 151/// on its longest side. 152#[derive(Clone, Copy, Debug, PartialEq, Eq)] 153pub struct Shot { 154 /// The golden's name without `.png`. 155 pub name: &'static str, 156 pub width: u32, 157 pub height: u32, 158 /// How many rows from the top are shown: the golden's `height`, or fewer when the rest of the 159 /// screen is empty black and only makes the writing smaller. 160 pub keep: u32, 161 /// What the picture shows, written for someone who cannot see it. 162 pub alt: &'static str, 163} 164 165impl Shot { 166 pub fn url(&self) -> String { 167 format!("{SCREENS_DIR}{}.png", self.name) 168 } 169 170 /// 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} 180 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." }; 197 198/// 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]; 200 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."; 204 205/// The link preview's files, served from `worker/public/preview/` (generated by 206/// `web/tools/preview`) at these paths. The size is the picture's and the video's alike. 207pub const PREVIEW_IMAGE: &str = "/preview/card.png"; 208pub const PREVIEW_VIDEO: &str = "/preview/loop.mp4"; 209pub const PREVIEW_WIDTH: u32 = 1200; 210pub const PREVIEW_HEIGHT: u32 = 630; 211 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}