Appearance
Branding & White-Labeling ​
Overview ​
- Three branding composables —
useBrand,useBrandingHead, anduseBrandingTheme— handle brand resolution, document head injection, and Vuetify theme application respectively. useBrandreadsdamStore.brandingDetailsand produces a render-readyBrandcomputed that components and layouts consume. It distinguishes Collage-domain requests from white-label host requests.- SSR plugin (
branding.server.ts) resolves branding before the initial HTML render via three sequential strategies: share page API, workspace ID API, and host-basedcheck-branding. - Client plugin (
branding.client.ts) fires a fallbackcheck-brandingcall for non-workspace, non-share routes where the SSR plugin could not resolve. usePortalBrandingTabcomposable drives the portal branding editor UI, handling logo/favicon upload, color pickers, domain configuration, and portal visibility.PortalBrandingTab.vuerenders the two-column branding settings form inside the portal detail page; it is the primary UI for customizing a portal's visual identity.
Architecture ​
Branding resolves at two moments. On the server, branding.server.ts runs before rendering and tries three strategies in order, calling damStore.setBrandingDetails on the first successful response. This seeds brandingDetails in the Pinia store so useBrand produces the correct Brand object during SSR, preventing a favicon or title flash on initial page load. On the client, useBrandingTheme watches the resolved brand and writes hex color values directly into Vuetify's live light theme, and useBrandingHead drives useHead with title, OG meta, and favicon derived from the brand.
Portal-level branding is separate from the host/workspace branding. It lives in PortalBrandingTab.vue and usePortalBrandingTab.ts and controls the specific visual settings of a brand portal instance (logo, favicon, colors, domain, visibility).
File Structure ​
Branding Composables ​
app/composables/branding/useBrand.ts— ResolvesdamStore.brandingDetailsinto aBrandcomputed; handles three cases: unknown white-label host (blank shell), Collage domain (defaults), and white-label enabled (mapped fields)app/composables/branding/useBrandingHead.ts— DrivesuseHeadwithtagPriority: 'high'; sets title, OG/Twitter meta, and favicon from the resolved brand; suppresses host-brand assets on/shared-assetsroutes unless the share is brandedapp/composables/branding/useBrandingTheme.ts— Watchesbrand.is_brandedanddamStore.brandingDetails?.branding; writes validated hexprimary_color/secondary_colorinto Vuetify's livelighttheme colors
Portal Branding Composable ​
app/composables/core/components/usePortalBrandingTab.ts— Full portal branding composable; manages form state (brand_name,description,brandUrl,domainUrl,primary_color,secondary_color,is_domain,is_pointed,is_public_portal), logo/favicon upload, color updates, portal visibility, and trending/recent upload toggles
Vue Component Files ​
app/components/dam/portals/PortalBrandingTab.vue— Two-column branding editor rendered on the portal detail page; left column: portal metadata and URL/domain config; right column: logo, favicon, and brand color pickersapp/pages/[workspace_id]/dam/portals/[id].vue— Portal detail page; hosts the Customize tab (which rendersPortalBrandingTab), Users tab, and Content Blocks tab
Plugin Files ​
app/plugins/branding.server.ts— SSR-only; resolves branding via three strategies before initial HTML renderapp/plugins/branding.client.ts— Client-only fallback; callsdamStore.fetchCheckBranding()for non-workspace, non-share routes where SSR did not resolve
Store ​
app/stores/dam.ts— HoldsbrandingDetails: BrandingDetails | null; exposessetBrandingDetails,fetchWorkspaceBranding, andfetchCheckBrandingactions
Type Files ​
app/types/store.ts—Brand,BrandingDetails,BrandingColorsinterfacesapp/types/portal.ts—PortalBranding,PortalInstance,BrandForm,BrandingUpdatePayload,SubscriptionFeatures(includescustom_brand_urlanddam_brandingfeature flags)
useBrand ​
File: app/composables/branding/useBrand.ts
Resolves damStore.brandingDetails into a render-ready Brand computed. Returns a brandingReady flag that is true once brandingDetails is non-null.
Features ​
- Host-origin detection via
X-Forwarded-Host/X-Forwarded-Protoheaders on SSR,window.location.originon client - Three resolution modes: unknown white-label host, Collage domain, white-label enabled
- A request counts as the Collage host when its host is internal (
isInternalHostfrom#shared/requestHost, e.g. a proxy's loopback name when the deployment does not forward the public Host) or its origin matchesruntimeConfig.public.baseUrl. On SSR the host comes fromauthoritativeHost(host / x-forwarded-host headers), falling back touseRequestURL().host - Exports
BLANK_FAVICON(transparent 1x1 SVG) used to prevent the Collage favicon from leaking onto a white-label host before branding resolves - Exports
COLLAGE_DEFAULTSfor fallback field values on blank branded shells
Returned Values ​
javascript
// Brand object shape
{
is_branded: boolean, // true on white-label hosts
brand_name: string, // workspace/portal name
tagline: string | null, // shown in OG description meta
logo: string | null, // full URL to logo asset
favicon: string | null, // full URL to favicon asset
support_email: string | null, // support email for the brand
brand_website_url: string, // request origin on white-label; collage.inc on default
sso: { enabled: boolean, url_slug: string } | null, // SsoBranding from check-branding
}sso is null while branding details are missing (Collage defaults), and otherwise copied from brandingDetails.sso, also on the unbranded Collage host. The login page uses the same object from check-branding to decide whether to show the SSO tile on a white-label host and whether it can skip the work-email step (url_slug known). See Single Sign-On.
brandingReady also gates the login forms in pages/index.vue (v-else-if="brandingReady"), and login-layout hides its decorative illustrations until branding resolves, so white-label hosts no longer flash Collage artwork on refresh (05d607b9, 72cc6d0f).
Usage ​
vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>
<template>
<v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
<span>{{ brand.brand_name || 'Collage' }}</span>
</template>useBrandingHead ​
File: app/composables/branding/useBrandingHead.ts
Drives useHead with tagPriority: 'high' so branded title and favicon override any page-level defaults.
Features ​
- Sets
<title>tobrand.brand_name(suppressed when not branded) - Sets
og:site_name,og:title,og:description,og:image,twitter:card og:imageis the brand logo when it is an absolutehttpURL. Otherwise it falls back to the bundled Collage card (~/assets/img/og-card.png, declared asog:image:width1200 andog:image:height630), made absolute against the request origin, orruntimeConfig.public.baseUrlwhen the request host is internal. No fallback card is emitted on share pages or for a resolved branded host- Sets favicon and shortcut-icon link elements from
brand.favicon || brand.logo - On
/shared-assetsroutes: host-brand assets are suppressed unlessbrand.is_brandedto avoid leaking the host workspace logo onto a partner's shared link - Falls back to
BLANK_FAVICONwhen assets are suppressed
Usage ​
vue
<!-- Call once from the main layout setup — not in individual pages -->
<script setup lang="ts">
useBrandingHead()
</script>useBrandingTheme ​
File: app/composables/branding/useBrandingTheme.ts
Applies white-label primary and secondary colors to Vuetify's light theme at runtime.
Features ​
- Watches
[brand.is_branded, damStore.brandingDetails?.branding]as a tuple; only runs on reference change (store replacesbrandingDetailswholesale, never mutates in place) - Validates hex values with
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/before writing totheme.themes.value.light.colors - Leaves static defaults intact when a color is missing or invalid
Usage ​
vue
<!-- Call once from the main layout setup -->
<script setup lang="ts">
useBrandingTheme()
</script>usePortalBrandingTab ​
File: app/composables/core/components/usePortalBrandingTab.ts
Full page composable for the PortalBrandingTab component. Manages all reactive state and async operations for portal branding configuration.
Features ​
- Form model:
brand_name,description,brandUrl(slug),domainUrl,is_domain(0|1),is_pointed,is_trading,is_recent_upload,is_public_portal,primary_color,secondary_color isDirtycomputed compares text fields only (not colors, not files)colorsDirtyseparate dirty check for color changes- Logo validation: max 10MB, jpg/png only; favicon validation: max 2MB, png/ico/svg
onLogoPick/onFaviconPickread file to data URL then open a crop dialoguploadAsset('logo' | 'favicon', file)builds multipart FormData and callsupdateInstancesubmit()validates, builds FormData (without logo/favicon), callsupdateInstanceupdateBranding(isBrandingDefault)postsupdate-workspace-brandingwith colors +is_branding: 0|1onPortalVisibilityChangepostschange-portal-visibility
Amplitude Events ​
javascript
PORTAL_LOGO_UPDATED
PORTAL_FAVICON_UPDATED
PORTAL_SETTINGS_UPDATED
PORTAL_THEME_UPDATED
PORTAL_BRANDING_UPDATED
PORTAL_VISIBILITY_CHANGEDPortalBrandingTab Component ​
File: app/components/dam/portals/PortalBrandingTab.vue
Two-column layout component rendered inside the portal detail page's Customize tab.
Features ​
- Left column: portal name, meta description, URL mode radio (Collage subdomain vs own domain), slug/domain text field with preview link, Submit/Cancel, display toggles (Trending, Recently Added, Public Portal)
- Right column: logo upload (pick → crop → immediate upload to S3), favicon upload (same flow), brand color pickers via
v-color-picker, Restore To Default checkbox and Update button - Default colors:
#070E22primary,#ffffffsecondary - White-label hosts: when
isBrandedprop is true, forcesis_domain = 1and hides the Collage subdomain radio
Props ​
javascript
{
instance: {
type: Object, // PortalInstance — portal data
required: true
},
contentLoading: {
type: Boolean,
default: false
},
customBrandAllowed: {
type: Boolean, // subscription gate for custom domain
default: false
},
isBranded: {
type: Boolean, // true on white-label hosts
default: false
}
}Events ​
javascript
{
'updated': (instance: PortalInstance) => {} // after successful save
}Usage ​
PortalBrandingTab is rendered inside the Customize tab of the portal detail page. All props come from usePortalDetail() — the portal detail page composable — not from local refs. onInstanceUpdated merges the API echo back via spread (immutable update); if the response shape is unexpected it re-fetches the full instance.
vue
<!-- app/pages/[workspace_id]/dam/portals/[id].vue (Customize tab) -->
<script setup lang="ts">
import type { PortalInstance } from '~/types/portal'
const {
contentLoading,
tab,
instance,
tabLabels,
customBrandAllowed,
whiteLabelEnabled,
fetchInstance,
} = usePortalDetail()
const onInstanceUpdated = (data: unknown): void => {
if (data && typeof data === 'object') {
instance.value = { ...(instance.value || {}), ...(data as PortalInstance) }
} else {
fetchInstance()
}
}
</script>
<template>
<v-window v-model="tab">
<v-window-item v-for="(label, index) in tabLabels" :key="index" :value="index">
<PortalBrandingTab
v-if="label === 'Customize'"
:instance="instance"
:content-loading="contentLoading"
:custom-brand-allowed="customBrandAllowed"
:is-branded="whiteLabelEnabled"
@updated="onInstanceUpdated"
/>
</v-window-item>
</v-window>
</template>Component Integration ​
Branding hooks are wired once at the layout level. Individual pages and components consume useBrand() to read the resolved brand — they never call the Pinia store or the branding API directly.
Layout-level setup ​
useBrandingHead() and useBrandingTheme() are called once from the root app or layout. They have no arguments and return nothing — they install watchers internally.
vue
<!-- app/app.vue or the primary layout -->
<script setup lang="ts">
useBrandingHead() // sets <title>, og:*, and favicon from the resolved brand
useBrandingTheme() // writes primary_color / secondary_color into Vuetify's light theme
</script>Consuming the resolved brand in a component ​
vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>
<template>
<v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
<span>{{ brand.brand_name }}</span>
</template>brandingReady is true once damStore.brandingDetails is non-null. On SSR it is true immediately if the server plugin resolved branding before rendering. On the client it becomes true after the store hydrates from the SSR payload.
Portal branding editor ​
See PortalBrandingTab — Usage above for the real portal-page integration pattern. The key point: props come from usePortalDetail(), not from local refs constructed in the page.
Workflows ​
SSR Branding Resolution Flow ​
Client Fallback Branding Flow ​
Portal Branding Update Flow ​
API Integration ​
Branding Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| GET | check-branding | Host-based brand lookup (Origin header required) |
| POST | get-branding | Workspace brand lookup by workspace_id |
| POST | show-share-assets | Share page brand lookup |
| POST | update-workspace-branding | Update portal primary/secondary colors |
| PUT | update-instance | Update portal metadata and domain settings |
json
// POST get-branding
{
"workspace_id": 42
}
// Response
{
"data": {
"id": 7,
"brand_name": "Acme Brand Assets",
"logo": "https://cdn.collage.inc/logos/acme.png",
"favicon": "https://cdn.collage.inc/favicons/acme.ico",
"tagline": "Official brand resources for Acme partners.",
"support_email": "[email protected]",
"white_label_enabled": true,
"branding": {
"primary_color": "#0A1F44",
"secondary_color": "#F4A300"
}
}
}json
// POST update-workspace-branding
{
"workspace_id": 42,
"instance_id": 7,
"primary_color": "#0A1F44",
"secondary_color": "#F4A300",
"is_branding": 1
}Related Documentation ​
- Portals — Brand portal creation and management
- Workspace — Account settings including logo and favicon upload
- Permissions — Brand portal settings access control
- Subscription — Plan gates for
custom_brand_urlanddam_branding - Real-time — Pusher channels for live branding updates