1pub(crate) struct FontPreset {
27 pub(crate) stack: &'static str,
28 pub(crate) import: Option<&'static str>,
29}
30
31pub(crate) fn resolve_font_preset(name: &str) -> Option<FontPreset> {
33 match name.trim().to_lowercase().as_str() {
34 "system" | "sans" => Some(FontPreset {
35 stack: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, sans-serif",
36 import: None,
37 }),
38 "serif" | "editorial" => Some(FontPreset {
39 stack: "Georgia, \"Palatino Linotype\", \"Book Antiqua\", Palatino, serif",
40 import: None,
41 }),
42 "mono" | "monospace" | "technical" => Some(FontPreset {
43 stack: "\"SF Mono\", \"Fira Code\", \"Cascadia Code\", Menlo, Consolas, monospace",
44 import: None,
45 }),
46 "inter" => Some(FontPreset {
47 stack: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
48 import: Some("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"),
49 }),
50 "montserrat" => Some(FontPreset {
51 stack: "'Montserrat', sans-serif",
52 import: Some("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap"),
53 }),
54 "jetbrains-mono" | "jetbrains" => Some(FontPreset {
55 stack: "'JetBrains Mono', monospace",
56 import: Some("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"),
57 }),
58 "playfair" | "playfair-display" => Some(FontPreset {
59 stack: "'Playfair Display', Georgia, serif",
60 import: Some("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap"),
61 }),
62 "surf-display" => Some(FontPreset {
67 stack: "'Surf Display', -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
68 import: None,
69 }),
70 "lato" => Some(FontPreset {
71 stack: "'Lato', -apple-system, BlinkMacSystemFont, sans-serif",
72 import: Some("https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap"),
73 }),
74 _ => None,
75 }
76}
77
78pub(crate) fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
85 let hex = hex.trim().trim_start_matches('#');
86 match hex.len() {
87 3 => Some((
88 u8::from_str_radix(&hex[0..1], 16).ok()? * 17,
89 u8::from_str_radix(&hex[1..2], 16).ok()? * 17,
90 u8::from_str_radix(&hex[2..3], 16).ok()? * 17,
91 )),
92 6 => Some((
93 u8::from_str_radix(&hex[0..2], 16).ok()?,
94 u8::from_str_radix(&hex[2..4], 16).ok()?,
95 u8::from_str_radix(&hex[4..6], 16).ok()?,
96 )),
97 _ => None,
98 }
99}
100
101pub(crate) fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
103 fn linearize(c: u8) -> f64 {
104 let s = c as f64 / 255.0;
105 if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
106 }
107 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
108}
109
110pub fn contrast_ratio(a: &str, b: &str) -> f64 {
113 let (Some((ar, ag, ab)), Some((br, bg, bb))) = (parse_hex_rgb(a), parse_hex_rgb(b)) else {
114 return 0.0;
115 };
116 let la = relative_luminance(ar, ag, ab);
117 let lb = relative_luminance(br, bg, bb);
118 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
119 (hi + 0.05) / (lo + 0.05)
120}
121
122pub(crate) fn accent_text_color(hex: &str) -> &'static str {
125 let Some((r, g, b)) = parse_hex_rgb(hex) else {
126 return "#fff"; };
128 let lum = relative_luminance(r, g, b);
129 if lum > 0.25 { "#1a1a2e" } else { "#fff" }
133}
134
135const INK_TARGET_LIGHT_BG: &str = "#eef1f7";
140const INK_TARGET_DARK_BG: &str = "#161616";
141
142pub fn accent_ink_color(hex: &str, dark: bool) -> String {
152 let Some((mut r, mut g, mut b)) = parse_hex_rgb(hex) else {
153 return if dark { "#ffffff".into() } else { "#1a1a2e".into() };
154 };
155 let target = if dark { INK_TARGET_DARK_BG } else { INK_TARGET_LIGHT_BG };
156 let (tr, tg, tb) = parse_hex_rgb(target).expect("ink targets are valid hex");
157 let bg_lum = relative_luminance(tr, tg, tb);
158 for _ in 0..200 {
159 let lum = relative_luminance(r, g, b);
160 let (hi, lo) = if lum >= bg_lum { (lum, bg_lum) } else { (bg_lum, lum) };
161 if (hi + 0.05) / (lo + 0.05) >= 4.5 {
162 break;
163 }
164 if dark {
165 r = (r as f64 + (255.0 - r as f64) * 0.05).round().min(255.0) as u8;
166 g = (g as f64 + (255.0 - g as f64) * 0.05).round().min(255.0) as u8;
167 b = (b as f64 + (255.0 - b as f64) * 0.05).round().min(255.0) as u8;
168 } else {
169 r = (r as f64 * 0.96).round() as u8;
170 g = (g as f64 * 0.96).round() as u8;
171 b = (b as f64 * 0.96).round() as u8;
172 }
173 }
174 format!("#{r:02x}{g:02x}{b:02x}")
175}
176
177pub const DEFAULT_ACCENT: &str = "#2563eb";
183
184pub const DEFAULT_STYLE_PACK: &str = "surf";
187
188#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct WsTokens {
193 pub radius_card: &'static str,
194 pub radius_btn: &'static str,
195 pub radius_chip: &'static str,
196 pub radius_img: &'static str,
197 pub border_w: &'static str,
198 pub border_style: &'static str,
199 pub shadow: &'static str,
200 pub shadow_hover: &'static str,
201 pub bg_texture: &'static str,
202 pub hero_bg: &'static str,
203
204 pub hero_btn_radius: &'static str,
213 pub banner_btn_radius: &'static str,
215 pub cta_radius: &'static str,
217 pub form_submit_radius: &'static str,
219 pub control_radius: &'static str,
228 pub feature_card_radius: &'static str,
230 pub feature_card_pad: &'static str,
232 pub feature_card_hover_transform: &'static str,
234 pub feature_card_bg: &'static str,
236 pub tile_surface_bg: &'static str,
238 pub post_card_bg: &'static str,
240 pub post_card_radius: &'static str,
242 pub pg_card_bg: &'static str,
244 pub pg_card_radius: &'static str,
246 pub pg_tile_radius: &'static str,
248 pub details_bg: &'static str,
250 pub details_radius: &'static str,
252 pub form_input_bg: &'static str,
254 pub doc_page_bg: &'static str,
256 pub doc_page_radius: &'static str,
258 pub drawer_link_size: &'static str,
260 pub drawer_link_weight: &'static str,
262}
263
264pub const SURF_SIMPLE_TOKENS: WsTokens = WsTokens {
268 radius_card: "16px",
269 radius_btn: "10px",
270 radius_chip: "999px",
271 radius_img: "10px",
272 border_w: "1px",
273 border_style: "solid",
274 shadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
275 shadow_hover: "0 10px 30px rgba(15, 23, 42, 0.10)",
276 bg_texture: "none",
277 hero_bg: "radial-gradient(120% 80% at 50% 0%, rgba(255, 255, 255, 0.12), transparent 60%), linear-gradient(160deg, var(--accent), color-mix(in oklab, var(--accent) 68%, #0b1023))",
278 hero_btn_radius: "var(--ws-radius-btn)",
283 banner_btn_radius: "var(--radius-sm)",
284 cta_radius: "var(--ws-radius-btn)",
285 form_submit_radius: "var(--ws-control-radius, var(--radius-sm))",
286 control_radius: "initial", feature_card_radius: "var(--ws-radius-card)",
288 feature_card_pad: "1.5rem",
289 feature_card_hover_transform: "translateY(-2px)",
290 feature_card_bg: "color-mix(in srgb, var(--surface) 50%, var(--surface-alt) 50%)",
291 tile_surface_bg: "var(--surface)",
292 post_card_bg: "var(--surface)",
293 post_card_radius: "var(--ws-radius-card)",
294 pg_card_bg: "color-mix(in srgb, var(--surface) 72%, transparent)",
295 pg_card_radius: "var(--ws-radius-card-lg, 20px)",
296 pg_tile_radius: "0",
297 details_bg: "var(--surface-alt)",
298 details_radius: "var(--radius-sm)",
299 form_input_bg: "var(--surface)",
300 doc_page_bg: "var(--surface)",
301 doc_page_radius: "var(--ws-radius-card)",
302 drawer_link_size: "0.9375rem",
303 drawer_link_weight: "500",
304};
305
306pub const COMIC_TOKENS: WsTokens = WsTokens {
311 radius_card: "4px",
312 radius_btn: "6px",
313 radius_chip: "6px",
314 radius_img: "4px",
315 border_w: "3px",
316 border_style: "solid",
317 shadow: "4px 4px 0 rgba(15, 23, 42, 0.85)",
318 shadow_hover: "6px 6px 0 rgba(15, 23, 42, 0.85)",
319 bg_texture: "repeating-linear-gradient(45deg, rgba(15, 23, 42, 0.025) 0 2px, transparent 2px 12px)",
320 hero_bg: "var(--accent)",
321 ..SURF_SIMPLE_TOKENS
325};
326
327pub fn resolve_style_pack(key: &str) -> (&'static str, WsTokens) {
331 match key.trim().to_lowercase().as_str() {
332 "comic" => ("comic", COMIC_TOKENS),
333 "surf" | "" => ("surf", SURF_SIMPLE_TOKENS),
334 _ => ("surf", SURF_SIMPLE_TOKENS),
335 }
336}
337
338#[derive(Debug, Clone, PartialEq)]
347pub struct ResolvedTheme {
348 pub pack_id: String,
350 pub accent: String,
352 pub on_accent: String,
354 pub accent_ink_light: String,
356 pub accent_ink_dark: String,
358 pub font_display: Option<String>,
360 pub font_body: Option<String>,
362 pub tokens: WsTokens,
364}
365
366pub fn resolve_theme(
370 accent: Option<&str>,
371 font: Option<&str>,
372 style_pack: Option<&str>,
373) -> ResolvedTheme {
374 let accent = accent
375 .map(str::trim)
376 .filter(|a| !a.is_empty())
377 .unwrap_or(DEFAULT_ACCENT)
378 .to_string();
379 let (pack_id, tokens) = resolve_style_pack(style_pack.unwrap_or(DEFAULT_STYLE_PACK));
380 let font_stack = font.and_then(resolve_font_preset).map(|p| p.stack.to_string());
381 ResolvedTheme {
382 on_accent: accent_text_color(&accent).to_string(),
383 accent_ink_light: accent_ink_color(&accent, false),
384 accent_ink_dark: accent_ink_color(&accent, true),
385 accent,
386 font_display: font_stack.clone(),
387 font_body: font_stack,
388 pack_id: pack_id.to_string(),
389 tokens,
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
401 fn surf_simple_tokens_match_css_root_defaults() {
402 let css = crate::SURFDOC_CSS;
403 for (token, value) in [
404 ("--ws-radius-card", SURF_SIMPLE_TOKENS.radius_card),
405 ("--ws-radius-btn", SURF_SIMPLE_TOKENS.radius_btn),
406 ("--ws-radius-chip", SURF_SIMPLE_TOKENS.radius_chip),
407 ("--ws-radius-img", SURF_SIMPLE_TOKENS.radius_img),
408 ("--ws-border-w", SURF_SIMPLE_TOKENS.border_w),
409 ("--ws-border-style", SURF_SIMPLE_TOKENS.border_style),
410 ("--ws-shadow-hover", SURF_SIMPLE_TOKENS.shadow_hover),
411 ("--ws-bg-texture", SURF_SIMPLE_TOKENS.bg_texture),
412 ("--ws-hero-bg", SURF_SIMPLE_TOKENS.hero_bg),
413 ] {
414 assert!(
415 css.contains(&format!("{token}: {value};")),
416 "{token} drifted: Rust says `{value}`, surfdoc.css disagrees"
417 );
418 }
419 assert!(css.contains(&format!("--ws-shadow: {};", SURF_SIMPLE_TOKENS.shadow)));
422
423 for (token, value) in [
426 ("--ws-hero-btn-radius", SURF_SIMPLE_TOKENS.hero_btn_radius),
427 ("--ws-banner-btn-radius", SURF_SIMPLE_TOKENS.banner_btn_radius),
428 ("--ws-cta-radius", SURF_SIMPLE_TOKENS.cta_radius),
429 ("--ws-form-submit-radius", SURF_SIMPLE_TOKENS.form_submit_radius),
430 ("--ws-feature-card-radius", SURF_SIMPLE_TOKENS.feature_card_radius),
431 ("--ws-feature-card-pad", SURF_SIMPLE_TOKENS.feature_card_pad),
432 (
433 "--ws-feature-card-hover-transform",
434 SURF_SIMPLE_TOKENS.feature_card_hover_transform,
435 ),
436 ("--ws-feature-card-bg", SURF_SIMPLE_TOKENS.feature_card_bg),
437 ("--ws-tile-surface-bg", SURF_SIMPLE_TOKENS.tile_surface_bg),
438 ("--ws-post-card-bg", SURF_SIMPLE_TOKENS.post_card_bg),
439 ("--ws-post-card-radius", SURF_SIMPLE_TOKENS.post_card_radius),
440 ("--ws-pg-card-bg", SURF_SIMPLE_TOKENS.pg_card_bg),
441 ("--ws-pg-card-radius", SURF_SIMPLE_TOKENS.pg_card_radius),
442 ("--ws-pg-tile-radius", SURF_SIMPLE_TOKENS.pg_tile_radius),
443 ("--ws-details-bg", SURF_SIMPLE_TOKENS.details_bg),
444 ("--ws-details-radius", SURF_SIMPLE_TOKENS.details_radius),
445 ("--ws-form-input-bg", SURF_SIMPLE_TOKENS.form_input_bg),
446 ("--ws-doc-page-bg", SURF_SIMPLE_TOKENS.doc_page_bg),
447 ("--ws-doc-page-radius", SURF_SIMPLE_TOKENS.doc_page_radius),
448 ("--ws-drawer-link-size", SURF_SIMPLE_TOKENS.drawer_link_size),
449 ("--ws-drawer-link-weight", SURF_SIMPLE_TOKENS.drawer_link_weight),
450 ] {
451 assert!(
452 css.contains(&format!("{token}: {value};")),
453 "{token} drifted: Rust says `{value}`, surfdoc.css disagrees"
454 );
455 }
456 assert_eq!(SURF_SIMPLE_TOKENS.control_radius, "initial");
461 assert!(
462 !css.contains("--ws-control-radius:"),
463 "--ws-control-radius must stay fallback-only in surfdoc.css"
464 );
465 assert!(css.contains("var(--ws-control-radius, var(--radius-sm))"));
466 assert!(css.contains("var(--ws-control-radius, 8px)"));
467 }
468
469 #[test]
476 fn ss1_additive_token_var_name_contract() {
477 let additive = [
478 "--ws-hero-btn-radius",
479 "--ws-banner-btn-radius",
480 "--ws-cta-radius",
481 "--ws-form-submit-radius",
482 "--ws-control-radius",
483 "--ws-feature-card-radius",
484 "--ws-feature-card-pad",
485 "--ws-feature-card-hover-transform",
486 "--ws-feature-card-bg",
487 "--ws-tile-surface-bg",
488 "--ws-post-card-bg",
489 "--ws-post-card-radius",
490 "--ws-pg-card-bg",
491 "--ws-pg-card-radius",
492 "--ws-pg-tile-radius",
493 "--ws-details-bg",
494 "--ws-details-radius",
495 "--ws-form-input-bg",
496 "--ws-doc-page-bg",
497 "--ws-doc-page-radius",
498 "--ws-drawer-link-size",
499 "--ws-drawer-link-weight",
500 ];
501 assert_eq!(additive.len(), 22, "SS-1 contract is exactly 22 additive tokens");
502 let unique: std::collections::BTreeSet<_> = additive.iter().collect();
503 assert_eq!(unique.len(), additive.len(), "duplicate var name in contract");
504 for name in additive {
507 assert!(
508 crate::SURFDOC_CSS.contains(name),
509 "{name} not present in surfdoc.css — contract/stylesheet drift"
510 );
511 }
512 }
513
514 #[test]
515 fn unknown_pack_falls_back_to_surf() {
516 let (id, tokens) = resolve_style_pack("old-school");
517 assert_eq!(id, "surf");
518 assert_eq!(tokens, SURF_SIMPLE_TOKENS);
519 }
520
521 #[test]
522 fn comic_pack_resolves() {
523 let (id, tokens) = resolve_style_pack("comic");
524 assert_eq!(id, "comic");
525 assert_eq!(tokens.border_w, "3px");
526 assert_eq!(tokens.shadow, "4px 4px 0 rgba(15, 23, 42, 0.85)");
527 }
528
529 #[test]
530 fn default_theme_is_surf_simple_with_platform_accent() {
531 let t = resolve_theme(None, None, None);
532 assert_eq!(t.pack_id, "surf");
533 assert_eq!(t.accent, DEFAULT_ACCENT);
534 assert_eq!(t.tokens, SURF_SIMPLE_TOKENS);
535 assert_eq!(t.on_accent, "#fff");
536 assert!(t.font_display.is_none());
537 }
538
539 #[test]
542 fn resolved_inks_are_aa_for_platform_palettes() {
543 for accent in ["#2563eb", "#0ea5e9", "#10b981", "#f59e0b", "#8b5cf6", "#3b82f6"] {
544 let t = resolve_theme(Some(accent), None, None);
545 assert!(
546 contrast_ratio(&t.accent_ink_light, "#eef1f7") >= 4.5,
547 "{accent} light ink fails AA"
548 );
549 assert!(
550 contrast_ratio(&t.accent_ink_dark, "#161616") >= 4.5,
551 "{accent} dark ink fails AA"
552 );
553 }
554 }
555
556 #[test]
557 fn font_preset_resolves_into_theme() {
558 let t = resolve_theme(None, Some("inter"), None);
559 assert!(t.font_body.as_deref().unwrap().contains("Inter"));
560 assert!(resolve_theme(None, Some("nope"), None).font_body.is_none());
562 }
563
564 #[test]
568 fn surf_display_font_preset_resolves_without_import() {
569 let p = resolve_font_preset("surf-display").expect("surf-display is a preset");
570 assert!(p.stack.contains("Surf Display"));
571 assert!(p.stack.contains("-apple-system"), "needs a graceful fallback stack");
572 assert!(p.import.is_none(), "surf-display must not emit an import URL");
573 let t = resolve_theme(None, Some("surf-display"), None);
575 assert!(t.font_display.as_deref().unwrap().contains("Surf Display"));
576 assert!(t.font_body.as_deref().unwrap().contains("Surf Display"));
577 }
578}