---
title: "Host Portal Branding"
slug: host-portal-branding
section: Portals
group: Host Portal
status: stable
description: "The Host Portal takes every sponsor identity fact (name, code, logo, colour, support address) from the Globus brand record for the host's agreement, and paints a default look when that record cannot be read."
last-updated: 2026-10-07
---

# Host Portal Branding

The Host Portal takes every sponsor identity fact (name, code, logo, colour, support address) from the Globus brand record for the host's agreement, and paints a default look when that record cannot be read.

## Overview

The Host Portal (repo `HostPortalPrototype`) does not hold a brand registry. Shared code in `shared/domain/v2/brand.ts` only carries the shape of the Hub brand record, the neutral sponsor wording and the agreement-to-brandId rule. The client layer in `client/src/lib/brand.ts` turns a record into presentation (theme class, colour, logo).

| Concern | Where it lives |
| --- | --- |
| Brand record shape, generic sponsor wording, brandId-from-agreements rule | `shared/domain/v2/brand.ts` |
| Brand model, styling variants, default look, program-to-brand choice | `client/src/lib/brand.ts` |
| Brand fetch and status/reason reporting | `client/src/hooks/usePortalBrand/usePortalBrand.ts` |
| Logo bytes through the portal proxy | `client/src/hooks/useBrandLogo/useBrandLogo.tsx` |
| Logo rendering with placeholder and fallback artwork | `client/src/components/layout/brand-logo.tsx` |
| Layout wiring, CSS variables, `BrandContext` | `client/src/components/layout/main-layout.tsx` |
| Server-side sponsor name for user-facing messages | `server/facades/v2/brand-name.ts` |
| Screens shown before sign-in | `client/src/components/auth/PreAuthBranding.tsx` |

> **No default brand**
>
> There is no default sponsor. `DEFAULT_BRAND_KEY` from the earlier registry no longer exists at `development` HEAD. An unresolved brand produces neutral wording ("your program sponsor") and a default look, never a named sponsor. [1] [2]

## Brand Source

### Brand Record

The Hub serves a brand record with these fields, and the portal consumes exactly this shape (`PortalBrandRecord`). [1]

| Field | Meaning |
| --- | --- |
| `id` | Globus brand UUID |
| `name` | Sponsor display name |
| `code` | Stable Globus brand code, for example `wild_packs` |
| `logoUrl` | Hub-relative or absolute logo URL, or null |
| `primaryColor` | Brand colour as hex, or null |
| `supportEmail` | Sponsor support address, or null |

The browser reads the record through the authenticated portal route `GET /api/v2/host/brands/:brandId`, and the logo bytes through `GET /api/v2/host/brands/:brandId/logo`. Both routes require the JWT middleware, and the Globus integration credential stays server-side. [3] [5]

### Hub Branding Endpoint (BackOfficePrototype, Task 2474)

The Hub side of this contract is `GET /api/partner/host/branding` (BackOfficePrototype, `server/routes/hostPortal/branding.ts`). Globus is the single source of host branding: the endpoint resolves server-side which brand the calling host organization is contracted under and serves that brand's full presentation contract, read live from the `brands` row, so an admin colour or theme edit changes the response with no code change and no redeploy. Access follows the same partner-auth and host-resolver chain as `GET /api/partner/host/context`, and is open to the host roles `admin`, `recruiter` and `readonly`.

**Resolution order** (`resolveHostBrandFromScope` in `server/services/hostBrandResolver.ts`). It consumes the host's already-resolved agreement scope (the shared `resolveHostAgreementScope` output, so the branding surface and the host context cannot disagree about which agreements were read):

1. `agreement_scope`: the brand ids the agreement scopes themselves name.
2. `scoped_programs`: when no scope names a brand, the brand behind the programs the agreement authorises. This is the common case, because `agreements[].brandId` is null on most rows and the brand is recorded indirectly through programs.

When the host's scope reaches several brands, the brand backing the most scope entries wins and ties break on brand slug, so identical data always yields the identical brand. The remaining brands are returned in `alternateBrands` (`id`, `slug`, `name`) with `multiBrand: true`.

**Response.** Always a well-formed 200 with the standard agreement-scope `meta` signal (`scopeEmpty`, `scopeEmptyReason`, `activeAgreementCount`, and related fields). When a brand resolves, `data` carries `resolved: true`, `reason: null`, `source`, `brand`, `alternateBrands` and `multiBrand`. The `brand` object carries `id`, `slug`, `name`, `description`, `logoUrl`, `colors` (the full persisted palette), `portalTheme` (the full portal theme token set), `contactEmail`, `contactNumber` and `website`. `logoUrl` is an absolute URL on Globus's own `/api/brands/:slug/logo` route (built from the platform base URL), safe to load from another origin, and null when the brand stores no logo; the internal root-relative stored path is never returned.

When nothing resolves the response is still a 200, never an error and never a fabricated default brand: `resolved: false`, `source: null`, `brand: null`, `alternateBrands: []`, `multiBrand: false`, and a `reason`:

| `reason` | Meaning |
| --- | --- |
| `no_active_agreement` | The host holds no active agreement; staff fix this by executing one. |
| `agreement_scope_missing_programs` | An agreement is active but carries no usable program scope (a data defect on the agreement). |
| `no_brand_on_agreement_or_scoped_programs` | The host's programs resolve, but none belongs to a brand. |
| `brand_record_missing` | The scope names a brand id that has no `brands` row. |

The first two reasons mirror the agreement scope resolver's own empty-scope classification.

**Host context brand block.** `GET /api/partner/host/context` now also returns an always-present `brand` block built from the same resolver: `resolved`, `id`, `slug`, `name`, `source` and `reason` (nulls when unresolved). `agreements[].brandId` is unchanged. The full palette and theme are only on the branding endpoint.

**Palette and theme keys.** `colors` is typed by `brandColorsSchema` and carries twelve keys: `primary`, `secondary`, `accent`, `background`, `text`, `buttonText`, `buttonBackground`, `buttonHoverText`, `buttonHoverBackground`, `buttonSelectedText`, `buttonSelectedBackground` and `headerBackground`. The schema was widened from five keys to the twelve actually stored, and the admin brand write path accepts all of them. Text and background contrast is checked on write against the WCAG AA 4.5:1 floor. `portalTheme` is the same brand-portal theme token set the Applicant Portal consumes.

## Brand Resolution

### Choosing the Brand

The brandId comes from the host's agreements and programs, deterministically and without any globally selected program. [2]

1. `portalBrandProgram` ranks the host's programs: programs backed by an active agreement first, then any agreement, then unbacked programs. Within a rank the earliest agreement start date wins, and ties break by program id. List position is never used, so a multi-program host does not flip brand between reloads.
2. `portalBrandId` takes the `brandId` on the agreement backing that program.
3. If no agreement carries a brandId, the Hub's context-level resolved brand is used, but only when its block has `resolved: true` and a non-empty id.
4. If neither source names a brand, the result is `undefined` and the portal does not invent one.

`brandAgreementForProgram` prefers active agreements and the earliest start date, and matches on an agreement's full program scope. [1]

## Loading, Fallback and Unbranded States

`usePortalBrand` returns the brand to render, a status and an optional reason. [4]

| State | Brand rendered | Status | Reason |
| --- | --- | --- | --- |
| Host context not settled, no brandId yet | `UNBRANDED` | `loading` | none |
| Brand request in flight | `UNBRANDED` | `loading` | none |
| Record resolved | Built from the Hub record | `resolved` | none |
| Host settled, no brandId | `WILDPACKS_FALLBACK_BRAND` | `unavailable` | `no-brand` |
| Hub returns 404 or `BRAND_NOT_FOUND` | `WILDPACKS_FALLBACK_BRAND` | `unavailable` | `unknown-brand` |
| Any other request error | `WILDPACKS_FALLBACK_BRAND` | `unavailable` | `hub-unavailable` |

Key properties of the fallback:

- `WILDPACKS_FALLBACK_BRAND` supplies visuals only: the WildPacks layout variant, the bundled fallback logo and the brand green `#11421E`. Its `id` and `code` are null, `resolved` is false and `displayName` stays the generic sponsor phrase, so the portal never asserts that the host's sponsor is WildPacks. [2]
- A record the Hub resolved always wins over the fallback. A resolved brand whose logo is missing or broken shows the neutral placeholder, not bundled artwork. [2] [6]
- While loading, the portal stays `UNBRANDED` so a host whose real sponsor is about to resolve does not flash another sponsor's colours. [4]
- The portal shows no banner when branding is unavailable. The status and reason remain on `BrandContext`, and a `console.warn` records the reason so a Hub outage is diagnosable. [4] [7]
- The brand record is fetched once per session (`staleTime` and `gcTime` are `Infinity`) and is not retried. [4]

## What the Brand Drives

`MainLayout` exposes `BrandContext` with `brand`, `remoteLogoSrc`, `brandStatus` and `brandUnavailableReason`, and applies the brand to the shell. [7]

