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>
8.4 KiB
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.svgfor modern browsers - The in-app
@brand/logoreference 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.svgbenefit and the recolorable in-app logo
When both logo.svg and logo.png are present, SVG wins.
brand.json schema
{
"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:
branding/<dep>/icons/<app>/logo.svgbranding/<dep>/icons/<app>/logo.pngbranding/<dep>/logo.svgbranding/<dep>/logo.png- 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):
branding/<dep>/icons/<app>/banner.svgbranding/<dep>/icons/<app>/banner.pngbranding/<dep>/banner.svgbranding/<dep>/banner.png- 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 withgrep -c '<text' banner.svg→ should be0.
How to use
Building with the default brand:
pnpm build # main shell
pnpm build:events # events standalone
# … one per standalone
Building with a deployer's brand:
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:
pnpm generate-pwa-assets
Outputs land in public/icons/ (gitignored).
Build pipeline
BRAND_DIRis resolved (defaults to./branding/default).vite-branding.tsreadsbrand.jsonand exposes@brand/<file>alias.brandAssetsPlugin()(registered in everyvite.*.config.ts) runsscripts/generate-pwa-assets.mjsonce per build viabuildStart.- The script stages the source logo into
public/icons/.brand-source.{svg,png}, runspwa-assets-generator, then deletes the staged source. - Vite copies
public/icons/intodist/icons/. Manifest referencesicons/<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:
{ 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_10regardless of consumer's nixpkgs, so the pnpmDeps hash stays stable across downstream nixpkgs versions. pkgs.autoPatchelfHook+stdenv.cc.cc.libpatch the prebuilt@img/sharp-libvips-linux-*binaries.CI=truebypasses 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:
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"; }'