Brand kit architecture: white-label PWA branding #95

Closed
opened 2026-06-09 19:30:17 +00:00 by padreug · 1 comment
Owner

Scope evolution note: this issue started as "per-standalone PWA icons" but the right framing is a brand kit that supports white-label deployment + per-standalone overrides + NixOS-driven branding. Per-standalone icons become a free composition rather than a standalone feature.

Background

Today's branding state across the multi-app shell:

  • In-app logo (src/assets/logo.png, 1024×1024) — hardcoded reference in Login.vue, LoginDemo.vue, AppSidebar.vue, MobileDrawer.vue.
  • Browser/OS icons in public/: favicon.ico, apple-touch-icon.png (180), mask-icon.svg, icon-{192,512}.png, icon-maskable-{192,512}.png. Hand-crafted, committed binaries.
  • Each vite.<app>.config.ts registers the same seven public/ filenames in VitePWA.includeAssets + manifest.icons. Same paths in every <app>.html.
  • PWA manifest name is already per-deployment via VITE_APP_NAME (commit 5541d2b).

So today: name varies per deployment, all icons are shared across every standalone and every deployment. A deployment branded "Sortir" still ships the generic aiolabs logo as the PWA install icon, the favicon, and the in-app <img>.

Public sector + cooperative deployers want their own logo. NixOS hosts in deploy/server-deploy (cfaun, four84, syntro, atio, …) want per-host branding. Per-standalone overrides are the cherry on top.

Goals

  1. White-label support: a deployer drops in their own logo + brand config, gets a fully branded PWA (in-app + favicon + install icons + manifest name + theme colors). No webapp fork required.
  2. Per-standalone overrides: within a deployment, individual standalones (events, wallet, market, …) can override the default brand with their own.
  3. NixOS-first deployment model: branding lives in deploy/server-deploy/hosts/<host>/branding/ and changing a logo is a server-deploy commit + redeploy, NOT a webapp release. Brand and code evolve on independent axes.
  4. Single source of truth: one image source (SVG preferred, PNG accepted) generates the full PWA icon set via @vite-pwa/assets-generator. No more hand-managing seven PNG files at seven sizes.

Proposed architecture

Brand kit contract

branding/
  README.md                  # docs for downstream branders
  default/                   # aiolabs branding, committed; the unparameterized default
    logo.svg                 # preferred source
    logo.png                 # fallback source (>=1024x1024, square, transparent bg)
    brand.json               # { name, themeColor, backgroundColor, ...optional overrides }
    icons/                   # optional per-standalone overrides
      events/logo.svg
      wallet/logo.png

Resolution order at build time:

  1. For app <X>: branding/<dep>/icons/<X>/logo.{svg,png} if present
  2. Else: branding/<dep>/logo.{svg,png}
  3. Else: build fails with a clear error message pointing at branding/README.md

Source format support

  • SVG strongly preferred. Vector source produces crisp icons at every size, enables a sharp favicon.svg (modern browsers prefer this over .ico), and the in-app @brand/logo can be tinted via CSS.
  • PNG accepted with documented constraints:
    • 1024×1024 minimum (smaller sources produce blurry install icons on high-DPI Android)
    • Square aspect ratio
    • Transparent background (generator handles maskable/apple-touch background colors)
    • PNG-source deployments lose the favicon.svg benefit and the recolorable in-app logo
  • aiolabs's own default ships PNG-only today (src/assets/logo.png is our 1024 source). An SVG upgrade is a separate, non-blocking task.

Build pipeline

  • @vite-pwa/assets-generator reads BRAND_DIR env var (default: ./branding/default).
  • Runs as a prebuild step before each vite build, generating into a gitignored public/icons/ directory.
  • Each vite.<app>.config.ts references generated paths in VitePWA.includeAssets + manifest.icons.
  • Manifest name, theme_color, background_color come from brand.json (replacing the current VITE_APP_NAME env injection).
  • <app>.html <link rel="icon"> / apple-touch-icon / mask-icon tags resolved via Vite HTML transforms.
  • In-app <img src="@/assets/logo.png"> references switch to a @brand/logo Vite alias resolved from BRAND_DIR.

