From e86e0040ccb3b3f85ecdb06b125ae57bdb499151 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Thu, 9 Jul 2026 00:19:44 -0700 Subject: [PATCH] =?UTF-8?q?fix(portal):=20brand=20the=20HTML=20document=20?= =?UTF-8?q?=E2=80=94=20tab=20title,=20favicon,=20og:title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html ships hardcoded 'OpenPartner' + the platform favicon: the one pair of brand marks no in-app setting, cache clear, or incognito window can change. First real-world report came from a white-label admin who 'changed the name and logo but it still appears' — in the browser tab. BrandDocument (mounted once in App) sets document.title, swaps the favicon, and rewrites og:title from the public branding payload: white-label → brand name + brand logo (or a brand-colored SVG monogram — never the platform mark); branded non-white-label → 'Brand · OpenPartner'; platform surfaces keep the shipped defaults. usePublicBrand now keys its cache by the /t// prefix so SPA navigation between platform and tenant surfaces can't serve stale branding, and exposes brandColor. Co-Authored-By: Claude Fable 5 --- apps/portal/src/App.tsx | 2 + .../__tests__/white-label-branding.test.tsx | 3 +- apps/portal/src/lib/BrandDocument.tsx | 59 +++++++++++++++++++ apps/portal/src/lib/useBrand.ts | 10 +++- 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 apps/portal/src/lib/BrandDocument.tsx diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 2b781475..766b64fc 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -71,6 +71,7 @@ import { SigninPage } from './pages/Signin.js'; import { WorkspacesPage } from './pages/Workspaces.js'; import { AddBrandPage } from './pages/AddBrand.js'; import { PartnerJoinPage } from './pages/PartnerJoin.js'; +import { BrandDocument } from './lib/BrandDocument.js'; import { PlatformMagicLandingPage } from './pages/auth/PlatformMagicLanding.js'; import { CreatorSignupPage } from './pages/creator/CreatorSignup.js'; import { CreatorSigninPage } from './pages/creator/CreatorSignin.js'; @@ -132,6 +133,7 @@ export function App() { return ( + {isMultiTenant && hostTenantSlug ? ( <> diff --git a/apps/portal/src/__tests__/white-label-branding.test.tsx b/apps/portal/src/__tests__/white-label-branding.test.tsx index 42c9481c..037c3667 100644 --- a/apps/portal/src/__tests__/white-label-branding.test.tsx +++ b/apps/portal/src/__tests__/white-label-branding.test.tsx @@ -34,7 +34,8 @@ function makeClient(whiteLabel: boolean): QueryClient { }); // Seed the caches useBrand()/usePublicBrand() read so no fetch happens. qc.setQueryData(['program-settings'], { ...BRAND, whiteLabel }); - qc.setQueryData(['public-branding'], { ...BRAND, whiteLabel, tenantSlug: 'acme' }); + // usePublicBrand keys by the /t// URL prefix; jsdom runs at '/'. + qc.setQueryData(['public-branding', null], { ...BRAND, whiteLabel, tenantSlug: 'acme' }); return qc; } diff --git a/apps/portal/src/lib/BrandDocument.tsx b/apps/portal/src/lib/BrandDocument.tsx new file mode 100644 index 00000000..15d99a24 --- /dev/null +++ b/apps/portal/src/lib/BrandDocument.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { usePublicBrand, DEFAULT_BRAND } from './useBrand.js'; + +/** + * Keeps the HTML document itself on-brand: tab title, favicon, and og:title. + * + * index.html ships hardcoded "OpenPartner" + the platform favicon — the one + * pair of brand marks that survives every in-app branding change, cache + * clear, and incognito window (first real-world report: a white-label + * admin who "changed the name and logo but it still appears" — in the tab). + * + * White-label: title is the brand name alone and the favicon is the brand + * logo (or a neutral monogram — never the platform mark). Branded but + * non-white-label tenants get "Brand · OpenPartner". Platform surfaces + * keep the shipped defaults. + */ +export function BrandDocument() { + // Subscribe to navigation so usePublicBrand re-reads the /t// URL + // prefix when the user moves between platform and tenant surfaces. + useLocation(); + const { programName, logoUrl, whiteLabel, brandColor, isLoading } = usePublicBrand(); + + useEffect(() => { + if (isLoading) return; + const branded = programName && programName !== DEFAULT_BRAND; + if (!branded && !whiteLabel) return; // platform surface — leave defaults + + const title = whiteLabel ? programName : `${programName} · ${DEFAULT_BRAND}`; + document.title = title; + document.querySelector('meta[property="og:title"]')?.setAttribute('content', title); + + const icon = logoUrl ?? (whiteLabel ? monogramIcon(programName, brandColor) : null); + if (icon) { + for (const el of Array.from(document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]'))) { + el.remove(); + } + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = icon; + document.head.appendChild(link); + } + }, [programName, logoUrl, whiteLabel, brandColor, isLoading]); + + return null; +} + +/** Tiny SVG monogram data-URI — the white-label no-logo fallback, so the + * tab never shows the platform mark. */ +function monogramIcon(name: string, brandColor: string | null): string { + const initial = (name.trim()[0] ?? '?').toUpperCase(); + const bg = brandColor && /^#[0-9a-fA-F]{3,8}$/.test(brandColor) ? brandColor : '#1f2937'; + const svg = + `` + + `` + + `${initial}` + + ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} diff --git a/apps/portal/src/lib/useBrand.ts b/apps/portal/src/lib/useBrand.ts index 9b33a41e..0ecd1780 100644 --- a/apps/portal/src/lib/useBrand.ts +++ b/apps/portal/src/lib/useBrand.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { api } from '../api.js'; +import { api, currentTenantSlug } from '../api.js'; /** * Shape of `GET /config/program`. Mirrors the API's `ProgramSettings` @@ -81,9 +81,12 @@ export interface PublicBranding { * pages (no tenant in the URL / platform host) the API returns nulls and * this falls back to the OpenPartner default. */ -export function usePublicBrand(): Brand { +export function usePublicBrand(): Brand & { brandColor: string | null } { + // api() scopes by the /t// URL prefix at call time — key the cache + // by it too, or an SPA navigation from a platform page (nulls) into a + // tenant page would keep serving the platform's empty branding. const { data, isLoading } = useQuery({ - queryKey: ['public-branding'], + queryKey: ['public-branding', currentTenantSlug()], queryFn: () => api('/branding'), staleTime: 60_000, }); @@ -92,6 +95,7 @@ export function usePublicBrand(): Brand { logoUrl: data?.logoUrl ?? null, supportEmail: data?.supportEmail || null, whiteLabel: data?.whiteLabel ?? false, + brandColor: data?.brandColor ?? null, isLoading, }; }