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/six-jeans-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"brand-shell": minor
---

header and footer brandIdentity pattern
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:
run: bun install --frozen-lockfile

- name: Build Storybook
run: bun run build-storybook
run: bun run build-storybook -- --stats-json

- name: Run Chromatic
if: ${{ env.CHROMATIC_PROJECT_TOKEN != '' }}
Expand Down
35 changes: 26 additions & 9 deletions src/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,32 @@ 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">
{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>
)}
{(() => {
const logoContent = normalizedDetails.logoSrc ? (
<img
src={normalizedDetails.logoSrc}
alt={normalizedDetails.logoAlt ?? normalizedDetails.name}
className="brand-shell-footer__logo"
/>
) : null;

if (normalizedDetails.homeHref) {
return (
<LinkEl
href={normalizedDetails.homeHref}
className="brand-shell-footer__name"
aria-label={`${normalizedDetails.name} home`}
target="_self"
>
{logoContent ?? normalizedDetails.name}
</LinkEl>
);
}

return logoContent
? logoContent
: <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
3 changes: 3 additions & 0 deletions src/core/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 18 additions & 0 deletions src/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<h1>xss</h1>",
});
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",
Expand Down
27 changes: 27 additions & 0 deletions src/stories/Footer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
};
27 changes: 27 additions & 0 deletions src/stories/Header.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
};
11 changes: 10 additions & 1 deletion src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
2 changes: 2 additions & 0 deletions styles/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down