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} -
{tile_inner}
-

{headline_escaped}

- {desc_html} -
- {cta_label_escaped} - {ghost_html} -

+
+ {eyebrow_html} +
{tile_inner}
+

{headline_escaped}

+ {desc_html} +
+ {cta_label_escaped} + {ghost_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,