Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-comics-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"brand-shell": minor
---

introduce logo src for customizable logo addition
2 changes: 2 additions & 0 deletions apps/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
7 changes: 5 additions & 2 deletions schemas/brand-shell.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
"customSocialLinks": {
"type": "array",
"items": { "$ref": "#/$defs/CustomSocialLink" }
}
},
"logoSrc": { "type": "string", "minLength": 1 },
"logoAlt": { "type": "string", "minLength": 1 }
}
},
"BrandTheme": {
Expand All @@ -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 }
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@ export function Footer({ details, theme, className, renderLink }: FooterProps) {
<div className="brand-shell-footer__inner">
<div className="brand-shell-footer__top">
<div className="brand-shell-footer__brand">
<p className="brand-shell-footer__name">{normalizedDetails.name}</p>
{normalizedDetails.logoSrc ? (
<img
src={normalizedDetails.logoSrc}
alt={normalizedDetails.logoAlt ?? normalizedDetails.name}
className="brand-shell-footer__logo"
/>
) : (
<p className="brand-shell-footer__name">{normalizedDetails.name}</p>
)}
{normalizedDetails.tagline && <p className="brand-shell-footer__tagline">{normalizedDetails.tagline}</p>}
</div>
{navLinks.length > 0 && (
Expand Down
14 changes: 12 additions & 2 deletions src/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,27 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) {
<a href={href} className={cls} aria-label={ariaLabel} target={target} rel={rel}>{children}</a>
);

const logoContent = normalizedDetails.logoSrc ? (
<img
src={normalizedDetails.logoSrc}
alt={normalizedDetails.logoAlt ?? normalizedDetails.name}
className="brand-shell-header__logo"
/>
) : (
normalizedDetails.name
);

const brandIdentity = normalizedDetails.homeHref ? (
<LinkEl
href={normalizedDetails.homeHref}
className="brand-shell-header__name"
aria-label={`${normalizedDetails.name} home`}
target="_self"
>
{normalizedDetails.name}
{logoContent}
</LinkEl>
) : (
<span className="brand-shell-header__name">{normalizedDetails.name}</span>
<span className="brand-shell-header__name">{logoContent}</span>
);

return (
Expand Down
2 changes: 2 additions & 0 deletions src/core/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/core/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
46 changes: 46 additions & 0 deletions src/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const THEME_KEYS = new Set<keyof BrandTheme>([
"headerHeight",
"footerPadding",
"secondaryButtonBg",
"logoHeight",
]);

type ValidationErrorPath = string;
Expand Down Expand Up @@ -64,6 +65,9 @@ export function validateBrandDetails(details: unknown): BrandValidationResult<No
validateOptionalString(details.discord, "details.discord", errors);
validateSafeHref(details.discord, "details.discord", errors);
validateOptionalString(details.tagline, "details.tagline", errors);
validateOptionalString(details.logoSrc, "details.logoSrc", errors);
validateSafeHref(details.logoSrc, "details.logoSrc", errors);
validateOptionalString(details.logoAlt, "details.logoAlt", errors);

if (details.navLinks != null) {
if (!Array.isArray(details.navLinks)) {
Expand Down
18 changes: 17 additions & 1 deletion src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ function createHeader(
const identity = details.homeHref
? createAnchor(details.homeHref, "brand-shell-header__name", details.name, `${details.name} home`, "_self", undefined, linkFactory)
: createSpan("brand-shell-header__name", details.name);
if (details.logoSrc) {
const img = document.createElement("img");
img.src = details.logoSrc;
img.alt = details.logoAlt ?? details.name;
img.className = "brand-shell-header__logo";
identity.textContent = "";
identity.append(img);
}
inner.append(identity);

const actions = document.createElement("div");
Expand Down Expand Up @@ -349,7 +357,15 @@ function createFooter(

const brand = document.createElement("div");
brand.className = "brand-shell-footer__brand";
brand.append(createParagraph("brand-shell-footer__name", details.name));
if (details.logoSrc) {
const img = document.createElement("img");
img.src = details.logoSrc;
img.alt = details.logoAlt ?? details.name;
img.className = "brand-shell-footer__logo";
brand.append(img);
} else {
brand.append(createParagraph("brand-shell-footer__name", details.name));
}
if (details.tagline) {
brand.append(createParagraph("brand-shell-footer__tagline", details.tagline));
}
Expand Down
20 changes: 20 additions & 0 deletions styles/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@
color: var(--_primary);
}

.brand-shell-header__logo {
height: var(--brand-logo-height, 2rem);
width: auto;
display: block;
object-fit: contain;
}

/* Hide decorative dot when logo is present */
.brand-shell-header__name:has(.brand-shell-header__logo)::before {
display: none;
}

.brand-shell-header__name:focus-visible {
outline: 2px solid var(--_primary);
outline-offset: 3px;
Expand Down Expand Up @@ -437,6 +449,14 @@
margin: 0 0 var(--brand-space-sm) 0;
}

.brand-shell-footer__logo {
height: calc(var(--brand-logo-height, 2rem) * 0.75);
width: auto;
display: block;
object-fit: contain;
margin-bottom: var(--brand-space-sm);
}

.brand-shell-footer__nav {
display: flex;
align-items: center;
Expand Down