Lets a deployer set the in-app color scheme a fresh visitor sees (e.g. cfaun → darkmatter light) without forking. Two optional brand.json fields, `theme` (light|dark|system) and `palette` (one of PALETTES), distinct from `themeColor` which is PWA chrome only. - vite-branding.ts surfaces them as VITE_BRAND_THEME / VITE_BRAND_PALETTE at module load, so the default applies app-wide (hub + all standalones) with no per-config wiring. - theme-provider reads them as the INITIAL value of theme/palette; a user's stored choice in localStorage still wins and persists. - Splits the catppuccin = bare `:root` invariant (now BASE_PALETTE, used by applyPalette to drop data-theme) from the configurable default. Without this, a non-catppuccin brand default would strip the data-theme attribute and silently render catppuccin instead. Unset → the app's built-ins (dark + catppuccin), unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
8.4 KiB
Markdown
172 lines
8.4 KiB
Markdown
# Brand kit
|
||
|
||
This directory holds the **white-label brand kit** that drives the PWA's icons, manifest name/colors, and in-app `<img>` logo across the hub and every standalone (events, wallet, chat, market, …).
|
||
|
||
The committed `default/` is aiolabs's brand and is what unparameterized builds use. Downstream deployers add a sibling directory (e.g. `branding/cfaun/`) and point `BRAND_DIR` at it to ship a fully rebranded build with no fork required.
|
||
|
||
## Directory layout
|
||
|
||
```
|
||
branding/
|
||
README.md
|
||
default/ # aiolabs default, committed
|
||
logo.svg # preferred source (sharp at every size)
|
||
logo.png # fallback source (≥ 1024×1024 if PNG-only)
|
||
brand.json # { name, shortName?, themeColor?, backgroundColor? }
|
||
icons/ # optional per-standalone overrides
|
||
events/logo.svg
|
||
wallet/logo.png
|
||
cfaun/ # downstream deployer's brand (gitignored or in deploy repo)
|
||
logo.svg
|
||
brand.json
|
||
```
|
||
|
||
aiolabs's `default/` currently ships PNG-only (1024×1024). Replace with `logo.svg` when a vector source becomes available — produces sharper icons at every size and unlocks `favicon.svg`.
|
||
|
||
## Source formats
|
||
|
||
**SVG strongly preferred:**
|
||
|
||
- Crisp at every output size (192 / 512 maskable / 180 apple / 48 favicon)
|
||
- Enables sharp `favicon.svg` for modern browsers
|
||
- The in-app `@brand/logo` reference can be tinted via CSS (`currentColor`, filters)
|
||
|
||
**PNG accepted with constraints:**
|
||
|
||
- **≥ 1024×1024** — smaller sources produce blurry icons on high-DPI Android install screens
|
||
- **Square aspect ratio** — PWA icon canvas is square
|
||
- **Transparent background** — the generator applies maskable/apple background colors itself
|
||
- PNG-source deployments lose the `favicon.svg` benefit and the recolorable in-app logo
|
||
|
||
When both `logo.svg` and `logo.png` are present, SVG wins.
|
||
|
||
## brand.json schema
|
||
|
||
```jsonc
|
||
{
|
||
"name": "AIO", // required — drives PWA manifest name
|
||
"shortName": "AIO", // optional — PWA home-screen label; defaults to `name`
|
||
"themeColor": "#1f2937", // optional — PWA chrome color override (otherwise each standalone keeps its accent)
|
||
"backgroundColor": "#fff", // optional — PWA splash background
|
||
"theme": "light", // optional — default in-app mode: light | dark | system
|
||
"palette": "darkmatter" // optional — default in-app palette (see PALETTES)
|
||
}
|
||
```
|
||
|
||
`themeColor` and `backgroundColor` are *overrides*, not defaults. When unset, each standalone's own accent applies (wallet yellow `#eab308`, chat green `#16a34a`, …) — so the default brand kit preserves the per-app visual identity, and a deployer who wants unified chrome adds the override.
|
||
|
||
`theme` and `palette` set the **in-app** color scheme — distinct from `themeColor` (which is only the PWA chrome / status-bar color). They define the *initial* default a fresh visitor sees; once a user picks a theme in-app it's stored in `localStorage` and always wins. `palette` must be one of the names in `src/components/theme-provider` (`PALETTES`): `catppuccin` (the built-in default), `countrysidecastle`, `darkmatter`, `emeraldforest`, `lightgreen`, `neobrut`, `starrynight`. Each palette has both a light and a dark variant, so e.g. "darkmatter light" is `{ "theme": "light", "palette": "darkmatter" }`. Unset → the app's built-ins (`dark` + `catppuccin`). Applies app-wide (hub + every standalone).
|
||
|
||
## Per-standalone overrides
|
||
|
||
Place a logo at `branding/<dep>/icons/<app>/logo.{svg,png}` to override the brand's primary logo for a single standalone build.
|
||
|
||
Resolution at build time:
|
||
|
||
1. `branding/<dep>/icons/<app>/logo.svg`
|
||
2. `branding/<dep>/icons/<app>/logo.png`
|
||
3. `branding/<dep>/logo.svg`
|
||
4. `branding/<dep>/logo.png`
|
||
5. Build fails with a clear error pointing here.
|
||
|
||
`<app>` is set via `BRAND_APP` env var (the standalone build script sets this; deployers don't touch it directly).
|
||
|
||
## Optional banner (logo + wordmark lockup)
|
||
|
||
A brand may ship a **banner** — a single wide image combining the logo and the wordmark — that replaces the logo + app-name pair in a standalone's header (currently the events page header). Banners are optional: brands without one keep the default logo + name rendering.
|
||
|
||
```
|
||
branding/<dep>/
|
||
banner.svg # preferred — crisp at any size, recolorable
|
||
banner.png # fallback (wide, transparent background)
|
||
icons/
|
||
events/banner.svg # optional per-standalone override
|
||
```
|
||
|
||
Resolution mirrors the logo chain (`resolveAppBanner` in `vite-branding.ts`):
|
||
|
||
1. `branding/<dep>/icons/<app>/banner.svg`
|
||
2. `branding/<dep>/icons/<app>/banner.png`
|
||
3. `branding/<dep>/banner.svg`
|
||
4. `branding/<dep>/banner.png`
|
||
5. No banner → header falls back to logo + name (no error).
|
||
|
||
SVG is strongly preferred — a banner is wide and rasterizes poorly when scaled. Components reference it via the build-time `@brand-app-banner` alias; whether it renders is driven by the `VITE_APP_BANNER` flag, so the component stays brand-agnostic.
|
||
|
||
> **⚠️ Outline text to paths in any SVG you ship.** The browser only has
|
||
> web-safe fonts — if a banner/logo SVG keeps live `<text>` elements that
|
||
> reference a designer font (e.g. a decorative display face), the browser
|
||
> substitutes a default font and the glyphs render wrong (we hit this with
|
||
> a mangled `!` in the "Oyez!" banner). In Inkscape: **Edit → Select All in
|
||
> All Layers (Ctrl+Alt+A)** — plain Select All only covers the current
|
||
> layer — then **Path → Object to Path (Shift+Ctrl+C)**, and save. Verify
|
||
> with `grep -c '<text' banner.svg` → should be `0`.
|
||
|
||
## How to use
|
||
|
||
**Building with the default brand:**
|
||
|
||
```bash
|
||
pnpm build # main shell
|
||
pnpm build:events # events standalone
|
||
# … one per standalone
|
||
```
|
||
|
||
**Building with a deployer's brand:**
|
||
|
||
```bash
|
||
BRAND_DIR=branding/cfaun pnpm build:events
|
||
```
|
||
|
||
`BRAND_DIR` accepts relative paths (resolved from the webapp repo root) or absolute paths (used by the NixOS builder, which mounts the brand directory into the sandbox at a `/nix/store/...-branding` path).
|
||
|
||
**Regenerating icons explicitly:**
|
||
|
||
The Vite plugin auto-runs the generator on every build/dev start. To run it standalone:
|
||
|
||
```bash
|
||
pnpm generate-pwa-assets
|
||
```
|
||
|
||
Outputs land in `public/icons/` (gitignored).
|
||
|
||
## Build pipeline
|
||
|
||
1. `BRAND_DIR` is resolved (defaults to `./branding/default`).
|
||
2. `vite-branding.ts` reads `brand.json` and exposes `@brand/<file>` alias.
|
||
3. `brandAssetsPlugin()` (registered in every `vite.*.config.ts`) runs `scripts/generate-pwa-assets.mjs` once per build via `buildStart`.
|
||
4. The script stages the source logo into `public/icons/.brand-source.{svg,png}`, runs `pwa-assets-generator`, then deletes the staged source.
|
||
5. Vite copies `public/icons/` into `dist/icons/`. Manifest references `icons/<name>.png`. HTML `<link>` tags reference `/icons/<name>.{ico,png}`.
|
||
|
||
## Integration with NixOS deployment
|
||
|
||
`flake.nix` exposes `lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }` for downstream consumers. Per-host wiring in `deploy/server-deploy/hosts/<host>/services/webapp.nix` looks like:
|
||
|
||
```nix
|
||
{ inputs, pkgs, ... }:
|
||
{
|
||
services.webapp.apps = {
|
||
main = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; };
|
||
events = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; };
|
||
wallet = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "wallet"; };
|
||
};
|
||
}
|
||
```
|
||
|
||
`brandDir` is either a path inside this flake (`./branding/<name>`) or an external path (e.g. `./../branding` from server-deploy). Either way Nix copies it into the build sandbox.
|
||
|
||
Builder details:
|
||
- Uses `pkgs.pnpm_10` regardless of consumer's nixpkgs, so the pnpmDeps hash stays stable across downstream nixpkgs versions.
|
||
- `pkgs.autoPatchelfHook` + `stdenv.cc.cc.lib` patch the prebuilt `@img/sharp-libvips-linux-*` binaries.
|
||
- `CI=true` bypasses pnpm 10's interactive modules-purge prompt in the sandbox.
|
||
|
||
The architectural payoff: brand and code become independent axes. Logo changes ship via server-deploy commits + redeploys — no webapp release, no `flake.lock` bump.
|
||
|
||
For local sanity:
|
||
|
||
```bash
|
||
nix build .#main # hub with aiolabs default brand
|
||
nix build .#events # events standalone with aiolabs default
|
||
# events with a custom brand (the impure way, ad-hoc):
|
||
nix build --impure --expr 'let pkgs = import <nixpkgs> {}; flake = builtins.getFlake (toString ./.); in flake.lib.mkWebapp { inherit pkgs; brandDir = /path/to/brand; app = "events"; }'
|
||
```
|