feat(branding): per-app banner + per-brand default theme via brand.json #104
5 changed files with 119 additions and 7 deletions
feat(branding): optional per-app banner replacing logo + name in header
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/<dep>/icons/<app>/banner.{svg,png}) then the brand's primary
banner (branding/<dep>/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) <noreply@anthropic.com>
commit
8f2c401e00
|
|
@ -67,6 +67,37 @@ Resolution at build time:
|
||||||
|
|
||||||
`<app>` is set via `BRAND_APP` env var (the standalone build script sets this; deployers don't touch it directly).
|
`<app>` 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/<dep>/
|
||||||
|
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/<dep>/icons/<app>/banner.svg`
|
||||||
|
2. `branding/<dep>/icons/<app>/banner.png`
|
||||||
|
3. `branding/<dep>/banner.svg`
|
||||||
|
4. `branding/<dep>/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 `<text>` 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 '<text' banner.svg` → should be `0`.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
**Building with the default brand:**
|
**Building with the default brand:**
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,17 @@ import {
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
|
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
|
||||||
import brandAppLogoUrl from '@brand-app-logo?url'
|
import brandAppLogoUrl from '@brand-app-logo?url'
|
||||||
|
import brandAppBannerUrl from '@brand-app-banner?url'
|
||||||
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
|
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
|
||||||
// from brand.name). cfaun → "Oyez!", default → "Events", etc. Falls
|
// from brand.name). cfaun → "Oyez!", default → "Events", etc. Falls
|
||||||
// back to the i18n string only when no brand is configured (shouldn't
|
// back to the i18n string only when no brand is configured (shouldn't
|
||||||
// happen in production builds, but defensive).
|
// happen in production builds, but defensive).
|
||||||
const appName: string = (import.meta.env.VITE_APP_NAME as string) || ''
|
const appName: string = (import.meta.env.VITE_APP_NAME as string) || ''
|
||||||
|
// When the active brand ships a banner (wide logo+wordmark lockup), it
|
||||||
|
// replaces the logo + name pair in the header. The flag is set at build
|
||||||
|
// time; brandAppBannerUrl falls back to the logo when unset, so we only
|
||||||
|
// render the banner when the flag is truthy.
|
||||||
|
const hasBanner = (import.meta.env.VITE_APP_BANNER as string) === '1'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
import { useEventsStore } from '../stores/events'
|
import { useEventsStore } from '../stores/events'
|
||||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||||
|
|
@ -81,17 +87,27 @@ function openCalendar() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-4 px-4">
|
<div class="container mx-auto py-4 px-4">
|
||||||
<!-- Page header — brand-kit logo (per-standalone override or
|
<!-- Page header. A brand may ship a wide banner (logo + wordmark in
|
||||||
global) paired with the standalone's localized name. Resolved
|
one image) that replaces the logo + name pair; otherwise we show
|
||||||
at build time via @brand-app-logo so deployers can override
|
the brand-kit logo (per-standalone override or global) paired
|
||||||
|
with the standalone's localized name. Both resolve at build time
|
||||||
|
via @brand-app-banner / @brand-app-logo so deployers can override
|
||||||
without touching this component. -->
|
without touching this component. -->
|
||||||
<h1 class="mb-3 flex items-center gap-2 text-xl sm:text-2xl font-bold text-foreground">
|
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
|
||||||
|
<img
|
||||||
|
v-if="hasBanner"
|
||||||
|
:src="brandAppBannerUrl"
|
||||||
|
:alt="appName || t('events.title')"
|
||||||
|
class="h-12 sm:h-14 w-auto max-w-full"
|
||||||
|
/>
|
||||||
|
<span v-else class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
:src="brandAppLogoUrl"
|
:src="brandAppLogoUrl"
|
||||||
:alt="appName || t('events.title')"
|
:alt="appName || t('events.title')"
|
||||||
class="h-10 w-10 sm:h-12 sm:w-12 shrink-0"
|
class="h-10 w-10 sm:h-12 sm:w-12 shrink-0"
|
||||||
/>
|
/>
|
||||||
{{ appName || t('events.title') }}
|
{{ appName || t('events.title') }}
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Search with dropdown overlay -->
|
<!-- Search with dropdown overlay -->
|
||||||
|
|
|
||||||
16
src/vite-env.d.ts
vendored
16
src/vite-env.d.ts
vendored
|
|
@ -9,3 +9,19 @@ declare module '@brand-app-logo?url' {
|
||||||
const src: string
|
const src: string
|
||||||
export default src
|
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.<app>.config.ts. */
|
||||||
|
readonly VITE_APP_NAME?: string
|
||||||
|
/** '1' when the active brand ships a banner, '' otherwise. */
|
||||||
|
readonly VITE_APP_BANNER?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,46 @@ export function brandAppLogoAliasEntry(app?: string) {
|
||||||
} as const
|
} 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
|
* 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.
|
* app's own label, or returns the bare brand when no label is given.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
import {
|
import {
|
||||||
brand,
|
brand,
|
||||||
brandAlias,
|
brandAlias,
|
||||||
|
brandAppBannerAliasEntry,
|
||||||
brandAppLogoAliasEntry,
|
brandAppLogoAliasEntry,
|
||||||
brandAssetsPlugin,
|
brandAssetsPlugin,
|
||||||
brandManifestName,
|
brandManifestName,
|
||||||
|
resolveAppBanner,
|
||||||
} from './vite-branding'
|
} from './vite-branding'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +57,12 @@ function eventsHtmlPlugin(): Plugin {
|
||||||
const APP_NAME = brandManifestName()
|
const APP_NAME = brandManifestName()
|
||||||
process.env.VITE_APP_NAME = APP_NAME
|
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 }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
base: process.env.VITE_BASE_PATH || '/',
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
// 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
|
||||||
|
|
@ -128,6 +136,7 @@ export default defineConfig(({ mode }) => ({
|
||||||
// aliases without one shadowing the other.
|
// aliases without one shadowing the other.
|
||||||
alias: [
|
alias: [
|
||||||
brandAppLogoAliasEntry('events'),
|
brandAppLogoAliasEntry('events'),
|
||||||
|
brandAppBannerAliasEntry('events'),
|
||||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue