feat(branding): white-label customisation via runtime branding.json#11
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
+ "appName" ++ "tagline" ++ "logoUrl" ++ "https://" +URL /+ "data:" +URL+ "brandColor" ++ "brandColorDark" ++ "supportEmail" ++ "footerText" +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+ "https:" +/+ "data:" +only —+ "javascript:" +/+ "file://" +/+ "ftp://" +rejected+ "BrandingProvider.tsx" +— React context. Fetches once on mount, never blocks render — first paint uses defaults, override takes effect on the second renderTests
+ "javascript:" +/+ "file://" +/+ "ftp://" +, accepts+ "/" +,+ "https://" +,+ "data:" +), string truncation, unknown-key handling, and 4 fetch-failure modes (network error, non-2xx, non-JSON, empty body)Docs
+ "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-sideBundle impact
CSS: 4.72 → 4.84 KB gzipped (+0.12 KB)
JS: 76.94 → 77.84 KB gzipped (+0.90 KB)
Test plan
+ "branding.json" +override renders Acme name + blue logo + blue Unlock + custom tagline+ "brandColor" +value (e.g.+ "#GG" +) silently falls back to default+ "branding.json" +returning 404 still boots the app+ "/branding/logo.svg" +and confirm the sidebar logo updates without a code change🤖 Generated with Claude Code