-
{normalizedDetails.name}
+ {normalizedDetails.logoSrc ? (
+

+ ) : (
+
{normalizedDetails.name}
+ )}
{normalizedDetails.tagline &&
{normalizedDetails.tagline}
}
{navLinks.length > 0 && (
diff --git a/src/Header.tsx b/src/Header.tsx
index d46f64c..fcf6398 100644
--- a/src/Header.tsx
+++ b/src/Header.tsx
@@ -55,6 +55,16 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) {
{children}
);
+ const logoContent = normalizedDetails.logoSrc ? (
+

+ ) : (
+ normalizedDetails.name
+ );
+
const brandIdentity = normalizedDetails.homeHref ? (
- {normalizedDetails.name}
+ {logoContent}
) : (
-
{normalizedDetails.name}
+
{logoContent}
);
return (
diff --git a/src/core/shell.ts b/src/core/shell.ts
index 1d8c085..18a4877 100644
--- a/src/core/shell.ts
+++ b/src/core/shell.ts
@@ -64,6 +64,8 @@ export function normalizeBrandDetails(details: BrandDetails): NormalizedBrandDet
primaryAction: normalizedPrimaryAction,
secondaryAction: normalizedSecondaryAction,
customSocialLinks: normalizeCustomSocialLinks(details.customSocialLinks),
+ logoSrc: normalizeSafeHref(details.logoSrc),
+ logoAlt: details.logoAlt,
};
}
diff --git a/src/core/theme.ts b/src/core/theme.ts
index 3b90253..327dad9 100644
--- a/src/core/theme.ts
+++ b/src/core/theme.ts
@@ -25,6 +25,7 @@ export function themeToCssVariables(theme?: BrandTheme | null): ThemeVariables {
if (theme.headerHeight != null) style[`--${THEME_VAR_PREFIX}-header-height`] = theme.headerHeight;
if (theme.footerPadding != null) style[`--${THEME_VAR_PREFIX}-footer-padding`] = theme.footerPadding;
if (theme.secondaryButtonBg != null) style[`--${THEME_VAR_PREFIX}-button-secondary`] = theme.secondaryButtonBg;
+ if (theme.logoHeight != null) style[`--${THEME_VAR_PREFIX}-logo-height`] = theme.logoHeight;
return style;
}
diff --git a/src/core/types.ts b/src/core/types.ts
index dd533fe..0f0043a 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -71,6 +71,10 @@ export interface BrandDetails {
tagline?: string;
/** Additional social links for custom platforms (Bluesky, Mastodon, YouTube, etc.) */
customSocialLinks?: CustomSocialLink[];
+ /** URL to logo image shown in header/footer. Replaces the text name visually. */
+ logoSrc?: string;
+ /** Alt text for the logo. Defaults to `name` if omitted. */
+ logoAlt?: string;
}
/**
@@ -102,4 +106,6 @@ export interface BrandTheme {
footerPadding?: string;
/** Secondary button background color → --brand-button-secondary */
secondaryButtonBg?: string;
+ /** Logo image height (e.g. "2rem", "32px") → --brand-logo-height */
+ logoHeight?: string;
}
diff --git a/src/core/validation.test.ts b/src/core/validation.test.ts
index 76ea315..421ca7b 100644
--- a/src/core/validation.test.ts
+++ b/src/core/validation.test.ts
@@ -75,6 +75,52 @@ describe("validateBrandDetails", () => {
expect(result.valid).toBe(false);
expect(result.errors).toContain("details.email must be a valid email or mailto URL.");
});
+
+ it("accepts valid logoSrc and logoAlt", () => {
+ const result = validateBrandDetails({
+ name: "Brand Shell",
+ logoSrc: "https://example.com/logo.svg",
+ logoAlt: "Brand Shell logo",
+ });
+
+ expect(result.valid).toBe(true);
+ expect(result.normalized?.logoSrc).toBe("https://example.com/logo.svg");
+ expect(result.normalized?.logoAlt).toBe("Brand Shell logo");
+ });
+
+ it("accepts relative logoSrc", () => {
+ const result = validateBrandDetails({
+ name: "Brand Shell",
+ logoSrc: "/images/logo.svg",
+ });
+
+ expect(result.valid).toBe(true);
+ expect(result.normalized?.logoSrc).toBe("/images/logo.svg");
+ expect(result.normalized?.logoAlt).toBeUndefined();
+ });
+
+ it("rejects unsafe logoSrc protocols", () => {
+ const result = validateBrandDetails({
+ name: "Brand Shell",
+ logoSrc: "javascript:alert(1)",
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ "details.logoSrc must use a safe URL/path (http, https, mailto, tel, or relative path).",
+ );
+ });
+
+ it("accepts logoSrc without logoAlt, logoAlt defaults to name", () => {
+ const result = validateBrandDetails({
+ name: "Acme Corp",
+ logoSrc: "https://example.com/logo.png",
+ });
+
+ expect(result.valid).toBe(true);
+ expect(result.normalized?.logoSrc).toBe("https://example.com/logo.png");
+ expect(result.normalized?.logoAlt).toBeUndefined();
+ });
});
describe("validateBrandTheme", () => {
diff --git a/src/core/validation.ts b/src/core/validation.ts
index 1570fa7..d8124d3 100644
--- a/src/core/validation.ts
+++ b/src/core/validation.ts
@@ -18,6 +18,7 @@ const THEME_KEYS = new Set
([
"headerHeight",
"footerPadding",
"secondaryButtonBg",
+ "logoHeight",
]);
type ValidationErrorPath = string;
@@ -64,6 +65,9 @@ export function validateBrandDetails(details: unknown): BrandValidationResult