feat(platform): add branding and UI customization settings#493
Conversation
Add a Settings > Branding page where admins can customize the platform's appearance with custom app name, logos, favicons, and brand/accent colors. Changes are previewed in real-time via a browser-chrome mockup before saving.
Greptile SummaryAdds a full-stack branding/white-labeling feature: a new Settings > Branding admin page with a form for customizing app name, text logo, logo image, favicons, and brand/accent colors, plus a live browser-chrome mockup preview. The backend introduces a
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| services/platform/app/components/branding/branding-provider.tsx | New branding context provider with title/favicon runtime overrides. Dark favicon URL is fetched but never applied — only faviconLightUrl is used in the favicon effect. |
| services/platform/app/features/settings/branding/components/branding-form.tsx | React Hook Form with Zod validation for branding settings. Uses useCallback-based refs for storage IDs (outside RHF). Solid form logic with proper dirty/submit state handling. |
| services/platform/app/features/settings/branding/components/branding-preview.tsx | Memoized browser-chrome mockup preview component. Renders live brand/accent color styling and conditional logo display. Well-structured presentational component. |
| services/platform/app/features/settings/branding/components/image-upload-field.tsx | Image upload component with Convex storage integration. Has two issues: object URL memory leak (cleanup returned from event handler, never invoked) and inverted size variant classes (sm renders larger than md). |
| services/platform/convex/branding/mutations.ts | Upsert mutation with ADMIN_ONLY RLS, storage cleanup on replacement, and audit logging. Color fields lack server-side hex validation (client-only Zod regex). |
| services/platform/convex/branding/queries.ts | Query resolves storage IDs to URLs via Promise.all. Properly returns null when no branding exists. Correct pattern. |
| services/platform/convex/branding/schema.ts | Schema definition with appropriate fields and by_organizationId index. All image fields use v.id('_storage') type. |
| services/platform/lib/shared/schemas/branding.ts | Shared Zod validation schema with hex color regex, form data type, settings type with storage IDs, and URL-resolved type. Proper schema composition. |
| services/platform/app/routes/dashboard/$id.tsx | Wraps dashboard layout in BrandingProvider as outermost context. Clean integration with existing provider hierarchy. |
Sequence Diagram
sequenceDiagram
participant Admin as Admin User
participant Form as BrandingForm
participant Upload as ImageUploadField
participant Convex as Convex Backend
participant Storage as Convex Storage
participant Provider as BrandingProvider
participant Logo as TaleLogo / TaleLogoText
Admin->>Form: Fill in branding fields
Form->>Form: Live preview update via onPreviewChange
Admin->>Upload: Select image file
Upload->>Convex: generateUploadUrl()
Convex-->>Upload: uploadUrl
Upload->>Storage: POST file to uploadUrl
Storage-->>Upload: storageId
Upload->>Form: onUpload(storageId)
Admin->>Form: Click Save
Form->>Convex: upsertBranding(orgId, fields, storageIds)
Convex->>Convex: validateOrganizationAccess(ADMIN_ONLY)
Convex->>Storage: delete(oldStorageId) if replaced
Convex->>Convex: db.patch() or db.insert()
Convex->>Convex: AuditLog.logSuccess()
Convex-->>Form: null (success)
Form->>Admin: Toast "Branding updated"
Note over Provider: On next render / reactivity update
Provider->>Convex: getBranding(orgId)
Convex->>Storage: getUrl(storageIds)
Convex-->>Provider: { appName, logoUrl, faviconUrls, colors }
Provider->>Provider: Update document.title suffix
Provider->>Provider: Update favicon link element
Provider->>Logo: Context provides logoUrl / textLogo
Logo->>Logo: Render custom logo or default
Last reviewed commit: b90c32e
📝 WalkthroughWalkthroughThis PR introduces a complete branding customization system enabling users to configure their organization's app name, logo, text logo, favicons, and colors. Changes span the backend (Convex schema, queries, mutations for branding data persistence), frontend (BrandingProvider context, form and preview components for UI configuration), client hooks for data fetching and mutations, a new settings route at Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@services/platform/app/components/branding/branding-provider.tsx`:
- Around line 67-86: The effect captures originalHref each run which can be a
previously set custom favicon; change this to store the initial default href
once and reuse it on cleanup: create a ref (e.g., initialDefaultHrefRef) outside
the effect, on first mount (or when there is no value) set
initialDefaultHrefRef.current = link.href before any modifications in the effect
that uses faviconUrl, update link.href to branding?.faviconLightUrl as now, and
in the returned cleanup restore link.href = initialDefaultHrefRef.current
(falling back to empty string if unset); update the effect that references
branding?.faviconLightUrl and use the ref names initialDefaultHrefRef and
originalHref only for local temporary reference inside the effect as needed.
- Around line 41-64: The effect currently only handles applying a custom suffix
when branding?.appName is set and returns early when it's cleared, leaving the
modified document.title in place; fix by capturing the original document.title
(or originalSuffix) at the start of the effect and using it to restore the title
when branding?.appName becomes falsy. Concretely: in the useEffect that contains
originalSuffix and updateTitle, read and store const originalFullTitle =
document.title (or derive originalSuffix) before doing any early returns, always
create the MutationObserver and apply updateTitle when branding?.appName is
present, and in the cleanup (or when branding?.appName is undefined) set
document.title = originalFullTitle to restore the original title, then
disconnect the observer; keep references to observer, titleElement, and
originalFullTitle within the effect so updateTitle and cleanup can use them.
In
`@services/platform/app/features/settings/branding/components/__tests__/branding-preview.test.tsx`:
- Around line 84-90: The test currently asserts firstIcon.style.color ===
'#00AAFF' but browsers return computed RGB strings; update the assertion to use
getComputedStyle(firstIcon).color and compare to the equivalent RGB string (e.g.
'rgb(0, 170, 255)') or use a regex that matches the RGB components; modify the
failing test in branding-preview.test.tsx (variables navIcons and firstIcon) to
call getComputedStyle(firstIcon).color and assert that value instead of the hex
string so the assertion is color-format agnostic.
- Around line 92-98: The test "renders browser chrome dots" is using the broad
selector container.querySelectorAll('.rounded-full') which matches avatars too;
update the test to scope the query to the browser chrome container in the
BrandingPreview output (e.g., locate the browser chrome element by its unique
container class or data-testid and call querySelectorAll('.rounded-full') on
that node, or use a more specific selector like '.browser-chrome .rounded-full'
or getByTestId('browser-chrome') then within it query for '.rounded-full') so
only the 3 chrome dots are selected.
In
`@services/platform/app/features/settings/branding/components/branding-form.tsx`:
- Around line 70-108: The three storage-id "refs" (logoStorageIdRef,
faviconLightStorageIdRef, faviconDarkStorageIdRef) are recreated each render
because they use useCallback(() => { ... }, [])(), so stored IDs are lost;
replace these with stable refs using React's useRef (or useState) so the object
persists across renders (e.g., create const logoStorageIdRef =
useRef<Id<'_storage'>|undefined>(undefined) and similarly for
faviconLightStorageIdRef and faviconDarkStorageIdRef) and update the get/set
usage to read/write .current to ensure uploaded IDs survive re-renders and are
included on submit.
In
`@services/platform/app/features/settings/branding/components/image-upload-field.tsx`:
- Around line 46-77: The handleFileChange flow creates object URLs via
URL.createObjectURL and never revokes them, causing memory leaks; add a
useEffect that watches the previewUrl state (the value set by setPreviewUrl in
handleFileChange) and in its cleanup revoke the current previewUrl with
URL.revokeObjectURL(previewUrl) (also ensure you check previewUrl is non-null),
so previous object URLs are revoked on every change and on unmount; keep
existing logic that clears previewUrl on error/finally and reset
fileInputRef.current.value as before.
- Around line 40-125: The remove button only clears previewUrl so displayUrl
still falls back to currentUrl; add a local removed flag (e.g., isRemoved via
useState) and change displayUrl to: previewUrl ?? (isRemoved ? null :
currentUrl). In handleRemove set isRemoved(true) and setPreviewUrl(null) and
call onRemove?.(); when a new file is selected (in handleFileChange) clear
isRemoved (setIsRemoved(false)); also reset isRemoved to false when currentUrl
changes (useEffect watching currentUrl) so updates restore the image. Use the
existing symbols displayUrl, previewUrl, currentUrl, handleRemove,
handleFileChange and onRemove to locate where to add this logic.
- Fix object URL memory leak in image-upload-field by tracking URLs in a ref and revoking on replacement, error, and unmount - Fix inverted size variants (sm was 48px, md was 40px) - Fix remove button not clearing image when currentUrl is set by adding isRemoved state to override the fallback - Fix document title not restoring to "Tale" when appName is cleared - Fix favicon cleanup capturing wrong originalHref on subsequent runs by using a ref to store the true default once - Add dark mode favicon support using matchMedia listener - Add server-side hex color validation in upsertBrandingHandler - Replace useCallback closure anti-pattern with idiomatic useRef for storage ID tracking in branding-form - Fix test assertions: use toHaveStyle for color checks, scope browser chrome dots query via data-testid
Use oxfmt formatting and remove unused brandingSettingsSchema, BrandingSettings, brandingWithUrlsSchema, BrandingWithUrls exports to fix CI lint/format and knip failures.
Summary
brandingSettingsConvex table with query, mutation (including storage cleanup and audit logging)What's included
Data layer:
brandingSettingstable,getBrandingquery,upsertBrandingmutation (admin-only, audit logged)UI components:
BrandingSettingsClient(two-column layout),BrandingForm(react-hook-form + Zod),BrandingPreview(live mockup),ColorPickerInput,ImageUploadFieldRuntime branding:
BrandingProvidercontext, custom logo rendering inTaleLogo/TaleLogoText, dynamic favicon and page titleTests: 43 tests across 5 test files (queries, mutations, color picker, preview, form)
Stories:
ColorPickerInput(5 stories),ImageUploadField(5 stories)Closes #323
Test plan
npm run test --workspace=@tale/platform(487 tests passing)npm run lint --workspace=@tale/platform(0 errors)Summary by CodeRabbit