Gitignored generated icons

public/icons/ becomes a build artifact, not committed. Removes "did I forget to commit the regenerated 512 maskable" from the failure modes. Build script regenerates each time.

NixOS deployment integration

The brand kit slots directly into the existing deploy/server-deploy fan-out:

deploy/server-deploy/
  hosts/
    aio-demo/
      services/webapp.nix          # uses default (aiolabs) branding
    cfaun/
      services/webapp.nix
      branding/                    # cfaun-specific
        logo.svg
        brand.json
    four84/
      services/webapp.nix
      branding/
        logo.png
        brand.json

The webapp's flake.nix exposes a parameterized builder instead of (or alongside) a fixed packages.default:

# webapp/flake.nix
outputs = { ... }: {
  lib.mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main", ... }:
    pkgs.buildNpmPackage {
      # copies brandDir into the sandbox as BRAND_DIR,
      # runs @vite-pwa/assets-generator,
      # runs vite build for `app`,
      # outputs static dist/
    };

  packages.${system}.default = self.lib.mkWebapp { inherit pkgs; };  # unparameterized aiolabs default
};

Each host calls it:

# deploy/server-deploy/hosts/cfaun/services/webapp.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"; };
  };
}

Why this is the right shape

1. Logo changes don't require a webapp release. This is the meaningful UX win. Today: webapp dev → bump webapp-demo lock → vet on aio-demo → fast-forward main → bump webapp lock → deploy. With brand kit: one server-deploy commit + redeploy. Nix sees the brand dir hash change → rebuilds closure → deploys.

2. CLAUDE.md's "production-bound changes need a flake.lock bump" still applies — but only to code. Brand assets live in server-deploy, change without flake input bumps. Brand and code become independent axes.

3. Each host's brand is co-located with its other host config. Same directory as nginx vhost, same dir as service module. Forking a host's branding is one cp.

4. Reproducibility for free. Brand assets become part of the nix derivation hash. Hosts sharing a brand share the build via the binary cache.

5. Composes with external white-labelers. A third party running webapp on their own NixOS infra calls inputs.webapp.lib.mkWebapp { brandDir = ./their-branding; }. Their brand stays in their infra repo. Real white-label — not just multi-tenancy.

Release flow becomes cleaner

The mental model: server-deploy stages branding decisions per-host; webapp stages code decisions per-channel. Two orthogonal axes.

  • dev → demo → main release flow continues to vet code on aio-demo.
  • Brand changes don't go through that vet cycle (they don't change code).

Scope / phasing

Phase 1 — this issue: brand kit architecture in webapp.

  • Create branding/default/ with brand.json + current logo as logo.png
  • Install @vite-pwa/assets-generator, sharp verification under nix
  • pwa-assets.config.ts reading BRAND_DIR
  • Vite HTML transforms for <link> tags in <app>.html
  • @brand/logo Vite alias, migrate <img src="@/assets/logo.png"> consumers
  • brand.json drives manifest name / theme_color / background_color (replaces VITE_APP_NAME env injection)
  • Gitignore public/icons/, remove the seven committed icon binaries
  • Expose lib.mkWebapp from flake.nix
  • Document the brand contract in branding/README.md
  • Update CLAUDE.md (webapp + workspace) under the standalone app pattern

Phase 2 — follow-up server-deploy PR: migrate hosts to mkWebapp.

  • Per-host branding/ directories created
  • Each host's services/webapp.nix switches to inputs.webapp.lib.mkWebapp { brandDir = ./../branding; }
  • VITE_APP_NAME env injection removed (now driven by brand.json)
  • Filed as a separate issue on aiolabs/server-deploy.

