From 725a3c5d84f61e11c5a20d4b035b5566236130b6 Mon Sep 17 00:00:00 2001 From: poduck Date: Tue, 16 Dec 2025 22:16:25 -0500 Subject: [PATCH] Add dynamic sidebar text color with brand color contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sidebar text color picker to Branding Settings page - Implement auto-calculated complementary text colors based on brand color luminance - Dark themes get light tinted text, light themes get dark tinted text - Add navigation preview showing text on gradient background - Support 10 new lighter color palettes (Soft Mint, Lavender, Peach, etc.) - Add CSS utility classes for brand-text with opacity support - Update sidebar and navigation components to use dynamic text colors - Add sidebar_text_color field to Tenant model with migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/Sidebar.tsx | 14 +- .../navigation/SidebarComponents.tsx | 26 +-- frontend/src/components/ui/Button.tsx | 2 +- frontend/src/components/ui/StepIndicator.tsx | 4 +- frontend/src/components/ui/TabGroup.tsx | 2 +- frontend/src/index.css | 73 ++++++++ frontend/src/layouts/BusinessLayout.tsx | 6 +- frontend/src/pages/customer/BookingPage.tsx | 4 +- .../src/pages/settings/BrandingSettings.tsx | 157 +++++++++++++++--- frontend/src/types.ts | 1 + frontend/src/utils/colorUtils.ts | 99 ++++++++++- frontend/tailwind.config.js | 2 + .../migrations/0030_add_sidebar_text_color.py | 18 ++ .../smoothschedule/identity/core/models.py | 6 + .../scheduling/schedule/api_views.py | 5 + 15 files changed, 365 insertions(+), 54 deletions(-) create mode 100644 smoothschedule/smoothschedule/identity/core/migrations/0030_add_sidebar_text_color.py diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9b9eca82..9fa692f3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -72,7 +72,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo return (
= ({ business, user, isCollapsed, toggleCo {!isCollapsed && business.logoDisplayMode !== 'logo-only' && (

{business.name}

-

{business.subdomain}.smoothschedule.com

+

{business.subdomain}.smoothschedule.com

)} @@ -331,22 +331,22 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo {/* User Section */} -
+
- + {!isCollapsed && ( - {t('nav.smoothSchedule')} + {t('nav.smoothSchedule')} )} {isOpen && !isCollapsed && ( -
+
{children}
)} @@ -210,8 +210,8 @@ export const SidebarSubItem: React.FC = ({ to={to} className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${ isActive - ? 'bg-white/10 text-white' - : 'text-white/60 hover:text-white hover:bg-white/5' + ? 'bg-brand-text/10 text-brand-text' + : 'text-brand-text/60 hover:text-brand-text hover:bg-brand-text/5' }`} title={label} > @@ -230,7 +230,7 @@ interface SidebarDividerProps { */ export const SidebarDivider: React.FC = ({ isCollapsed }) => { return ( -
+
); }; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 5184a358..f1a78f6e 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -15,7 +15,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { } const variantClasses: Record = { - primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent', + primary: 'bg-brand-600 hover:bg-brand-700 text-brand-text border-transparent', secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent', outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600', ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent', diff --git a/frontend/src/components/ui/StepIndicator.tsx b/frontend/src/components/ui/StepIndicator.tsx index c1f4efb2..9d23be15 100644 --- a/frontend/src/components/ui/StepIndicator.tsx +++ b/frontend/src/components/ui/StepIndicator.tsx @@ -29,8 +29,8 @@ const colorClasses = { connectorPending: 'bg-gray-200 dark:bg-gray-700', }, brand: { - active: 'bg-brand-600 text-white', - completed: 'bg-brand-600 text-white', + active: 'bg-brand-600 text-brand-text', + completed: 'bg-brand-600 text-brand-text', pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500', textActive: 'text-brand-600 dark:text-brand-400', textPending: 'text-gray-400', diff --git a/frontend/src/components/ui/TabGroup.tsx b/frontend/src/components/ui/TabGroup.tsx index 8d0469a9..8bbeff20 100644 --- a/frontend/src/components/ui/TabGroup.tsx +++ b/frontend/src/components/ui/TabGroup.tsx @@ -46,7 +46,7 @@ const activeColorClasses = { underline: 'border-green-600 text-green-600 dark:text-green-400', }, brand: { - active: 'bg-brand-600 text-white', + active: 'bg-brand-600 text-brand-text', pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300', underline: 'border-brand-600 text-brand-600 dark:text-brand-400', }, diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e205a95..fbe74d56 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -15,12 +15,85 @@ --color-brand-700: #1d4ed8; --color-brand-800: #1e40af; --color-brand-900: #1e3a8a; + + /* Dynamic brand text color - uses RGB for opacity support */ + /* Format: rgb(R G B / alpha) where R G B come from --color-brand-text-rgb */ + --color-brand-text: rgb(var(--color-brand-text-rgb) / 1); } :root { font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; + + /* Default brand text color (light blue-white for dark backgrounds) */ + /* This is dynamically updated by applyBrandColors() based on brand color luminance */ + --color-brand-text-rgb: 233 239 255; +} + +/* Custom text-brand-text utility classes with opacity support */ +.text-brand-text { + color: rgb(var(--color-brand-text-rgb)); +} +.text-brand-text\/5 { + color: rgb(var(--color-brand-text-rgb) / 0.05); +} +.text-brand-text\/10 { + color: rgb(var(--color-brand-text-rgb) / 0.1); +} +.text-brand-text\/20 { + color: rgb(var(--color-brand-text-rgb) / 0.2); +} +.text-brand-text\/30 { + color: rgb(var(--color-brand-text-rgb) / 0.3); +} +.text-brand-text\/40 { + color: rgb(var(--color-brand-text-rgb) / 0.4); +} +.text-brand-text\/50 { + color: rgb(var(--color-brand-text-rgb) / 0.5); +} +.text-brand-text\/60 { + color: rgb(var(--color-brand-text-rgb) / 0.6); +} +.text-brand-text\/70 { + color: rgb(var(--color-brand-text-rgb) / 0.7); +} +.text-brand-text\/80 { + color: rgb(var(--color-brand-text-rgb) / 0.8); +} +.text-brand-text\/90 { + color: rgb(var(--color-brand-text-rgb) / 0.9); +} + +/* Hover variants */ +.hover\:text-brand-text:hover { + color: rgb(var(--color-brand-text-rgb)); +} +.hover\:text-brand-text\/60:hover { + color: rgb(var(--color-brand-text-rgb) / 0.6); +} +.hover\:text-brand-text\/80:hover { + color: rgb(var(--color-brand-text-rgb) / 0.8); +} + +/* Background variants using brand-text color */ +.bg-brand-text\/5 { + background-color: rgb(var(--color-brand-text-rgb) / 0.05); +} +.bg-brand-text\/10 { + background-color: rgb(var(--color-brand-text-rgb) / 0.1); +} +.hover\:bg-brand-text\/5:hover { + background-color: rgb(var(--color-brand-text-rgb) / 0.05); +} + +/* Border variants */ +.border-brand-text\/10 { + border-color: rgb(var(--color-brand-text-rgb) / 0.1); +} +.border-brand-text\/20 { + border-color: rgb(var(--color-brand-text-rgb) / 0.2); } html, body { diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx index d423966d..a21199d2 100644 --- a/frontend/src/layouts/BusinessLayout.tsx +++ b/frontend/src/layouts/BusinessLayout.tsx @@ -66,17 +66,19 @@ const BusinessLayoutContent: React.FC = ({ business, user, setTicketModalId(null); }; - // Set CSS custom properties for brand colors (primary palette + secondary color) + // Set CSS custom properties for brand colors (primary palette + secondary color + sidebar text) useEffect(() => { applyBrandColors( business.primaryColor || '#2563eb', - business.secondaryColor || business.primaryColor || '#2563eb' + business.secondaryColor || business.primaryColor || '#2563eb', + business.sidebarTextColor // Optional custom sidebar text color ); // Cleanup: reset to defaults when component unmounts return () => { applyColorPalette(defaultColorPalette); document.documentElement.style.setProperty('--color-brand-secondary', '#0ea5e9'); + document.documentElement.style.setProperty('--color-brand-text-rgb', '255 255 255'); }; }, [business.primaryColor, business.secondaryColor]); diff --git a/frontend/src/pages/customer/BookingPage.tsx b/frontend/src/pages/customer/BookingPage.tsx index d6265b8d..360319c7 100644 --- a/frontend/src/pages/customer/BookingPage.tsx +++ b/frontend/src/pages/customer/BookingPage.tsx @@ -215,7 +215,7 @@ const BookingPage: React.FC = () => { )}
-
@@ -242,7 +242,7 @@ const BookingPage: React.FC = () => {
Go to Dashboard - +
) diff --git a/frontend/src/pages/settings/BrandingSettings.tsx b/frontend/src/pages/settings/BrandingSettings.tsx index fa291104..8b05b976 100644 --- a/frontend/src/pages/settings/BrandingSettings.tsx +++ b/frontend/src/pages/settings/BrandingSettings.tsx @@ -10,22 +10,34 @@ import { useTranslation } from 'react-i18next'; import { useOutletContext } from 'react-router-dom'; import { Palette, Save, Check, Upload, X, Image as ImageIcon } from 'lucide-react'; import { Business, User } from '../../types'; -import { applyBrandColors } from '../../utils/colorUtils'; +import { applyBrandColors, getContrastTextColor } from '../../utils/colorUtils'; import { UpgradePrompt } from '../../components/UpgradePrompt'; import { FeatureKey } from '../../hooks/usePlanFeatures'; -// Color palette options +// Color palette options - organized by type const colorPalettes = [ + // Bold/Dark themes (white text) { name: 'Ocean Blue', primary: '#2563eb', secondary: '#0ea5e9' }, { name: 'Sky Blue', primary: '#0ea5e9', secondary: '#38bdf8' }, - { name: 'Mint Green', primary: '#10b981', secondary: '#34d399' }, - { name: 'Coral Reef', primary: '#f97316', secondary: '#fb923c' }, - { name: 'Lavender', primary: '#a78bfa', secondary: '#c4b5fd' }, - { name: 'Rose Pink', primary: '#ec4899', secondary: '#f472b6' }, - { name: 'Forest Green', primary: '#059669', secondary: '#10b981' }, - { name: 'Royal Purple', primary: '#7c3aed', secondary: '#a78bfa' }, - { name: 'Slate Gray', primary: '#475569', secondary: '#64748b' }, - { name: 'Crimson Red', primary: '#dc2626', secondary: '#ef4444' }, + { name: 'Emerald', primary: '#10b981', secondary: '#34d399' }, + { name: 'Coral', primary: '#f97316', secondary: '#fb923c' }, + { name: 'Rose', primary: '#ec4899', secondary: '#f472b6' }, + { name: 'Forest', primary: '#059669', secondary: '#10b981' }, + { name: 'Violet', primary: '#7c3aed', secondary: '#a78bfa' }, + { name: 'Slate', primary: '#475569', secondary: '#64748b' }, + { name: 'Crimson', primary: '#dc2626', secondary: '#ef4444' }, + { name: 'Indigo', primary: '#4f46e5', secondary: '#6366f1' }, + // Light/Pastel themes (dark text) + { name: 'Soft Mint', primary: '#6ee7b7', secondary: '#a7f3d0' }, + { name: 'Lavender', primary: '#c4b5fd', secondary: '#ddd6fe' }, + { name: 'Peach', primary: '#fdba74', secondary: '#fed7aa' }, + { name: 'Baby Blue', primary: '#7dd3fc', secondary: '#bae6fd' }, + { name: 'Blush', primary: '#fda4af', secondary: '#fecdd3' }, + { name: 'Lemon', primary: '#fde047', secondary: '#fef08a' }, + { name: 'Aqua', primary: '#5eead4', secondary: '#99f6e4' }, + { name: 'Lilac', primary: '#d8b4fe', secondary: '#e9d5ff' }, + { name: 'Apricot', primary: '#fcd34d', secondary: '#fde68a' }, + { name: 'Cloud', primary: '#94a3b8', secondary: '#cbd5e1' }, ]; const BrandingSettings: React.FC = () => { @@ -38,12 +50,19 @@ const BrandingSettings: React.FC = () => { lockedFeature?: FeatureKey; }>(); + // Calculate initial text color (use saved value or auto-calculate) + const getInitialTextColor = () => { + if (business.sidebarTextColor) return business.sidebarTextColor; + return getContrastTextColor(business.primaryColor, business.primaryColor); + }; + const [formState, setFormState] = useState({ logoUrl: business.logoUrl, emailLogoUrl: business.emailLogoUrl, logoDisplayMode: business.logoDisplayMode || 'text-only', primaryColor: business.primaryColor, secondaryColor: business.secondaryColor || business.primaryColor, + sidebarTextColor: getInitialTextColor(), }); const [showToast, setShowToast] = useState(false); @@ -51,25 +70,36 @@ const BrandingSettings: React.FC = () => { const savedColorsRef = useRef({ primary: business.primaryColor, secondary: business.secondaryColor || business.primaryColor, + sidebarText: getInitialTextColor(), }); // Live preview: Update CSS variables as user cycles through palettes useEffect(() => { - applyBrandColors(formState.primaryColor, formState.secondaryColor); + applyBrandColors( + formState.primaryColor, + formState.secondaryColor, + formState.sidebarTextColor || undefined + ); // Cleanup: Restore saved colors when component unmounts (navigation away) return () => { - applyBrandColors(savedColorsRef.current.primary, savedColorsRef.current.secondary); + applyBrandColors( + savedColorsRef.current.primary, + savedColorsRef.current.secondary, + savedColorsRef.current.sidebarText || undefined + ); }; - }, [formState.primaryColor, formState.secondaryColor]); + }, [formState.primaryColor, formState.secondaryColor, formState.sidebarTextColor]); // Update savedColorsRef when business data changes (after successful save) useEffect(() => { + const textColor = business.sidebarTextColor || getContrastTextColor(business.primaryColor, business.primaryColor); savedColorsRef.current = { primary: business.primaryColor, secondary: business.secondaryColor || business.primaryColor, + sidebarText: textColor, }; - }, [business.primaryColor, business.secondaryColor]); + }, [business.primaryColor, business.secondaryColor, business.sidebarTextColor]); const handleSave = async () => { await updateBusiness(formState); @@ -77,13 +107,35 @@ const BrandingSettings: React.FC = () => { savedColorsRef.current = { primary: formState.primaryColor, secondary: formState.secondaryColor, + sidebarText: formState.sidebarTextColor, }; setShowToast(true); setTimeout(() => setShowToast(false), 3000); }; + // Calculate the effective text color (custom or auto-calculated) + const getEffectiveTextColor = () => { + if (formState.sidebarTextColor) { + return formState.sidebarTextColor; + } + return getContrastTextColor(formState.primaryColor, formState.primaryColor); + }; + + // Update sidebar text color to the auto-calculated value + const resetToAutoTextColor = () => { + const autoColor = getContrastTextColor(formState.primaryColor, formState.primaryColor); + setFormState(prev => ({ ...prev, sidebarTextColor: autoColor })); + }; + const selectPalette = (primary: string, secondary: string) => { - setFormState(prev => ({ ...prev, primaryColor: primary, secondaryColor: secondary })); + // When selecting a palette, auto-calculate the text color for it + const autoTextColor = getContrastTextColor(primary, primary); + setFormState(prev => ({ + ...prev, + primaryColor: primary, + secondaryColor: secondary, + sidebarTextColor: autoTextColor, + })); }; const isOwner = user.role === 'owner'; @@ -285,7 +337,7 @@ const BrandingSettings: React.FC = () => {
{/* Custom Colors */} -
+
-
+
-
+
+ setFormState(prev => ({ ...prev, sidebarTextColor: e.target.value }))} + className="w-10 h-10 rounded cursor-pointer" + /> + setFormState(prev => ({ ...prev, sidebarTextColor: e.target.value }))} + className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> + +
+
+
+ + {/* Sidebar Preview */} +
+ +
+
+
+ {business.name.substring(0, 2).toUpperCase()} +
+
+

{business.name}

+

{business.subdomain}.smoothschedule.com

+
+
+
+
+ + Dashboard +
+
+ + Scheduler +
+
+ + Customers +
+
+ + Settings +
+
@@ -340,7 +451,7 @@ const BrandingSettings: React.FC = () => {