diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 7e0a3cb..872b44a 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -5,6 +5,11 @@ on: branches: [main] push: branches: [main] + # Manual one-click deploy of any branch to sandbox: + # gh workflow run server-ci.yml --ref + # (or the "Run workflow" button in the Actions tab). Runs lint + test, then + # deploy-sandbox only — deploy-prod stays gated to pushes on main. + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -63,7 +68,7 @@ jobs: deploy-sandbox: name: Deploy to Sandbox needs: [lint, test] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && github.event_name == 'push') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index bec5ba3..c2332ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,12 @@ cargo test # All tests pass - If an import is unused, remove it — don't `#[allow(unused_imports)]` - Run `cargo fmt` before committing to avoid formatting failures in CI +**Final test ordering.** The full `cargo test` run is slow. After `cargo fmt --check` +and `cargo clippy` pass, **push the branch and open the PR first, then run the final +`cargo test` locally.** This lets remote CI run the suite in parallel with your local +run instead of serializing them. (Only the final full test run gets this treatment — +fast/targeted tests during development still run before pushing as normal.) + ## Testing Tests must live in **separate files**, not inline in source files. This keeps source files focused on production code and keeps PR diffs reviewable. diff --git a/server/src/api/links/landing.rs b/server/src/api/links/landing.rs index 1641c1e..8f003f0 100644 --- a/server/src/api/links/landing.rs +++ b/server/src/api/links/landing.rs @@ -1,11 +1,20 @@ //! Smart landing page renderer used by `do_resolve` for browser-targeted //! GETs against `/r/{link_id}` and `/{link_id}` (custom domain). Returns //! HTML; the JSON resolve flow lives in `routes.rs`. - +//! +//! The visual layer is the first-party `Default` template. It consumes a +//! [`LandingTheme`] (brand config) + the link's content, derives a full color +//! palette from the brand accent (`palette::derive_palette`), and renders +//! everything through CSS custom properties so a single template flatters many +//! brands without anyone being able to author trash. + +use mongodb::bson::DateTime; use serde_json::json; +use super::palette::{derive_palette, DerivedPalettes, Palette}; use super::routes::{append_query_param, html_escape}; use crate::core::platform::Os; +use crate::services::landing::models::{CornerStyle, FontPreset, LandingTheme, DEFAULT_ACCENT}; use crate::services::links::models::{AgentContext, Link, SocialPreview}; // ── Public surface ── @@ -14,9 +23,8 @@ pub(crate) struct LandingPageContext<'a> { pub os: Os, pub link: &'a Link, pub link_id: &'a str, - pub app_name: Option<&'a str>, - pub icon_url: Option<&'a str>, - pub theme_color: Option<&'a str>, + /// Resolved brand config — the renderer's sole branding input. + pub theme: &'a LandingTheme, pub social_preview: Option<&'a SocialPreview>, pub agent_context: Option<&'a AgentContext>, pub link_status: &'a str, @@ -26,12 +34,21 @@ pub(crate) struct LandingPageContext<'a> { } pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { - let app_name_display = ctx.app_name.unwrap_or("App"); - let theme = ctx.theme_color.unwrap_or("#0d9488"); + let theme = ctx.theme; + let app_name_display = theme.brand_name.as_deref().unwrap_or("App"); let os = ctx.os; let link = ctx.link; let platform_js = js_escape(os.as_str()); + // Derive the palette from the brand accent, then express it as CSS custom + // properties. Every rule below references var(--…) so the dark/light split + // and per-brand tint are data, not duplicated CSS. + let palettes = derive_palette( + theme.theme_color.as_deref().unwrap_or(DEFAULT_ACCENT), + theme.color_scheme, + ); + let css_vars = build_css_vars(&palettes, theme.font, theme.corner_style); + let metadata_fallback = if ctx.social_preview.is_none() { social_preview_from_metadata(link.metadata.as_ref()) } else { @@ -84,89 +101,15 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { let og_title = preview_title.unwrap_or(app_name_display); let og_description = preview_description.unwrap_or("Open in app"); - let json_ld = if let Some(ac) = ctx.agent_context { - if ac.action.is_some() || ac.cta.is_some() || ac.description.is_some() { - let action_type = ac - .action - .as_deref() - .map(action_to_schema_type) - .unwrap_or("ViewAction"); - - let entry_points: Vec<_> = [ - ( - ctx.link.ios_deep_link.as_deref(), - "http://schema.org/IOSPlatform", - ), - ( - ctx.link.android_deep_link.as_deref(), - "http://schema.org/AndroidPlatform", - ), - ( - ctx.link.web_url.as_deref(), - "http://schema.org/DesktopWebPlatform", - ), - ] - .into_iter() - .filter_map(|(opt, platform)| { - opt.map(|url| { - json!({ - "@type": "EntryPoint", - "urlTemplate": url, - "actionPlatform": platform, - }) - }) - }) - .collect(); - - let mut action = json!({ - "@context": "https://schema.org", - "@type": action_type, - }); - if let Some(cta) = &ac.cta { - action["name"] = json!(cta); - } - if let Some(desc) = &ac.description { - action["description"] = json!(desc); - } - if !entry_points.is_empty() { - action["target"] = json!(entry_points); - } - - // Add public preview info if available. - if preview_title.is_some() || preview_description.is_some() { - let mut product = json!({"@type": "Product"}); - if let Some(t) = preview_title { - product["name"] = json!(t); - } - if let Some(d) = preview_description { - product["description"] = json!(d); - } - action["object"] = product; - } + // CTA label: a brand override, else "Open in {brand}". The platform script + // swaps in store/web verbs ("Get …", "Continue") when the app can't open. + let cta_label = theme + .cta_label + .clone() + .unwrap_or_else(|| format!("Open in {app_name_display}")); + let cta_label_js = js_escape(&cta_label); - // Add provenance metadata. - action["provider"] = json!({ - "@type": "Organization", - "name": ctx.tenant_domain.unwrap_or("unknown"), - "additionalProperty": [ - { "@type": "PropertyValue", "name": "status", "value": ctx.link_status }, - { "@type": "PropertyValue", "name": "verified", "value": ctx.tenant_verified }, - ] - }); - - let json_str = serde_json::to_string(&action).unwrap_or_default(); - // Escape in JSON-LD to prevent XSS. - let json_str = json_str.replace("{}"#, - json_str - ) - } else { - String::new() - } - } else { - String::new() - }; + let json_ld = build_json_ld(ctx, preview_title, preview_description); let og_image_tag = preview_image .map(|img| { @@ -178,11 +121,17 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { }) .unwrap_or_default(); - let icon_html = ctx + // Preview image rendered as a hero banner (previously only in OG tags). + let hero_html = preview_image + .map(|img| format!(r#""#, html_escape(img))) + .unwrap_or_default(); + + let icon_html = theme .icon_url + .as_deref() .map(|url| { format!( - r#"{}"#, + r#"{}"#, html_escape(url), html_escape(app_name_display), ) @@ -190,23 +139,18 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { .unwrap_or_default(); let title_html = preview_title - .map(|t| { - format!( - r#"

