- {normalizedDetails.logoSrc ? (
-

- ) : (
-
{normalizedDetails.name}
- )}
+ {(() => {
+ const logoContent = normalizedDetails.logoSrc ? (
+

+ ) : null;
+
+ if (normalizedDetails.homeHref) {
+ return (
+
+ {logoContent ?? normalizedDetails.name}
+
+ );
+ }
+
+ return logoContent
+ ? logoContent
+ :
{normalizedDetails.name}
;
+ })()}
{normalizedDetails.tagline &&
{normalizedDetails.tagline}
}
{navLinks.length > 0 && (
diff --git a/src/core/links.ts b/src/core/links.ts
index 8658b30..2b54dab 100644
--- a/src/core/links.ts
+++ b/src/core/links.ts
@@ -16,6 +16,9 @@ export function normalizeSafeHref(href: unknown): string | undefined {
}
const protocol = `${schemeMatch[1]?.toLowerCase()}:`;
+ if (protocol === "data:") {
+ return trimmed.startsWith("data:image/") ? trimmed : undefined;
+ }
if (!ALLOWED_PROTOCOLS.has(protocol)) {
return undefined;
}
diff --git a/src/core/validation.test.ts b/src/core/validation.test.ts
index 421ca7b..92bd3e7 100644
--- a/src/core/validation.test.ts
+++ b/src/core/validation.test.ts
@@ -46,6 +46,24 @@ describe("validateBrandDetails", () => {
expect(result.errors).toContain("details.primaryAction.target must be one of: _blank, _self, _parent, _top.");
});
+ it("allows data:image/ URIs for logoSrc and rejects other data: URIs", () => {
+ const validResult = validateBrandDetails({
+ name: "Brand Shell",
+ logoSrc: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E",
+ });
+ expect(validResult.valid).toBe(true);
+ expect(validResult.normalized?.logoSrc).toBeDefined();
+
+ const invalidResult = validateBrandDetails({
+ name: "Brand Shell",
+ logoSrc: "data:text/html,
xss
",
+ });
+ expect(invalidResult.valid).toBe(false);
+ expect(invalidResult.errors).toContain(
+ "details.logoSrc must use a safe URL/path (http, https, mailto, tel, or relative path).",
+ );
+ });
+
it("rejects unsafe href protocols", () => {
const result = validateBrandDetails({
name: "Brand Shell",
diff --git a/src/stories/Footer.stories.tsx b/src/stories/Footer.stories.tsx
index 068bfb7..ed6235b 100644
--- a/src/stories/Footer.stories.tsx
+++ b/src/stories/Footer.stories.tsx
@@ -144,3 +144,30 @@ export const MobileStackedCtas: Story = {
},
},
};
+
+// Inline SVG logo — no network dependency, works in Chromatic
+const svgLogoSrc =
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 32'%3E%3Crect width='120' height='32' rx='6' fill='%230ea5e9'/%3E%3Ctext x='10' y='22' font-family='system-ui%2C sans-serif' font-size='14' font-weight='700' fill='%23fff'%3EBrandShell%3C/text%3E%3C/svg%3E";
+
+export const WithLogo: Story = {
+ args: {
+ details: {
+ ...sharedDetails,
+ logoSrc: svgLogoSrc,
+ logoAlt: "Brand Shell",
+ },
+ },
+};
+
+export const WithLogoAndCustomHeight: Story = {
+ args: {
+ details: {
+ ...sharedDetails,
+ logoSrc: svgLogoSrc,
+ logoAlt: "Brand Shell",
+ },
+ theme: {
+ logoHeight: "1.75rem",
+ },
+ },
+};
diff --git a/src/stories/Header.stories.tsx b/src/stories/Header.stories.tsx
index 87c4625..1987356 100644
--- a/src/stories/Header.stories.tsx
+++ b/src/stories/Header.stories.tsx
@@ -168,3 +168,30 @@ export const MobileStackedCtas: Story = {
},
},
};
+
+// Inline SVG logo — no network dependency, works in Chromatic
+const svgLogoSrc =
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 32'%3E%3Crect width='120' height='32' rx='6' fill='%230ea5e9'/%3E%3Ctext x='10' y='22' font-family='system-ui%2C sans-serif' font-size='14' font-weight='700' fill='%23fff'%3EBrandShell%3C/text%3E%3C/svg%3E";
+
+export const WithLogo: Story = {
+ args: {
+ details: {
+ ...sampleDetails,
+ logoSrc: svgLogoSrc,
+ logoAlt: "Brand Shell",
+ },
+ },
+};
+
+export const WithLogoAndCustomHeight: Story = {
+ args: {
+ details: {
+ ...sampleDetails,
+ logoSrc: svgLogoSrc,
+ logoAlt: "Brand Shell",
+ },
+ theme: {
+ logoHeight: "1.75rem",
+ },
+ },
+};
diff --git a/src/web/index.ts b/src/web/index.ts
index 52a9b37..e0823b4 100644
--- a/src/web/index.ts
+++ b/src/web/index.ts
@@ -362,7 +362,16 @@ function createFooter(
img.src = details.logoSrc;
img.alt = details.logoAlt ?? details.name;
img.className = "brand-shell-footer__logo";
- brand.append(img);
+ if (details.homeHref) {
+ const identity = createAnchor(details.homeHref, "brand-shell-footer__name", details.name, `${details.name} home`, "_self", undefined, linkFactory);
+ identity.textContent = "";
+ identity.append(img);
+ brand.append(identity);
+ } else {
+ brand.append(img);
+ }
+ } else if (details.homeHref) {
+ brand.append(createAnchor(details.homeHref, "brand-shell-footer__name", details.name, `${details.name} home`, "_self", undefined, linkFactory));
} else {
brand.append(createParagraph("brand-shell-footer__name", details.name));
}
diff --git a/styles/default.css b/styles/default.css
index 0edf867..dff8263 100644
--- a/styles/default.css
+++ b/styles/default.css
@@ -446,6 +446,8 @@
.brand-shell-footer__name {
font-size: 1rem;
font-weight: 600;
+ color: var(--_text);
+ text-decoration: none;
margin: 0 0 var(--brand-space-sm) 0;
}