#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SmolwebTheme {
Site,
Plain,
Light,
Dark,
App(SmolwebPalette),
System,
}
impl Default for SmolwebTheme {
fn default() -> Self {
Self::Site
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SmolwebPalette {
pub bg: String,
pub fg: String,
pub link: String,
pub quote: String,
pub pre_bg: String,
}
pub fn stylesheet(theme: SmolwebTheme, site_url: &str) -> String {
let palette = match theme {
SmolwebTheme::Site => site_palette(site_url),
SmolwebTheme::Plain => plain(),
SmolwebTheme::Light | SmolwebTheme::System => light(),
SmolwebTheme::Dark => dark(),
SmolwebTheme::App(palette) => palette,
};
render_css(&palette)
}
fn plain() -> SmolwebPalette {
SmolwebPalette {
bg: "#ffffff".into(),
fg: "#1a1a1a".into(),
link: "#0b57d0".into(),
quote: "#555555".into(),
pre_bg: "#f4f4f4".into(),
}
}
fn light() -> SmolwebPalette {
SmolwebPalette {
bg: "#fbfaf7".into(),
fg: "#23211c".into(),
link: "#1a6e57".into(),
quote: "#5b574e".into(),
pre_bg: "#f0eee8".into(),
}
}
fn dark() -> SmolwebPalette {
SmolwebPalette {
bg: "#16181c".into(),
fg: "#e6e3dc".into(),
link: "#7db4ff".into(),
quote: "#a8a49a".into(),
pre_bg: "#21242a".into(),
}
}
fn site_palette(site_url: &str) -> SmolwebPalette {
let hue = hue_from_host(site_url);
SmolwebPalette {
bg: format!("hsl({hue}, 30%, 97%)"),
fg: format!("hsl({hue}, 25%, 15%)"),
link: format!("hsl({hue}, 70%, 36%)"),
quote: format!("hsl({hue}, 18%, 40%)"),
pre_bg: format!("hsl({hue}, 28%, 93%)"),
}
}
fn hue_from_host(site_url: &str) -> u16 {
let after_scheme = site_url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(site_url);
let host = after_scheme.split('/').next().unwrap_or("");
let mut hash: u32 = 5381;
for byte in host.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(u32::from(byte));
}
(hash % 360) as u16
}
fn render_css(p: &SmolwebPalette) -> String {
let SmolwebPalette {
bg,
fg,
link,
quote,
pre_bg,
} = p;
format!(
".gemtext {{ background:{bg}; color:{fg}; padding:1.5rem 2rem; \
line-height:1.5; font-family:serif; max-width:48rem; }}
.gemtext-h1 {{ font-size:1.8rem; font-weight:700; margin:1.2rem 0 0.6rem; }}
.gemtext-h2 {{ font-size:1.4rem; font-weight:700; margin:1.1rem 0 0.5rem; }}
.gemtext-h3 {{ font-size:1.15rem; font-weight:700; margin:1rem 0 0.4rem; }}
.gemtext-text {{ margin:0.5rem 0; }}
.gemtext-linkline {{ margin:0.25rem 0; }}
.gemtext-link {{ color:{link}; text-decoration:none; }}
.gemtext-link:hover {{ text-decoration:underline; }}
.gemtext-list {{ margin:0.5rem 0; padding-left:1.5rem; }}
.gemtext-item {{ margin:0.15rem 0; }}
.gemtext-quote {{ margin:0.6rem 0; padding:0.2rem 0 0.2rem 1rem; \
border-left:3px solid {quote}; color:{quote}; font-style:italic; }}
.gemtext-pre {{ background:{pre_bg}; padding:0.75rem 1rem; overflow-x:auto; \
font-family:monospace; white-space:pre; margin:0.6rem 0; }}
.gopher {{ background:{bg}; color:{fg}; padding:1.5rem 2rem; line-height:1.5; \
font-family:serif; max-width:48rem; }}
.gopher-info {{ background:{pre_bg}; font-family:monospace; white-space:pre; \
overflow-x:auto; padding:0.5rem 1rem; margin:0.4rem 0; }}
.gopher-error {{ color:{quote}; font-style:italic; margin:0.25rem 0; }}
.gopher-itemline {{ margin:0.2rem 0; }}
.gopher-type {{ color:{quote}; font-family:monospace; font-size:0.85em; \
margin-right:0.5rem; }}
.gopher-link {{ color:{link}; text-decoration:none; }}
.gopher-link:hover {{ text-decoration:underline; }}
.feed {{ background:{bg}; color:{fg}; padding:1.5rem 2rem; line-height:1.5; \
font-family:serif; max-width:48rem; }}
.feed-title {{ font-size:1.8rem; font-weight:700; margin:0.5rem 0 0.2rem; }}
.feed-subtitle {{ color:{quote}; margin:0 0 1rem; }}
.feed-entry {{ border-top:1px solid {pre_bg}; padding:0.9rem 0; }}
.feed-entry-title {{ font-size:1.2rem; font-weight:600; margin:0 0 0.2rem; }}
.feed-entry-link {{ color:{link}; text-decoration:none; }}
.feed-entry-link:hover {{ text-decoration:underline; }}
.feed-entry-date {{ color:{quote}; font-size:0.85em; }}
.feed-entry-summary {{ margin:0.3rem 0 0; }}
"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn site_hue_is_stable_and_host_scoped() {
let a = stylesheet(SmolwebTheme::Site, "gemini://example.test/a");
let b = stylesheet(SmolwebTheme::Site, "gemini://example.test/b/c");
assert_eq!(a, b);
let other = stylesheet(SmolwebTheme::Site, "gemini://elsewhere.test/");
assert_ne!(a, other);
}
#[test]
fn presets_render_their_classes() {
for theme in [SmolwebTheme::Plain, SmolwebTheme::Light, SmolwebTheme::Dark] {
let css = stylesheet(theme, "");
assert!(css.contains(".gemtext-link"));
assert!(css.contains(".gemtext-pre"));
}
}
#[test]
fn default_is_site() {
assert_eq!(SmolwebTheme::default(), SmolwebTheme::Site);
}
#[test]
fn app_theme_uses_the_host_palette() {
let palette = SmolwebPalette {
bg: "#102030".into(),
fg: "#fafafa".into(),
link: "#33ccff".into(),
quote: "#99aabb".into(),
pre_bg: "#0a1622".into(),
};
let css = stylesheet(SmolwebTheme::App(palette), "gemini://x.test/");
assert!(css.contains("#102030"), "uses the host background");
assert!(css.contains("color:#33ccff"), "uses the host link colour");
assert!(!css.contains("hsl("), "App does not derive a per-site hue");
}
}