From d118a8d4ac2269ebfa20541cc7ab7c29b9f9863b Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 11 May 2026 11:25:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(apollo-vertex):=20add=20customize=20appear?= =?UTF-8?q?ance=20=E2=80=94=20form=20component=20and=20theme=20enforcer=20?= =?UTF-8?q?hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the React layer for the Customize Appearance pattern: - CustomizeAppearance.tsx: settings form with app title, logo upload/clear, and oklch color pickers for primary and accent colors. Reads/writes through the BrandingAdapter so the persistence layer is swappable. Toasts on save/reset via Sonner - use-branding-theme-enforcer.ts: hook that locks the app to light mode whenever a custom theme is active (custom brand colors are calibrated for light-mode lightness values) and restores the user's previous theme on revert Co-Authored-By: Claude Sonnet 4.6 --- .../CustomizeAppearance.tsx | 330 ++++++++++++++++++ .../use-branding-theme-enforcer.ts | 43 +++ 2 files changed, 373 insertions(+) create mode 100644 apps/apollo-vertex/templates/customize-appearance/CustomizeAppearance.tsx create mode 100644 apps/apollo-vertex/templates/customize-appearance/use-branding-theme-enforcer.ts diff --git a/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearance.tsx b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearance.tsx new file mode 100644 index 000000000..57e9a7aa1 --- /dev/null +++ b/apps/apollo-vertex/templates/customize-appearance/CustomizeAppearance.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { Upload, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + PageHeader, + PageHeaderDescription, + PageHeaderTitle, +} from "@/components/ui/page-header"; +import { Spinner } from "@/components/ui/spinner"; +import { + brandingStore, + type ThemeMode, + useBrandingHasChanges, + useBrandingStore, +} from "./branding-store"; +import { hexToOklchString, toHexForPicker } from "./color-utils"; + +const DEFAULT_PRIMARY_COLOR = "oklch(0.64 0.115 208)"; +const DEFAULT_ACCENT_COLOR = "oklch(0.78 0.112 207.1)"; + +const COLOR_FIELDS = [ + { + key: "primaryColor" as const, + label: "Primary", + defaultValue: DEFAULT_PRIMARY_COLOR, + }, + { + key: "accentColor" as const, + label: "Accent", + defaultValue: DEFAULT_ACCENT_COLOR, + }, +]; + +export function CustomizeAppearance() { + const fileInputRef = useRef(null); + const branding = useBrandingStore(); + const hasChanges = useBrandingHasChanges(); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + void brandingStore.hydrate(); + }, []); + + useEffect(() => { + if (!hasChanges) return; + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasChanges]); + + const isCustom = branding.themeMode === "custom"; + const hasLogo = !!branding.logoUrl; + const hasAnyCustomization = + isCustom || + !!branding.primaryColor || + !!branding.accentColor || + hasLogo || + !!branding.appTitle; + + const handleFieldChange = (key: keyof typeof branding, value: string) => { + brandingStore.setCurrent({ [key]: value }); + }; + + const handleModeChange = (mode: ThemeMode) => { + if (mode === branding.themeMode) return; + + if (mode === "default") { + brandingStore.setCurrent({ + themeMode: mode, + primaryColor: "", + accentColor: "", + }); + } else { + brandingStore.setCurrent({ + themeMode: mode, + primaryColor: branding.primaryColor || DEFAULT_PRIMARY_COLOR, + accentColor: branding.accentColor || DEFAULT_ACCENT_COLOR, + }); + } + }; + + const handleLogoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 10 * 1024 * 1024) { + toast.error("Logo file is too large", { + description: "Maximum size is 10MB.", + }); + return; + } + + if (file.size > 500 * 1024) { + toast.warning("Large logo file", { + description: + "Consider using a smaller image (under 500KB) for better performance.", + }); + } + + try { + await brandingStore.stageLogo(file); + } catch (error) { + toast.error("Failed to read logo file", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + } + }; + + const handleRemoveLogo = () => { + brandingStore.removeLogo(); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await brandingStore.save(); + toast.success("Settings saved", { + description: "Your appearance changes have been applied.", + }); + } catch (error) { + toast.error("Failed to save settings", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsSaving(false); + } + }; + + const handleReset = async () => { + setIsSaving(true); + try { + await brandingStore.reset(); + toast.success("Defaults restored", { + description: "Appearance has been reset to defaults.", + }); + } catch (error) { + toast.error("Failed to reset settings", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + Customize appearance + + Changes to the appearance apply to all users in your organization + + + +
+
+ +
+ + {hasLogo && ( + + )} +
+ { + void handleLogoUpload(e); + }} + className="hidden" + /> +

+ PNG or SVG, under 500KB recommended +

+
+ +
+ + handleFieldChange("appTitle", e.target.value)} + placeholder="Enter company name..." + className="max-w-sm" + /> +
+ +
+ +

+ Choose between built-in themes or a custom theme +

+
+ +
+ handleModeChange("default")} + > + + Default themes + +

+ Light, dark, and system preference. +

+
+
+ +
+ handleModeChange("custom")} + > + + Custom theme + +

+ Apply your brand colors. Light theme only. +

+
+
+ + + +
+ {COLOR_FIELDS.map(({ key, label, defaultValue }) => { + const rawValue = branding[key]; + const displayValue = rawValue || defaultValue; + const hexForPicker = toHexForPicker(displayValue); + + return ( +
+ +
+ + handleFieldChange( + key, + hexToOklchString(e.target.value), + ) + } + title={`Pick ${label.toLowerCase()} color`} + /> + handleFieldChange(key, e.target.value)} + className="text-sm pl-10" + placeholder={defaultValue} + /> +
+
+ ); + })} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/apollo-vertex/templates/customize-appearance/use-branding-theme-enforcer.ts b/apps/apollo-vertex/templates/customize-appearance/use-branding-theme-enforcer.ts new file mode 100644 index 000000000..9128e7f93 --- /dev/null +++ b/apps/apollo-vertex/templates/customize-appearance/use-branding-theme-enforcer.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTheme } from "@/registry/shell/shell-theme-provider"; +import { useBrandingStore } from "./branding-store"; + +const PREVIOUS_THEME_KEY = "vertex-branding-previous-theme"; + +// Custom brand colors are calibrated for the light palette (the primary ramp +// generator targets light-mode lightness values). In dark mode the ramp +// renders against dark chrome and looks wrong. This hook locks the app to +// light mode whenever the user selects a custom theme and restores their +// previous theme when they switch back to the default themes. +// +// Call from your app root, inside the ThemeProvider: +// +// function App() { +// useBrandingThemeEnforcer(); +// return ; +// } +export function useBrandingThemeEnforcer() { + const { themeMode } = useBrandingStore(); + const { theme, setTheme } = useTheme(); + const prevMode = useRef(themeMode); + + useEffect(() => { + if (themeMode === "custom") { + if (prevMode.current !== "custom" && typeof window !== "undefined") { + window.localStorage.setItem(PREVIOUS_THEME_KEY, theme); + } + if (theme !== "light") { + setTheme("light"); + } + } else if (prevMode.current === "custom" && typeof window !== "undefined") { + const saved = window.localStorage.getItem(PREVIOUS_THEME_KEY); + if (saved === "light" || saved === "dark" || saved === "system") { + setTheme(saved); + window.localStorage.removeItem(PREVIOUS_THEME_KEY); + } + } + prevMode.current = themeMode; + }, [themeMode, theme, setTheme]); +}