Acceptance

  • Building webapp without BRAND_DIR produces the aiolabs default (no regression for current behavior).
  • Setting BRAND_DIR=branding/test-fixture (with a different logo) produces a build whose favicon, apple-touch icon, PWA install icons, in-app logo, and manifest name/colors all reflect the test fixture.
  • Per-standalone override: a fixture with icons/events/logo.svg produces an events standalone whose icons differ from the same fixture's main webapp.
  • nix build against the webapp flake's lib.mkWebapp produces a usable static dist/ derivation.
  • PNG-only deployer with a 1024×1024 source builds cleanly and gets reasonable-quality icons (documented quality caveats apply).
  • Documented in branding/README.md + CLAUDE.md.

Open questions

  • In-app logo recolorability: SVG <img> can be tinted via CSS filters / currentColor. Do we want the in-app brand resolution to inline SVG as a Vue component (full color control) or just <img src> (simpler, no recoloring)? Probably <img> for v1.
  • brand.json schema: minimum is { name }. Should also include themeColor, backgroundColor, maybe siteUrl, maybe supportEmail. Define + Zod-validate at build time.
  • Sharp under nix: @vite-pwa/assets-generator uses sharp (native libvips). Historically a nix pain point. Verify on first deploy under buildNpmPackage; document the fix if non-trivial.
  • Service worker cache invalidation on icon swaps: installed PWAs cache the icon set. Verify the existing PWA update flow busts the cache cleanly when brand assets change.
  • Backwards compatibility during migration: Phase 1 and Phase 2 land separately. Between them, server-deploy hosts still build the unparameterized default. Phase 1 must preserve that path.

Context

Discovered while answering "how does the logo work and how do I change it." Original answer was "swap the shared files; per-standalone icons aren't supported; per-deployment branding isn't supported either." This issue closes that gap properly — not as a per-standalone tweak but as the white-label brand kit the deployment model actually wants.

