diff --git a/apps/apollo-vertex/app/patterns/_meta.ts b/apps/apollo-vertex/app/patterns/_meta.ts index 3e0062d3d..1f1eb8daf 100644 --- a/apps/apollo-vertex/app/patterns/_meta.ts +++ b/apps/apollo-vertex/app/patterns/_meta.ts @@ -1,5 +1,6 @@ export default { "ai-chat": "AI Chat", + "customize-appearance": "Customize Appearance", "feedback-vote-widget": "Feedback Vote Widget", "metric-card": "Metric Card", "page-header": "Page Header", diff --git a/apps/apollo-vertex/app/patterns/customize-appearance/page.mdx b/apps/apollo-vertex/app/patterns/customize-appearance/page.mdx new file mode 100644 index 000000000..497210396 --- /dev/null +++ b/apps/apollo-vertex/app/patterns/customize-appearance/page.mdx @@ -0,0 +1,248 @@ +import { CustomizeAppearanceTemplate } from '@/templates/customize-appearance/CustomizeAppearanceTemplate'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; + +# Customize Appearance + +> **Platform persistence required before shipping.** +> The branding store ships with a `localStorage` adapter so the pattern runs out of the box in this demo. In a real deployment, changes made by one user will not be visible to others until you replace the adapter with a server-backed implementation. See [Persisting to a backend](#persisting-to-a-backend) below. + +A self-serve branding settings page that lets a tenant admin swap in their own company name, logo, and brand colors. Edits preview live across the shell — sidebar logo, product title, and primary-driven surfaces (active nav, buttons, focus rings) all update as the user types. + + + + + +## Minimal header variant + +The same pattern works with the minimal shell variant. The customization form is identical; the shell chrome is just a horizontal header instead of a sidebar. + + + + + +## Composition + +From top to bottom the pattern stacks: + +- **[`Shell`](/patterns/shell)** (outer) — wrap your app in `ApolloShell`. Read `companyName` and `companyLogo` from the branding store so the shell updates live. +- **`PageHeader`** (`size="content"`) — in-page title and description for the settings form. +- **Company logo** — file-upload tile with hover-to-remove. Accepts any image; converts to a data URL for local preview. +- **Company name** — text `Input`, feeds `ApolloShell`'s `companyName`. +- **Theming** — two-up `Card` selector (Default / Custom). Custom mode locks to light theme and reveals color pickers. +- **Primary & Accent** — native `` overlaid on an `Input` that shows the raw `oklch()` string. Conversion is bidirectional. +- **Actions** — `Reset to defaults` and `Save changes` `Button`s. + +The content region uses the [layout grid](/foundation/grid): `4` columns on mobile, `8` on tablet, `12` on desktop, with `px-4 / sm:px-6 / lg:px-8` margins. The form itself spans `col-span-7` on desktop for a readable measure. + +Files: + +- [`templates/customize-appearance/CustomizeAppearance.tsx`](https://github.com/UiPath/apollo-ui/blob/main/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearance.tsx) — the form composition. +- [`templates/customize-appearance/branding-store.ts`](https://github.com/UiPath/apollo-ui/blob/main/apps/apollo-vertex/templates/customize-appearance/branding-store.ts) — store + persistence adapter. +- [`templates/customize-appearance/color-utils.ts`](https://github.com/UiPath/apollo-ui/blob/main/apps/apollo-vertex/templates/customize-appearance/color-utils.ts) — oklch ↔ hex conversion and primary-ramp generation. +- [`templates/customize-appearance/use-branding-theme-enforcer.ts`](https://github.com/UiPath/apollo-ui/blob/main/apps/apollo-vertex/templates/customize-appearance/use-branding-theme-enforcer.ts) — hook that locks the app to light mode when a custom theme is active. + +## Installation + +Copy the three files above into your app and wire them into your router. The underlying components install from the registry: + +```bash +npx shadcn@latest add @uipath/shell +npx shadcn@latest add @uipath/page-header +npx shadcn@latest add @uipath/card +npx shadcn@latest add @uipath/collapsible +npx shadcn@latest add @uipath/button +npx shadcn@latest add @uipath/input +npx shadcn@latest add @uipath/label +npx shadcn@latest add @uipath/spinner +npx shadcn@latest add @uipath/sonner +``` + +In your app root, render a ``, call `useBrandingThemeEnforcer()` inside the `ThemeProvider`, and pass live branding to ``: + +```tsx +import { ApolloShell } from '@/components/ui/shell'; +import { ThemeProvider } from '@/components/ui/shell/shell-theme-provider'; +import { Toaster } from '@/components/ui/sonner'; +import { useEffect } from 'react'; +import { brandingStore, useBrandingStore } from './branding-store'; +import { buildBrandingStyle } from './color-utils'; +import { useBrandingThemeEnforcer } from './use-branding-theme-enforcer'; + +function BrandedApp() { + useBrandingThemeEnforcer(); + const { appTitle, logoUrl, logoAlt, themeMode, primaryColor, accentColor } = + useBrandingStore(); + + useEffect(() => { + brandingStore.hydrate(); + }, []); + + const style = + themeMode === 'custom' + ? buildBrandingStyle(primaryColor, accentColor) + : undefined; + + return ( +
+ + + + +
+ ); +} + +export function App() { + return ( + + + + ); +} +``` + +The `style` prop on the outer `
` 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 }, +);