From 137007633663ae00ce43e6ad5c39b9f21032d974 Mon Sep 17 00:00:00 2001 From: venwork-dev Date: Mon, 23 Feb 2026 14:06:08 -0600 Subject: [PATCH] feat(release): add logo src capabilities --- .changeset/real-comics-marry.md | 5 ++++ apps/docs/src/App.tsx | 2 ++ schemas/brand-shell.schema.json | 7 +++-- src/Footer.tsx | 10 ++++++- src/Header.tsx | 14 ++++++++-- src/core/shell.ts | 2 ++ src/core/theme.ts | 1 + src/core/types.ts | 6 +++++ src/core/validation.test.ts | 46 +++++++++++++++++++++++++++++++++ src/core/validation.ts | 4 +++ src/web/index.ts | 18 ++++++++++++- styles/default.css | 20 ++++++++++++++ 12 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 .changeset/real-comics-marry.md diff --git a/.changeset/real-comics-marry.md b/.changeset/real-comics-marry.md new file mode 100644 index 0000000..d38c154 --- /dev/null +++ b/.changeset/real-comics-marry.md @@ -0,0 +1,5 @@ +--- +"brand-shell": minor +--- + +introduce logo src for customizable logo addition diff --git a/apps/docs/src/App.tsx b/apps/docs/src/App.tsx index 767ce0f..79c3329 100644 --- a/apps/docs/src/App.tsx +++ b/apps/docs/src/App.tsx @@ -32,6 +32,8 @@ const DEFAULT_DETAILS: BrandDetails = { twitter: "https://x.com/example", email: "hello@example.com", tagline: "One shared brand shell across frameworks.", + logoSrc: "https://avatars.githubusercontent.com/u/9919?s=64&v=4", + logoAlt: "Brand Shell", }; const DEFAULT_THEME: BrandTheme = { diff --git a/schemas/brand-shell.schema.json b/schemas/brand-shell.schema.json index 7460501..d6bbc63 100644 --- a/schemas/brand-shell.schema.json +++ b/schemas/brand-shell.schema.json @@ -83,7 +83,9 @@ "customSocialLinks": { "type": "array", "items": { "$ref": "#/$defs/CustomSocialLink" } - } + }, + "logoSrc": { "type": "string", "minLength": 1 }, + "logoAlt": { "type": "string", "minLength": 1 } } }, "BrandTheme": { @@ -101,7 +103,8 @@ "borderRadius": { "type": "string", "minLength": 1 }, "headerHeight": { "type": "string", "minLength": 1 }, "footerPadding": { "type": "string", "minLength": 1 }, - "secondaryButtonBg": { "type": "string", "minLength": 1 } + "secondaryButtonBg": { "type": "string", "minLength": 1 }, + "logoHeight": { "type": "string", "minLength": 1 } } } } diff --git a/src/Footer.tsx b/src/Footer.tsx index 3b5eae4..a6aac7a 100644 --- a/src/Footer.tsx +++ b/src/Footer.tsx @@ -53,7 +53,15 @@ export function Footer({ details, theme, className, renderLink }: FooterProps) {
-

{normalizedDetails.name}

+ {normalizedDetails.logoSrc ? ( + {normalizedDetails.logoAlt + ) : ( +

{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.logoAlt + ) : ( + 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