webapp/vite-branding.ts
Padreug 2cc8e34b9d feat(layout): re-enable "Back to hub" with a sticky sheet footer
Reverses the events-only hide (c037d45) now that the link has a real
home. Three parts:

- Add a @brand-hub-logo alias (brandHubLogoAliasEntry) resolving to the
  brand's primary/global logo — the HUB's logo, never the per-standalone
  @brand-app-logo. Wired into all 9 app vite configs since the shared
  ProfileSheetContent renders it.
- Restructure the profile sheet into a fixed-height flex column: a
  flex-1 min-h-0 overflow-y-auto scroll region over a shrink-0 footer,
  so "Back to hub" + the log-in/out bar stay pinned to the bottom while
  the identity/preferences area scrolls.
- Move the edit-profile Dialog out of the flex root (it portals to body,
  so it's not part of the sheet flow).

Logo bumped to w-8 h-8, centered, with tightened footer padding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:26:52 +02:00

208 lines
7.2 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import type { Plugin } from 'vite'
/**
* Absolute path to the active brand kit. Deployers point this at their
* own `branding/<name>/` directory (see branding/README.md).
*
* Defaults to the committed aiolabs default brand. Used by vite configs
* for the `@brand` import alias and by pwa-assets.config.ts.
*/
export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default')
/** Fields parsed from brand.json. All but `name` are optional. */
export interface Brand {
/** Brand label — drives PWA manifest name. */
name: string
/** PWA install/home-screen short label. Defaults to `name`. */
shortName?: string
/**
* Optional PWA chrome theme color (status bar / title bar when installed).
* When unset, each standalone's vite config keeps its hardcoded accent.
*/
themeColor?: string
/**
* Optional PWA splash background. When unset, each standalone's vite
* 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
* hardcoding `@/assets/logo.png`.
*/
export const brandAlias = {
'@brand': BRAND_DIR,
} as const
/**
* Resolution order for the in-app logo of a given standalone. Mirrors
* what pwa-assets.config.ts does for PWA icons: per-standalone override
* first (SVG then PNG), then the brand's primary logo (SVG then PNG).
*
* Returned path is absolute so vite alias can map directly to it.
*/
export function resolveAppLogo(app?: string): string {
const candidates: string[] = []
if (app) {
candidates.push(
join(BRAND_DIR, 'icons', app, 'logo.svg'),
join(BRAND_DIR, 'icons', app, 'logo.png'),
)
}
candidates.push(
join(BRAND_DIR, 'logo.svg'),
join(BRAND_DIR, 'logo.png'),
)
const found = candidates.find((p) => existsSync(p))
if (!found) {
throw new Error(
`No brand logo found for app="${app ?? ''}". Tried:\n ${candidates.join('\n ')}\n` +
`See branding/README.md for the brand kit contract.`,
)
}
return found
}
/**
* Standalone-aware brand-logo alias entry. Append to a vite config's
* `resolve.alias` array alongside the rest of the alias map. The
* regex matches `@brand-app-logo` with or without a `?url` query so
* `import logoUrl from '@brand-app-logo?url'` resolves to the active
* standalone's logo file (per-app override or global), with no
* fallback chain in the component itself.
*
* Note: when used with the object form of resolve.alias, a bare
* `@brand` entry would shadow this — combine the two as an array
* (see vite.events.config.ts).
*/
export function brandAppLogoAliasEntry(app?: string) {
const resolved = resolveAppLogo(app)
return {
find: /^@brand-app-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/**
* Hub-logo alias entry. Resolves `@brand-hub-logo` to the brand's
* primary/global logo (the hub's logo), independent of which standalone
* is building. Unlike {@link brandAppLogoAliasEntry}, this never takes an
* `app` argument — the "Back to hub" link in every standalone must point
* at the HUB's logo, not the current standalone's own logo. Wire it into
* every vite.<app>.config.ts that builds ProfileSheetContent.vue.
*/
export function brandHubLogoAliasEntry() {
const resolved = resolveAppLogo()
return {
find: /^@brand-hub-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} 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.
*
* Example: `brandManifestName('Wallet')` → "AIO Wallet" / "Cfaun Wallet".
* Example: `brandManifestName()` → "AIO" / "Sortir".
*/
export function brandManifestName(appLabel?: string): string {
return appLabel ? `${brand.name} ${appLabel}` : brand.name
}
/**
* Vite plugin: regenerate PWA icons under public/icons/ once per build
* / dev-server start, so vite.<app>.config.ts's includeAssets +
* manifest.icons always have something to include. Source resolution
* lives in pwa-assets.config.ts.
*/
export function brandAssetsPlugin(): Plugin {
let generated = false
return {
name: 'brand-assets-generator',
buildStart() {
if (generated) return
const { status } = spawnSync(
'node',
[resolve('scripts/generate-pwa-assets.mjs')],
{ stdio: 'inherit' },
)
if (status !== 0) {
throw new Error('pwa-assets-generator failed; see output above')
}
generated = true
},
}
}