From a91b7c96689757660ec5903ccec875a0ce705f1e Mon Sep 17 00:00:00 2001 From: venwork-dev Date: Mon, 23 Feb 2026 21:04:25 -0600 Subject: [PATCH 1/2] feat(release): header and footer brand identity pattern --- .changeset/six-jeans-study.md | 5 +++++ src/Footer.tsx | 35 +++++++++++++++++++++++++--------- src/core/links.ts | 3 +++ src/core/validation.test.ts | 18 +++++++++++++++++ src/stories/Footer.stories.tsx | 27 ++++++++++++++++++++++++++ src/stories/Header.stories.tsx | 27 ++++++++++++++++++++++++++ src/web/index.ts | 11 ++++++++++- styles/default.css | 2 ++ 8 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 .changeset/six-jeans-study.md diff --git a/.changeset/six-jeans-study.md b/.changeset/six-jeans-study.md new file mode 100644 index 0000000..3409871 --- /dev/null +++ b/.changeset/six-jeans-study.md @@ -0,0 +1,5 @@ +--- +"brand-shell": minor +--- + +header and footer brandIdentity pattern diff --git a/src/Footer.tsx b/src/Footer.tsx index a6aac7a..4b1c3d9 100644 --- a/src/Footer.tsx +++ b/src/Footer.tsx @@ -53,15 +53,32 @@ export function Footer({ details, theme, className, renderLink }: FooterProps) {
- {normalizedDetails.logoSrc ? ( - {normalizedDetails.logoAlt - ) : ( -

{normalizedDetails.name}

- )} + {(() => { + const logoContent = normalizedDetails.logoSrc ? ( + {normalizedDetails.logoAlt + ) : 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; } From bc77c949aa0b1dde81c48950f8c6d46f8c9ae919 Mon Sep 17 00:00:00 2001 From: venwork-dev Date: Mon, 23 Feb 2026 21:12:57 -0600 Subject: [PATCH 2/2] feat(release): add --stats-json flag to Storybook build for Chromatic TurboSnap Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4478aca..2398d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 != '' }}