Skip to main content

allowthem_server/
branding.rs

1use std::sync::Arc;
2
3use allowthem_core::AllowThem;
4use allowthem_core::applications::BrandingConfig;
5use allowthem_core::types::{AccentInk, ClientId};
6use axum::Extension;
7
8/// Default allowthem accent (white on dark; black on light).
9pub const DEFAULT_ACCENT_HEX: &str = "#ffffff";
10
11/// Pick an AAA-safe text color to pair with an accent fill.
12///
13/// Uses the classic YIQ luminance formula. Threshold 160 was chosen against
14/// the standard Wave Funk pastel palette and a fixture of 20 accents: it
15/// keeps every pastel above the line (black text) and every saturated deep
16/// color below (white text). Invalid hex falls back to white ink — safest
17/// for an accent we can't reason about.
18pub fn derive_ink(hex: &str) -> AccentInk {
19    match parse_hex(hex) {
20        Some((r, g, b)) => {
21            let y = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
22            if y >= 160 {
23                AccentInk::Black
24            } else {
25                AccentInk::White
26            }
27        }
28        None => AccentInk::White,
29    }
30}
31
32/// Resolve the accent quad `(accent_dark, accent_ink_dark, accent_light,
33/// accent_ink_light)` for template emission across both color modes.
34///
35/// Falls back to allowthem's monochrome default when the integrator has no
36/// branding or no accent set. The default inverts between modes so contrast
37/// is AAA either way: white-on-black in dark mode, black-on-white in light
38/// mode. When the integrator sets an accent, the same brand color is used
39/// in both modes (with YIQ-derived ink) so theme toggles never clobber it.
40/// The light-mode pair is computed symmetrically to the dark-mode pair so a
41/// future `accent_hex_light` override can slot in without signature churn.
42/// Also reads the legacy `primary_color` field so existing tenants keep
43/// working until they migrate to `accent_hex`.
44pub fn resolve_accent(
45    branding: Option<&BrandingConfig>,
46) -> (String, &'static str, String, &'static str) {
47    let branded = branding.and_then(|b| b.accent_hex.as_deref().or(b.primary_color.as_deref()));
48    match branded {
49        Some(hex) => {
50            let accent = hex.to_string();
51            let ink = branding
52                .and_then(|b| b.accent_ink)
53                .unwrap_or_else(|| derive_ink(&accent));
54            let accent_light = accent.clone();
55            let ink_light = branding
56                .and_then(|b| b.accent_ink)
57                .unwrap_or_else(|| derive_ink(&accent_light));
58            (accent, ink.as_hex(), accent_light, ink_light.as_hex())
59        }
60        None => (
61            DEFAULT_ACCENT_HEX.to_string(),
62            "#000000",
63            "#000000".to_string(),
64            "#ffffff",
65        ),
66    }
67}
68
69/// Look up branding for an application by client_id.
70///
71/// Returns `None` for missing or inactive applications.
72/// Logs a warning on unexpected DB errors and falls back to `None`.
73pub async fn lookup_branding(
74    ath: &AllowThem,
75    client_id: Option<&ClientId>,
76) -> Option<BrandingConfig> {
77    let cid = client_id?;
78    match ath.db().get_branding_by_client_id(cid).await {
79        Ok(branding) => branding,
80        Err(e) => {
81            tracing::warn!(client_id = %cid, error = %e, "branding lookup failed");
82            None
83        }
84    }
85}
86
87/// Embedder-provided fallback branding, attached to the router via
88/// `Extension<Arc<DefaultBranding>>` when the embedder calls
89/// `AllRoutesBuilder::default_branding`.
90///
91/// Wrapping the `BrandingConfig` in a newtype keeps it disjoint from any
92/// handler that takes `Extension<BrandingConfig>` directly.
93#[derive(Debug, Clone)]
94pub struct DefaultBranding(pub BrandingConfig);
95
96/// Resolve branding for a handler: per-client row if the lookup matches,
97/// else the embedder-supplied default, else `None`.
98pub async fn resolve_branding(
99    ath: &AllowThem,
100    client_id: Option<&ClientId>,
101    default: Option<&BrandingConfig>,
102) -> Option<BrandingConfig> {
103    if let Some(b) = lookup_branding(ath, client_id).await {
104        return Some(b);
105    }
106    default.cloned()
107}
108
109/// Projection of `BrandingConfig` into the flat context keys every pre-auth
110/// template reads directly (not via `branding.*` dotted access): `app_name`,
111/// `logo_url`, and the accent quad.
112///
113/// Handlers also emit `branding => branding` as a separate context key so
114/// templates keep their existing dotted access to `splash_*`, `forced_mode`,
115/// and `font_*` fields.
116pub struct BrandingCtx<'a> {
117    pub app_name: &'a str,
118    pub title_brand: &'a str,
119    pub accent: String,
120    pub accent_ink: &'static str,
121    pub accent_light: String,
122    pub accent_ink_light: &'static str,
123    pub logo_url: Option<&'a str>,
124}
125
126impl<'a> BrandingCtx<'a> {
127    pub fn from_branding(branding: Option<&'a BrandingConfig>) -> Self {
128        let (accent, accent_ink, accent_light, accent_ink_light) = resolve_accent(branding);
129        let app_name = branding
130            .map(|b| b.application_name.as_str())
131            .unwrap_or("allowthem");
132        let title_brand = branding
133            .and_then(|b| b.title_brand.as_deref())
134            .unwrap_or(app_name);
135        Self {
136            app_name,
137            title_brand,
138            accent,
139            accent_ink,
140            accent_light,
141            accent_ink_light,
142            logo_url: branding.and_then(|b| b.logo_url.as_deref()),
143        }
144    }
145}
146
147/// Flatten the embedder default-branding extension into the plain reference
148/// form handlers need to feed into `resolve_branding`.
149///
150/// Handlers declare the extractor as
151/// `Option<Extension<Arc<DefaultBranding>>>`; they all then need the inner
152/// `&BrandingConfig`. This helper removes the per-site `as_ref().map(|Extension(d)| &d.0)`
153/// boilerplate.
154pub fn default_branding_ref(
155    ext: &Option<Extension<Arc<DefaultBranding>>>,
156) -> Option<&BrandingConfig> {
157    ext.as_ref().map(|Extension(d)| &d.0)
158}
159
160fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
161    let bytes = hex.as_bytes();
162    if bytes.len() != 7 || bytes[0] != b'#' {
163        return None;
164    }
165    let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
166    let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
167    let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
168    Some((r, g, b))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use allowthem_core::applications::BrandingConfig;
175    use allowthem_core::types::AccentInk;
176    use allowthem_core::{AllowThem, AllowThemBuilder};
177
178    async fn test_ath() -> AllowThem {
179        AllowThemBuilder::new("sqlite::memory:")
180            .cookie_secure(false)
181            .csrf_key(*b"test-csrf-key-for-binary-tests!!")
182            .build()
183            .await
184            .unwrap()
185    }
186
187    #[tokio::test]
188    async fn resolve_branding_returns_default_when_client_id_is_none() {
189        let ath = test_ath().await;
190        let default = BrandingConfig::new("Fallback Co");
191        let result = resolve_branding(&ath, None, Some(&default)).await;
192        assert_eq!(
193            result.as_ref().map(|b| b.application_name.as_str()),
194            Some("Fallback Co")
195        );
196    }
197
198    #[tokio::test]
199    async fn resolve_branding_returns_none_when_no_client_and_no_default() {
200        let ath = test_ath().await;
201        let result = resolve_branding(&ath, None, None).await;
202        assert!(result.is_none());
203    }
204
205    #[tokio::test]
206    async fn resolve_branding_returns_default_when_client_id_does_not_match() {
207        let ath = test_ath().await;
208        let default = BrandingConfig::new("Fallback Co");
209        let unknown: allowthem_core::types::ClientId =
210            serde_json::from_str("\"ath_does_not_exist\"").unwrap();
211        let result = resolve_branding(&ath, Some(&unknown), Some(&default)).await;
212        assert_eq!(
213            result.as_ref().map(|b| b.application_name.as_str()),
214            Some("Fallback Co")
215        );
216    }
217
218    #[test]
219    fn derive_ink_pastels_pair_with_black() {
220        // Pastel violet, pastel peach, pastel mint — all light enough for black.
221        assert_eq!(derive_ink("#cba6f7"), AccentInk::Black);
222        assert_eq!(derive_ink("#fab387"), AccentInk::Black);
223        assert_eq!(derive_ink("#a6e3a1"), AccentInk::Black);
224    }
225
226    #[test]
227    fn derive_ink_saturated_darks_pair_with_white() {
228        // Deep purple, indigo, near-black — need white ink.
229        assert_eq!(derive_ink("#5b21b6"), AccentInk::White);
230        assert_eq!(derive_ink("#1e1b4b"), AccentInk::White);
231        assert_eq!(derive_ink("#000000"), AccentInk::White);
232    }
233
234    #[test]
235    fn derive_ink_pure_white_pairs_with_black() {
236        assert_eq!(derive_ink("#ffffff"), AccentInk::Black);
237    }
238
239    #[test]
240    fn derive_ink_invalid_hex_defaults_to_white() {
241        // YIQ of an unknown color shouldn't panic; default to White ink
242        // (accent interpreted as near-black).
243        assert_eq!(derive_ink("not-a-color"), AccentInk::White);
244        assert_eq!(derive_ink("#zz"), AccentInk::White);
245    }
246
247    #[test]
248    fn resolve_accent_defaults_without_branding() {
249        let (accent, ink, accent_light, ink_light) = resolve_accent(None);
250        assert_eq!(accent, "#ffffff");
251        assert_eq!(ink, "#000000");
252        assert_eq!(accent_light, "#000000");
253        assert_eq!(ink_light, "#ffffff");
254    }
255
256    #[test]
257    fn resolve_accent_branded_quad_pins_color_in_both_modes() {
258        let b = BrandingConfig {
259            application_name: "test".into(),
260            logo_url: None,
261            primary_color: None,
262            accent_hex: Some("#ff6600".into()),
263            accent_ink: None,
264            forced_mode: None,
265            font_css_url: None,
266            font_family: None,
267            splash_text: None,
268            splash_image_url: None,
269            splash_primitive: None,
270            splash_url: None,
271            shader_cell_scale: None,
272            title_brand: None,
273        };
274        let (accent, ink, accent_light, ink_light) = resolve_accent(Some(&b));
275        // Same brand color in both modes — theme toggles must not clobber it.
276        assert_eq!(accent, "#ff6600");
277        assert_eq!(accent_light, "#ff6600");
278        // YIQ-derived ink is stable across the symmetric call sites.
279        assert_eq!(ink, ink_light);
280    }
281
282    #[test]
283    fn resolve_accent_uses_accent_hex_over_primary_color() {
284        let b = BrandingConfig {
285            application_name: "test".into(),
286            logo_url: None,
287            primary_color: Some("#ff0000".into()),
288            accent_hex: Some("#00ff00".into()),
289            accent_ink: None,
290            forced_mode: None,
291            font_css_url: None,
292            font_family: None,
293            splash_text: None,
294            splash_image_url: None,
295            splash_primitive: None,
296            splash_url: None,
297            shader_cell_scale: None,
298            title_brand: None,
299        };
300        let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
301        assert_eq!(accent, "#00ff00");
302    }
303
304    #[test]
305    fn resolve_accent_falls_back_to_primary_color() {
306        let b = BrandingConfig {
307            application_name: "test".into(),
308            logo_url: None,
309            primary_color: Some("#ff0000".into()),
310            accent_hex: None,
311            accent_ink: None,
312            forced_mode: None,
313            font_css_url: None,
314            font_family: None,
315            splash_text: None,
316            splash_image_url: None,
317            splash_primitive: None,
318            splash_url: None,
319            shader_cell_scale: None,
320            title_brand: None,
321        };
322        let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
323        assert_eq!(accent, "#ff0000");
324    }
325
326    #[test]
327    fn resolve_accent_honors_explicit_ink() {
328        let b = BrandingConfig {
329            application_name: "test".into(),
330            logo_url: None,
331            primary_color: None,
332            accent_hex: Some("#ffffff".into()), // would derive Black ink
333            accent_ink: Some(AccentInk::White), // but explicitly White
334            forced_mode: None,
335            font_css_url: None,
336            font_family: None,
337            splash_text: None,
338            splash_image_url: None,
339            splash_primitive: None,
340            splash_url: None,
341            shader_cell_scale: None,
342            title_brand: None,
343        };
344        let (_accent, ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
345        assert_eq!(ink, "#ffffff");
346    }
347
348    #[test]
349    fn branding_ctx_none_gives_allowthem_defaults() {
350        let ctx = BrandingCtx::from_branding(None);
351        assert_eq!(ctx.app_name, "allowthem");
352        assert_eq!(ctx.title_brand, "allowthem");
353        assert_eq!(ctx.accent, "#ffffff");
354        assert_eq!(ctx.accent_ink, "#000000");
355        assert_eq!(ctx.accent_light, "#000000");
356        assert_eq!(ctx.accent_ink_light, "#ffffff");
357        assert!(ctx.logo_url.is_none());
358    }
359
360    #[test]
361    fn branding_ctx_some_projects_fields() {
362        let b = BrandingConfig::new("Fixture Co")
363            .with_accent("#ff00aa", AccentInk::Black)
364            .with_logo_url("https://cdn.example/logo.svg");
365        let ctx = BrandingCtx::from_branding(Some(&b));
366        assert_eq!(ctx.app_name, "Fixture Co");
367        assert_eq!(ctx.title_brand, "Fixture Co");
368        assert_eq!(ctx.accent, "#ff00aa");
369        assert_eq!(ctx.accent_ink, "#000000"); // YIQ pastel → black ink
370        assert_eq!(ctx.logo_url, Some("https://cdn.example/logo.svg"));
371    }
372
373    #[test]
374    fn default_branding_ref_none_passes_through() {
375        let ext: Option<Extension<Arc<DefaultBranding>>> = None;
376        assert!(default_branding_ref(&ext).is_none());
377    }
378
379    #[test]
380    fn default_branding_ref_some_unwraps_to_inner_branding() {
381        let branding = BrandingConfig::new("Acme");
382        let ext = Some(Extension(Arc::new(DefaultBranding(branding))));
383        let got = default_branding_ref(&ext).expect("should unwrap");
384        assert_eq!(got.application_name, "Acme");
385    }
386
387    #[test]
388    fn branding_ctx_title_brand_defaults_to_app_name() {
389        let b = BrandingConfig::new("Acme Corp");
390        let ctx = BrandingCtx::from_branding(Some(&b));
391        assert_eq!(ctx.title_brand, "Acme Corp");
392    }
393
394    #[test]
395    fn branding_ctx_explicit_title_brand_overrides_app_name() {
396        let b = BrandingConfig::new("Acme Corp").with_title_brand("Acme");
397        let ctx = BrandingCtx::from_branding(Some(&b));
398        assert_eq!(ctx.app_name, "Acme Corp");
399        assert_eq!(ctx.title_brand, "Acme");
400    }
401}