From 8f2c401e0031e17d02767b7ba132053a0bc8c460 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 22:11:01 +0200 Subject: [PATCH 1/2] feat(branding): optional per-app banner replacing logo + name in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand may ship a wide banner (logo + wordmark in one image) that replaces the brand-kit logo + app-name pair in a standalone's header. Events is the first consumer. Banners are optional and resolve at build time, mirroring the existing @brand-app-logo chain: - resolveAppBanner(app?) checks per-standalone override first (branding//icons//banner.{svg,png}) then the brand's primary banner (branding//banner.{svg,png}); returns null when absent instead of throwing, so brands without a banner keep logo + name. - brandAppBannerAliasEntry() always registers the @brand-app-banner alias (falling back to the logo) so the static import resolves; whether it renders is gated by the VITE_APP_BANNER build flag. - EventsPage renders the banner when the flag is set, else logo + name. Deployers override per-standalone without touching the component. SVG banners must have their text outlined to paths (browsers lack designer fonts) — documented in branding/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- branding/README.md | 31 +++++++++++++++++++ src/modules/events/views/EventsPage.vue | 30 ++++++++++++++----- src/vite-env.d.ts | 16 ++++++++++ vite-branding.ts | 40 +++++++++++++++++++++++++ vite.events.config.ts | 9 ++++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/branding/README.md b/branding/README.md index b84ba6c..a68e8b9 100644 --- a/branding/README.md +++ b/branding/README.md @@ -67,6 +67,37 @@ Resolution at build time: `` 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// + 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//icons//banner.svg` +2. `branding//icons//banner.png` +3. `branding//banner.svg` +4. `branding//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 `` 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 '
- -

+

- {{ appName || t('events.title') }} + + + {{ appName || t('events.title') }} +

diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2e36090..64fa22a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,3 +9,19 @@ declare module '@brand-app-logo?url' { const src: string export default src } + +// Optional brand banner (wide logo+wordmark lockup). Resolved at build +// time by vite-branding.ts; falls back to the logo when the active brand +// ships no banner, so the import always resolves. The component gates on +// `VITE_APP_BANNER` before rendering it. +declare module '@brand-app-banner?url' { + const src: string + export default src +} + +interface ImportMetaEnv { + /** Brand name, set from brand.json in vite..config.ts. */ + readonly VITE_APP_NAME?: string + /** '1' when the active brand ships a banner, '' otherwise. */ + readonly VITE_APP_BANNER?: string +} diff --git a/vite-branding.ts b/vite-branding.ts index ccfd33c..7e8eda1 100644 --- a/vite-branding.ts +++ b/vite-branding.ts @@ -92,6 +92,46 @@ export function brandAppLogoAliasEntry(app?: string) { } as const } +/** + * Optional brand banner — a wide lockup (logo + wordmark in one image) + * that replaces the logo + app-name pair in a standalone's header. + * + * Resolution mirrors {@link resolveAppLogo} (per-standalone override + * first, then the brand's primary banner), but a banner is OPTIONAL: + * returns `null` when none is found instead of throwing. Brands that + * don't ship a banner keep the default logo + name rendering. + */ +export function resolveAppBanner(app?: string): string | null { + const candidates: string[] = [] + if (app) { + candidates.push( + join(BRAND_DIR, 'icons', app, 'banner.svg'), + join(BRAND_DIR, 'icons', app, 'banner.png'), + ) + } + candidates.push( + join(BRAND_DIR, 'banner.svg'), + join(BRAND_DIR, 'banner.png'), + ) + return candidates.find((p) => existsSync(p)) ?? null +} + +/** + * Standalone-aware brand-banner alias entry, the banner sibling of + * {@link brandAppLogoAliasEntry}. Always registers the + * `@brand-app-banner` alias so the static `import '@brand-app-banner?url'` + * in the component resolves cleanly — when the active brand has no + * banner it falls back to the resolved logo, which the component never + * renders (it gates on the `VITE_APP_BANNER` flag instead). + */ +export function brandAppBannerAliasEntry(app?: string) { + const resolved = resolveAppBanner(app) ?? resolveAppLogo(app) + return { + find: /^@brand-app-banner(\?.*)?$/, + replacement: `${resolved}$1`, + } 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. diff --git a/vite.events.config.ts b/vite.events.config.ts index 4cea47d..5fbc96c 100644 --- a/vite.events.config.ts +++ b/vite.events.config.ts @@ -8,9 +8,11 @@ import { visualizer } from 'rollup-plugin-visualizer' import { brand, brandAlias, + brandAppBannerAliasEntry, brandAppLogoAliasEntry, brandAssetsPlugin, brandManifestName, + resolveAppBanner, } from './vite-branding' /** @@ -55,6 +57,12 @@ function eventsHtmlPlugin(): Plugin { const APP_NAME = brandManifestName() process.env.VITE_APP_NAME = APP_NAME +// When the active brand ships a banner (wide logo+wordmark lockup), the +// events header renders it in place of the logo + name pair. Surfaced as +// a '1'/'' flag the component reads; the actual file comes through the +// @brand-app-banner alias below. See branding/README.md. +process.env.VITE_APP_BANNER = resolveAppBanner('events') ? '1' : '' + export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASE_PATH || '/', // Per-app dep cache so concurrent dev servers don't race on .vite/deps @@ -128,6 +136,7 @@ export default defineConfig(({ mode }) => ({ // aliases without one shadowing the other. alias: [ brandAppLogoAliasEntry('events'), + brandAppBannerAliasEntry('events'), ...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })), { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, ], -- 2.53.0 From d4d088fb503de0ec6ed0fa57b790c86d9469d430 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 22:12:13 +0200 Subject: [PATCH 2/2] feat(branding): per-brand default theme + palette via brand.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- branding/README.md | 6 +++++- src/components/theme-provider/index.ts | 24 +++++++++++++++++++++--- src/vite-env.d.ts | 4 ++++ vite-branding.ts | 23 +++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/branding/README.md b/branding/README.md index a68e8b9..00aac49 100644 --- a/branding/README.md +++ b/branding/README.md @@ -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//icons//logo.{svg,png}` to override the brand's primary logo for a single standalone build. diff --git a/src/components/theme-provider/index.ts b/src/components/theme-provider/index.ts index 36b9079..bfbb2b6 100644 --- a/src/components/theme-provider/index.ts +++ b/src/components/theme-provider/index.ts @@ -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('dark') + const theme = ref(DEFAULT_THEME) const systemTheme = ref<'dark' | 'light'>('light') const palette = ref(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) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 64fa22a..b743dac 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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 } diff --git a/vite-branding.ts b/vite-branding.ts index 7e8eda1..55ec901 100644 --- a/vite-branding.ts +++ b/vite-branding.ts @@ -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..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/` instead of -- 2.53.0