feat(brand): create @safe-global/brand workspace

Exposes a Brand type and a default brand object whose values match
upstream Safe strings. Per-brand builds override via NEXT_PUBLIC_BRAND_*
(web) or EXPO_PUBLIC_BRAND_* (mobile) env vars.

Single source of truth for product name, URLs, support contacts, and
asset paths. No 'Safe' / 'safe.global' literal lives in apps/ or
packages/*/src/ once the codemod passes land.
This commit is contained in:
Hanzo AI
2026-05-15 02:46:09 -07:00
parent b3f6ff8a2c
commit 2c46473905
9 changed files with 332 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"paths": {
"@safe-global/brand/*": ["../../packages/brand/src/*"],
"@safe-global/store/*": ["../../packages/store/src/*"],
"@safe-global/utils/*": ["../../packages/utils/src/*"],
"@safe-global/test/*": ["../../config/test/*"]
+54
View File
@@ -0,0 +1,54 @@
# @safe-global/brand
White-label brand tokens for the Safe wallet monorepo.
## Why
The Safe wallet codebase is the upstream OSS reference. Lux Safe, Hanzo Vault,
and future partner builds all ship from the same source. To keep them in lock-
step we keep brand strings (product name, URLs, support contacts, asset paths,
primary color) out of the source — they live here, in one file, behind one
type — and per-brand builds override them via env vars.
## Shape
```ts
import { brand } from '@safe-global/brand'
brand.name // "Safe{Wallet}" (or "Lux Safe", "Hanzo Vault", …)
brand.domain // "safe.global"
brand.email // "support@safe.global"
brand.helpUrl // "https://help.safe.global"
brand.logoUrl // "/images/safe-logo-green.png"
brand.primaryColor // "#12FF80"
```
See [`src/index.ts`](src/index.ts) for the full `Brand` interface.
## Setting brand values at build time
Web uses `NEXT_PUBLIC_BRAND_*`, mobile uses `EXPO_PUBLIC_BRAND_*`. The brand
module reads whichever is set, so a single env file can drive both apps.
```bash
# apps/web/.env.lux
NEXT_PUBLIC_BRAND_NAME="Lux Safe"
NEXT_PUBLIC_BRAND_DOMAIN="safe.lux.network"
NEXT_PUBLIC_BRAND_EMAIL="support@lux.network"
NEXT_PUBLIC_BRAND_HELP_URL="https://help.lux.network"
NEXT_PUBLIC_BRAND_LOGO_URL="/brand/lux/logo.svg"
```
The repo ships three sample env files in `apps/web/`:
- `.env.safe` — upstream Safe defaults (sanity check)
- `.env.lux` — Lux Safe
- `.env.hanzo` — Hanzo Vault
`scripts/select-brand.sh <slug>` copies the selected env file and the matching
asset directory into place, then the normal Next build picks them up.
## Rule
There is exactly one way to render the product name. It is `brand.name`. There
is no `'Safe'` string literal in source files. If you find one, replace it.
+20
View File
@@ -0,0 +1,20 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['dist', 'node_modules'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: {
process: 'readonly',
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
},
},
)
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
},
}
+30
View File
@@ -0,0 +1,30 @@
{
"private": true,
"name": "@safe-global/brand",
"description": "White-label brand tokens. One source of truth for product name, URLs, support contacts, and asset paths across web and mobile.",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"scripts": {
"test": "jest",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"type-check": "tsc --noEmit",
"prettier": "prettier --check . --config ../../.prettierrc --ignore-path ../../.prettierignore",
"prettier:fix": "prettier --write . --config ../../.prettierrc --ignore-path ../../.prettierignore"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/jest": "^29.5.14",
"eslint": "^9.29.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.31.1"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { brand, type Brand } from './index'
describe('brand', () => {
it('exposes the upstream Safe defaults when no env vars are set', () => {
expect(brand.name).toBe('Safe{Wallet}')
expect(brand.shortName).toBe('Safe')
expect(brand.domain).toBe('safe.global')
expect(brand.email).toBe('support@safe.global')
expect(brand.helpUrl).toBe('https://help.safe.global')
})
it('matches the Brand interface', () => {
const b: Brand = brand
expect(typeof b.name).toBe('string')
expect(typeof b.shortName).toBe('string')
expect(typeof b.domain).toBe('string')
expect(typeof b.email).toBe('string')
expect(typeof b.logoUrl).toBe('string')
})
it('exposes every documented URL', () => {
const required: Array<keyof Brand> = [
'helpUrl',
'termsUrl',
'privacyUrl',
'imprintUrl',
'cookieUrl',
'licensesUrl',
'statusUrl',
'developerUrl',
'gatewayUrl',
'discordUrl',
'twitterUrl',
]
for (const key of required) {
expect(brand[key]).toMatch(/^https?:\/\//)
}
})
})
+105
View File
@@ -0,0 +1,105 @@
/**
* @safe-global/brand — white-label brand tokens
*
* One source of truth for the human-facing brand. Every wallet shipped from
* this monorepo reads these values; no `'Safe'` / `'safe.global'` literal
* lives in `apps/` or `packages/*` source.
*
* Defaults match upstream Safe values, so an unbranded build is byte-for-byte
* the same as today. Per-brand builds override via `NEXT_PUBLIC_BRAND_*`
* (web) or `EXPO_PUBLIC_BRAND_*` (mobile) env vars at build time.
*/
export interface Brand {
/** Product name as it appears in chrome, titles, marketing copy. */
name: string
/** Short form for tight UI (sidebar, badges). */
shortName: string
/** Apex domain — `safe.global`, `safe.lux.network`, `vault.hanzo.ai`. */
domain: string
/** App host — `app.safe.global`, `safe.lux.network`. */
appHost: string
/** Full app URL with scheme. */
appUrl: string
/** Support email. */
email: string
/** Help center / docs URL (no trailing slash). */
helpUrl: string
/** Terms of service URL. */
termsUrl: string
/** Privacy policy URL. */
privacyUrl: string
/** Imprint / legal URL. */
imprintUrl: string
/** Cookie policy URL. */
cookieUrl: string
/** Licenses page URL. */
licensesUrl: string
/** Brand logo path (served by the app, swap per brand). */
logoUrl: string
/** Favicon path. */
faviconUrl: string
/** Primary brand color (hex). */
primaryColor: string
/** Status page URL. */
statusUrl: string
/** Developer portal URL. */
developerUrl: string
/** Gateway service URL (CGW — Safe Transaction Service). */
gatewayUrl: string
/** Staging gateway URL. */
gatewayStagingUrl: string
/** Discord / chat invite URL. */
discordUrl: string
/** Twitter / X URL. */
twitterUrl: string
/** Support chat alias domain (Pylon). */
supportChatAliasDomain: string
/** GitHub repo URL. */
githubUrl: string
/** Apple App Store URL (mobile). */
appStoreUrl: string
/** Google Play Store URL (mobile). */
playStoreUrl: string
/** Help article slug prefix (legacy `articles/` path). */
helpArticlesPath: string
}
const env = (key: string, fallback: string): string => {
// Read NEXT_PUBLIC_* (web) and EXPO_PUBLIC_* (mobile) interchangeably so the
// same brand bundle works in both apps.
const next = `NEXT_PUBLIC_BRAND_${key}`
const expo = `EXPO_PUBLIC_BRAND_${key}`
return process.env[next] ?? process.env[expo] ?? fallback
}
export const brand: Brand = {
name: env('NAME', 'Safe{Wallet}'),
shortName: env('SHORT_NAME', 'Safe'),
domain: env('DOMAIN', 'safe.global'),
appHost: env('APP_HOST', 'app.safe.global'),
appUrl: env('APP_URL', 'https://app.safe.global'),
email: env('EMAIL', 'support@safe.global'),
helpUrl: env('HELP_URL', 'https://help.safe.global'),
termsUrl: env('TERMS_URL', 'https://safe.global/terms'),
privacyUrl: env('PRIVACY_URL', 'https://safe.global/privacy'),
imprintUrl: env('IMPRINT_URL', 'https://safe.global/imprint'),
cookieUrl: env('COOKIE_URL', 'https://safe.global/cookie'),
licensesUrl: env('LICENSES_URL', 'https://safe.global/licenses'),
logoUrl: env('LOGO_URL', '/images/safe-logo-green.png'),
faviconUrl: env('FAVICON_URL', '/favicon.ico'),
primaryColor: env('PRIMARY_COLOR', '#12FF80'),
statusUrl: env('STATUS_URL', 'https://status.safe.global'),
developerUrl: env('DEVELOPER_URL', 'https://developer.safe.global'),
gatewayUrl: env('GATEWAY_URL', 'https://safe-client.safe.global'),
gatewayStagingUrl: env('GATEWAY_STAGING_URL', 'https://safe-client.staging.5afe.dev'),
discordUrl: env('DISCORD_URL', 'https://chat.safe.global'),
twitterUrl: env('TWITTER_URL', 'https://twitter.com/safe'),
supportChatAliasDomain: env('SUPPORT_CHAT_ALIAS_DOMAIN', 'anon.safe.global'),
githubUrl: env('GITHUB_URL', 'https://github.com/safe-global/safe-wallet-monorepo'),
appStoreUrl: env('APP_STORE_URL', 'https://apps.apple.com/app/id1515759131'),
playStoreUrl: env('PLAY_STORE_URL', 'https://play.google.com/store/apps/details?id=io.gnosis.safe'),
helpArticlesPath: env('HELP_ARTICLES_PATH', 'articles'),
}
export default brand
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../config/tsconfig/confs/base.json",
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@safe-global/brand/*": ["../../packages/brand/src/*"]
}
}
}
+65 -1
View File
@@ -13068,6 +13068,21 @@ __metadata:
languageName: node
linkType: hard
"@safe-global/brand@workspace:packages/brand":
version: 0.0.0-use.local
resolution: "@safe-global/brand@workspace:packages/brand"
dependencies:
"@eslint/js": "npm:^9.18.0"
"@types/jest": "npm:^29.5.14"
eslint: "npm:^9.29.0"
jest: "npm:^29.7.0"
prettier: "npm:^3.6.2"
ts-jest: "npm:^29.4.0"
typescript: "npm:~5.9.2"
typescript-eslint: "npm:^8.31.1"
languageName: unknown
linkType: soft
"@safe-global/mobile@workspace:apps/mobile":
version: 0.0.0-use.local
resolution: "@safe-global/mobile@workspace:apps/mobile"
@@ -29215,7 +29230,7 @@ __metadata:
languageName: node
linkType: hard
"handlebars@npm:^4.7.8":
"handlebars@npm:^4.7.8, handlebars@npm:^4.7.9":
version: 4.7.9
resolution: "handlebars@npm:4.7.9"
dependencies:
@@ -40736,6 +40751,15 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.7.4":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
semver: bin/semver.js
checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75
languageName: node
linkType: hard
"send@npm:0.19.0":
version: 0.19.0
resolution: "send@npm:0.19.0"
@@ -43327,6 +43351,46 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.4.0":
version: 29.4.9
resolution: "ts-jest@npm:29.4.9"
dependencies:
bs-logger: "npm:^0.2.6"
fast-json-stable-stringify: "npm:^2.1.0"
handlebars: "npm:^4.7.9"
json5: "npm:^2.2.3"
lodash.memoize: "npm:^4.1.2"
make-error: "npm:^1.3.6"
semver: "npm:^7.7.4"
type-fest: "npm:^4.41.0"
yargs-parser: "npm:^21.1.1"
peerDependencies:
"@babel/core": ">=7.0.0-beta.0 <8"
"@jest/transform": ^29.0.0 || ^30.0.0
"@jest/types": ^29.0.0 || ^30.0.0
babel-jest: ^29.0.0 || ^30.0.0
jest: ^29.0.0 || ^30.0.0
jest-util: ^29.0.0 || ^30.0.0
typescript: ">=4.3 <7"
peerDependenciesMeta:
"@babel/core":
optional: true
"@jest/transform":
optional: true
"@jest/types":
optional: true
babel-jest:
optional: true
esbuild:
optional: true
jest-util:
optional: true
bin:
ts-jest: cli.js
checksum: 10/f5e81b1e13fff08da5b92d5a72f984f3393f40df73a1ae54473a780436b95dddb1452c78256e6d70a701c09ea427449657a5fbb3d142dc7e7a82eb192e80c3db
languageName: node
linkType: hard
"ts-mixer@npm:^6.0.3":
version: 6.0.4
resolution: "ts-mixer@npm:6.0.4"