webapp/branding/README.md
Padreug d4d088fb50 feat(branding): per-brand default theme + palette via brand.json
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>
2026-06-15 22:12:13 +02:00

8.4 KiB
Raw Permalink Blame History

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

{
  "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:

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

  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:

{ 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:

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"; }'