Brand as Single Source of Truth
Status: Draft · Owner: Jeff Stolz
Three settings pages edit a vendor's branding. Settings → Brand becomes the only editor for all of it — portal, email, sign-in pages, outbound integrations, admin chrome — and the or_vendors branding columns become a derived read model rather than a second source of truth.
What exists today
Settings → Organization → Details
"Primary logo" (1:1) and "Secondary logo" (16:9).
| Storage | or_vendors.logo_path, or_vendors.secondary_logo_path — a unique filename under /static/img/logos/, or S3 when offload_profile_images_to_s3 |
| Writer | POST /vendor_profile, multipart vendor_logo_path / vendor_secondary_logo_path (app/api/vendor/routes.py) → Logo.set_logo. Replace-only; no clear path |
| Gate | OWNER / ONRAMP_ADMIN, no feature flag |
Settings → Portals
Brand color, Background color, Button text color — writing the same vendor_branding_primary_color / portal_background_color / portal_button_text_color columns Brand's palette writes through to. Already hidden behind v-if="vendorProfile && !isPortalStudioEnabled" (Portal.vue:311) — the precedent this proposal follows for the logo rows.
Settings → Brand → Logos & images
"Logo", "Hero image", "Favicon" (BrandPanel.vue).
| Storage | the BRAND_DEFAULTS portal-config document at theme.assets.*. logo and favicon are inline base64 data URLs; hero_image is a hosted URL. Caps in THEME_ASSET_MAX_KB |
| Gate | OWNER / ONRAMP_ADMIN and the portal-studio feature flag, disabled-by-default per vendor |
| Reach | the v3 renderer, as a snapshot default baked into portals created from here on (bake_brand_defaults) — deliberately not retroactive. Plus email branding when the flag is on |
Where the primary logo actually renders
Verified consumer by consumer. logo_path is an organization identity field, not a portal field:
| Surface | Path |
|---|---|
| Email body logo | Email._set_template_data — CRM account logo → Studio logo → Logo.getVendorLogo → OnRamp default |
| Auth / join / signup, all three portal versions | /api/portal/vendor → logo (populated only from logo_path), rendered in pages/public, pages/v2/public, pages/v3/public |
| Org-admin user menu | relative_vendor_logo, app/api/base/services.py |
| Email-action landing pages | logo_url=Logo.getVendorLogo(vendor), email_action_controller.py |
| Embed widget (live) | Logo.getVendor(project), embed_widget/routes.py |
| Outbound CRM / Slack payloads | vendor.logo_url on every project, task, module, note, subtask event — get_integration_data.py |
| Super-admin org screens | logo_primary, OrganizationMapper |
secondary_logo_path reaches only the authenticated portal header (v1/v2 prefer it over primary; v3 prefers studio_logo first), plus super-admin and the assets.logo_dark seed. Not email, not public pages, not integrations.
The overlap, and the defect in it
Already bidirectional:
- Legacy → Brand.
_vendor_legacy_themeseeds a Studio theme fromlogo_path→assets.logoandsecondary_logo_path→assets.logo_dark. - Brand → Legacy. Publishing the default TEMPLATE portal runs
_sync_legacy_vendor_branding, writingtheme.assets.logointoor_vendors.logo_path.
assets.logo is a data: URI. Logo.get_logo_path special-cases only http:// and https://; anything else is treated as a filename, os.path.exists fails, and it returns None. So for a portal-studio vendor, setting a logo in Brand and publishing the default portal blanks the logo on every surface in the table above — email, v3's own sign-in pages, outbound CRM payloads included. Email carries a hand-rolled guard for the inverse case; nothing guards this direction.
Established by reading the path; not yet reproduced against a running vendor.
Why the obvious fix does not work
Making the logo slot hosted (as hero_image is) does not fix this. POST /file/upload/otexteditor/image returns static_path — a relative/static/images/… path, not the absolute file_url it also computes (upload_file_controller.py). A relative path fails is_absolute_url exactly as a data URI does. The same endpoint also rejects SVG (jpg/jpeg/png/gif only), so switching would regress SVG logo uploads, which the field's own help text invites.
The fix is to materialize the asset into the legacy logo store at write-through time, keeping logo_path's filename contract and the existing S3 branch intact.
Decisions
- Brand is the only branding editor, app-wide. The
or_vendorsbranding columns stay as the derived read model every non-portal consumer already reads. Storage does not move. - Feature gate — conditional and self-retiring. Organization's logo rows render only while
portal-studiois off, mirroring whatPortal.vuealready does with the color pickers. Roles match on both sides (OWNER / ONRAMP_ADMIN), so the flag is the only gap, and the duplication deletes itself as the flag rolls out. - Secondary logo — frozen, no editor. The column keeps its legacy value; Studio vendors are no longer offered an editor. Nearly inert for them already. A first-class wide mark would need its own
logo_wideslot, notlogo_dark. - Language must state per-asset reach. "Brand" cannot describe itself as portal defaults once it owns email and CRM branding, and it cannot claim app-wide reach for assets that are portal-only. Each field says where it lands.
Phases
Three PRs, in order. Phase 0 stands alone and is worth shipping on its own.
Phase 0 — make the write-through store a servable value
Backend only. app/api/utils/Logo.py, app/api/portal_studio/services/portal_config_service.py.
- Add
Logo.set_logo_from_data_uri(obj, data_uri, property_name): decode, wrap in awerkzeug.datastructures.FileStorage, delegate to the existingset_logoso the S3-vs-local branch and the filename contract are reused rather than reimplemented. - Rework
_sync_legacy_vendor_brandingto classify before writing:data:URI → materialize, store the resulting filename- absolute
http(s)URL → store as-is (already supported byget_logo_path) - anything else (relative path, unreadable value) → skip and log, never write a value the resolver cannot serve
- Idempotence without a schema change: derive the filename from a content hash (
brand-<sha256[:16]>.<ext>) and skip when the column already holds it, so a republish neither rewrites the column nor orphans a new file each time. - Tests: data URI → servable filename; https passthrough; relative skipped + logged; republish is a no-op; S3-offload branch still reached.
Phase 1 — Brand publish drives the write-through
Backend only. portal_config_service.publish.
Today the sync fires only inside the TEMPLATE branch, gated on scope.is_default. Saving Brand itself therefore updates nothing outside the portal. Call it on BRAND_DEFAULTS publish too.
Open call to settle in review: two scopes then write one derived column. Proposed rule — a BRAND_DEFAULTS publish always applies, and the default-TEMPLATE sync stays as the seed/back-compat path but no longer clobbers a slot brand defaults set. In practice they usually agree, because a template's theme is baked from brand defaults at create/publish.
Phase 2 — one editor, honest language
Frontend + copy.
OrganizationDetails.vue: wrap both logo rows inv-if="!isPortalStudioEnabled"; when on, replace with a single row pointing at Settings → Brand.Settings → Portals: nothing to do, colors are already gated.
Rewrite the copy that currently frames Brand as portal-scoped, stating reach per asset rather than once for the page:
Field Reach to state Palette portal and email Logo portal header and hero, plus email, sign-in and join pages, CRM notifications, admin chrome Hero image portal only Favicon portal browser tab only Sites:
BrandSettings.vuepage title/description,brand/router.jsmeta + aliases (these feed settings search), theBrandDefaultsEditorpreview footer ("saving won't change portals that already exist" stays true and stays visible), and eachBrandAssetField/ color fieldhelp.Tests:
BrandSettings.test.jsfor copy anchors; anOrganizationDetailstest covering both flag states.
Phase 3 — delete, at full rollout
Logo rows and cropper wiring out of OrganizationDetails.vue; the !isPortalStudioEnabled color form out of Portal.vue; the orphaned OrganizationBilling.vue; the dead vendor_logo_path form-data in Portal.vue built from a vendorPic that component never assigns.
Out of scope, worth filing
embed_widget/routes.pybuilds the preview logo URL by raw interpolation oforg.logo_path, bypassingLogo.get_logo_path. Already wrong for S3-offloaded vendors, independent of this work.Logo.save_logo_imagesreadsoffload_profile_images_to_s3with novendor_idwhileget_logo_pathpasses one — a read/write asymmetry that predates this.- Favicon and hero image have no legacy column, so nothing to reconcile; they stay portal-only until a non-portal surface wants them.