From 8f2c401e0031e17d02767b7ba132053a0bc8c460 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 22:11:01 +0200 Subject: [PATCH] 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)) }, ],