whiskers.git / web / site / src / config.rs
config.rsannotatedconfig.rssource294 lines · 16.8 KB · raw
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}