{}

"#, - html_escape(t) - ) - }) + .map(|t| format!(r#"

{}

"#, html_escape(t))) .unwrap_or_default(); - let desc_html = preview_description - .map(|d| { - format!( - r#"

{}

"#, - html_escape(d) - ) - }) + let desc_html = theme + .tagline + .as_deref() + .or(preview_description) + .map(|d| format!(r#"

{}

"#, html_escape(d))) .unwrap_or_default(); + let expiry_html = expiry_notice(link); + let agent_description = ctx.agent_context.and_then(|ac| ac.description.as_deref()); let meta_desc_tag = agent_description @@ -219,7 +163,11 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { }) .unwrap_or_default(); - let agent_panel = build_agent_panel(ctx); + let (agent_panel, layout_class) = if theme.show_agent_panel { + (build_agent_panel(ctx), "split") + } else { + (String::new(), "split solo") + }; format!( r##" @@ -237,66 +185,77 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { {og_image_tag} {json_ld} -
+
+ {hero_html} {icon_html}
{app_name_escaped}
{title_html} {desc_html} - Open in {app_name_escaped} + {cta_label_escaped}

+ {expiry_html}
@@ -310,6 +269,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { var iosStoreUrl = "{ios_store_url_js}"; var webUrl = "{web_url_js}"; var alternateUrl = "{alternate_url_js}"; + var ctaLabel = "{cta_label_js}"; var btn = document.getElementById("open-btn"); var msg = document.getElementById("fallback-msg"); @@ -336,7 +296,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { // a corrected iPad never falls through to the Mac App Store. if (alternateUrl) {{ btn.href = alternateUrl; - btn.textContent = "Open in {app_name_escaped}"; + btn.textContent = ctaLabel; }} else if (iosStoreUrl) {{ btn.href = iosStoreUrl; btn.textContent = "Get {app_name_escaped}"; @@ -349,7 +309,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { // Cross-domain hop triggers App Links. If app installed → opens. // If not → alternate domain redirects to the Play Store. btn.href = alternateUrl; - btn.textContent = "Open in {app_name_escaped}"; + btn.textContent = ctaLabel; }} else if (storeUrl) {{ btn.href = storeUrl; btn.textContent = "Get {app_name_escaped}"; @@ -385,22 +345,176 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String { meta_desc_tag = meta_desc_tag, og_image_tag = og_image_tag, json_ld = json_ld, - theme = html_escape(theme), + css_vars = css_vars, + hero_html = hero_html, icon_html = icon_html, app_name_escaped = html_escape(app_name_display), + cta_label_escaped = html_escape(&cta_label), title_html = title_html, desc_html = desc_html, + expiry_html = expiry_html, + layout_class = layout_class, agent_panel = agent_panel, platform_js = platform_js, store_url_js = store_url_js, ios_store_url_js = ios_store_url_js, web_url_js = web_url_js, alternate_url_js = alternate_url_js, + cta_label_js = cta_label_js, ) } // ── Helpers ── +/// Emit the `:root` custom-property block (plus an optional +/// `prefers-color-scheme: light` override) from a derived palette. +fn build_css_vars(palettes: &DerivedPalettes, font: FontPreset, corners: CornerStyle) -> String { + let (radius_card, radius_btn) = corner_radii(corners); + let font_stack = font_stack(font); + let root = render_palette_vars(&palettes.root); + let mut css = format!( + ":root {{ {root} --font:{font_stack}; --radius:{radius_card}; --radius-btn:{radius_btn}; }}" + ); + if let Some(light) = &palettes.prefers_light { + let light_vars = render_palette_vars(light); + css.push_str(&format!( + " @media (prefers-color-scheme: light) {{ :root {{ {light_vars} }} }}" + )); + } + css +} + +fn render_palette_vars(p: &Palette) -> String { + format!( + "--bg:{}; --surface:{}; --border:{}; --text:{}; --text-muted:{}; --accent:{}; --accent-text:{}; --accent-glow:{};", + p.bg, p.surface, p.border, p.text, p.text_muted, p.accent, p.accent_text, p.accent_glow + ) +} + +fn font_stack(font: FontPreset) -> &'static str { + match font { + FontPreset::System => "system-ui,-apple-system,'Segoe UI',sans-serif", + FontPreset::Serif => "Georgia,'Times New Roman',serif", + FontPreset::Rounded => { + "ui-rounded,'SF Pro Rounded','Hiragino Maru Gothic ProN',system-ui,sans-serif" + } + FontPreset::Mono => "ui-monospace,'SF Mono','Cascadia Code',Menlo,monospace", + } +} + +/// `(card_radius, button_radius)` for a corner style. +fn corner_radii(corners: CornerStyle) -> (&'static str, &'static str) { + match corners { + CornerStyle::Sharp => ("4px", "4px"), + CornerStyle::Rounded => ("14px", "10px"), + CornerStyle::Pill => ("16px", "999px"), + } +} + +/// Human-readable expiry line, or empty when the link never expires. +fn expiry_notice(link: &Link) -> String { + let Some(expires_at) = link.expires_at else { + return String::new(); + }; + let remaining_ms = expires_at.timestamp_millis() - DateTime::now().timestamp_millis(); + if remaining_ms <= 0 { + return r#"

This link has expired

"#.to_string(); + } + let days = remaining_ms / (1000 * 60 * 60 * 24); + let label = match days { + 0 => "Expires today".to_string(), + 1 => "Expires in 1 day".to_string(), + n => format!("Expires in {n} days"), + }; + format!(r#"

{label}

"#) +} + +fn build_json_ld( + ctx: &LandingPageContext, + preview_title: Option<&str>, + preview_description: Option<&str>, +) -> String { + let Some(ac) = ctx.agent_context else { + return String::new(); + }; + if ac.action.is_none() && ac.cta.is_none() && ac.description.is_none() { + return String::new(); + } + + let action_type = ac + .action + .as_deref() + .map(action_to_schema_type) + .unwrap_or("ViewAction"); + + let entry_points: Vec<_> = [ + ( + ctx.link.ios_deep_link.as_deref(), + "http://schema.org/IOSPlatform", + ), + ( + ctx.link.android_deep_link.as_deref(), + "http://schema.org/AndroidPlatform", + ), + ( + ctx.link.web_url.as_deref(), + "http://schema.org/DesktopWebPlatform", + ), + ] + .into_iter() + .filter_map(|(opt, platform)| { + opt.map(|url| { + json!({ + "@type": "EntryPoint", + "urlTemplate": url, + "actionPlatform": platform, + }) + }) + }) + .collect(); + + let mut action = json!({ + "@context": "https://schema.org", + "@type": action_type, + }); + if let Some(cta) = &ac.cta { + action["name"] = json!(cta); + } + if let Some(desc) = &ac.description { + action["description"] = json!(desc); + } + if !entry_points.is_empty() { + action["target"] = json!(entry_points); + } + + // Add public preview info if available. + if preview_title.is_some() || preview_description.is_some() { + let mut product = json!({"@type": "Product"}); + if let Some(t) = preview_title { + product["name"] = json!(t); + } + if let Some(d) = preview_description { + product["description"] = json!(d); + } + action["object"] = product; + } + + // Add provenance metadata. + action["provider"] = json!({ + "@type": "Organization", + "name": ctx.tenant_domain.unwrap_or("unknown"), + "additionalProperty": [ + { "@type": "PropertyValue", "name": "status", "value": ctx.link_status }, + { "@type": "PropertyValue", "name": "verified", "value": ctx.tenant_verified }, + ] + }); + + let json_str = serde_json::to_string(&action).unwrap_or_default(); + // Escape in JSON-LD to prevent XSS. + let json_str = json_str.replace("{json_str}"#) +} + fn action_to_schema_type(action: &str) -> &'static str { match action { "purchase" => "BuyAction", @@ -458,15 +572,14 @@ fn js_escape(s: &str) -> String { fn build_agent_panel(ctx: &LandingPageContext) -> String { let ac = ctx.agent_context; let link = ctx.link; - let theme = ctx.theme_color.unwrap_or("#0d9488"); let mut html = String::new(); - // Badge - html.push_str(&format!( - r#"
Machine-Readable Link
"#, - theme = html_escape(theme) - )); + // Badge — colors inherit from CSS vars (`.badge { color: var(--accent) }`), + // so the inline SVG uses currentColor. + html.push_str( + r#"
Machine-Readable Link
"#, + ); html.push_str( r#"

This link is structured for both humans and AI agents.

"#, ); @@ -557,10 +670,12 @@ fn build_agent_panel(ctx: &LandingPageContext) -> String { } // Footer - html.push_str(r#""); + if !ctx.theme.hide_powered_by { + html.push_str(r#""); + } html } diff --git a/server/src/api/links/mod.rs b/server/src/api/links/mod.rs index a932c4e..a757878 100644 --- a/server/src/api/links/mod.rs +++ b/server/src/api/links/mod.rs @@ -1,5 +1,6 @@ pub mod landing; pub mod models; +pub mod palette; pub mod qr; pub mod routes; diff --git a/server/src/api/links/palette.rs b/server/src/api/links/palette.rs new file mode 100644 index 0000000..eacc82c --- /dev/null +++ b/server/src/api/links/palette.rs @@ -0,0 +1,211 @@ +//! Palette derivation engine for the landing page. +//! +//! Turns a single brand `theme_color` into a full, contrast-correct set of CSS +//! color tokens. The accent is clamped into a tasteful saturation/lightness +//! band so even a neon or muddy input renders well, and the on-accent text +//! color is chosen by WCAG luminance so the CTA is always readable. A garbage +//! color falls back to Rift's default accent rather than breaking the page. +//! +//! Pure presentation logic with no dependencies — lives in the HTTP transport +//! layer because the landing page is HTTP-only (MCP never renders HTML). + +use crate::services::landing::models::{ColorScheme, DEFAULT_ACCENT}; + +// ── Public surface ── + +/// A resolved set of CSS color values for one canvas (dark or light). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Palette { + pub bg: String, + pub surface: String, + pub border: String, + pub text: String, + pub text_muted: String, + pub accent: String, + /// Text/icon color that sits *on* the accent (the CTA button label). + pub accent_text: String, + /// `rgba(...)` glow used for the button shadow. + pub accent_glow: String, +} + +/// The palette(s) a template applies: `root` at `:root`, plus an optional +/// `prefers_light` override emitted under `@media (prefers-color-scheme: light)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DerivedPalettes { + pub root: Palette, + pub prefers_light: Option, +} + +/// Derive the palette(s) for `theme_color` under the given `scheme`. +/// +/// - `Dark` → dark `root`, no override +/// - `Light` → light `root`, no override +/// - `Auto` → dark `root` + a light `prefers-color-scheme` override +pub(crate) fn derive_palette(theme_color: &str, scheme: ColorScheme) -> DerivedPalettes { + let rgb = parse_hex(theme_color) + .or_else(|| parse_hex(DEFAULT_ACCENT)) + .unwrap_or((13, 148, 136)); + let accent_rgb = clamp_accent(rgb); + let accent = to_hex(accent_rgb); + let accent_text = if relative_luminance(accent_rgb) > 0.45 { + "#0a0a0a".to_string() + } else { + "#fafafa".to_string() + }; + let accent_glow = { + let (r, g, b) = accent_rgb; + format!("rgba({r},{g},{b},0.35)") + }; + + let make = |dark: bool| { + let (base_bg, base_surface, base_border, text, text_muted) = if dark { + ( + (10, 10, 10), + (17, 17, 19), + (38, 38, 43), + "#fafafa", + "#a1a1aa", + ) + } else { + ( + (255, 255, 255), + (250, 250, 251), + (228, 228, 231), + "#18181b", + "#52525b", + ) + }; + Palette { + bg: to_hex(mix(base_bg, accent_rgb, 0.06)), + surface: to_hex(mix(base_surface, accent_rgb, 0.08)), + border: to_hex(mix(base_border, accent_rgb, 0.12)), + text: text.to_string(), + text_muted: text_muted.to_string(), + accent: accent.clone(), + accent_text: accent_text.clone(), + accent_glow: accent_glow.clone(), + } + }; + + match scheme { + ColorScheme::Dark => DerivedPalettes { + root: make(true), + prefers_light: None, + }, + ColorScheme::Light => DerivedPalettes { + root: make(false), + prefers_light: None, + }, + ColorScheme::Auto => DerivedPalettes { + root: make(true), + prefers_light: Some(make(false)), + }, + } +} + +// ── Helpers ── + +/// Parse `#RGB` or `#RRGGBB` into an `(r, g, b)` triple. Mirrors the format +/// accepted by `core::validation::validate_hex_color`. +fn parse_hex(s: &str) -> Option<(u8, u8, u8)> { + let hex = s.trim().strip_prefix('#')?; + let expand = |c: char| -> Option { + let d = c.to_digit(16)? as u8; + Some(d * 16 + d) + }; + match hex.len() { + 3 => { + let mut it = hex.chars(); + Some(( + expand(it.next()?)?, + expand(it.next()?)?, + expand(it.next()?)?, + )) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some((r, g, b)) + } + _ => None, + } +} + +fn to_hex((r, g, b): (u8, u8, u8)) -> String { + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Linear-RGB blend: `amount` is the weight of `tint` mixed into `base` +/// (`0.0` → all base, `1.0` → all tint). +fn mix(base: (u8, u8, u8), tint: (u8, u8, u8), amount: f64) -> (u8, u8, u8) { + let a = amount.clamp(0.0, 1.0); + let lerp = |x: u8, y: u8| -> u8 { (f64::from(x) * (1.0 - a) + f64::from(y) * a).round() as u8 }; + ( + lerp(base.0, tint.0), + lerp(base.1, tint.1), + lerp(base.2, tint.2), + ) +} + +/// WCAG relative luminance in `[0, 1]` (black → 0, white → 1). +fn relative_luminance((r, g, b): (u8, u8, u8)) -> f64 { + let lin = |c: u8| { + let s = f64::from(c) / 255.0; + if s <= 0.03928 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} + +/// Clamp an accent into a tasteful saturation/lightness band so neon stays +/// usable and muddy gets lifted — the "can't make trash" guarantee. +fn clamp_accent(rgb: (u8, u8, u8)) -> (u8, u8, u8) { + let (h, s, l) = rgb_to_hsl(rgb); + hsl_to_rgb((h, s.clamp(0.30, 0.90), l.clamp(0.40, 0.65))) +} + +fn rgb_to_hsl((r, g, b): (u8, u8, u8)) -> (f64, f64, f64) { + let r = f64::from(r) / 255.0; + let g = f64::from(g) / 255.0; + let b = f64::from(b) / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + let delta = max - min; + if delta.abs() < f64::EPSILON { + return (0.0, 0.0, l); + } + let s = delta / (1.0 - (2.0 * l - 1.0).abs()); + let h = if (max - r).abs() < f64::EPSILON { + 60.0 * (((g - b) / delta).rem_euclid(6.0)) + } else if (max - g).abs() < f64::EPSILON { + 60.0 * (((b - r) / delta) + 2.0) + } else { + 60.0 * (((r - g) / delta) + 4.0) + }; + (h.rem_euclid(360.0), s, l) +} + +fn hsl_to_rgb((h, s, l): (f64, f64, f64)) -> (u8, u8, u8) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h / 60.0).rem_euclid(2.0) - 1.0).abs()); + let m = l - c / 2.0; + let (r1, g1, b1) = match h { + h if h < 60.0 => (c, x, 0.0), + h if h < 120.0 => (x, c, 0.0), + h if h < 180.0 => (0.0, c, x), + h if h < 240.0 => (0.0, x, c), + h if h < 300.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8; + (to_u8(r1), to_u8(g1), to_u8(b1)) +} + +#[cfg(test)] +#[path = "palette_tests.rs"] +mod tests; diff --git a/server/src/api/links/palette_tests.rs b/server/src/api/links/palette_tests.rs new file mode 100644 index 0000000..8598ab2 --- /dev/null +++ b/server/src/api/links/palette_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::services::landing::models::ColorScheme; + +#[test] +fn parses_short_and_long_hex() { + assert_eq!(parse_hex("#fff"), Some((255, 255, 255))); + assert_eq!(parse_hex("#000000"), Some((0, 0, 0))); + assert_eq!(parse_hex(" #0d9488 "), Some((13, 148, 136))); + assert_eq!(parse_hex("#abc"), Some((170, 187, 204))); +} + +#[test] +fn rejects_malformed_hex() { + assert_eq!(parse_hex("0d9488"), None); // missing '#' + assert_eq!(parse_hex("#12"), None); // wrong length + assert_eq!(parse_hex("#gggggg"), None); // non-hex digits + assert_eq!(parse_hex("rebeccapurple"), None); // named color +} + +#[test] +fn garbage_color_falls_back_to_default_accent() { + // A garbage value must never break rendering — it resolves to Rift teal. + let garbage = derive_palette("not-a-color", ColorScheme::Dark); + let teal = derive_palette("#0d9488", ColorScheme::Dark); + assert_eq!(garbage.root.accent, teal.root.accent); +} + +#[test] +fn accent_is_clamped_into_tasteful_band() { + // Pure neon green: saturation 1.0, lightness 0.5 → saturation must be + // pulled below the 0.90 ceiling, lightness stays within [0.40, 0.65]. + let p = derive_palette("#00ff00", ColorScheme::Dark); + let rgb = parse_hex(&p.root.accent).expect("accent is valid hex"); + let (_, s, l) = rgb_to_hsl(rgb); + assert!( + s <= 0.90 + 1e-3, + "saturation {s} should be clamped to <= 0.90" + ); + assert!( + (0.40..=0.65).contains(&l), + "lightness {l} should be within [0.40, 0.65]" + ); +} + +#[test] +fn very_dark_accent_is_lifted() { + // Near-black accent must be lifted to the lightness floor so it works as a button. + let p = derive_palette("#020202", ColorScheme::Dark); + let rgb = parse_hex(&p.root.accent).unwrap(); + let (_, _, l) = rgb_to_hsl(rgb); + assert!( + l >= 0.40 - 1e-3, + "lightness {l} should be lifted to >= 0.40" + ); +} + +#[test] +fn accent_text_contrasts_with_accent() { + // Bright yellow accent → dark on-accent text. + let yellow = derive_palette("#ffe600", ColorScheme::Dark); + assert_eq!(yellow.root.accent_text, "#0a0a0a"); + // Deep blue accent → light on-accent text. + let blue = derive_palette("#1e3a8a", ColorScheme::Dark); + assert_eq!(blue.root.accent_text, "#fafafa"); +} + +#[test] +fn scheme_controls_which_palettes_are_emitted() { + assert!(derive_palette("#0d9488", ColorScheme::Dark) + .prefers_light + .is_none()); + assert!(derive_palette("#0d9488", ColorScheme::Light) + .prefers_light + .is_none()); + assert!(derive_palette("#0d9488", ColorScheme::Auto) + .prefers_light + .is_some()); +} + +#[test] +fn dark_and_light_backgrounds_differ() { + let auto = derive_palette("#0d9488", ColorScheme::Auto); + let light = auto.prefers_light.unwrap(); + assert_ne!(auto.root.bg, light.bg); + // Dark canvas is near-black; light canvas is near-white. + assert!(relative_luminance(parse_hex(&auto.root.bg).unwrap()) < 0.1); + assert!(relative_luminance(parse_hex(&light.bg).unwrap()) > 0.8); +} + +#[test] +fn background_is_tinted_not_flat_black() { + // The brand tint must actually shift the canvas off pure #0a0a0a. + let p = derive_palette("#ff0000", ColorScheme::Dark); + assert_ne!(p.root.bg, "#0a0a0a"); +} diff --git a/server/src/api/links/routes.rs b/server/src/api/links/routes.rs index e851d3b..ad4fe70 100644 --- a/server/src/api/links/routes.rs +++ b/server/src/api/links/routes.rs @@ -17,6 +17,7 @@ use crate::services::auth::permissions::AuthContext; use crate::services::auth::tenants::models::RedirectMode; use crate::services::domains::models::DomainRole; use crate::services::domains::repo::DomainsRepository; +use crate::services::landing::models::LandingTheme; use crate::services::links::models::LinkError; use crate::services::links::models::*; @@ -643,34 +644,10 @@ async fn do_resolve( lookup_tenant_domain(state.domains_repo.as_deref(), &link.tenant_id).await; if has_platform_destinations { - // Fetch app branding from apps_repo, preferring the detected platform. - let (app_name, icon_url, theme_color) = if let Some(apps_repo) = &state.apps_repo { - let preferred = match os { - Os::Android => "android", - _ => "ios", - }; - let fallback = match os { - Os::Android => "ios", - _ => "android", - }; - let app = apps_repo - .find_by_tenant_platform(&link.tenant_id, preferred) - .await - .ok() - .flatten() - .or(apps_repo - .find_by_tenant_platform(&link.tenant_id, fallback) - .await - .ok() - .flatten()); - ( - app.as_ref().and_then(|a| a.app_name.clone()), - app.as_ref().and_then(|a| a.icon_url.clone()), - app.as_ref().and_then(|a| a.theme_color.clone()), - ) - } else { - (None, None, None) - }; + // Brand config for the landing page. Phase 1 uses Rift defaults (App + // branding is intentionally not consumed); Phase 2 resolves this from + // tenant-level config with per-link content overrides. + let theme = LandingTheme::default(); // Look up alternate domain for the "Open in App" button. let alternate_domain = if let Some(domains_repo) = &state.domains_repo { @@ -688,9 +665,7 @@ async fn do_resolve( os, link: &link, link_id, - app_name: app_name.as_deref(), - icon_url: icon_url.as_deref(), - theme_color: theme_color.as_deref(), + theme: &theme, social_preview: link.social_preview.as_ref(), agent_context: link.agent_context.as_ref(), link_status, diff --git a/server/src/services/landing/mod.rs b/server/src/services/landing/mod.rs new file mode 100644 index 0000000..fca1900 --- /dev/null +++ b/server/src/services/landing/mod.rs @@ -0,0 +1,6 @@ +//! Landing-page branding config. Owns the [`models::LandingTheme`] type — the +//! single brand input the landing-page renderer consumes. Phase 1 ships the +//! type with a `Default` impl (Rift defaults); Phase 2 persists it on the +//! tenant and adds a cascade (`Link override → LandingTheme → Rift default`). + +pub mod models; diff --git a/server/src/services/landing/models.rs b/server/src/services/landing/models.rs new file mode 100644 index 0000000..ce9aaf5 --- /dev/null +++ b/server/src/services/landing/models.rs @@ -0,0 +1,135 @@ +//! Brand-config data types for the link landing page. +//! +//! [`LandingTheme`] is the renderer's sole brand input. Customers *select* a +//! [`Template`] and feed brand inputs (colors, fonts, copy); they never author +//! HTML/CSS. Every knob is an enum or a constrained string so the rendered page +//! can't land in "trash" territory — the palette derivation engine +//! (`api/links/palette.rs`) fills in the hundred sub-decisions tastefully. +//! +//! All types derive both schema systems (the shared REST+MCP pattern from +//! CLAUDE.md) so Phase 2 can expose them over both transports without drift. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Rift's default accent — the teal the landing page shipped with. Used when a +/// theme carries no `theme_color`. +pub const DEFAULT_ACCENT: &str = "#0d9488"; + +/// Which first-party template renders the landing page. First-party and +/// code-owned: the variant is a *selector*, not customer-authored markup. New +/// variants are added over time without breaking existing links. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum Template { + #[default] + Default, +} + +/// Light/dark canvas strategy. `Auto` emits both palettes and lets the +/// browser pick via `prefers-color-scheme`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ColorScheme { + Dark, + Light, + #[default] + Auto, +} + +/// Typeface preset. An allowlist — never an arbitrary font URL (privacy + perf). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum FontPreset { + #[default] + System, + Serif, + Rounded, + Mono, +} + +/// Corner treatment for cards and the CTA button. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum CornerStyle { + Sharp, + #[default] + Rounded, + Pill, +} + +/// Brand configuration for a tenant's landing pages. Phase 1: held in code with +/// a `Default` impl. Phase 2: persisted on `TenantDoc`, with per-link content +/// overrides layered on top. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))] +pub struct LandingTheme { + /// Which first-party template to render. + #[serde(default)] + pub template: Template, + /// Light/dark canvas strategy. + #[serde(default)] + pub color_scheme: ColorScheme, + /// Brand accent (hex `#RGB`/`#RRGGBB`). `None` ⇒ Rift's default teal. The + /// palette engine clamps wild values into a tasteful range. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(example = "#FF6B35")] + pub theme_color: Option, + /// Typeface preset (allowlist). + #[serde(default)] + pub font: FontPreset, + /// Corner treatment for cards and the CTA button. + #[serde(default)] + pub corner_style: CornerStyle, + /// Brand display name shown in the CTA and header. `None` ⇒ "App". + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(example = "TableFour")] + pub brand_name: Option, + /// Square brand mark URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(example = "https://cdn.example.com/icon-512.png")] + pub icon_url: Option, + /// Wordmark/logo URL (reserved for future template use). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + /// Short tagline shown under the title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tagline: Option, + /// CTA button label override. `None` ⇒ "Open in {brand}". + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(example = "Reserve a table")] + pub cta_label: Option, + /// Hide the "Powered by Rift" footer (paid tiers). + #[serde(default)] + pub hide_powered_by: bool, + /// Show the machine-readable agent panel (the 40% side). + #[serde(default = "default_true")] + pub show_agent_panel: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for LandingTheme { + fn default() -> Self { + Self { + template: Template::default(), + color_scheme: ColorScheme::default(), + theme_color: None, + font: FontPreset::default(), + corner_style: CornerStyle::default(), + brand_name: None, + icon_url: None, + logo_url: None, + tagline: None, + cta_label: None, + hide_powered_by: false, + show_agent_panel: true, + } + } +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs index 12e6f5c..2513949 100644 --- a/server/src/services/mod.rs +++ b/server/src/services/mod.rs @@ -7,6 +7,7 @@ pub mod billing; pub mod conversions; pub mod domains; pub mod install_events; +pub mod landing; pub mod links; pub mod tokens; pub mod webhooks; diff --git a/server/tests/api/resolve.rs b/server/tests/api/resolve.rs index f65efce..741a6a9 100644 --- a/server/tests/api/resolve.rs +++ b/server/tests/api/resolve.rs @@ -141,6 +141,50 @@ async fn resolve_ios_deep_link_shows_smart_landing() { assert!(body.contains("apps.apple.com")); // Deep link data is still in JSON-LD for agents/crawlers. assert!(body.contains("myapp://product/42")); + // The Default template drives the palette through CSS custom properties. + assert!(body.contains(":root")); + assert!(body.contains("--accent:")); +} + +#[tokio::test] +async fn resolve_landing_renders_preview_hero_image() { + let app = common::spawn_app().await; + let (key, tenant_id) = common::seed_api_key(&app).await; + common::seed_verified_domain(&app, &tenant_id, "go.example.com").await; + + app.client + .post(app.url("/v1/links")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ + "custom_id": "promo", + "ios_deep_link": "myapp://promo", + "ios_store_url": "https://apps.apple.com/app/id999", + "social_preview": { + "title": "Summer Sale", + "description": "50% off everything", + "image_url": "https://cdn.example.com/promo-banner.jpg" + } + })) + .send() + .await + .unwrap(); + + let resp = app + .client + .get(app.url("/r/promo")) + .header( + "User-Agent", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + ) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body = resp.text().await.unwrap(); + // Preview image is now rendered as a hero (previously only an OG meta tag). + assert!(body.contains(r#"