- The root wrapper takes `brand.themeClass`, with a neutral gray background when the class is empty. The theme class is also mirrored onto `document.body` so Radix portals (dialogs, popovers) inherit it.
- When `primaryColor` is a valid 3 or 6 digit hex, the layout sets `--brand-primary` and `--brand-primary-hsl` and adds the `brand-colored` class, on the wrapper and on `body`. Without a usable colour nothing is emitted and neutral design tokens stay in force. [2] [7]
- The mobile header renders the logo through `BrandLogo`, and passes the brand and logo source on to the sidebar and mobile menu.

### Logo Proxy

`useBrandLogo` requests `/api/v2/host/brands/:id/logo` through the authenticated image hook and returns an object URL, or null while loading or on failure. The layout only requests it when the brand record has a `logoUrl`. `BrandLogo` picks, in order: the Hub logo, the bundled fallback artwork (only when `fallbackSrc` is passed, which only the fallback brand carries), and otherwise a neutral placeholder. Images are letterboxed with `object-contain`. [6] [8]

On the Hub side, brand logos live in the Cloud Storage bucket named by the environment variable `GCS_BRANDING_BUCKET`. `POST /api/brands/:slug/logo` (`brands.manage`; PNG, JPEG or WebP up to 2 MB) stores the file at `brands/<slug>/logo-<uuid>.<ext>` and saves that path as the brand's `logoUrl`; if the storage write fails it falls back to saving a data URL. The public `GET /api/brands/:slug/logo` streams the object back with a one-hour cache header (502 when storage is unavailable). When the variable is unset the Hub uses the default bucket `development-globus-branding-assets`; the QA manifest sets `qa-globus-branding-assets`, so an environment that does not set it writes to the development bucket. The reference-letter PDF service reads the same variable.

### Styling Variants

Presentation is keyed by the normalized Globus brand code (lowercase, alphanumeric only). Unrecognized codes render neutral. [1] [2]

| Normalized code | Variant |
| --- | --- |
| `allianceabroad` | Default chrome with the `aag-gradient-text` heading and active-nav treatment |
| `wildpacks` | `wildpacks-theme`, flat tabs, `brand-primary-button` and `nav-active-brand` classes that read the brand colour token |
| any other | `NEUTRAL_BRAND_STYLE` |

## Sponsor Wording

Copy that names the sponsor uses `sponsorName` and `sponsorNameSentenceStart`. They return the Hub brand name, or "your program sponsor" (capitalized at sentence start) when there is none. [1]

On the server, `resolveHostBrandName(hostOrgId, userEmail, programId?)` reads the host context, derives the brandId from the agreements with `brandIdFromAgreements`, fetches the brand name through `hostGlobusFacade.getBrandBranding`, and never throws. Missing arguments return the generic phrase silently; no brand-bearing agreement, an unknown brand, gate refusals and Hub errors return it and log a console warning. It costs a Hub context read, so it is meant for message-building paths, not hot success paths. [9]

## Pre-Authentication Screens

Login, registration, invitation acceptance, access denied and loading screens render `PreAuthBranding`, which shows the shared partner lockup (`PORTAL_LOGIN_BRAND`, alt text "Alliance Abroad, Stepwest, Teacher Lounge, and WildPacks"). It is deliberately independent of the authenticated portal's brand, because no host is known before sign-in. [2] [10]

## History

| PR | Effect | Status at HEAD |
| --- | --- | --- |
| `HostPortalPrototype#480` | Introduced Hub-derived branding in the UAT batch 11 merge | Current model described above |
| `HostPortalPrototype#494` | Changed the default brand key from WildPacks to Alliance Abroad, with tests asserting neutral copy | Superseded: the registry and `DEFAULT_BRAND_KEY` were later removed in favor of the Hub record |
| `HostPortalPrototype#511` | Added the default WildPacks look (layout variant, bundled logo, brand green) for unresolvable brands, visuals only | Current fallback behavior |

## Sources

- HostPortalPrototype#480, #494, #511 (merged to `development`)
- [1] `shared/domain/v2/brand.ts`
- [2] `client/src/lib/brand.ts`
- [3] `server/routes/v2/host.ts`
- [4] `client/src/hooks/usePortalBrand/usePortalBrand.ts`
- [5] `client/src/hooks/useBrandLogo/useBrandLogo.tsx`
- [6] `client/src/components/layout/brand-logo.tsx`
- [7] `client/src/components/layout/main-layout.tsx`
- [8] `wildpacks-fallback-logo.png` (bundled fallback artwork imported through the `@assets` alias, added in #511)
- [9] `server/facades/v2/brand-name.ts`
- [10] `client/src/components/auth/PreAuthBranding.tsx`

Related pages: [Host Portal Endpoint Reference](/docs/host-portal-endpoints), [Host Portal Governance](/docs/host-governance).
