feat(branding): per-app banner + per-brand default theme via brand.json #104

Merged
padreug merged 2 commits from feat/brand-banner-and-default-theme into dev 2026-06-15 20:15:49 +00:00
4 changed files with 53 additions and 4 deletions
Showing only changes of commit d4d088fb50 - Show all commits

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>
Padreug 2026-06-15 22:12:13 +02:00

View file

@ -47,12 +47,16 @@ When both `logo.svg` and `logo.png` are present, SVG wins.
"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
"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.

View file

@ -21,10 +21,28 @@ export const PALETTES: Palette[] = [
'starrynight',
]
const DEFAULT_PALETTE: Palette = 'catppuccin'
// The palette styled by the bare `:root` in index.css (no `data-theme`
// attribute). This is a fixed invariant of the CSS, NOT the configurable
// default — applyPalette() removes the attribute only for this one.
const BASE_PALETTE: Palette = 'catppuccin'
// Brand-configurable initial defaults, surfaced from brand.json via
// vite-branding.ts (VITE_BRAND_THEME / VITE_BRAND_PALETTE). These set the
// first-load value when the user has no saved preference; a stored choice
// always overrides (see onMounted). Invalid/unset values fall back to the
// app's built-ins ('dark' + catppuccin).
const BRAND_THEME = (import.meta.env.VITE_BRAND_THEME as string) || ''
const BRAND_PALETTE = (import.meta.env.VITE_BRAND_PALETTE as string) || ''
const DEFAULT_THEME: Theme =
BRAND_THEME === 'dark' || BRAND_THEME === 'light' || BRAND_THEME === 'system'
? BRAND_THEME
: 'dark'
const DEFAULT_PALETTE: Palette = (PALETTES as string[]).includes(BRAND_PALETTE)
? (BRAND_PALETTE as Palette)
: BASE_PALETTE
const useTheme = () => {
const theme = ref<Theme>('dark')
const theme = ref<Theme>(DEFAULT_THEME)
const systemTheme = ref<'dark' | 'light'>('light')
const palette = ref<Palette>(DEFAULT_PALETTE)
@ -45,7 +63,7 @@ const useTheme = () => {
}
const applyPalette = () => {
if (palette.value === DEFAULT_PALETTE) {
if (palette.value === BASE_PALETTE) {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', palette.value)

4
src/vite-env.d.ts vendored
View file

@ -24,4 +24,8 @@ interface ImportMetaEnv {
readonly VITE_APP_NAME?: string
/** '1' when the active brand ships a banner, '' otherwise. */
readonly VITE_APP_BANNER?: string
/** Brand default theme mode ('light'|'dark'|'system'), set from brand.json. */
readonly VITE_BRAND_THEME?: string
/** Brand default palette name, set from brand.json. */
readonly VITE_BRAND_PALETTE?: string
}

View file

@ -28,12 +28,35 @@ export interface Brand {
* config keeps its hardcoded value.
*/
backgroundColor?: string
/**
* Optional default in-app theme mode (light / dark / system). Sets the
* INITIAL value the theme-provider uses when the user has no saved
* preference; a user's later choice still wins and persists. Unset
* the app's built-in default ('dark'). Distinct from `themeColor`,
* which is PWA chrome only.
*/
theme?: 'light' | 'dark' | 'system'
/**
* Optional default in-app color palette (e.g. 'darkmatter'). Same
* initial-default semantics as `theme`. Must be one of the palettes in
* src/components/theme-provider (PALETTES). Unset 'catppuccin'.
*/
palette?: string
}
export const brand: Brand = JSON.parse(
readFileSync(resolve(BRAND_DIR, 'brand.json'), 'utf-8'),
)
// Surface the brand's in-app theme defaults to the client as VITE_*
// env vars (read by the theme-provider). Set here at module load — every
// vite.<app>.config.ts imports this file — so the default applies
// app-wide (hub + all standalones) without per-config wiring. Always
// assigned (empty when unset) so a prior brand's value can't leak into a
// later build in the same process.
process.env.VITE_BRAND_THEME = brand.theme ?? ''
process.env.VITE_BRAND_PALETTE = brand.palette ?? ''
/**
* Spread into a vite config's `resolve.alias` map. Lets components
* import deployer-provided assets via `@brand/<file>` instead of