> **Scope evolution note:** this issue started as "per-standalone PWA icons" but the right framing is a **brand kit** that supports white-label deployment + per-standalone overrides + NixOS-driven branding. Per-standalone icons become a free composition rather than a standalone feature. ## Background Today's branding state across the multi-app shell: - **In-app logo** (`src/assets/logo.png`, 1024×1024) — hardcoded reference in `Login.vue`, `LoginDemo.vue`, `AppSidebar.vue`, `MobileDrawer.vue`. - **Browser/OS icons** in `public/`: `favicon.ico`, `apple-touch-icon.png` (180), `mask-icon.svg`, `icon-{192,512}.png`, `icon-maskable-{192,512}.png`. Hand-crafted, committed binaries. - **Each `vite.<app>.config.ts`** registers the same seven `public/` filenames in `VitePWA.includeAssets` + `manifest.icons`. Same paths in every `<app>.html`. - **PWA manifest *name*** is already per-deployment via `VITE_APP_NAME` (commit 5541d2b). So today: name varies per deployment, **all icons are shared across every standalone and every deployment**. A deployment branded "Sortir" still ships the generic aiolabs logo as the PWA install icon, the favicon, and the in-app `<img>`. Public sector + cooperative deployers want their own logo. NixOS hosts in `deploy/server-deploy` (cfaun, four84, syntro, atio, …) want per-host branding. Per-standalone overrides are the cherry on top. ## Goals 1. **White-label support:** a deployer drops in their own logo + brand config, gets a fully branded PWA (in-app + favicon + install icons + manifest name + theme colors). No webapp fork required. 2. **Per-standalone overrides:** within a deployment, individual standalones (events, wallet, market, …) can override the default brand with their own. 3. **NixOS-first deployment model:** branding lives in `deploy/server-deploy/hosts/<host>/branding/` and changing a logo is a **server-deploy commit + redeploy**, NOT a webapp release. Brand and code evolve on independent axes. 4. **Single source of truth:** one image source (SVG preferred, PNG accepted) generates the full PWA icon set via `@vite-pwa/assets-generator`. No more hand-managing seven PNG files at seven sizes. ## Proposed architecture ### Brand kit contract ``` branding/ README.md # docs for downstream branders default/ # aiolabs branding, committed; the unparameterized default logo.svg # preferred source logo.png # fallback source (>=1024x1024, square, transparent bg) brand.json # { name, themeColor, backgroundColor, ...optional overrides } icons/ # optional per-standalone overrides events/logo.svg wallet/logo.png ``` **Resolution order at build time:** 1. For app `<X>`: `branding/<dep>/icons/<X>/logo.{svg,png}` if present 2. Else: `branding/<dep>/logo.{svg,png}` 3. Else: build fails with a clear error message pointing at `branding/README.md` ### Source format support - **SVG strongly preferred.** Vector source produces crisp icons at every size, enables a sharp `favicon.svg` (modern browsers prefer this over `.ico`), and the in-app `@brand/logo` can be tinted via CSS. - **PNG accepted** with documented constraints: - 1024×1024 minimum (smaller sources produce blurry install icons on high-DPI Android) - Square aspect ratio - Transparent background (generator handles maskable/apple-touch background colors) - PNG-source deployments lose the `favicon.svg` benefit and the recolorable in-app logo - aiolabs's own default ships PNG-only today (`src/assets/logo.png` is our 1024 source). An SVG upgrade is a separate, non-blocking task. ### Build pipeline - `@vite-pwa/assets-generator` reads `BRAND_DIR` env var (default: `./branding/default`). - Runs as a prebuild step before each `vite build`, generating into a gitignored `public/icons/` directory. - Each `vite.<app>.config.ts` references generated paths in `VitePWA.includeAssets` + `manifest.icons`. - Manifest `name`, `theme_color`, `background_color` come from `brand.json` (replacing the current `VITE_APP_NAME` env injection). - `<app>.html` `<link rel="icon">` / `apple-touch-icon` / `mask-icon` tags resolved via Vite HTML transforms. - In-app `<img src="@/assets/logo.png">` references switch to a `@brand/logo` Vite alias resolved from `BRAND_DIR`. ### Gitignored generated icons `public/icons/` becomes a build artifact, not committed. Removes "did I forget to commit the regenerated 512 maskable" from the failure modes. Build script regenerates each time. ## NixOS deployment integration The brand kit slots directly into the existing `deploy/server-deploy` fan-out: ``` deploy/server-deploy/ hosts/ aio-demo/ services/webapp.nix # uses default (aiolabs) branding cfaun/ services/webapp.nix branding/ # cfaun-specific logo.svg brand.json four84/ services/webapp.nix branding/ logo.png brand.json ``` The webapp's `flake.nix` exposes a **parameterized builder** instead of (or alongside) a fixed `packages.default`: ```nix # webapp/flake.nix outputs = { ... }: { lib.mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main", ... }: pkgs.buildNpmPackage { # copies brandDir into the sandbox as BRAND_DIR, # runs @vite-pwa/assets-generator, # runs vite build for `app`, # outputs static dist/ }; packages.${system}.default = self.lib.mkWebapp { inherit pkgs; }; # unparameterized aiolabs default }; ``` Each host calls it: ```nix # deploy/server-deploy/hosts/cfaun/services/webapp.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"; }; }; } ``` ### Why this is the right shape **1. Logo changes don't require a webapp release.** This is the meaningful UX win. Today: webapp dev → bump `webapp-demo` lock → vet on aio-demo → fast-forward main → bump `webapp` lock → deploy. With brand kit: one server-deploy commit + redeploy. Nix sees the brand dir hash change → rebuilds closure → deploys. **2. CLAUDE.md's "production-bound changes need a flake.lock bump" still applies — but only to code.** Brand assets live in server-deploy, change without flake input bumps. **Brand and code become independent axes.** **3. Each host's brand is co-located with its other host config.** Same directory as nginx vhost, same dir as service module. Forking a host's branding is one cp. **4. Reproducibility for free.** Brand assets become part of the nix derivation hash. Hosts sharing a brand share the build via the binary cache. **5. Composes with external white-labelers.** A third party running webapp on their own NixOS infra calls `inputs.webapp.lib.mkWebapp { brandDir = ./their-branding; }`. Their brand stays in their infra repo. Real white-label — not just multi-tenancy. ### Release flow becomes cleaner The mental model: **server-deploy stages branding decisions per-host; webapp stages code decisions per-channel.** Two orthogonal axes. - dev → demo → main release flow continues to vet *code* on aio-demo. - Brand changes don't go through that vet cycle (they don't change code). ## Scope / phasing **Phase 1 — this issue:** brand kit architecture in webapp. - [ ] Create `branding/default/` with `brand.json` + current logo as `logo.png` - [ ] Install `@vite-pwa/assets-generator`, sharp verification under nix - [ ] `pwa-assets.config.ts` reading `BRAND_DIR` - [ ] Vite HTML transforms for `<link>` tags in `<app>.html` - [ ] `@brand/logo` Vite alias, migrate `<img src="@/assets/logo.png">` consumers - [ ] `brand.json` drives manifest `name` / `theme_color` / `background_color` (replaces `VITE_APP_NAME` env injection) - [ ] Gitignore `public/icons/`, remove the seven committed icon binaries - [ ] Expose `lib.mkWebapp` from `flake.nix` - [ ] Document the brand contract in `branding/README.md` - [ ] Update `CLAUDE.md` (webapp + workspace) under the standalone app pattern **Phase 2 — follow-up server-deploy PR:** migrate hosts to `mkWebapp`. - Per-host `branding/` directories created - Each host's `services/webapp.nix` switches to `inputs.webapp.lib.mkWebapp { brandDir = ./../branding; }` - `VITE_APP_NAME` env injection removed (now driven by `brand.json`) - Filed as a separate issue on `aiolabs/server-deploy`. ## Acceptance - Building webapp without `BRAND_DIR` produces the aiolabs default (no regression for current behavior). - Setting `BRAND_DIR=branding/test-fixture` (with a different logo) produces a build whose favicon, apple-touch icon, PWA install icons, in-app logo, and manifest name/colors all reflect the test fixture. - Per-standalone override: a fixture with `icons/events/logo.svg` produces an events standalone whose icons differ from the same fixture's main webapp. - `nix build` against the webapp flake's `lib.mkWebapp` produces a usable static `dist/` derivation. - PNG-only deployer with a 1024×1024 source builds cleanly and gets reasonable-quality icons (documented quality caveats apply). - Documented in `branding/README.md` + `CLAUDE.md`. ## Open questions - **In-app logo recolorability:** SVG `<img>` can be tinted via CSS filters / `currentColor`. Do we want the in-app brand resolution to inline SVG as a Vue component (full color control) or just `<img src>` (simpler, no recoloring)? Probably `<img>` for v1. - **`brand.json` schema:** minimum is `{ name }`. Should also include `themeColor`, `backgroundColor`, maybe `siteUrl`, maybe `supportEmail`. Define + Zod-validate at build time. - **Sharp under nix:** `@vite-pwa/assets-generator` uses sharp (native libvips). Historically a nix pain point. Verify on first deploy under `buildNpmPackage`; document the fix if non-trivial. - **Service worker cache invalidation on icon swaps:** installed PWAs cache the icon set. Verify the existing PWA update flow busts the cache cleanly when brand assets change. - **Backwards compatibility during migration:** Phase 1 and Phase 2 land separately. Between them, server-deploy hosts still build the unparameterized default. Phase 1 must preserve that path. ## Context Discovered while answering "how does the logo work and how do I change it." Original answer was "swap the shared files; per-standalone icons aren't supported; per-deployment branding isn't supported either." This issue closes that gap properly — not as a per-standalone tweak but as the white-label brand kit the deployment model actually wants.
padreug changed title from Per-standalone PWA icons + favicon to Brand kit architecture: white-label PWA branding 2026-06-09 19:49:33 +00:00
Author
Owner

Phase 2 follow-up filed: aiolabs/server-deploy#8 — "Migrate webapp hosts to brand-kit (mkWebapp) deployment". Tracks the host-side migration once lib.mkWebapp lands here.

Phase 2 follow-up filed: [aiolabs/server-deploy#8](https://git.atitlan.io/aiolabs/server-deploy/issues/8) — "Migrate webapp hosts to brand-kit (mkWebapp) deployment". Tracks the host-side migration once `lib.mkWebapp` lands here.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/webapp#95
No description provided.