feat(branding): per-app banner + per-brand default theme via brand.json #104
6 changed files with 172 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
@ -67,6 +71,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).
|
||||
|
||||
## 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
|
||||
|
||||
**Building with the default brand:**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,17 @@ import {
|
|||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
|
||||
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
|
||||
// from brand.name). cfaun → "Oyez!", default → "Events", etc. Falls
|
||||
// back to the i18n string only when no brand is configured (shouldn't
|
||||
// happen in production builds, but defensive).
|
||||
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 { useEventsStore } from '../stores/events'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
|
|
@ -81,17 +87,27 @@ function openCalendar() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto py-4 px-4">
|
||||
<!-- Page header — brand-kit logo (per-standalone override or
|
||||
global) paired with the standalone's localized name. Resolved
|
||||
at build time via @brand-app-logo so deployers can override
|
||||
<!-- Page header. A brand may ship a wide banner (logo + wordmark in
|
||||
one image) that replaces the logo + name pair; otherwise we show
|
||||
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. -->
|
||||
<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
|
||||
:src="brandAppLogoUrl"
|
||||
:alt="appName || t('events.title')"
|
||||
class="h-10 w-10 sm:h-12 sm:w-12 shrink-0"
|
||||
/>
|
||||
{{ appName || t('events.title') }}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<!-- Search with dropdown overlay -->
|
||||
|
|
|
|||
20
src/vite-env.d.ts
vendored
20
src/vite-env.d.ts
vendored
|
|
@ -9,3 +9,23 @@ 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.<app>.config.ts. */
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -92,6 +115,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.
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue