feat(branding): drive PWA manifest from brand.json

vite-branding.ts now loads brand.json into a typed `brand` object and
exports a `brandManifestName()` helper. Schema:

  { name (required), shortName?, themeColor?, backgroundColor? }

Default brand.json drops themeColor/backgroundColor — they're optional
overrides; per-app accents (wallet yellow, chat green, …) keep working
via `?? '<existing>'` fallbacks in each standalone's vite config.

events: manifest.name/short_name driven by brand. VITE_APP_NAME env
override stays (Phase 2 server-deploy migration still in flight) and,
when set, overrides both name and short_name to preserve pre-#95
behavior. cfaun's VITE_APP_NAME=Bouge keeps working unchanged.

hub (vite.config.ts): brand.name flows into %VITE_APP_NAME% Hub title.

7 other standalones (wallet, chat, market, forum, tasks, restaurant,
libra): only theme_color/background_color get brand overrides. Their
manifest.name/short_name stay hardcoded so multi-PWA home-screen
labels remain differentiated ("Wallet", "Chat", …) rather than all
collapsing to the brand short_name.

Verified default build: events manifest name=AIO; wallet keeps
"Wallet — Lightning" + #eab308 accent.
Verified VITE_APP_NAME=Sortir override: events name+short_name=Sortir.

Part of aiolabs/webapp#95.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-09 23:11:30 +02:00
commit ce5a1a6a56
11 changed files with 74 additions and 32 deletions

View file

@ -1,6 +1,4 @@
{ {
"name": "AIO", "name": "AIO",
"shortName": "AIO", "shortName": "AIO"
"themeColor": "#ffffff",
"backgroundColor": "#ffffff"
} }

View file

@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
/** /**
@ -9,6 +10,28 @@ import { resolve } from 'node:path'
*/ */
export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default') export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default')
/** Fields parsed from brand.json. All but `name` are optional. */
export interface Brand {
/** Brand label — drives PWA manifest name. */
name: string
/** PWA install/home-screen short label. Defaults to `name`. */
shortName?: string
/**
* Optional PWA chrome theme color (status bar / title bar when installed).
* When unset, each standalone's vite config keeps its hardcoded accent.
*/
themeColor?: string
/**
* Optional PWA splash background. When unset, each standalone's vite
* config keeps its hardcoded value.
*/
backgroundColor?: string
}
export const brand: Brand = JSON.parse(
readFileSync(resolve(BRAND_DIR, 'brand.json'), 'utf-8'),
)
/** /**
* Spread into a vite config's `resolve.alias` map. Lets components * Spread into a vite config's `resolve.alias` map. Lets components
* import deployer-provided assets via `@brand/<file>` instead of * import deployer-provided assets via `@brand/<file>` instead of
@ -17,3 +40,14 @@ export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default')
export const brandAlias = { export const brandAlias = {
'@brand': BRAND_DIR, '@brand': BRAND_DIR,
} as const } as const
/**
* PWA manifest name for a standalone. Combines the brand name with the
* app's own label, or returns the bare brand when no label is given.
*
* Example: `brandManifestName('Wallet')` "AIO Wallet" / "Cfaun Wallet".
* Example: `brandManifestName()` "AIO" / "Sortir".
*/
export function brandManifestName(appLabel?: string): string {
return appLabel ? `${brand.name} ${appLabel}` : brand.name
}

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
function chatHtmlPlugin(): Plugin { function chatHtmlPlugin(): Plugin {
return { return {
@ -72,8 +72,8 @@ export default defineConfig(({ mode }) => ({
name: 'Chat — Encrypted', name: 'Chat — Encrypted',
short_name: 'Chat', short_name: 'Chat',
description: 'End-to-end encrypted Nostr chat', description: 'End-to-end encrypted Nostr chat',
theme_color: '#16a34a', theme_color: brand.themeColor ?? '#16a34a',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
import Inspect from 'vite-plugin-inspect' import Inspect from 'vite-plugin-inspect'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
// https://vite.dev/config/ // https://vite.dev/config/
// //
@ -13,6 +13,12 @@ import { brandAlias } from './vite-branding'
// the entire origin and blocked Chrome from offering installs for the // the entire origin and blocked Chrome from offering installs for the
// path-mounted standalones at /libra/, /market/, etc. The hub is a // path-mounted standalones at /libra/, /market/, etc. The hub is a
// launcher page; users install the standalones they actually use. // launcher page; users install the standalones they actually use.
// Brand name flows into index.html's `%VITE_APP_NAME% Hub` title.
// VITE_APP_NAME env override stays during the Phase 2 server-deploy
// migration (aiolabs/webapp#95); drop once all hosts move to BRAND_DIR.
process.env.VITE_APP_NAME = process.env.VITE_APP_NAME ?? brand.name
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
// Per-app dep cache so concurrent dev servers don't race on .vite/deps // Per-app dep cache so concurrent dev servers don't race on .vite/deps
cacheDir: 'node_modules/.vite-hub', cacheDir: 'node_modules/.vite-hub',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias, brandManifestName } from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to events.html * Plugin to rewrite dev server requests to events.html
@ -42,10 +42,12 @@ function eventsHtmlPlugin(): Plugin {
* VITE_BASE_PATH=/events/ app.ariege.io/events/ (shared auth) * VITE_BASE_PATH=/events/ app.ariege.io/events/ (shared auth)
* (default: /) bouge.ariege.io (standalone subdomain) * (default: /) bouge.ariege.io (standalone subdomain)
* *
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console * Brand name resolves from brand.json under $BRAND_DIR (see
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events". * vite-branding.ts and aiolabs/webapp#95). VITE_APP_NAME remains an
* env override during the Phase 2 server-deploy migration; drop the
* env path once all hosts have moved to BRAND_DIR.
*/ */
const APP_NAME = process.env.VITE_APP_NAME || 'Events' const APP_NAME = process.env.VITE_APP_NAME ?? brandManifestName()
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME% // Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
// substitution picks up the fallback when nothing was explicitly set. // substitution picks up the fallback when nothing was explicitly set.
process.env.VITE_APP_NAME = APP_NAME process.env.VITE_APP_NAME = APP_NAME
@ -86,10 +88,12 @@ export default defineConfig(({ mode }) => ({
], ],
manifest: { manifest: {
name: APP_NAME, name: APP_NAME,
short_name: APP_NAME, // VITE_APP_NAME, when set, overrides both name and short_name to
// preserve the pre-#95 behavior. Otherwise brand.shortName drives.
short_name: process.env.VITE_APP_NAME ?? brand.shortName ?? APP_NAME,
description: 'Discover events near you', description: 'Discover events near you',
theme_color: '#1f2937', theme_color: brand.themeColor ?? '#1f2937',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
function forumHtmlPlugin(): Plugin { function forumHtmlPlugin(): Plugin {
return { return {
@ -72,8 +72,8 @@ export default defineConfig(({ mode }) => ({
name: 'Forum — Discussions', name: 'Forum — Discussions',
short_name: 'Forum', short_name: 'Forum',
description: 'Decentralized link aggregator and discussion forum on Nostr', description: 'Decentralized link aggregator and discussion forum on Nostr',
theme_color: '#2563eb', theme_color: brand.themeColor ?? '#2563eb',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to libra.html * Plugin to rewrite dev server requests to libra.html
@ -79,8 +79,8 @@ export default defineConfig(({ mode }) => ({
name: 'Libra — Team Accounting', name: 'Libra — Team Accounting',
short_name: 'Libra', short_name: 'Libra',
description: 'Team accounting and expense management', description: 'Team accounting and expense management',
theme_color: '#1f2937', theme_color: brand.themeColor ?? '#1f2937',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
function marketHtmlPlugin(): Plugin { function marketHtmlPlugin(): Plugin {
return { return {
@ -72,8 +72,8 @@ export default defineConfig(({ mode }) => ({
name: 'Market — Nostr', name: 'Market — Nostr',
short_name: 'Market', short_name: 'Market',
description: 'Decentralized marketplace on Nostr with Lightning payments', description: 'Decentralized marketplace on Nostr with Lightning payments',
theme_color: '#dc2626', theme_color: brand.themeColor ?? '#dc2626',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
function restaurantHtmlPlugin(): Plugin { function restaurantHtmlPlugin(): Plugin {
return { return {
@ -79,8 +79,8 @@ export default defineConfig(({ mode }) => ({
description: 'Order from your local Nostr-native restaurant with Lightning payments', description: 'Order from your local Nostr-native restaurant with Lightning payments',
// Green to differentiate from market red. PDF tile is purple // Green to differentiate from market red. PDF tile is purple
// (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png). // (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png).
theme_color: '#16a34a', theme_color: brand.themeColor ?? '#16a34a',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
function tasksHtmlPlugin(): Plugin { function tasksHtmlPlugin(): Plugin {
return { return {
@ -72,8 +72,8 @@ export default defineConfig(({ mode }) => ({
name: 'Tasks — Work Orders', name: 'Tasks — Work Orders',
short_name: 'Tasks', short_name: 'Tasks',
description: 'Decentralized task management on Nostr', description: 'Decentralized task management on Nostr',
theme_color: '#4338ca', theme_color: brand.themeColor ?? '#4338ca',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',

View file

@ -5,7 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brandAlias } from './vite-branding' import { brand, brandAlias } from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to wallet.html * Plugin to rewrite dev server requests to wallet.html
@ -78,8 +78,8 @@ export default defineConfig(({ mode }) => ({
name: 'Wallet — Lightning', name: 'Wallet — Lightning',
short_name: 'Wallet', short_name: 'Wallet',
description: 'Lightning Network wallet — send, receive, and manage sats', description: 'Lightning Network wallet — send, receive, and manage sats',
theme_color: '#eab308', theme_color: brand.themeColor ?? '#eab308',
background_color: '#ffffff', background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',