Skip to content

feat(branding): white-label customisation via runtime branding.json#11

Merged
valehdba merged 1 commit into
mainfrom
feat/white-label-branding
May 9, 2026
Merged

feat(branding): white-label customisation via runtime branding.json#11
valehdba merged 1 commit into
mainfrom
feat/white-label-branding

Conversation

@valehdba
Copy link
Copy Markdown
Owner

@valehdba valehdba commented May 9, 2026

Summary

Adds runtime-loaded branding so any company can deploy Passman under their own logo, name, and accent colour — without rebuilding the web bundle. Operators edit a single + "branding.json" + (or mount it as a Kubernetes ConfigMap) and the app re-themes on next reload.

Defaults bake in: existing deployments need zero changes.

Side-by-side

Default White-labelled (Acme override)
Default Passman Acme Vault

The brand colour drives + "--brand" + / + "--brand-2" + / + "--brand-soft" + / + "--brand-line" + CSS variables at runtime, so every accent in the UI (Connect button on every grid row, focus rings, protocol pills, selection highlight, engine tile borders) re-themes from a single + "brandColor" + value.

What you can customise

Field Effect
+ "appName" + Sidebar, browser tab title, login/register heroes
+ "tagline" + One-liner under "Unlock your vault" / "Create your vault"
+ "logoUrl" + Same-origin path / + "https://" + URL / + "data:" + URL
+ "brandColor" + Primary accent (#RRGGBB / #RGB / #RRGGBBAA)
+ "brandColorDark" + Hover / gradient stop
+ "supportEmail" + Adds a Support mailto link in the user card
+ "footerText" + Sidebar footer line

Every field is optional. A missing or malformed + "branding.json" + falls back to defaults — the app always boots into a usable state.

Architecture ( + "packages/web/src/branding/" + )

  • + "types.ts" + + "Branding" + shape + + "BrandingOverride" + partial
  • + "defaults.ts" + — frozen + "DEFAULT_BRANDING" +
  • + "load.ts" + — fetch + sanitise + merge + + "applyBranding()" + CSS-var injector
    • Hex-colour regex; reject malformed
    • LogoUrl protocol allowlist: same-origin / + "https:" + / + "data:" + only — + "javascript:" + / + "file://" + / + "ftp://" + rejected
    • String length caps (appName 64, tagline 160, footer 200)
  • + "BrandingProvider.tsx" + — React context. Fetches once on mount, never blocks render — first paint uses defaults, override takes effect on the second render

Tests

  • 17 new branding tests covering defaults shape, frozen-defaults, merge semantics, hex validation, logoUrl protocol allowlist (rejects + "javascript:" + / + "file://" + / + "ftp://" + , accepts + "/" + , + "https://" + , + "data:" + ), string truncation, unknown-key handling, and 4 fetch-failure modes (network error, non-2xx, non-JSON, empty body)
  • Total web tests: 73 passing (was 56)
  • Typecheck clean across core / web / extension
  • Live-rendered via Vite + Claude Preview with the Acme override applied to verify the blue logo + blue Unlock button + custom tagline work end-to-end (proof in the screenshot above)

Docs

  • New + "docs/BRANDING.md" + — 60-second quick-start, full field reference, CSP notes, "what is NOT customised and why" (per-user themes, light/dark toggle, extension popup — all out of scope for v1)
  • + "docs/USER_GUIDE.md" + §7 shows the default and white-label login side-by-side
  • README links the new doc

Bundle impact

CSS: 4.72 → 4.84 KB gzipped (+0.12 KB)
JS: 76.94 → 77.84 KB gzipped (+0.90 KB)

Test plan

  • Build clean (CSS 21.62 KB / 4.84 KB gzipped, JS 234 KB / 77.84 KB gzipped)
  • All 73 unit tests pass
  • Typecheck clean
  • Live-rendered: default branding renders Passman name + green logo + green Unlock
  • Live-rendered: + "branding.json" + override renders Acme name + blue logo + blue Unlock + custom tagline
  • Manual: verify a malformed + "brandColor" + value (e.g. + "#GG" + ) silently falls back to default
  • Manual: verify + "branding.json" + returning 404 still boots the app
  • Manual: replace + "/branding/logo.svg" + and confirm the sidebar logo updates without a code change

🤖 Generated with Claude Code

Adds runtime-loaded branding so any company can swap the logo, app name,
tagline, accent colour, support email, and footer line — without
rebuilding the web bundle. The same image can serve N customers; mount
a different `branding.json` per deployment.

Defaults bake in, so existing deployments need zero changes.

## What you can customise

| Field            | Effect                                                         |
| ---------------- | -------------------------------------------------------------- |
| `appName`        | Sidebar, browser tab title, login/register heroes              |
| `tagline`        | One-liner under "Unlock your vault" / "Create your vault"      |
| `logoUrl`        | Same-origin path / `https://` URL / `data:` URL                |
| `brandColor`     | Primary accent (#RRGGBB / #RGB / #RRGGBBAA)                    |
| `brandColorDark` | Hover / gradient stop                                          |
| `supportEmail`   | Adds a Support mailto link in the user card                    |
| `footerText`     | Sidebar footer line (e.g. "© Acme Corp · Internal use only")    |

The brand colour drives the `--brand` / `--brand-2` / `--brand-soft` /
`--brand-line` CSS variables at runtime, so EVERY accent in the UI
re-themes — Connect button, focus rings, protocol pills, selection
highlight, engine tile borders.

## packages/web/src/branding/ — new module

- `types.ts` — `Branding` shape, `BrandingOverride` partial
- `defaults.ts` — frozen `DEFAULT_BRANDING`
- `load.ts` — fetch + sanitise + merge + `applyBranding()` CSS-var
  injector. Validation is silent on bad input (typo in branding.json
  shouldn't break the app):
  - colours must match `^#(RGB|RRGGBB|RRGGBBAA)$`
  - logoUrl restricted to same-origin paths, https://, and data: —
    `javascript:`/`file://`/`ftp://` are rejected
  - strings clamped (appName 64 chars, tagline 160, footer 200)
- `BrandingProvider.tsx` — React context, fetches once on mount, never
  blocks render — first paint uses defaults, override takes effect on
  the second render. The app boots even if `branding.json` is
  unreachable.

## UI surfaces

- **Sidebar**: brand mark or `<img>` from logoUrl, app name from
  `appName`, optional `Support` mailto link in the user-card "Vault
  unlocked" subtitle, optional footer text below the user card
- **LoginPage / RegisterPage**: new `<BrandHeader>` above the heading,
  optional tagline below it
- **Page title**: `document.title = branding.appName` after fetch

## Operator JSON shape

`packages/web/public/branding.json` ships as `{}` (defaults). A fully
populated example is at `branding.example.json` and a sample SVG at
`public/branding/logo.example.svg`.

## Tests

- 17 new tests in `tests/branding.test.ts` covering defaults shape,
  frozen-defaults, partial merge semantics, hex-colour validation,
  logoUrl protocol allowlist (rejects javascript: / file: / ftp:,
  accepts /, https://, data:), string truncation, unknown-key
  ignored, and four fetch-failure modes (network error, non-2xx,
  non-JSON, empty body — all fall back to defaults)
- Total web tests: **73 passing** (was 56)
- Typecheck clean across core / web / extension
- Live-rendered the Acme override via Vite + Claude Preview to verify
  the blue logo + blue Unlock button + custom tagline work end-to-end.

## Docs

- New `docs/BRANDING.md` — 60-second quick-start, full field reference,
  CSP notes, "what is NOT customised and why" (per-user themes,
  light/dark toggle, extension popup — all out of scope for v1)
- `docs/USER_GUIDE.md` §7 shows the default and white-label login
  side-by-side, links to BRANDING.md
- README links the new doc alongside ARCHITECTURE / SECURITY /
  USER_GUIDE

## Bundle impact

CSS: 4.72 KB → 4.84 KB gzipped (+0.12 KB)
JS:  76.94 KB → 77.84 KB gzipped (+0.90 KB)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@valehdba valehdba merged commit 7b3dc84 into main May 9, 2026
9 checks passed
@valehdba valehdba deleted the feat/white-label-branding branch May 10, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant