diff --git a/server/src/api/links/landing.rs b/server/src/api/links/landing.rs
index bce672f..ae6f73e 100644
--- a/server/src/api/links/landing.rs
+++ b/server/src/api/links/landing.rs
@@ -12,9 +12,12 @@ use mongodb::bson::DateTime;
use serde_json::json;
use super::palette::{derive_palette, DerivedPalettes, Palette};
+use super::qr::qr_svg;
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::landing::models::{
+ ButtonFill, CornerStyle, Elevation, FontPreset, LandingTheme, DEFAULT_ACCENT,
+};
use crate::services::links::models::{AgentContext, Link, SocialPreview};
// ── Public surface ──
@@ -23,6 +26,8 @@ pub(crate) struct LandingPageContext<'a> {
pub os: Os,
pub link: &'a Link,
pub link_id: &'a str,
+ /// Canonical public URL of this link, encoded into the desktop QR code.
+ pub link_url: &'a str,
/// Resolved brand config — the renderer's sole branding input.
pub theme: &'a LandingTheme,
pub social_preview: Option<&'a SocialPreview>,
@@ -47,7 +52,32 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
theme.theme_color.as_deref().unwrap_or(DEFAULT_ACCENT),
theme.color_scheme,
);
- let css_vars = build_css_vars(&palettes, theme.font, theme.corner_style);
+ // Font: a Google Fonts family (loaded via ) layered over the preset's
+ // system-font fallback stack. font_family is validated to letters/digits/
+ // spaces, so it's safe in both the URL and the CSS value.
+ let fallback_stack = font_stack(theme.font);
+ let font_value = match theme.font_family.as_deref() {
+ Some(f) => format!("'{f}', {fallback_stack}"),
+ None => fallback_stack.to_string(),
+ };
+ let font_link = theme
+ .font_family
+ .as_deref()
+ .map(|f| {
+ let enc = f.replace(' ', "+");
+ format!(
+ r#"
+
+ "#
+ )
+ })
+ .unwrap_or_default();
+ let css_vars = build_css_vars(&palettes, &font_value, theme.corner_style);
+
+ // Composable button styling — each axis maps to a CSS class.
+ let btn_fill = button_fill_class(theme.button.fill);
+ let elev_class = elevation_class(theme.button.elevation);
+ let btn_radius = button_radius_class(theme.button.radius);
let metadata_fallback = if ctx.social_preview.is_none() {
social_preview_from_metadata(link.metadata.as_ref())
@@ -167,6 +197,9 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
let expiry_html = expiry_notice(link);
+ // Desktop-only QR: a desktop visitor to an app page wants it on their phone.
+ let qr_block = build_qr_block(ctx.link_url);
+
let agent_description = ctx.agent_context.and_then(|ac| ac.description.as_deref());
let meta_desc_tag = agent_description
@@ -191,6 +224,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
+{font_link}
{og_title} — Rift
@@ -209,19 +243,31 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
.side-human, .side-agent {{ min-width:0; }}
/* ── Human side (60%): brand showcase on a localized accent spotlight ── */
.side-human {{ position:relative; overflow:hidden; width:60%; display:flex; align-items:center; justify-content:center; padding:48px 40px; border-right:1px solid var(--border);
- background:radial-gradient(90% 65% at 50% 36%, color-mix(in srgb, var(--accent) 24%, transparent) 0%, color-mix(in srgb, var(--accent) 5%, transparent) 30%, transparent 60%), var(--bg); }}
+ background:radial-gradient(85% 60% at 50% 36%, color-mix(in srgb, var(--accent) 16%, transparent) 0%, color-mix(in srgb, var(--accent) 4%, transparent) 32%, transparent 62%), var(--bg); }}
.side-human::before {{ content:""; position:absolute; inset:0; pointer-events:none; background-image:radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px); background-size:22px 22px; -webkit-mask-image:radial-gradient(70% 60% at 50% 40%, #000, transparent 78%); mask-image:radial-gradient(70% 60% at 50% 40%, #000, transparent 78%); }}
.side-human::after {{ content:""; position:absolute; inset:0; pointer-events:none; background:radial-gradient(140% 130% at 50% 40%, transparent 52%, rgba(0,0,0,0.45) 100%); }}
.side-agent {{ width:40%; background:var(--surface); padding:36px 28px; display:flex; flex-direction:column; overflow-y:auto; }}
- .split.solo .side-human {{ width:100%; border-right:none; }}
+ .split.solo .side-human {{ width:100%; border-right:none; padding:48px 64px; }}
.split.solo .side-agent {{ display:none; }}
+ /* Solo mode (no agent panel) becomes a horizontal two-column hero so the
+ full width is used instead of one tall centered stack. */
+ @media (min-width:900px) {{
+ .split.solo .human-inner {{ display:grid; grid-template-columns:1fr auto; gap:72px; align-items:center; max-width:940px; text-align:left; }}
+ .split.solo .hero-content {{ text-align:left; }}
+ .split.solo .eyebrow {{ margin-bottom:22px; }}
+ .split.solo .tile-wrap {{ margin:0 0 24px; }}
+ .split.solo .subtitle {{ margin:0 0 32px; max-width:38ch; }}
+ .split.solo .cta-row {{ align-items:flex-start; }}
+ .split.solo .qr-block {{ margin-top:0; }}
+ }}
.human-inner {{ position:relative; z-index:1; text-align:center; max-width:400px; width:100%; }}
.eyebrow {{ display:inline-flex; align-items:center; gap:8px; font-size:11px; font-weight:700; letter-spacing:0.14em; text-transform:uppercase; color:var(--text-muted); margin-bottom:26px; }}
.eyebrow .spark {{ width:5px; height:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 10px 1px var(--accent-glow); }}
.tile-wrap {{ position:relative; width:104px; height:104px; margin:0 auto 30px; }}
- .tile-wrap::before {{ content:""; position:absolute; inset:-26px; background:radial-gradient(closest-side, var(--accent-glow), transparent 72%); filter:blur(8px); opacity:0.7; z-index:-1; }}
- .tile {{ position:relative; width:100%; height:100%; border-radius:24px; background:linear-gradient(160deg, var(--surface), var(--bg)); display:grid; place-items:center; overflow:hidden;
- box-shadow:0 0 0 1px var(--border), 0 1px 0 0 rgba(255,255,255,0.08) inset, 0 0 0 6px color-mix(in srgb, var(--accent) 7%, transparent), 0 18px 50px -12px var(--accent-glow), 0 30px 60px -20px rgba(0,0,0,0.7); }}
+ .tile-wrap.elev-glow::before {{ content:""; position:absolute; inset:-26px; background:radial-gradient(closest-side, var(--accent-glow), transparent 72%); filter:blur(8px); opacity:0.7; z-index:-1; }}
+ .tile {{ position:relative; width:100%; height:100%; border-radius:24px; background:linear-gradient(160deg, var(--surface), var(--bg)); display:grid; place-items:center; overflow:hidden; box-shadow:0 0 0 1px var(--border); }}
+ .tile-wrap.elev-soft .tile {{ box-shadow:0 0 0 1px var(--border), 0 16px 40px -20px rgba(0,0,0,0.7); }}
+ .tile-wrap.elev-glow .tile {{ box-shadow:0 0 0 1px var(--border), 0 1px 0 0 rgba(255,255,255,0.08) inset, 0 0 0 6px color-mix(in srgb, var(--accent) 7%, transparent), 0 18px 50px -16px var(--accent-glow), 0 30px 60px -24px rgba(0,0,0,0.7); }}
.tile::after {{ content:""; position:absolute; inset:0; background:linear-gradient(180deg, rgba(255,255,255,0.10), transparent 42%); pointer-events:none; }}
.tile img {{ width:72px; height:72px; object-fit:contain; filter:drop-shadow(0 6px 16px var(--accent-glow)); }}
.monogram {{ font-size:42px; font-weight:700; color:var(--accent); letter-spacing:-0.02em; }}
@@ -229,17 +275,34 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
.human-inner .subtitle {{ margin:0 auto 32px; font-size:16px; line-height:1.5; color:var(--text-muted); max-width:34ch; }}
.cta-row {{ display:flex; flex-direction:column; align-items:center; gap:12px; }}
.btn {{ appearance:none; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; gap:9px; font-weight:600; font-size:16px; letter-spacing:-0.01em; border-radius:var(--radius-btn); transition:transform .18s ease, box-shadow .25s ease; }}
- .btn-primary {{ color:var(--accent-text); padding:15px 34px; background:linear-gradient(180deg, var(--accent-bright), var(--accent-deep));
- box-shadow:0 0 0 1px rgba(255,255,255,0.16) inset, 0 1px 0 0 rgba(255,255,255,0.30) inset, 0 12px 30px -8px var(--accent-glow), 0 2px 6px rgba(0,0,0,0.4); }}
- .btn-primary:hover {{ transform:translateY(-2px); box-shadow:0 0 0 1px rgba(255,255,255,0.20) inset, 0 1px 0 0 rgba(255,255,255,0.36) inset, 0 18px 44px -8px var(--accent-glow), 0 2px 6px rgba(0,0,0,0.4); }}
+ .btn-primary {{ padding:15px 34px; }}
+ .btn-primary:hover {{ transform:translateY(-2px); }}
.btn-primary:active {{ transform:translateY(0); }}
.btn-primary .chev {{ transition:transform .2s ease; }}
.btn-primary:hover .chev {{ transform:translateX(3px); }}
+ /* Button — composable fill */
+ .fill-solid {{ background:var(--accent); color:var(--accent-text); }}
+ .fill-gradient {{ background:linear-gradient(180deg, var(--accent-bright), var(--accent-deep)); color:var(--accent-text); }}
+ .fill-tint {{ background:color-mix(in srgb, var(--accent) 16%, transparent); color:var(--accent-bright); }}
+ .fill-outline {{ background:transparent; color:var(--accent); border:1.5px solid color-mix(in srgb, var(--accent) 55%, transparent); }}
+ /* Button — composable elevation */
+ .btn-primary.elev-soft {{ box-shadow:0 10px 26px -14px rgba(0,0,0,0.75); }}
+ .btn-primary.elev-glow {{ box-shadow:0 8px 24px -12px var(--accent-glow), 0 1px 2px rgba(0,0,0,0.3); }}
+ .btn-primary.elev-glow:hover {{ box-shadow:0 14px 32px -12px var(--accent-glow), 0 1px 2px rgba(0,0,0,0.3); }}
+ /* Button — composable radius override */
+ .btn.rad-sharp {{ border-radius:4px; }}
+ .btn.rad-rounded {{ border-radius:12px; }}
+ .btn.rad-pill {{ border-radius:999px; }}
.btn-ghost {{ color:var(--text-muted); padding:13px 26px; background:color-mix(in srgb, var(--text) 3%, transparent); border:1px solid var(--border); }}
.btn-ghost:hover {{ color:var(--text); border-color:var(--text-muted); }}
.sub {{ color:var(--text-muted); font-size:12px; margin-top:4px; }}
.expiry {{ font-size:12px; color:var(--text-muted); margin-top:18px; }}
.expiry.expired {{ color:#f59e0b; font-weight:600; }}
+ .qr-block {{ margin-top:40px; display:flex; flex-direction:column; align-items:center; gap:12px; }}
+ .qr-card {{ background:#fff; padding:14px; border-radius:16px; box-shadow:0 0 0 1px var(--border), 0 16px 40px -16px rgba(0,0,0,0.7); }}
+ .qr-card svg {{ display:block; width:140px; height:140px; }}
+ .qr-label {{ display:inline-flex; align-items:center; gap:8px; font-size:11px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; color:var(--text-muted); }}
+ .qr-label .spark {{ width:5px; height:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 10px 1px var(--accent-glow); }}
.badge {{ display:inline-flex; align-items:center; gap:8px; background:color-mix(in srgb, var(--accent) 10%, transparent); border:1px solid color-mix(in srgb, var(--accent) 28%, transparent); border-radius:20px; padding:6px 14px; font-size:12px; font-weight:600; color:var(--accent); margin-bottom:8px; width:fit-content; }}
.badge svg {{ flex-shrink:0; stroke:currentColor; }}
.agent-tagline {{ font-size:13px; color:var(--text-muted); margin-bottom:24px; }}
@@ -277,6 +340,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
.side-agent {{ width:100%; padding:28px 20px; }}
.cta-row {{ width:100%; }}
.btn {{ width:100%; max-width:340px; }}
+ .qr-block {{ display:none; }}
}}
@@ -284,16 +348,19 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
- {eyebrow_html}
-
-
{headline_escaped}
- {desc_html}
-
-
{cta_label_escaped}
- {ghost_html}
-
+
+ {eyebrow_html}
+
+
{headline_escaped}
+ {desc_html}
+
+ {expiry_html}
- {expiry_html}
+ {qr_block}
@@ -385,6 +452,10 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
og_image_tag = og_image_tag,
json_ld = json_ld,
css_vars = css_vars,
+ font_link = font_link,
+ btn_fill = btn_fill,
+ elev_class = elev_class,
+ btn_radius = btn_radius,
eyebrow_html = eyebrow_html,
tile_inner = tile_inner,
headline_escaped = headline_escaped,
@@ -393,6 +464,7 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
desc_html = desc_html,
ghost_html = ghost_html,
expiry_html = expiry_html,
+ qr_block = qr_block,
layout_class = layout_class,
agent_panel = agent_panel,
platform_js = platform_js,
@@ -408,12 +480,11 @@ pub(crate) fn render_smart_landing_page(ctx: &LandingPageContext) -> String {
/// 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 {
+fn build_css_vars(palettes: &DerivedPalettes, font_value: &str, 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}; }}"
+ ":root {{ {root} --font:{font_value}; --radius:{radius_card}; --radius-btn:{radius_btn}; }}"
);
if let Some(light) = &palettes.prefers_light {
let light_vars = render_palette_vars(light);
@@ -442,6 +513,33 @@ fn font_stack(font: FontPreset) -> &'static str {
}
}
+fn button_fill_class(fill: ButtonFill) -> &'static str {
+ match fill {
+ ButtonFill::Solid => "fill-solid",
+ ButtonFill::Tint => "fill-tint",
+ ButtonFill::Outline => "fill-outline",
+ ButtonFill::Gradient => "fill-gradient",
+ }
+}
+
+fn elevation_class(elevation: Elevation) -> &'static str {
+ match elevation {
+ Elevation::Flat => "elev-flat",
+ Elevation::Soft => "elev-soft",
+ Elevation::Glow => "elev-glow",
+ }
+}
+
+/// Per-button radius override; empty string means inherit the theme's `--radius-btn`.
+fn button_radius_class(radius: Option
) -> &'static str {
+ match radius {
+ Some(CornerStyle::Sharp) => "rad-sharp",
+ Some(CornerStyle::Rounded) => "rad-rounded",
+ Some(CornerStyle::Pill) => "rad-pill",
+ None => "",
+ }
+}
+
/// `(card_radius, button_radius)` for a corner style.
fn corner_radii(corners: CornerStyle) -> (&'static str, &'static str) {
match corners {
@@ -481,6 +579,17 @@ fn availability_eyebrow(link: &Link) -> String {
format!(r#"{label}"#)
}
+/// Desktop "Scan to install" QR card. Encodes the link's own URL so scanning
+/// it on a phone runs the same resolve flow. Empty if QR generation fails.
+fn build_qr_block(link_url: &str) -> String {
+ match qr_svg(link_url) {
+ Some(svg) => format!(
+ r#"{svg}
Scan to install on your phone
"#
+ ),
+ None => String::new(),
+ }
+}
+
/// 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 {
diff --git a/server/src/api/links/qr.rs b/server/src/api/links/qr.rs
index 4a30d8f..e626056 100644
--- a/server/src/api/links/qr.rs
+++ b/server/src/api/links/qr.rs
@@ -316,6 +316,31 @@ async fn fetch_logo(url: &str) -> Result {
Ok(LogoImage { png_bytes })
}
+/// Render a plain, high-contrast (dark-on-white) SVG QR for inline embedding on
+/// the landing page. Returns `None` on failure so the caller simply omits the
+/// QR rather than breaking the page. Kept dark-on-white — not brand-tinted — so
+/// scannability never depends on the tenant's color choice.
+pub(crate) fn qr_svg(url: &str) -> Option {
+ let dark = Color::from_hex("#101013").ok()?;
+ let light = Color::from_hex("#ffffff").ok()?;
+ let qr = QRCodeStyling::builder()
+ .data(url)
+ .size(200)
+ .margin(8)
+ .shape(ShapeType::Square)
+ .qr_options(QROptions::new().with_error_correction_level(ErrorCorrectionLevel::M))
+ .dots_options(DotsOptions::new(DotType::Rounded).with_color(dark))
+ .corners_square_options(
+ CornersSquareOptions::new(CornerSquareType::ExtraRounded).with_color(dark),
+ )
+ .corners_dot_options(CornersDotOptions::new(CornerDotType::Dot).with_color(dark))
+ .background_options(BackgroundOptions::new(light))
+ .build()
+ .ok()?;
+ let bytes = qr.render(OutputFormat::Svg).ok()?;
+ String::from_utf8(bytes).ok()
+}
+
fn render_qr(
url: &str,
options: &QrRenderOptions,
diff --git a/server/src/api/links/routes.rs b/server/src/api/links/routes.rs
index 46d562d..660aa1e 100644
--- a/server/src/api/links/routes.rs
+++ b/server/src/api/links/routes.rs
@@ -661,10 +661,14 @@ async fn do_resolve(
None
};
+ // Canonical URL of this link — encoded into the desktop QR.
+ let link_url = canonical_link_url(state, &link).await;
+
let html = render_smart_landing_page(&LandingPageContext {
os,
link: &link,
link_id,
+ link_url: &link_url,
theme: &theme,
social_preview: link.social_preview.as_ref(),
agent_context: link.agent_context.as_ref(),
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 2453faa..5a3caa5 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -117,6 +117,9 @@ use crate::app::AppState;
crate::services::landing::models::ColorScheme,
crate::services::landing::models::FontPreset,
crate::services::landing::models::CornerStyle,
+ crate::services::landing::models::ButtonStyle,
+ crate::services::landing::models::ButtonFill,
+ crate::services::landing::models::Elevation,
crate::services::links::models::CreateLinkRequest,
crate::services::links::models::CreateLinkResponse,
crate::services::links::models::BulkCreateLinksRequest,
@@ -211,6 +214,7 @@ use crate::app::AppState;
(name = "Team Members", description = "Invite and manage users on your team"),
(name = "Domains", description = "Custom domain setup and verification"),
(name = "Apps", description = "App configuration and association files (AASA / Asset Links)"),
+ (name = "Tenant", description = "Tenant-level settings — landing-page branding"),
(name = "Links", description = "Create, list, update, delete, and resolve deep links"),
(name = "Lifecycle", description = "Funnel event ingestion — click, attribute, identify, convert"),
(name = "Analytics", description = "Funnel stats and (future) timeseries across any set of links — branched response (new vs returning) → conversions"),
@@ -233,7 +237,7 @@ pub fn router(state: Arc) -> Router> {
"x-tagGroups",
serde_json::json!([
{ "name": "Authentication", "tags": ["Signup", "Secret Keys", "Publishable Keys", "Team Members"] },
- { "name": "Configuration", "tags": ["Domains", "Apps"] },
+ { "name": "Configuration", "tags": ["Domains", "Apps", "Tenant"] },
{ "name": "Links", "tags": ["Links", "Lifecycle"] },
{ "name": "Analytics", "tags": ["Analytics"] },
{ "name": "Integrations", "tags": ["Webhooks", "Sources", "Affiliates"] },
diff --git a/server/src/services/auth/tenants/service.rs b/server/src/services/auth/tenants/service.rs
index 9c4b804..d0bcb49 100644
--- a/server/src/services/auth/tenants/service.rs
+++ b/server/src/services/auth/tenants/service.rs
@@ -84,6 +84,18 @@ fn validate_landing_theme(theme: &LandingTheme) -> Result<(), String> {
if let Some(cta) = &theme.cta_label {
validate_cta(cta).map_err(|e| format!("cta_label: {e}"))?;
}
+ if let Some(family) = &theme.font_family {
+ // A Google Fonts family name goes into a URL + CSS; allow only letters,
+ // digits, and spaces so it can't inject into either.
+ let ok = !family.is_empty()
+ && family.len() <= 50
+ && family
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == ' ');
+ if !ok {
+ return Err("font_family must be a Google Fonts name (letters, digits, spaces)".into());
+ }
+ }
if theme
.brand_name
.as_deref()
diff --git a/server/src/services/landing/models.rs b/server/src/services/landing/models.rs
index ce9aaf5..508549e 100644
--- a/server/src/services/landing/models.rs
+++ b/server/src/services/landing/models.rs
@@ -62,6 +62,51 @@ pub enum CornerStyle {
Pill,
}
+/// How the CTA button is filled. One axis of the composable [`ButtonStyle`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
+#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
+#[serde(rename_all = "snake_case")]
+pub enum ButtonFill {
+ #[default]
+ Solid,
+ /// Low-opacity brand tint.
+ Tint,
+ /// Transparent with a brand border.
+ Outline,
+ /// Brand gradient (bright → deep).
+ Gradient,
+}
+
+/// Depth treatment shared by the CTA button and the app tile, so the page's
+/// elevation reads as one coherent property rather than per-element opinions.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
+#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
+#[serde(rename_all = "snake_case")]
+pub enum Elevation {
+ /// No shadow.
+ Flat,
+ /// Soft neutral drop shadow.
+ Soft,
+ /// Accent-tinted glow.
+ #[default]
+ Glow,
+}
+
+/// Composable CTA button styling — assembled from orthogonal properties rather
+/// than chosen from named presets, so brands compose their own look while every
+/// axis stays a constrained enum (no arbitrary CSS).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
+#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
+pub struct ButtonStyle {
+ #[serde(default)]
+ pub fill: ButtonFill,
+ #[serde(default)]
+ pub elevation: Elevation,
+ /// Corner radius; `None` inherits the theme's `corner_style`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub radius: Option,
+}
+
/// 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.
@@ -79,12 +124,21 @@ pub struct LandingTheme {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = "#FF6B35")]
pub theme_color: Option,
- /// Typeface preset (allowlist).
+ /// Typeface preset — the system-font fallback stack when no `font_family`
+ /// is set (or while a web font loads).
#[serde(default)]
pub font: FontPreset,
+ /// Google Fonts family name (e.g. "Inter", "Space Grotesk"). Loaded from
+ /// Google Fonts when set; falls back to the `font` preset's stack.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[schema(example = "Space Grotesk")]
+ pub font_family: Option,
/// Corner treatment for cards and the CTA button.
#[serde(default)]
pub corner_style: CornerStyle,
+ /// Composable CTA button styling (fill / elevation / radius).
+ #[serde(default)]
+ pub button: ButtonStyle,
/// Brand display name shown in the CTA and header. `None` ⇒ "App".
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = "TableFour")]
@@ -122,7 +176,9 @@ impl Default for LandingTheme {
color_scheme: ColorScheme::default(),
theme_color: None,
font: FontPreset::default(),
+ font_family: None,
corner_style: CornerStyle::default(),
+ button: ButtonStyle::default(),
brand_name: None,
icon_url: None,
logo_url: None,