Skip to content
86 changes: 86 additions & 0 deletions docs/tasks/project-branding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Project branding — the Admin Portal shows the project's name and logo

Item C of the Norbix "Project" coverage campaign. Branch `audit/project-branding`
(admin repo, from `origin/main`).

## Goal

The portal shell, every sign-in screen and the browser tab show the project's
name and logo (from the gateway's public config) instead of "Account",
"Norbix" or "Admin Portal".

Not in scope: new branding fields on the gateway, dark mode, a mobile header
(the portal has none today — see Findings), the colour tokens (already applied).

## Plan

1. docs(admin:branding): this task file — done
2. feat(admin:branding): `AppLayout` sidebar header shows the logo (alt = project name) or the project name; every `AuthLayout` use passes `brandName` + `logoUrl` from the store — done, `src/components/layouts.tsx`, `src/features/auth/passwordReset.tsx`, `src/features/auth/callback.tsx`. Committed together with step 3 as `[2-3]` (one shared helper `projectNameOf` in `projectConfig.ts`).
3. feat(admin:branding): `applyBranding` sets `document.title` to the project name; the static metadata `Admin Portal` stays as the pre-load fallback — done, `src/config/projectConfig.ts`
4. test(admin:branding): vitest — public-config mapping (name fallback order, branding absent / present, empty logo, endpoint fails), `projectNameOf`, `applyBranding` (title, favicon, CSS variables) — done, `src/config/projectConfig.branding.test.ts` (12 tests)
5. test(admin:branding): fake host serves its own SVG logo and returns a `branding` block (logo + icon, no colours); `tests/e2e/branding.spec.ts` (3 tests) + goldens `branding-sign-in` and `branding-shell` (`-chromium-darwin`) — done
6. chore(admin:branding): checks green, push, pull request — done (results below)
7. feat(admin:branding): `dashboard.tsx:42` "Norbix Admin" → project name — dropped: that line is the `Placeholder` shown only when NO project resolved, so there is no name to show (see Rejected)

### Results (step 6)

- `npm run lint` — pass
- `npm run format:check` — pass
- `npm test` (vitest) — 13 files, 92 tests pass
- `npm run test:e2e` (fake host) — 6 / 6 pass (3 branding + 3 chat); the chat goldens did NOT change (the chat screenshots cover only the chat region, and the fake brand has no colours)
- `npm run typecheck` — 6 errors, all inside `../sdks/norbix-react-redux` and the same 6 on `origin/main` without this change (see Findings). Zero errors in `src/`.

### Local setup (not committed)

`package.json` and `tsconfig*.json` point at `../sdks/…`. From this worktree that is
`worktrees/admin/audit/project/sdks`, so a scratch symlink was made OUTSIDE the
repo: `ln -s ~/Projects/norbix/sdks ~/Projects/norbix/worktrees/admin/audit/project/sdks`,
then `npm ci`. No change to the SDK link paths. Logs: `~/scratch/project-branding/`.

## Changes

| file | what changed | plan step # |
|------|--------------|-------------|
| `docs/tasks/project-branding.md` | this file | 1, 6 |
| `src/config/projectConfig.ts` | `PLACEHOLDER_DISPLAY_NAME` + `projectNameOf()` (name, or undefined for the "Sign in" placeholder); `applyBranding` sets `document.title` | 2, 3 |
| `src/components/layouts.tsx` | `AppLayout` sidebar header: logo `<img alt=name>` or the name; "Account" only when no name; `data-testid="sidebar-brand"` | 2 |
| `src/features/auth/passwordReset.tsx` | both screens pass `brandName` + `logoUrl` from `selectProjectBranding` | 2 |
| `src/features/auth/callback.tsx` | passes `brandName` + `logoUrl` from the store | 2 |
| `src/config/projectConfig.branding.test.ts` | new vitest file (jsdom), 12 tests | 4 |
| `tests/e2e/fake-api-host.mjs` | serves `/__assets/logo.svg`; config gets `branding { displayName, logoUrl, iconUrl }` | 5 |
| `tests/e2e/branding.spec.ts` | new: sign-in, password reset, signed-in shell; tab title; favicon | 5 |
| `tests/e2e/branding.spec.ts-snapshots/branding-{sign-in,shell}-chromium-darwin.png` | new goldens | 5 |

## Findings

- fix(sdks:react-redux): the local SDK `main` checkouts do not typecheck together — `norbix-react-redux` calls `getEmailCampaignMessage` / `getSmsCampaignMessage` / `getPushCampaignMessage`, which `norbix-js` `main` (b7230dc) does not have — open, `sdks/norbix-react-redux/src/hooks/hub/notifications.ts:63`. `npm run typecheck` in admin fails with these 6 errors on `origin/main` too.
```ts
type GetEmailCampaignMessage = Norbix['hub']['notifications']['getEmailCampaignMessage']; // <-- here: not on NotificationsModule (only …Messages)
```
- chore(admin:ci): CI never runs the Playwright suite, so the `-darwin` goldens are only checked on a Mac — open, `.github/workflows/ci.yml` (steps: lint, format, typecheck, test, build; no `test:e2e`). If e2e is added on `ubuntu-latest`, `-linux` goldens are needed.
- chore(admin:ci): CI checks out only the admin repo, yet `package.json` links `file:../sdks/norbix-js` — open, not checked here how `npm ci` resolves it on GitHub.
```json
"@norbix.ai/ts": "file:../sdks/norbix-js", // <-- here: no ../sdks on the CI runner
```
- fix(admin:branding): the placeholder name is a page label ("Sign in"), so a project with no name shows "Sign in to Sign in" on the login heading — open, `src/features/auth/login.tsx:98` + `src/config/projectConfig.ts` `PLACEHOLDER_DISPLAY_NAME`. The new sidebar and tab title avoid it via `projectNameOf`; the login heading was left as it was (out of scope).
```tsx
Sign in to {config.branding.displayName} {/* <-- here: "Sign in to Sign in" when the project has no name */}
```
- note(admin:layout): the task brief mentioned a mobile header at `layouts.tsx:~103` — there is none. That line is the "Account" label of the sign-out menu at the bottom of the sidebar; kept as is (it is the user's account menu, not the brand) — `src/components/layouts.tsx:120`.
- note(admin:layout): the sidebar is a fixed `w-60` column with no small-screen variant — open, `src/components/layouts.tsx` `<aside className="flex w-60 …">`.
- note(admin:branding): no component test for the name-only sidebar (brand exposed without logo); covered only through `projectNameOf` unit tests and the e2e logo case — open.
- note(admin:branding): the brand colours are not part of any golden (the fake brand has none, to keep the chat goldens stable) — open; a separate golden with `mainColor` would cover the token mapping visually.

## Rejected / moved out

- `dashboard.tsx:42` "Norbix Admin" stays: it is the `Placeholder` shown when no project was resolved, so no project name exists there. The signed-in home heading "Your account" was left unchanged.
- Tab title format: plain `<Project name>` (not `<Project name> · Account`) — shorter, and it reads well for every route.

## Needs you

- [ ] Review and merge the pull request (not merged by the agent).
- [ ] Decide on the Findings (SDK typecheck mismatch, e2e in CI / linux goldens, "Sign in to Sign in").

## Open questions

None.
23 changes: 20 additions & 3 deletions src/components/layouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
} from './icons';
import { DropdownMenu } from './ui';
import { ROUTES } from '@/routes';
import { useAppDispatch } from '@/app/hooks';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { selectProjectBranding } from '@/features/project/slice';
import { projectNameOf } from '@/config/projectConfig';
import { reset } from '@/features/auth/slice';
import { useLogoutMutation } from '@/services/norbix';
import { AiChatLauncher } from '@/features/aiChat/launcher';
Expand Down Expand Up @@ -57,6 +59,9 @@ export function AppLayout({ children }: { children: ReactNode }) {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [logout] = useLogoutMutation();
const branding = useAppSelector(selectProjectBranding);
const projectName = projectNameOf(branding);
const logoUrl = branding?.logoUrl;

const handleSignOut = async () => {
try {
Expand All @@ -71,8 +76,20 @@ export function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen bg-app">
<aside className="flex w-60 flex-col border-r border-border-token bg-surface">
<div className="px-6 py-5 text-lg font-semibold text-brand">
Account
{/* Project brand: the logo (alt = project name) or the name itself.
"Account" only while no project name is known. */}
<div className="px-6 py-5" data-testid="sidebar-brand">
{logoUrl ? (
<img
src={logoUrl}
alt={projectName ?? 'Account'}
className="h-8 max-w-full object-contain"
/>
) : (
<span className="text-lg font-semibold text-brand">
{projectName ?? 'Account'}
</span>
)}
</div>
<nav className="flex-1 space-y-1 px-3">
{NAV.map(({ to, label, icon: Icon }) => (
Expand Down
189 changes: 189 additions & 0 deletions src/config/projectConfig.branding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyBranding,
loadProjectConfig,
PLACEHOLDER_DISPLAY_NAME,
projectNameOf,
} from './projectConfig';
import type { ProjectConfig } from '@/types/projectConfig';

/** Serve one public-config body from the stubbed fetch; record the request. */
const servePublicConfig = (body: unknown, ok = true) => {
const fetchMock = vi.fn(
async () => ({ ok, json: async () => body }) as unknown as Response,
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};

beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('loadProjectConfig — name and branding from the public config', () => {
it('asks the proxy for the project config with the project headers', async () => {
const fetchMock = servePublicConfig({ displayName: 'Shop' });
await loadProjectConfig('pr_a');
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ headers: Headers },
];
expect(url).toMatch(/\/public\/projects\/pr_a\/config$/);
expect(init.headers.get('nb-project-id')).toBe('pr_a');
});

it('prefers the top-level name over the brand name', async () => {
servePublicConfig({
displayName: 'Top Name',
branding: { displayName: 'Brand Name' },
});
const c = await loadProjectConfig('pr_b');
expect(c.branding.displayName).toBe('Top Name');
});

it('falls back to the brand name when the top-level name is empty', async () => {
servePublicConfig({ displayName: '', branding: { displayName: 'Brand' } });
const c = await loadProjectConfig('pr_c');
expect(c.branding.displayName).toBe('Brand');
});

it('falls back to the placeholder when no name comes back', async () => {
servePublicConfig({ branding: null });
const c = await loadProjectConfig('pr_d');
expect(c.branding.displayName).toBe(PLACEHOLDER_DISPLAY_NAME);
expect(projectNameOf(c.branding)).toBeUndefined();
});

it('keeps the name but no colours or logo when the brand is not exposed', async () => {
servePublicConfig({ displayName: 'Plain Shop', branding: null });
const c = await loadProjectConfig('pr_e');
expect(c.branding).toEqual({
displayName: 'Plain Shop',
mainColor: undefined,
accentColor: undefined,
logoUrl: undefined,
iconUrl: undefined,
});
});

it('maps colours, logo and icon when the brand is exposed', async () => {
servePublicConfig({
displayName: 'Brand Shop',
branding: {
mainColor: '#aa3300',
accentColor: '#0055ff',
logoUrl: 'https://cdn.test/logo.svg',
iconUrl: 'https://cdn.test/icon.png',
},
});
const c = await loadProjectConfig('pr_f');
expect(c.branding).toEqual({
displayName: 'Brand Shop',
mainColor: '#aa3300',
accentColor: '#0055ff',
logoUrl: 'https://cdn.test/logo.svg',
iconUrl: 'https://cdn.test/icon.png',
});
});

it('turns an empty logo / icon string into "no logo"', async () => {
servePublicConfig({
displayName: 'X',
branding: { logoUrl: '', iconUrl: '' },
});
const c = await loadProjectConfig('pr_g');
expect(c.branding.logoUrl).toBeUndefined();
expect(c.branding.iconUrl).toBeUndefined();
});

it('keeps the neutral defaults when the endpoint fails', async () => {
servePublicConfig({}, false);
const c = await loadProjectConfig('pr_h');
expect(c.branding).toEqual({ displayName: PLACEHOLDER_DISPLAY_NAME });
});
});

describe('projectNameOf', () => {
it('is the trimmed name, undefined for the placeholder / blank / absent', () => {
expect(projectNameOf({ displayName: ' Shop ' })).toBe('Shop');
expect(projectNameOf({ displayName: PLACEHOLDER_DISPLAY_NAME })).toBe(
undefined,
);
expect(projectNameOf({ displayName: ' ' })).toBeUndefined();
expect(projectNameOf(null)).toBeUndefined();
});
});

describe('applyBranding', () => {
const config = (branding: ProjectConfig['branding']): ProjectConfig => ({
projectId: 'pr_x',
branding,
auth: {
socialProviders: [],
passkey: false,
methods: ['email'],
passwordPolicy: { minLength: 3 },
exposed: false,
},
links: {},
});

beforeEach(() => {
// The static Next metadata title, as the server-rendered page has it.
document.head.innerHTML = '<title>Admin Portal</title>';
document.documentElement.removeAttribute('style');
});

it('sets the tab title, the favicon and the colour tokens', () => {
applyBranding(
config({
displayName: 'Brand Shop',
mainColor: '#aa3300',
accentColor: '#0055ff',
iconUrl: 'https://cdn.test/icon.png',
}),
);
const style = document.documentElement.style;
expect({
title: document.title,
favicon: document
.querySelector<HTMLLinkElement>('link[rel="icon"]')
?.getAttribute('href'),
primary: style.getPropertyValue('--admin-primary'),
primaryHover: style.getPropertyValue('--admin-primary-hover'),
accent: style.getPropertyValue('--admin-accent'),
}).toEqual({
title: 'Brand Shop',
favicon: 'https://cdn.test/icon.png',
primary: '#aa3300',
// #aa3300 darkened 12 %: 0xaa*0.88 = 150 (0x96), 0x33*0.88 = 45 (0x2d).
primaryHover: '#962d00',
accent: '#0055ff',
});
});

it('reuses an existing favicon link instead of adding a second one', () => {
document.head.insertAdjacentHTML(
'beforeend',
'<link rel="icon" href="/favicon.ico">',
);
applyBranding(config({ displayName: 'S', iconUrl: '/brand.png' }));
const links = document.querySelectorAll('link[rel="icon"]');
expect(links).toHaveLength(1);
expect(links[0].getAttribute('href')).toBe('/brand.png');
});

it('leaves the title, favicon and tokens alone when the brand is empty', () => {
applyBranding(config({ displayName: PLACEHOLDER_DISPLAY_NAME }));
expect({
title: document.title,
favicon: document.querySelector('link[rel="icon"]'),
style: document.documentElement.getAttribute('style'),
}).toEqual({ title: 'Admin Portal', favicon: null, style: null });
});
});
24 changes: 22 additions & 2 deletions src/config/projectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// AccentColor a secondary; everything else stays neutral grey/white.

import type {
ProjectBranding,
ProjectConfig,
PublicAiChat,
StaticProjectConfig,
Expand All @@ -39,12 +40,26 @@ import { setProjectHeaders } from './project';
// (matching the Hub PasswordComplexity default), no socials, no passkey.
const DEFAULT_PASSWORD_POLICY = { minLength: 3 };

/** Placeholder `displayName` used until (or unless) a project name resolves. */
export const PLACEHOLDER_DISPLAY_NAME = 'Sign in';

/**
* The project's readable name, or undefined while only the placeholder is
* known. The sidebar and the browser tab use it; they must not say "Sign in".
*/
export function projectNameOf(
branding?: ProjectBranding | null,
): string | undefined {
const name = branding?.displayName?.trim();
return name && name !== PLACEHOLDER_DISPLAY_NAME ? name : undefined;
}

const NEUTRAL_DEFAULTS: StaticProjectConfig = {
branding: {
// Neutral placeholder used only before the project config resolves (or when
// the project is unknown). Once the public config loads, the readable
// project name replaces this — see loadDynamicConfig.
displayName: 'Sign in',
displayName: PLACEHOLDER_DISPLAY_NAME,
// mainColor intentionally unset → the token default (#0a558c Norbix blue)
// applies; the Hub MainColor overrides it when present.
},
Expand Down Expand Up @@ -292,7 +307,7 @@ function darken(hex: string, amount = 0.12): string | undefined {
/**
* Map the project's Hub Brand onto the CSS design tokens. Only sets a token
* when the brand provides a value, so unset fields keep the neutral default.
* Also sets the favicon from the brand icon.
* Also sets the favicon from the brand icon and the tab title from the name.
*/
export function applyBranding(config: ProjectConfig): void {
if (typeof document === 'undefined') return;
Expand All @@ -304,6 +319,11 @@ export function applyBranding(config: ProjectConfig): void {
}
setVar('--admin-accent', b.accentColor);

// Browser tab: the project name once known. Until then (and when the boot
// never resolves a project) the static metadata title "Admin Portal" stays.
const name = projectNameOf(b);
if (name) document.title = name;

if (b.iconUrl) {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
Expand Down
Loading
Loading