` applies the custom CSS variables via inheritance, scoped to that subtree. No global `document.documentElement` mutation.
+
+### Why the theme enforcer
+
+The primary-ramp generator (`buildPrimaryVars`) is calibrated for light-palette lightness values. Applied in dark mode the ramp looks wrong against dark chrome, and many brand colors lose sufficient contrast on dark surfaces. `useBrandingThemeEnforcer` locks the app to light whenever `themeMode === 'custom'` and restores the user's previous theme (light / dark / system) when they switch back to the default themes. Call it once at app root, inside `ThemeProvider` — never inside the customization form itself.
+
+## Persisting to a backend
+
+The store ships with a `localStorage` adapter so the pattern works out of the box. For real deployments, replace the adapter with one that syncs to your backend. The recommended target for UiPath vertical solutions is **Data Fabric**, which stores the branding record on a first-class entity and uploads the logo as an attachment.
+
+### Adapter interface
+
+```ts
+export interface BrandingAdapter {
+ load(): Promise
| null>;
+ save(settings: BrandingSettings): Promise;
+ uploadLogo?(file: File): Promise;
+ clearLogo?(): Promise;
+}
+```
+
+Logo uploads are deferred: `CustomizeAppearance` stages the `File` locally (reading it as a data URL for instant preview) and only calls `adapter.uploadLogo` when the user clicks **Save changes**. Backends that separate the settings record from file attachments (like Data Fabric) should implement both `uploadLogo` and `clearLogo` so the store can orchestrate the upload → save (or clear → save) flow atomically. `save` should only persist the non-logo fields — the store writes the resolved `logoUrl` back into the record after `uploadLogo` resolves.
+
+### Data Fabric adapter (sketch)
+
+```ts
+import dataFabricService from '@/services/dataFabricService';
+import {
+ readFileAsDataUrl,
+ type BrandingAdapter,
+ type BrandingSettings,
+} from './branding-store';
+
+const ENTITY_NAME = 'AppBranding';
+const LOGO_FIELD = 'Logo';
+
+export function createDataFabricAdapter(): BrandingAdapter {
+ let entityId: string | null = null;
+ let recordId: string | null = null;
+
+ async function ensureEntity() {
+ if (entityId) return entityId;
+ const entities = await dataFabricService.getAllEntities();
+ const entity = entities.find((e) => e.name === ENTITY_NAME);
+ if (!entity) throw new Error(`${ENTITY_NAME} entity not found`);
+ entityId = entity.id;
+ return entityId;
+ }
+
+ return {
+ async load() {
+ const id = await ensureEntity();
+ const response = await dataFabricService.queryEntityRecords(id, {
+ selectedFields: ['Id', 'HeaderTitle', 'PrimaryColor', 'AccentColor', 'Logo'],
+ limit: 1,
+ });
+ const record = response.value[0];
+ if (!record) return null;
+ recordId = record.Id;
+
+ let logoUrl = '';
+ if (record.Logo) {
+ logoUrl = await dataFabricService.getFileAsDataUrl(
+ ENTITY_NAME,
+ record.Id,
+ LOGO_FIELD,
+ );
+ }
+
+ return {
+ appTitle: record.HeaderTitle ?? '',
+ primaryColor: record.PrimaryColor ?? '',
+ accentColor: record.AccentColor ?? '',
+ themeMode: record.PrimaryColor || record.AccentColor ? 'custom' : 'default',
+ logoUrl,
+ };
+ },
+
+ async save(settings: BrandingSettings) {
+ const id = await ensureEntity();
+ const record = {
+ HeaderTitle: settings.appTitle,
+ PrimaryColor: settings.primaryColor,
+ AccentColor: settings.accentColor,
+ };
+ if (recordId) {
+ await dataFabricService.updateRecords(id, [{ Id: recordId, ...record }]);
+ } else {
+ const result = await dataFabricService.insertRecords(id, [record]);
+ recordId = result.items[0].Id;
+ }
+ },
+
+ async uploadLogo(file: File) {
+ const id = await ensureEntity();
+ if (!recordId) {
+ const result = await dataFabricService.insertRecords(id, [{ HeaderTitle: '' }]);
+ recordId = result.items[0].Id;
+ }
+ await dataFabricService.uploadFileToEntity(
+ ENTITY_NAME,
+ recordId,
+ LOGO_FIELD,
+ file,
+ );
+ // Return a data URL so the shell can render the logo without a refetch.
+ return readFileAsDataUrl(file);
+ },
+
+ async clearLogo() {
+ if (!recordId) return;
+ await dataFabricService.deleteFileFromEntity(
+ ENTITY_NAME,
+ recordId,
+ LOGO_FIELD,
+ );
+ },
+ };
+}
+```
+
+Wire it up once at app startup, before the first `brandingStore.hydrate()` call:
+
+```ts
+brandingStore.setAdapter(createDataFabricAdapter());
+```
+
+Model the entity in Data Fabric with `HeaderTitle`, `PrimaryColor`, `AccentColor` (plus any additional fields you want to expose — `BodyFont`, `LogoAlt`, etc.) and a file field `Logo` for the uploaded image.
+
+## Customizing
+
+- **Add more fields** — the pattern generalizes: add `bodyFont`, `brandVoice`, `favicon`, etc. to `BrandingSettings`, surface them in `CustomizeAppearance`, and update `BrandingAdapter` implementations to persist them. `buildBrandingStyle` is the single place where CSS variables are derived.
+- **Lock dark mode** — the example locks custom theming to light mode (the primary-ramp generator targets light-palette lightness values). To support custom dark mode, generate a second ramp in `buildPrimaryVars` and merge `dark: {...}` into the style map.
+- **Restrict access** — this is a tenant-admin surface. Gate the route behind a group check (see [`GroupMembershipGuard`](/patterns/shell#gating-access-by-group-membership)) so only admins can change the tenant's branding.
+- **Accessibility** — warn users before they save colors with insufficient contrast. Run `primaryColor` through a contrast check (WCAG 4.5:1 against `--primary-foreground`) and surface a toast if it fails. The color picker does not validate this by default.
+- **Debounce saves** — if you wire the adapter to hit the backend on every keystroke, your users will DOS themselves. The pattern defers persistence to the explicit `Save changes` button; keep that interaction model when you swap adapters.
diff --git a/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearancePreview.tsx b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearancePreview.tsx
new file mode 100644
index 000000000..9981dde23
--- /dev/null
+++ b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearancePreview.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ redirect,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { BarChart3, FolderOpen, Home, Settings } from "lucide-react";
+import { useEffect, useState } from "react";
+import { ApolloShell, type ShellNavItem } from "@/components/ui/shell";
+import { Toaster } from "@/components/ui/sonner";
+import { brandingStore, useBrandingStore } from "./branding-store";
+import { buildBrandingStyle } from "./color-utils";
+import { CustomizeAppearance } from "./CustomizeAppearance";
+
+export interface CustomizeAppearancePreviewProps {
+ variant?: "minimal";
+}
+
+const PLACEHOLDER_KEYS = {
+ dashboard: "Dashboard",
+ projects: "Projects",
+ analytics: "Analytics",
+} as const;
+
+function PlaceholderPage({ title }: { title: string }) {
+ return (
+
+
+
{title}
+
+ Navigate to Settings to customize appearance.
+
+
+
+ );
+}
+
+function BrandedShell({ variant }: { variant?: "minimal" }) {
+ const { appTitle, logoUrl, logoAlt } = useBrandingStore();
+
+ const navItems: ShellNavItem[] =
+ variant === "minimal"
+ ? [
+ {
+ path: "/preview/customize-appearance-minimal",
+ label: "dashboard",
+ icon: Home,
+ },
+ {
+ path: "/preview/customize-appearance-minimal/projects",
+ label: "projects",
+ icon: FolderOpen,
+ },
+ {
+ path: "/preview/customize-appearance-minimal/settings",
+ label: "settings",
+ icon: Settings,
+ },
+ ]
+ : [
+ {
+ path: "/preview/customize-appearance/dashboard",
+ label: "dashboard",
+ icon: Home,
+ },
+ {
+ path: "/preview/customize-appearance/projects",
+ label: "projects",
+ icon: FolderOpen,
+ },
+ {
+ path: "/preview/customize-appearance/analytics",
+ label: "analytics",
+ icon: BarChart3,
+ },
+ {
+ path: "/preview/customize-appearance/settings",
+ label: "settings",
+ icon: Settings,
+ },
+ ];
+
+ return (
+
+
+
+ );
+}
+
+function buildRouter(variant?: "minimal") {
+ const rootRoute = createRootRoute();
+ const basePath =
+ variant === "minimal"
+ ? "/preview/customize-appearance-minimal"
+ : "/preview/customize-appearance";
+
+ const shellRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: basePath,
+ component: () => ,
+ });
+
+ const routes = [
+ createRoute({
+ getParentRoute: () => shellRoute,
+ path: "/",
+ component: () => ,
+ }),
+ createRoute({
+ getParentRoute: () => shellRoute,
+ path: "/dashboard",
+ component: () => ,
+ }),
+ createRoute({
+ getParentRoute: () => shellRoute,
+ path: "/projects",
+ component: () => ,
+ }),
+ createRoute({
+ getParentRoute: () => shellRoute,
+ path: "/analytics",
+ component: () => ,
+ }),
+ createRoute({
+ getParentRoute: () => shellRoute,
+ path: "/settings",
+ component: CustomizeAppearance,
+ }),
+ ];
+
+ // The shell's company logo links to "/" — redirect any hit outside the
+ // preview subtree (including "/") back to the dashboard so the logo click
+ // lands somewhere meaningful inside the memory router.
+ const catchAllRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "$",
+ beforeLoad: () => {
+ // oxlint-disable-next-line typescript-eslint/only-throw-error -- TanStack Router uses thrown redirects as its redirect API
+ throw redirect({ to: `${basePath}/dashboard` });
+ },
+ });
+
+ const routeTree = rootRoute.addChildren([
+ shellRoute.addChildren(routes),
+ catchAllRoute,
+ ]);
+
+ const storageKey =
+ variant === "minimal"
+ ? "customize-appearance-minimal-preview-path"
+ : "customize-appearance-preview-path";
+
+ const initialPath =
+ typeof window === "undefined"
+ ? `${basePath}/settings`
+ : (window.localStorage.getItem(storageKey) ?? `${basePath}/settings`);
+
+ const history = createMemoryHistory({ initialEntries: [initialPath] });
+ const router = createRouter({ routeTree, history });
+
+ router.subscribe("onResolved", ({ toLocation }) => {
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(storageKey, toLocation.pathname);
+ }
+ });
+
+ return router;
+}
+
+const queryClient = new QueryClient();
+
+function BrandingScope({ children }: { children: React.ReactNode }) {
+ const { themeMode, primaryColor, accentColor } = useBrandingStore();
+ const style =
+ themeMode === "custom" ? buildBrandingStyle(primaryColor, accentColor) : {};
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function CustomizeAppearancePreview({
+ variant,
+}: CustomizeAppearancePreviewProps) {
+ const [router] = useState(() => buildRouter(variant));
+
+ useEffect(() => {
+ void brandingStore.hydrate();
+ }, []);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearanceTemplate.tsx b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearanceTemplate.tsx
new file mode 100644
index 000000000..45095a320
--- /dev/null
+++ b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearanceTemplate.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+export const CustomizeAppearanceTemplate = dynamic(
+ () =>
+ import("./CustomizeAppearancePreview").then((mod) => ({
+ default: mod.CustomizeAppearancePreview,
+ })),
+ { ssr: false },
+);