Skip to main content

RFC: Unified Branding Service (Whitelabel) — hub-chat frontend consumption

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Replace placeholder values; mark sections N/A — reason when truly inapplicable rather than deleting them.

It is also agent-execution-ready: the §1 Design References, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan

  • Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

The YAML frontmatter at the very top is the machine-readable index agents parse. The metadata table below is the human-readable governance record. Both must agree on every shared field (status, owner, type, dates).

Metadata

FieldValueNotes
StatusIDEAIDEA / RFC / ABANDON / AGREED
OwnerQontak ChatTeam owning the RFC
Author(s)Engineering (hub-chat)Primary author(s)
Reviewershub-chat tech lead · qontak.com backend owner · Pixel3/DS ownerTech reviewers across affected squads
Approver(s)hub-chat tech leader · infosec approver (TBD)Tech leaders + infosec approver
Submitted Date2026-07-23ISO-8601; date RFC opened for discussion
Last Updated2026-07-23ISO-8601; bump on every material edit
Target Release2026-Q4Depends on GET /branding availability (§1 Dependencies)
Related DocumentsUnified Branding Service (Design) · rfc-unified-branding-service-be.md (backend RFC — the GET /branding producer)Design doc is not a full PRD (no §13b story list; Detail 1.C stories are RFC-derived). BE RFC is the contract authority for §2.4.
Discussion#TBDSet the Slack thread before promoting to RFC

Type: frontend Sub-type: new-feature

Sections at a Glance

  1. Overview (incl. Dependencies, Communication & Stakeholders, §1 Design References — the GET /branding contract + next-theme prerequisite)
  2. Technical Design (Repo Reading Guide → mermaid architecture → UI/state contracts → Asset Inventory)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Scope of this RFC. This is the hub-chat frontend half of the Unified Branding initiative. It implements the consumption layer described in the design doc §5/§8: at app boot, hub-chat fetches GET /branding and applies the tenant's brand — Pixel3 --mp-colors-* color tokens, favicon, product name, and optional web font — with no per-component color logic, because Pixel3 components already read --mp-colors-* at render time (verified: assets/styles/pixel.css:38 consumes --mp-colors-border-brand).

The branding service itself (GET /branding, branding_config store, Redis cache, tenant resolver) is out of scope here — the design doc assigns it to qontak.com. This RFC treats GET /branding as an external dependency and pins the FE against the contract documented in design-doc §4. The crm-fe-v3 and legacy hub consumers are separate efforts (crm-fe-v3 is not checked out in this workspace; legacy hub uses bootstrap-vue + old pixel and cannot be themed by CSS-variable override at all — design doc §4b). Both are tracked as n/a — covered elsewhere in Detail 1.C.

Success Criteria

  1. On any hub-chat route running the next theme, setting the tenant's brand tokens on :root restyles Pixel3 components (buttons, banners, inputs, …) to the tenant color with zero component edits. Verifiable: a themed snapshot/visual check of MpButton renders the tenant brand fill, not blue.400.
  2. Favicon, browser/tab title, and in-app product name reflect the tenant's brand from the /branding payload rather than the hard-coded "Qontak Chat" (nuxt.config.ts:27, configs/*.json META).
  3. The tenant brand paints on first render with no flash of default theme (FODT) for returning users (last payload cached in localStorage, revalidated on boot — design doc §7).
  4. The feature is fully behind a flag defaulting off; with the flag off, hub-chat renders exactly today's Qontak brand (no regression).

Out of Scope

  • Building GET /branding, the branding_config/tenant_domain store, the tenant resolver, the Redis cache, or the edge/ingress allow-list — all owned by qontak.com (design-doc Decisions #2/#3). This RFC only consumes the endpoint.
  • crm-fe-v3 and legacy hub consumption (separate RFCs / repos).
  • Backend email/PDF brand rendering (design-doc §8, backend-owned).
  • Completing hub-chat's next-theme migration for legacy routes — this RFC depends on that migration for full component coverage but does not perform it (design-doc §4b).
  • A tenant-admin UI to edit branding (this is a consumer, not an authoring surface).
  • Introducing a neutral --mp-colors-brand-primary alias in Pixel3 (design-doc §4 raises it; it is a design-system change, owned by the DS squad — tracked as an Open Question).
  • Whitelabel — Unified Branding Service (Design) — source design/architecture doc. Note: it is a design + worked-example doc, not a full PRD; it has no §13b user-story list, so Detail 1.C stories are RFC-derived from its numbered sections and must be confirmed by the PM.
  • Design-system reference (in-repo): docs/mekari-pixel-design-system.md, docs/architecture/flows/cross-cutting/design-system/README.md.

Assumptions

  1. GET /branding will return exactly the shape in design-doc §4 (keys: tenant, productName, colors as Pixel3 token names, assets.{logo,favicon,appleTouchIcon}, font.{family,cssUrl}, links.{support,legal}). Any drift is a blocker (Detail 2.G-style contract check → §5).
  2. GET /branding is public + unauthenticated and reachable before login on the tenant domain (design-doc Decision #3). hub-chat can therefore fetch it during boot without a token, and must not send credentials to it.
  3. The endpoint returns only non-PII brand data (color, URLs, product name) — design-doc §4. hub-chat treats the payload as world-readable and never stores anything sensitive from it.
  4. Pixel3's next theme (data-panda-theme=next) is the coverage boundary: brand-token override styles a component only where the next theme is active (design-doc §4b, verified against NEXT_THEME_AVAILIBILITY = 28 route prefixes, app.vue:104).
  5. Asset URLs are CDN-hosted and CORS-safe for <link rel=icon> / <link rel=stylesheet> font loading.

Dependencies

DependencyTypeStatusOwnerNotes
GET /branding endpoint + contractBackend endpointneeds-buildingqontak.comFE builds against a local mock matching design-doc §4 until the endpoint ships; contract must be frozen before the boot-fetch chunk (4.C-6) can be verified against staging.
Edge/ingress allow-list of /branding as unauthenticated routeInfraneeds-buildingPlatform / qontak.comNot an FE task; blocks end-to-end verification only.
hub-chat next-theme migration of remaining routesFE (this repo, separate track)in-progress (28/‹all› routes migrated)Qontak ChatFull component coverage of the brand override is gated on this. Legacy routes stay hard-coded blue.400 until migrated (design-doc §4b).
Pixel3 neutral --mp-colors-brand-primary alias (optional)Design systemnot-startedDS squadOptional — lets the payload not be named after Qontak. Not required for v1 (override --mp-colors-brand-qontak).
@mekari/pixel3 / @mekari/pixel3-utils (already deps)PackageexistsDS squadusePixelTheme, PixelPlugin; no version bump required.

Communication & Stakeholders

Whitelabel branding for hub-chat spans multiple squads: hub-chat consumes a contract owned by another team, depends on a design-system capability, and shares the initiative with the other frontend consumers. This table names each stakeholder, what they own for this RFC, and the channel through which decisions and blockers are raised.

Squad / roleOwns (for this RFC)Contact / channelCoordination cadence
Qontak Chat (hub-chat FE) — RFC ownerThe consumption layer: BrandingStore, applyBranding, <BrandLogo/>, boot wiring, flag, literal de-hardcodinghub-chat tech lead · #TBD (set the whitelabel thread)Drives this RFC; resolves OQ1–OQ4 owners
qontak.com backendGET /branding endpoint + contract, branding_config/tenant_domain store, tenant resolver, Redis cacheqontak.com backend owner (reviewer)Contract freeze before chunk 4.C-6 staging verification (OQ2)
Design System (Pixel3)Next-theme rollout guidance; optional neutral --mp-colors-brand-primary aliasPixel3/DS owner (reviewer)Consulted on token-naming decision (design-doc §1/§4b)
Design QAWCAG-AA contrast / legibility sign-off for arbitrary tenant brand colorsTBD — name before pilot (OQ1)Gate before pilot-tenant stage
Platform / InfraEdge/ingress allow-list of /branding as an unauthenticated route; CSP origin allow-listPlatform (reviewer)Blocks end-to-end verification (OQ2/OQ3)
crm-fe-v3 squadParallel consumer RFC (separate repo, not in this workspace)crm-fe-v3 squadKeep payload contract aligned; not a hub-chat blocker
Product (PM)Confirm/renumber the RFC-derived stories (BR-1…BR-7); flag-provisioner decisionPM (design-doc author: A. Firdha Shafridhi)Before promoting this RFC to RFC status (OQ4)

Channels of record. Design decisions and blockers are raised in the Slack discussion thread (frontmatter discussion — currently #TBD, set before promoting to RFC); durable decisions and reviewer feedback land in §6 Comment logs; the frozen API contract lives in the backend RFC rfc-unified-branding-service-be.md referenced from §2.4. Cross-squad blockers (OQ1–OQ4) are tracked in §5 until an owner and resolution are recorded here.

Design References (frontend-specific)

This feature introduces no new UI screen or component layout — it re-themes the existing Pixel3 component library via CSS-variable override and swaps three brand artifacts (logo, favicon, product name). There is therefore no Figma frame to implement pixel-for-pixel; the "design" is the per-tenant brand payload plus a design-QA judgement on how arbitrary tenant colors look against the existing layout.

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
App shell re-themed by brand tokens (navbar, sidebar, buttons, banners)n/a — no new UI surface; re-themes existing Pixel3 components via --mp-colors-* overriden/a@mekari/pixel3 (current repo dep — see package.json)TBD — see §5 Open Question 1Visual QA is a per-tenant brand/contrast review (esp. text-on-brand contrast for dark/light tenant colors), not a frame comparison.
Favicon / browser title / product namen/a — asset + string swap, no layoutn/an/aTBDDriven by assets.favicon + productName from payload.

Because there is no Figma frame, the design-QA gate here is contrast/legibility of arbitrary tenant brand colors against the existing surfaces (WCAG AA). That gate is an Open Question, not a resolved reference — see §5.

Detail 1.A — PRD Traceability Matrix

Forward (Design-doc → RFC):

Design-doc requirementRFC sectionComponent / file
§5 applyBranding(b) core: set --mp-colors-* on :root, swap favicon, load font, store logo/name§2.A UI Contract · §4.C chunk 3–4common/utils/applyBranding.ts (new)
§5 reactive brandingStore consumed by logo + useHead§2.E State Surface Contract · §4.C chunk 2common/store/BrandingStore.ts (new)
§2 boot-time fetch of GET /branding then apply§2.2 Sequence · §4.C chunk 5–6app.vue onMounted (extend, alongside getAppConfig() at app.vue:205)
§4 payload shape (colors/assets/font/productName/links)§2.4 APIs Consumed · §2.Acommon/store/BrandingStore.ts types
§4b next-theme prerequisite (override only works under data-panda-theme=next)§1 Dependencies · §3 Known Limitationscommon/constants/nextThemeAvailibility.ts (read-only anchor)
§7 caching: localStorage last payload, paint instantly, revalidate§2.B Data-Fetching Strategy · §4.C chunk 5common/store/BrandingStore.ts
§8 replace "Qontak Chat" literals + favicon/title in nuxt.config§2.D Scope Boundaries · §4.C chunk 7nuxt.config.ts, navbar/sidebar components

Reverse (RFC → Design-doc):

New component / RFC decisionDesign-doc requirement driving it
common/utils/applyBranding.ts (framework-agnostic core)§5 "one small composable, reused everywhere"
common/store/BrandingStore.ts (Pinia setup store)§5 "reactive store consumed by <BrandLogo/> / useHead"
BrandLogo.vue component§5 step-4 logo binding + §6 per-tenant logo
Feature flag branding.whitelabel default-offRFC success criterion #4 (no-regression safety) — not in design doc, added for safe rollout
localStorage cache + revalidate§7 "avoid a flash of default theme"

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE endpoint)Required writes (BE endpoint)Status surface (which field/event reflects state)
App shell (navbar/sidebar/buttons re-themed)web (hub-chat SPA)GET /brandingnone (read-only consumer)BrandingStore.branding.colors applied to :root; BrandingStore.status
Favicon + tab titlewebGET /brandingnoneBrandingStore.branding.assets.favicon, .productName via useHead
In-app product name (<BrandLogo/>, titles)webGET /brandingnoneBrandingStore.branding.productName
Tenant font (optional)webGET /branding (font.cssUrl)noneBrandingStore.branding.font

Role Coverage

PRD roleUI surface visibilityAction buttons enabledAuth scope expected from BENotes
Anonymous / pre-login visitor on tenant domainThemed login/boot screenn/a (no actions)none/branding is public/unauthenticated (design-doc Decision #3)Primary reason the fetch runs before auth.
Authenticated agentFull app, themedexisting app buttons (unchanged)none for branding; app auth unchangedBranding is orthogonal to the user's app permissions.
Super adminSame as agent for brandingunchangednone for brandingisSuperAdmin path in app.vue:54 unaffected.
Support / read-only internalSame themed shellunchangednone for brandingBranding does not gate on role.

All roles see the same brand; branding carries no per-role authorization. The only auth-relevant fact is that the endpoint is unauthenticated — enforced by not attaching credentials (§3 Security).

PRD Section Coverage

Source is the design doc's numbered sections (it has no PRD-style numbering beyond these).

Design-doc section #TitleWhere covered (RFC section) or n/a — reason
1Architecture§1 Overview (scope split) · §2.1 (consumer slice only; service topology is BE-owned)
2How a tenant gets resolved and themed§2.2 Sequence (FE half: fetch → apply); resolver itself n/a — BE-owned
3Data modeln/a — BE-owned (branding_config/tenant_domain tables); FE consumes payload only (§2.4)
4API contract GET /branding§2.4 APIs Consumed · §2.A UI Contract (payload type)
4bDoes the override reach components? (next-theme)§1 Dependencies · §3 Known Limitations · §5
5Consumer side — applyBranding composable§2.A · §2.B · §4.C chunks 3–5
6Proof: two tenants, same code§2.C UI State Matrix · §4.C chunk 8 (multi-tenant test)
7Caching & invalidation§2.B (FE localStorage half); Redis/HTTP cache n/a — BE-owned
8What this replaces, per service§2.D Scope Boundaries (hub-chat row only)
DecisionsColors-as-tokens / owner / public-endpointAssumptions (§1) · §3 Security · §5

Detail 1.B — Decisions Closed

DecisionChosen optionAlternatives rejectedWhy rejected
Where the brand is appliedFramework-agnostic applyBranding() util + a Pinia setup store, invoked from app.vue boot(a) a Nuxt plugin that fetches at plugin-initPlugins load alphabetically before customFetch.ts provides $customFetch (verified: plugin list, no numeric prefixes); AppConfig already fetches in onMounted (app.vue:205) — mirror that proven pattern.
How colors are appliedWrite the payload's --mp-colors-* keys verbatim to document.documentElement.stylea per-app token-mapping tableDesign-doc §4 returns Pixel3 token names on purpose; a mapping table re-introduces the per-app logic the design explicitly removes.
Store shapeSetup-style defineStore("Branding", () => {...}) with status ref + actionOptions API storeMatches every existing store (AppConfigStore.ts:71, OrganizationStore.ts:168).
Fetch client$customFetch (useNuxtApp().$customFetch) with no auth requirementraw $fetchReuse the repo's one HTTP wrapper (retry, error-toast shape). Endpoint is public, but $customFetch only adds an auth header when a token cookie exists — acceptable for a public endpoint. See §3 for the credential caveat.
First-paint strategylocalStorage cache → paint → revalidatefetch-then-paint (block on network)Design-doc §7 explicitly calls for no flash of default theme.
Safety mechanismFeature flag branding.whitelabel, default offship unflagged286 Qontak literal sites + global :root mutation = high blast radius; flag gives an instant kill-switch. no alternative considered — safety is non-negotiable for a global re-theme.

Honesty rule: if no alternative was seriously considered, the row says so.

Detail 1.C — Per-Story Change Map (RFC-derived — the design doc has no §13b story list)

The linked doc is a design doc, not a PRD; it contains no user-story list. The stories below are RFC-derived from its numbered sections so traceability is complete — the PM must confirm/renumber them before this RFC is promoted to RFC status (§5 Open Question 4).

Story #Story titleLayer scopeChanges (concrete FE artifacts + cross-layer references)Acceptance criteria (verifiable)RFC anchors
BR-1Branding store + typed payloadFE + BE existing (consumes GET /branding, status needs-building — build vs mock)• new common/store/BrandingStore.ts (defineStore("Branding", …), status ref, branding ref, fetchBranding() action via $customFetch) • Branding TS interface matching design-doc §4 • unit spec BrandingStore.spec.tspnpm test common/store/BrandingStore.spec.ts passes: store resolves a mocked payload; status transitions idle→pending→resolved; malformed payload → rejected without throwing to boot§2.A · §2.4 · §4.C chunk 2
BR-2applyBranding() core utilFE-only (Runtime/behavior)• new common/utils/applyBranding.ts (set --mp-colors-* on :root, swap favicon <link>, inject font <link>, set brandingStore) • unit specpnpm test on the util: after applyBranding(fixture), document.documentElement.style.getPropertyValue('--mp-colors-background-brand-bold') equals fixture value; favicon href updated; --brand-font-family set§2.A · §4.C chunk 3
BR-3<BrandLogo/> + head/title from storeFE-only• new common/components/BrandLogo.vue reading brandingStore.branding.productName/logo • useHead title/favicon bound to store (extend the existing useHead pattern, e.g. pages/customers/index.vue:9)Component test: <BrandLogo/> renders tenant productName and logo src; falls back to "Qontak Chat" + default logo when store empty§2.A · §2.E · §4.C chunk 4
BR-4Boot fetch + cache-first paintFE + BE existing• extend app.vue onMounted (beside getAppConfig() at app.vue:205) to read localStorage cache → applyBrandingfetchBranding() → re-apply + re-cacheTest (mounted app / mocked fetch): cached payload applies synchronously on boot; network payload re-applies; no unhandled rejection when /branding errors (falls back to default brand)§2.2 · §2.B · §4.C chunk 5–6
BR-5Feature flag gateConfig• flag branding.whitelabel (see §4.A — sourced from AppConfigStore/runtimeConfig) wraps BR-4 boot logicWith flag off, boot does not fetch /branding and :root is unmutated (snapshot identical to today); with flag on, BR-4 runs§4.A · §4.C chunk 1
BR-6De-hardcode brand literalsFE-only• replace nuxt.config.ts head title/favicon literals + configs/*.json META with store-driven values where runtime-settable; audit the 286 Qontak occurrences and route the user-facing ones through the store/i18nGrep shows user-facing product-name renders come from the store, not string literals, on themed routes; pnpm build succeeds; title reflects productName when flag on§2.D · §4.C chunk 7
BR-7Two-tenant proof (design-doc §6)FE-only (test)• integration/visual test applying two payloads (purple #7A2FF2 / green #0E9F6E), asserting identical code path, different rendered brandTest: same applyBranding call with two fixtures yields two distinct :root color sets; a themed MpButton snapshot differs by brand color only§2.C · §4.C chunk 8
crm-fe-v3 consumptionCross-squadn/a — covered in separate crm-fe-v3 RFC (repo not in this workspace)n/adesign-doc §8
legacy hub consumptionCross-squadn/a — covered elsewhere; design-doc §4b: bootstrap-vue + old pixel, not themeable by token overriden/adesign-doc §4b
GET /branding service + store + resolver + edge allow-listCross-squadn/a — covered in qontak.com backend RFC (design-doc Decisions #2/#3)n/adesign-doc §1–4,7

Coverage rule satisfied: every design-doc section maps to a story or an explicit n/a — covered elsewhere. Cross-check against §2 — every artifact named above appears in §2.A / §2.D / §4.C.


2. Technical Design

Detail 2.0 — Repo Reading Guide (read this first; understand the existing code as documentation before writing any new code)

Repo Map (mermaid)

flowchart LR
subgraph boot["hub-chat boot"]
appvue["app.vue\n(onMounted: getAppConfig)"]
end
subgraph plugins["plugins/"]
cf["customFetch.ts\n($customFetch)"]
px["pixel.ts\n(PixelPlugin)"]
end
subgraph store["common/store/"]
acs["AppConfigStore.ts\n(pattern to copy)"]
bs["BrandingStore.ts\n(NEW)"]
end
subgraph util["common/utils/ (NEW)"]
ab["applyBranding.ts\n(NEW)"]
end
subgraph comp["common/components/"]
bl["BrandLogo.vue\n(NEW)"]
end
subgraph theme["theming"]
nta["constants/nextThemeAvailibility.ts"]
pcss["assets/styles/pixel.css"]
end
ext[/"GET /branding (qontak.com, external)"/]
appvue --> bs --> cf --> ext
bs --> ab
ab --> pcss
ab --> bl
px --> theme
appvue --> nta

Existing Code Anchors

PathWhy the agent reads itWhat pattern it teaches
common/store/AppConfigStore.tsThe store BrandingStore.ts is modelled onSetup defineStore + status state-machine + $customFetch action + idempotency guard (L71–104)
common/store/OrganizationStore.tsSecond store example (boot fetch + computed getter)defineStore("organization", …) with getDetail + storeToRefs consumption
plugins/customFetch.tsHow to call an API + error shape$fetch.createprovide.customFetch; retry/onResponseError toast shape (L154, L227)
app.vueWhere boot-time fetches and theme wiring liveonMounted(() => getAppConfig()) (L205) is the branding-fetch hook; next-theme watch (L86–112)
common/constants/nextThemeAvailibility.tsThe coverage boundary of the brand override28 route prefixes gate setNextTheme (design-doc §4b)
plugins/pixel.tsConfirms Pixel3 theme is enabledPixelPlugin { pixelTheme: true } (L4–9)
assets/styles/pixel.cssConfirms app consumes --mp-colors-* tokensvar(--mp-colors-border-brand) at L38 (consume-only; no override exists yet)
nuxt.config.tsBrand literals + runtimeConfig sourcehead title/favicon (L27, L50); runtimeConfig.public from configs/*.json (L419–478)
configs/production.jsonWhere brand strings/URLs live todayMETA.title, API_BASE_URL (L5, L11); no /branding key yet
common/components/__tests__/InputPeriod.spec.tsThe component-test pattern to copy// @vitest-environment nuxt + vi.mock("@mekari/pixel3-utils", …)

Patterns to Follow (and where to find them)

ConcernPattern in repoReference fileDeviation in this RFC?
State managementPinia setup store, status ref + async actioncommon/store/AppConfigStore.ts:71none
Folder conventionstores in common/store/, utils auto-imported from common/utils/**nuxt.config.ts:18-20 (imports.dirs)none — new util lands in common/utils/
API calluseNuxtApp().$customFetch(path)common/store/AppConfigStore.ts:76,84Endpoint is public/base-URL differs — may need a brandingApiBaseUrl (see §2.4)
Error / toast / retryonResponseError normalizes response._data.messageplugins/customFetch.ts:181-224Branding fetch must not toast on failure — it silently falls back to default brand
Theme wiringusePixelTheme().setNextTheme per-route watchapp.vue:84-112none — read-only; RFC does not change next-theme logic
Head/titleper-page useHead({...})pages/customers/index.vue:9Extend to bind title/favicon to brandingStore
Component test// @vitest-environment nuxt, mount, mock pixel utilscommon/components/__tests__/InputPeriod.spec.ts:1-13none

Reading Order for the Agent

  1. common/store/AppConfigStore.ts — the exact store shape to copy for BrandingStore.
  2. app.vue (L83–112, L203–239) — next-theme watch + onMounted boot fetches (the hook point).
  3. plugins/customFetch.ts (L146–228) — how $customFetch is provided and errors normalized.
  4. common/constants/nextThemeAvailibility.ts — the routes where a brand override actually takes effect.
  5. assets/styles/pixel.css (L1, L38) — confirms --mp-colors-* are consumed (so :root override works).
  6. nuxt.config.ts (L15–68, L419–478) — head literals + runtimeConfig/configs/*.json wiring.
  7. common/components/__tests__/InputPeriod.spec.ts — the component-test harness to reuse.
  8. docs/mekari-pixel-design-system.md — Pixel3 next-theme background.

Existing API check

GET /brandingnew-with-justification: no /branding route or config key exists in hub-chat (verified: no brandingApiBaseUrl//branding in nuxt.config.ts or configs/*.json; store grep shows only client_configs/config, sso_oauth/token, etc.). Reuse is impossible because no endpoint returns tenant brand tokens today. The endpoint is owned by qontak.com (design-doc Decision #2), so from hub-chat's side it is a consumed external contract, not something this RFC creates.

Source Verification (anti-hallucination)

Anchor / pattern / contractVerified byEvidence (1-line)
common/store/AppConfigStore.tsreadexport const useAppConfigStore = defineStore("AppConfig", () => { L71; await $customFetch("/api/core/v1/client_configs/config") L84; status idle/pending/resolved/rejected L72,82,88,91
common/store/OrganizationStore.tsread (Explore)defineStore("organization", () => { L168; getDetail L173; computed organizationSettings L213
plugins/customFetch.tsreadconst $customFetch = $fetch.create({ … baseURL: config.public.apiBaseUrl … }) L154; return { provide: { customFetch: $customFetch } } L227; error-normalize L217-224
app.vue boot hookreadonMounted(() => { … getAppConfig(); … }) L203-205
app.vue next-themereadconst { setNextTheme } = usePixelTheme(); L84; NEXT_THEME_AVAILIBILITY.some((base) => …) L104; setNextTheme(isNextTheme) L109
common/constants/nextThemeAvailibility.tsreadexport const NEXT_THEME_AVAILIBILITY = [ "/inbox", … ] — 28 entries L1-30
plugins/pixel.tsreadnuxtApp.vueApp.use(PixelPlugin, { … pixelTheme: true }) L4-9
assets/styles/pixel.css consumes tokensread/grepborder: 1.5px solid var(--mp-colors-border-brand); L38
nuxt.config.ts head literalsreadtitle: "Qontak Chat" L27; { rel: "icon", href: "/qontak-favicon.ico" } L50; runtimeConfig.public L423, apiBaseUrl: CONFIGENVIRONMENT.env.API_BASE_URL L427
configs/production.jsonread"title": "Qontak Chat" L5; "API_BASE_URL": "https://chat-service.qontak.com" L11; no /branding key
Qontak literal countgrep286 matches across app common configs i18n pages layouts (*.ts/*.vue/*.json); top clusters TheNavbar.vue 37, OneNavbar.vue 31, useSidebar.ts 22
--mp-colors*brand* override absentgrep2 matches, only 1 in code (pixel.css:38 consume); no existing :root override
--mp-colors-text-inverse in repogrep0 matches — the design-doc "48 uses" span all repos/Pixel3, not hub-chat app code (payload still valid; token is defined by Pixel3, just not referenced in hub-chat source)
Test commandsread package.jsontest: "vitest --dom --pool=forks", type-check: "vue-tsc --noEmit", lint: "pnpm lint:js && pnpm lint:prettier", build: "nuxt build"
Component test patternreadcommon/components/__tests__/InputPeriod.spec.ts:1 // @vitest-environment nuxt; vi.mock("@mekari/pixel3-utils", …) L10-13
BrandLogo/brandingStore/applyBranding are newgrep0 matches repo-wide — all greenfield

Design ↔ Code Mapping (frontend-specific)

No Figma frames exist (§1 Design References). This maps the design-doc's code artifacts (its §5 is literally TypeScript) to their hub-chat implementation files and tokens.

Design-doc artifactImplementing file (path)Reuse vs newDesign tokens usedDeviation from design
applyBranding(b) (design-doc §5 TS)common/utils/applyBranding.tsnewwrites payload's --mp-colors-* keys verbatim + --brand-font-familynone — code faithful; adds a null-guard for missing assets/font
Branding interface (design-doc §5)common/store/BrandingStore.ts (exported type)newn/anone
brandingStore.set(b) (design-doc §5 step 4)common/store/BrandingStore.ts actionnewn/astore is Pinia (repo convention) rather than a bare module singleton
<BrandLogo/> (design-doc §5 step 4)common/components/BrandLogo.vuenewconsumes --mp-colors-* via Pixel3none

There is no design QA sign-off attached (no Figma); the deviation that does need design approval is legibility of arbitrary tenant colors — tracked in §5 Open Question 1.

Detail 2.1 — Architecture (mermaid)

Component diagram

flowchart TB
user([User on tenant domain]) --> app[app.vue boot]
app --> flag{branding.whitelabel on?}
flag -- no --> default[render default Qontak brand]
flag -- yes --> store[BrandingStore]
store --> lscache[(localStorage cache)]
store --> client[$customFetch]
client --> ext[/GET /branding — qontak.com/]
store --> apply[applyBranding util]
apply --> root["document.documentElement\n(:root --mp-colors-*)"]
apply --> head[useHead: favicon + title]
apply --> logo[BrandLogo.vue]
root --> pixel[Pixel3 components re-render in tenant colors]

State machine (branding boot flow)

stateDiagram-v2
[*] --> flag_off
flag_off --> [*]: render default brand (no fetch)
[*] --> cache_check: flag on
cache_check --> painted_cached: localStorage hit -> applyBranding
cache_check --> default_pending: localStorage miss
painted_cached --> revalidating: fetchBranding()
default_pending --> revalidating: fetchBranding()
revalidating --> painted_fresh: 2xx -> applyBranding + cache
revalidating --> painted_cached: network error & had cache (keep cached)
revalidating --> default_final: network error & no cache (default brand)
painted_fresh --> [*]
painted_cached --> [*]
default_final --> [*]

Detail 2.2 — Sequence (mermaid, incl. failure paths)

Happy path — returning user, cache-first paint then revalidate

sequenceDiagram
actor U as User
participant APP as app.vue (onMounted)
participant ST as BrandingStore
participant LS as localStorage
participant CF as $customFetch
participant EXT as GET /branding (qontak.com)
participant ROOT as :root / Pixel3

U->>APP: load hub-chat on acme.com
APP->>ST: if flag on -> initBranding()
ST->>LS: read branding:cache
LS-->>ST: cached payload (last visit)
ST->>ROOT: applyBranding(cached) %% instant paint, no FODT
ROOT-->>U: app shell already in tenant colors
ST->>CF: GET /branding (no credentials)
CF->>EXT: HTTPS GET (Host: acme.com)
EXT-->>CF: 200 { colors, assets, font, productName }
CF-->>ST: payload
ST->>ROOT: applyBranding(fresh) %% idempotent re-apply
ST->>LS: write branding:cache (fresh)
Note over ROOT: only next-theme routes restyle fully (design-doc §4b)

Failure path — /branding unreachable / 5xx / timeout

sequenceDiagram
participant APP as app.vue
participant ST as BrandingStore
participant LS as localStorage
participant CF as $customFetch
participant EXT as GET /branding

APP->>ST: initBranding() (flag on)
ST->>LS: read cache
alt cache present
LS-->>ST: cached payload
ST->>ST: applyBranding(cached)
else no cache
ST->>ST: keep default Qontak brand
end
ST->>CF: GET /branding
CF->>EXT: HTTPS GET
Note right of EXT: timeout ~5s / 5xx
EXT--xCF: no usable response
CF-->>ST: error (retry disabled for this call)
ST->>ST: status=rejected; DO NOT toast; keep current brand
Note over ST: boot never blocks on branding; app remains fully usable

Detail 2.3 — Database Model

N/A for this frontend RFC. Client-side persistence used:

  • localStorage key branding:cache — stores the last successful /branding payload (JSON). Shape = the Branding interface (§2.A). Eviction: overwritten on each successful fetch; cleared when the feature flag is off (BR-5) and on logout is not required (payload is public, non-PII). No migration needed (new key); if the shape ever changes, guard the parse and treat a parse failure as a cache miss.

Detail 2.4 — APIs Consumed

MethodPathStatusContract authorityNotes
GET/brandingneeds-building (build FE against local mock until live)Design-doc §4 + rfc-unified-branding-service-be.md (the producer RFC)Public, unauthenticated, cacheable. Base URL TBD: hub-chat's apiBaseUrl is chat-service.qontak.com (configs/production.json:11) but the service is qontak.com-owned; a dedicated brandingApiBaseUrl config key or an edge route on the tenant domain is required — §5 Open Question 2.

Payload contract (design-doc §4, verbatim keys the FE writes to :root):

{
"tenant": "acme",
"productName": "Acme Chat",
"colors": {
"--mp-colors-brand-qontak": "#7A2FF2",
"--mp-colors-background-brand": "#7A2FF2",
"--mp-colors-background-brand-hovered": "#6A1FE0",
"--mp-colors-background-brand-bold": "#7A2FF2",
"--mp-colors-border-brand": "#7A2FF2",
"--mp-colors-icon-brand": "#7A2FF2",
"--mp-colors-text-inverse": "#FFFFFF"
},
"assets": {
"logo": "https://cdn.brand.example/acme/logo.svg",
"favicon": "https://cdn.brand.example/acme/favicon.ico",
"appleTouchIcon": "https://cdn.brand.example/acme/apple-touch-icon.png"
},
"font": { "family": "Inter", "cssUrl": "https://cdn.brand.example/acme/fonts/inter.css" },
"links": { "support": "https://help.acme.com", "legal": "https://acme.com/terms" }
}

The FE iterates colors generically (writes every key/value to :root) — it does not hard-code the token list, so a payload adding/removing a token needs no FE change. links are consumed only if/where the app has a support/legal surface (else ignored).

Detail 2.A — UI Contract

applyBranding core utilcommon/utils/applyBranding.ts (design-doc §5)

  • Implementation file path: common/utils/applyBranding.ts
  • No Figma frame (see §1).
  • Type:
export interface Branding {
tenant?: string
productName: string
colors: Record<string, string> // Pixel3 token names -> hex; written verbatim to :root
assets: { logo: string; favicon: string; appleTouchIcon?: string }
font?: { family: string; cssUrl: string }
links?: { support?: string; legal?: string }
}
export function applyBranding(b: Branding): void
  • State shape & ownership: BrandingStore (Pinia) owns branding + status; the util is pure DOM side-effect + brandingStore.set().
  • Event payloads: analytics event whitelabel.branding_applied with { tenant, source: "cache" | "network", flag: true } fired once per boot after first successful apply. (New event — naming mirrors existing mixpanel usage; register in the mixpanel plugin.)
  • Conditional rendering: assets.appleTouchIcon and font are optional — skip the DOM write when absent. links optional.
  • Slots / children contract: n/a (util).
  • A11y notes: after applying colors, the contrast of text-inverse on brand fills must meet WCAG AA — validated at design-QA time per tenant (§5 OQ1), not enforced in code v1.

<BrandLogo/>common/components/BrandLogo.vue

  • Type:
interface BrandLogoProps {
variant?: 'full' | 'mark' // default 'full'
alt?: string // default productName from store
}
  • State shape & ownership: reads useBrandingStore().branding (logo URL + productName).
  • Event payloads: none.
  • Conditional rendering: store empty / flag off → render the existing default Qontak logo asset (public/ asset) so the component is safe pre-fetch.
  • A11y: <img alt> defaults to product name; decorative mark uses alt="".

Detail 2.B — Data-Fetching Strategy

  • Library: $customFetch (ofetch wrapper) via useNuxtApp().$customFetch — repo standard.
  • Cache key structure: localStorage["branding:cache"] = serialized Branding; in-memory truth is BrandingStore.branding.
  • TTL & refetch triggers: fetch once on boot (onMounted). No interval. Server-side freshness is the backend's Redis + HTTP Cache-Control (design-doc §7, BE-owned).
  • Stale-while-revalidate: yes — paint the localStorage payload immediately, then fetch and re-apply. This is the FODT-avoidance behavior from design-doc §7.
  • Optimistic updates + rollback: n/a (read-only consumer). On fetch failure, keep whatever is already applied (cached or default) — never revert a good cached brand to default.

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
App shell colorsCached brand painted (or default if no cache) — never a blank/unstyled shellFlag off → default Qontak brandFetch failed → keep cached/default brand, no toastnext-theme routes themed; legacy routes stay blue.400 (design-doc §4b)All next-theme routes render tenant colors
Favicon / titleDefault until store resolves, then swappedDefault /qontak-favicon.ico + "Qontak Chat"Keep currentfavicon may swap before font loads (independent)Tenant favicon + productName title
<BrandLogo/>Default logo until store setDefault Qontak logoDefault logo (broken tenant URL → onerror fallback)logo shown, product name still default until store setTenant logo + name
Tenant fontSystem/default font until cssUrl loadsNo font in payload → default font<link> load fails → default font (FOUT acceptable)text reflows once font loadsTenant font applied via --brand-font-family

Detail 2.D — Scope Boundaries

  • Files to create:
    • common/store/BrandingStore.ts
    • common/utils/applyBranding.ts
    • common/components/BrandLogo.vue
    • common/store/BrandingStore.spec.ts, common/utils/__tests__/applyBranding.spec.ts, common/components/__tests__/BrandLogo.spec.ts
  • Files to modify (+reason):
    • app.vue — add flag-gated branding boot in onMounted beside getAppConfig() (L203-205).
    • nuxt.config.ts — make head title/favicon default-only (still "Qontak Chat" when flag off); add brandingApiBaseUrl runtimeConfig key + branding.whitelabel flag source if config-driven.
    • configs/{development,staging-alpha,production}.json — add branding base URL + default flag value.
    • Navbar/sidebar brand-name renders: layouts/components/TheNavbar/TheNavbar.vue, layouts/components/OneNavbar/OneNavbar.vue, common/composables/useSidebar.ts — route user-facing product-name strings through the store (biggest literal clusters: 37/31/22).
  • Files explicitly NOT touched (non-goals):
    • common/constants/nextThemeAvailibility.ts and app.vue next-theme watch (L86-112) — next-theme migration is a separate track.
    • plugins/customFetch.ts auth logic — branding reuses it as-is.
    • Any qontak.com / crm-fe-v3 / legacy-hub code.
  • Shared components touched + impact: <BrandLogo/> is new (no existing usages to break). Navbar/sidebar edits touch high-traffic layout components used on every page — gate behind the flag and snapshot-test before/after.

Detail 2.E — State Surface Contract

EntityState field / event consumedDefault valuesSource endpoint / eventStale-tolerance window
Brand colorsBrandingStore.branding.colors:root varsPixel3 default Qontak paletteGET /brandingUntil next boot (cache-first; backend controls freshness via Redis/Cache-Control)
Product nameBrandingStore.branding.productName"Qontak Chat" (nuxt.config.ts:27)GET /brandingUntil next boot
Logo / faviconBrandingStore.branding.assets.*/qontak-favicon.ico, default logo assetGET /brandingUntil next boot
FontBrandingStore.branding.fontSystem/default font stackGET /brandingUntil next boot

Detail 2.F — Asset Inventory (frontend-specific)

Asset nameTypeSourceFormat & sizesPath in repo
Tenant logoimage/logoRuntime from CDN (assets.logo in payload)SVG/PNG (tenant-provided)none — fetched at runtime, never committed
Tenant faviconfaviconRuntime from CDN (assets.favicon).ico/PNGnone — runtime
Tenant apple-touch-iconiconRuntime from CDN (assets.appleTouchIcon)PNG 180×180none — runtime
Tenant fontfontRuntime from CDN (font.cssUrl)@font-face CSSnone — runtime
Default Qontak logo fallbackimage/logoexisting repo assetexistingpublic/ (existing default; confirm/keep)

This feature imports no new committed assets — all tenant assets are runtime CDN URLs. The only static asset is the existing default Qontak logo/favicon used as fallback.


3. High-Availability & Security

HA narrative. Branding is a non-blocking enhancement: the app boots and is fully usable regardless of /branding availability. Failure modes degrade gracefully to the cached brand, then to the default Qontak brand. The branding fetch has retry disabled (a slow branding call must never delay app interactivity) and never surfaces an error toast.

Performance Requirement

  • LCP target: no regression vs current hub-chat baseline; cache-first paint avoids a re-theme repaint on returning-user boot.
  • INP target: unaffected (one-time boot-side effect).
  • CLS target: font swap may cause minor reflow → use font-display: swap semantics; keep logo dimensions fixed in <BrandLogo/> to avoid layout shift.
  • Bundle size budget + delta: target < ~3 KB gzip added (one util + one small store + one component; no new dependency). Verify with the build output.
  • Code-splitting strategy: none needed — boot-critical, ships in the entry chunk.
  • Image strategy: tenant logo/favicon are external URLs; <BrandLogo/> sets explicit width/height.
  • Browser support matrix: same as hub-chat baseline (CSS custom properties are universally supported in that matrix).
  • Responsive breakpoints / touch targets: unchanged (no new layout).
  • i18n / l10n / RTL: product name is now dynamic; ensure i18n strings that embedded "Qontak" use an interpolated {brandName} where user-facing (BR-6).

Monitoring & Alerting

  • Analytics event: whitelabel.branding_applied { tenant, source: "cache"|"network", flag } — fired once after first apply. Also whitelabel.branding_fetch_failed { status } on fetch error (for silent-failure visibility).
  • Error monitoring: Datadog RUM is already initialized (app.vue:19, datadog.client.ts). Log a RUM action/error branding_fetch_failed (non-fatal, low sample is fine).
  • Core Web Vitals: watch LCP on themed routes post-rollout via the existing Datadog RUM dashboard.
  • User-facing success metric: % of tenant sessions where branding_applied.source is set (adoption), and absence of FODT (source=cache for returning users).

Logging

  • Frontend log fields: event, tenant, source, status (on failure). Level: info for applied, warn for fetch failure.
  • PII removed: payload is non-PII by contract; nonetheless log only tenant slug + status, never full URLs or headers.

Security Implications

  • Threat model:
    • Malicious/compromised /branding payload — because the FE writes colors values to :root and injects a font.cssUrl <link> and a favicon URL, a hostile payload could attempt CSS-value injection or load attacker CSS. Mitigations: (a) validate each colors value against a strict color pattern (#hex / rgb()/hsl()), rejecting anything else before setProperty; (b) validate assets.*/font.cssUrl are https: absolute URLs on an allow-listed host set (CDN); (c) rely on CSP (see below). Do not inject raw CSS text — only individual property values and a stylesheet <link href>.
    • CSP — adding tenant CDN origins to style-src/font-src/img-src is required if a CSP is enforced; coordinate the allow-list (§5 OQ3). Prefer a single known branding-CDN origin over arbitrary tenant URLs.
    • XSS via product name — render productName as text (Vue escapes by default); never via v-html.
  • Input sanitization per field: colors[*] → color-syntax regex; productName → length cap + plain text; assets.*/font.cssUrlhttps + host allow-list.
  • dangerouslySetInnerHTML / v-html: none — do not use for any branding value.
  • Auth token storage: unchanged. The branding call is public; ensure it does not require a token. $customFetch attaches Authorization only when a token cookie exists — acceptable (server ignores it), but confirm the backend does not treat a present-but-foreign token as an error. If it might, use raw $fetch for /branding instead. (§5 OQ2)
  • HTTPS-only resources + CSP additions: all branding asset URLs must be https.
  • PII handling: none — payload is non-PII by contract (design-doc §4/Decision #3).
  • Secret management: no secrets involved.

Detail 3.A — Failure Mode Catalog

API call401403404429500Timeout (s)OfflineRetry mechanism
GET /brandingkeep cached/default brand (endpoint is public; 401 is unexpected → treat as failure)samekeep default brand (tenant not branded)keep cached/default; no retrykeep cached/default~5s → keep cached/defaultuse localStorage cache if present, else defaultretry disabled for this call (options.retry = 0 pattern, customFetch.ts:174-177)

Narrative: rapid re-mounts (SPA nav) do not refetch (boot-once guard, mirroring AppConfigStore's idempotency guard at L78). Font-load failure → default font, no error. Broken logo URL → <img onerror> falls back to default logo.

Detail 3.B — Error Message Catalog

Error codeUser-facing message (i18n key)Surface (toast/inline/banner)User-facing?
branding_fetch_failed(none)(silent — no UI)no
branding_invalid_payload(none — log + default brand)(silent)no

By design, branding failures are never shown to the user; the app simply stays on its current brand.

Detail 3.C — Accessibility

  • WCAG level: AA.
  • Keyboard navigation: unchanged (no new interactive elements beyond a logo image).
  • Focus management: unchanged.
  • ARIA labels: <BrandLogo/> image has meaningful/empty alt per variant.
  • Color contrast: the key a11y risk — arbitrary tenant brand colors vs text-inverse must stay AA-legible. v1 relies on a per-tenant design-QA check (§5 OQ1); a future enhancement could compute contrast and warn.
  • prefers-reduced-motion: n/a (no animation introduced).

4. Backwards Compatibility and Rollout Plan

Compatibility

  • API contracts changed: none (new consumed endpoint).
  • Saved client state: new localStorage["branding:cache"] key; guarded parse — a missing or malformed value is a cache miss, never an error. Removing the feature simply orphans the key.
  • Old bundle / CDN cache invalidation: standard hub-chat deploy; no special handling.

Rollout Strategy

  • Feature flag: branding.whitelabel, default off. Preferred provisioner: backend AppConfigStore (/api/core/v1/client_configs/config) so it can be enabled per-org without a redeploy (mirrors existing flags like seamless_auth); a build-time configs/*.json fallback is acceptable for the first internal stage. (§5 OQ4 — confirm which.)
  • Rollout stages:
    1. Internal / staging — flag on for a test tenant with a mocked/real /branding; verify themed MpButton + favicon + title on a next-theme route (/inbox).
    2. 1 pilot tenant — real /branding, real CDN assets; design-QA contrast sign-off.
    3. 10% / selected tenants — monitor branding_fetch_failed rate + LCP.
    4. 100% of branded tenants — non-branded tenants keep flag off → default Qontak brand.
  • Stop conditions: branding_fetch_failed rate > 2% of branded sessions; any FODT regression; any contrast/legibility complaint; LCP regression on themed routes.
  • Rollback mechanism: toggle branding.whitelabel off → next boot renders default brand; mid-session users are unaffected until reload.
  • Blast radius: worst case = a bad global :root override on every themed route for branded tenants. Mitigated by the flag (instant off) + color-value validation + cache-keeps-last-good.
  • PIC + timeline per stage: TBD with the Chat squad lead.

Detail 4.A — Configuration Contract

Env var / build config / flagTypeDefaultRequiredProvisioner
branding.whitelabel (AppConfig flag)booleanfalseyesbackend client_configs (preferred) or configs/*.json
BRANDING_API_BASE_URLruntimeConfig.public.brandingApiBaseUrlstring (URL)(unset → use edge /branding on current origin)yes if service is not same-originconfigs/*.json (mirrors API_BASE_URL at configs/production.json:11)

Detail 4.B — Test Plan (commands sourced from the repo)

LayerCommand (source)What it must prove
Unit — storepnpm test common/store/BrandingStore.spec.ts (source: package.json test: "vitest --dom --pool=forks")fetch resolves/rejects; status state-machine; cache read/write; no throw to boot on error
Unit — utilpnpm test common/utils/__tests__/applyBranding.spec.ts:root gets each --mp-colors-*; favicon/font/link side-effects; invalid color rejected
Componentpnpm test common/components/__tests__/BrandLogo.spec.ts (pattern: common/components/__tests__/InputPeriod.spec.ts, // @vitest-environment nuxt)renders tenant name/logo; falls back when store empty
Typecheckpnpm type-check (source: package.json type-check: "vue-tsc --noEmit")Branding types compile; no any leaks
Lintpnpm lint (source: package.json lint: "pnpm lint:js && pnpm lint:prettier")eslint + prettier clean
Buildpnpm build (source: package.json build: "nuxt build")bundle builds; head defaults intact
Coveragepnpm coverage (source: Makefile:12pnpm coverage = vitest run --dom --pool=forks --coverage)new files covered (config include: common/**)
Visual regressionn/a — no visual-regression harness in repo (verified: no Playwright/Chromatic script in package.json); use component snapshot in the two-tenant test (BR-7) insteadthemed button differs by brand color only

Detail 4.C — Agent Execution Plan

OrderChunkFiles to modify/createCommands to runAcceptance criteria (verifiable)
1Feature flag + config scaffoldingnuxt.config.ts (add brandingApiBaseUrl), configs/*.json (base URL + default flag), a useWhitelabelFlag read via AppConfigStorepnpm type-checkflag reads false by default; runtimeConfig.public.brandingApiBaseUrl resolvable
2BrandingStore + types + speccommon/store/BrandingStore.ts, common/store/BrandingStore.spec.tspnpm test common/store/BrandingStore.spec.tsstore models AppConfigStore; fetchBranding() resolves mock, status transitions; error → rejected, no throw
3applyBranding util + spec (incl. color validation)common/utils/applyBranding.ts, common/utils/__tests__/applyBranding.spec.tspnpm test common/utils/__tests__/applyBranding.spec.ts:root receives each valid --mp-colors-*; invalid color value rejected; favicon/font <link> created; missing font/assets handled
4<BrandLogo/> + head binding + speccommon/components/BrandLogo.vue, common/components/__tests__/BrandLogo.spec.tspnpm test common/components/__tests__/BrandLogo.spec.tsrenders store name/logo; default fallback when empty
5Cache-first paint (localStorage)common/store/BrandingStore.ts (cache read/write)pnpm test common/store/BrandingStore.spec.tscached payload applied before network; fresh payload re-applied + re-cached; malformed cache = miss
6Boot wiring behind flagapp.vue (onMounted, beside getAppConfig() L205)pnpm test; pnpm buildflag off → no fetch, :root unmutated (snapshot == baseline); flag on → applies mock; fetch error never blocks boot
7De-hardcode user-facing brand literalsnuxt.config.ts head, configs/*.json META, TheNavbar.vue, OneNavbar.vue, useSidebar.tspnpm lint; pnpm build; pnpm testuser-facing product-name renders come from store when flag on; default "Qontak Chat" when off; build/title intact
8Two-tenant proof test (design-doc §6)common/utils/__tests__/applyBranding.spec.ts (add cases) or a dedicated specpnpm testsame applyBranding call, two fixtures (#7A2FF2 / #0E9F6E) → two distinct :root color sets; identical code path

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge verification commands (run in order):
    1. pnpm lint
    2. pnpm type-check
    3. pnpm test
    4. pnpm build
  • Post-deploy verification signals:
    • Datadog RUM: branding_fetch_failed action rate < 2% of branded sessions over first 30 min.
    • Analytics: whitelabel.branding_applied events present with source set for the pilot tenant.
    • Manual: load the pilot tenant domain on a next-theme route (/inbox) → MpButton renders brand color, favicon + tab title match tenant.
  • Rollback recipe (in order):
    1. Toggle branding.whitelabel off (backend client_configs for the affected org, or config redeploy).
    2. Confirm a fresh boot renders default Qontak brand (:root has no --mp-colors-* override; title = "Qontak Chat").
    3. If shipped via code and the flag is insufficient, revert the feature PR.
    4. Confirm branding_fetch_failed returns to ~0 and LCP on themed routes matches baseline.

5. Concern, Questions, or Known Limitations

Open Questions (blockers for Ready: yes):

  1. Design-QA owner + contrast rule. No Figma frame exists; who signs off that arbitrary tenant brand colors stay WCAG-AA legible (esp. text-inverse on brand fills)? Name a design-QA contact and a pass/fail rule (§1 Design References, §3.C).
  2. /branding base URL + credential behavior. Is /branding served same-origin via the edge on each tenant domain, or at a dedicated host needing brandingApiBaseUrl? And will the backend tolerate $customFetch sending an Authorization header when a token cookie happens to exist, or must hub-chat use raw $fetch with no credentials? (§2.4, §3)
  3. CSP allow-list. If a CSP is enforced, which origins go in img-src/font-src/ style-src for tenant CDN assets? Prefer one branding-CDN origin over arbitrary hosts.
  4. Story list + flag provisioner. The linked doc is a design doc, not a PRD — confirm the RFC-derived stories (BR-1…BR-7) and numbering with the PM, and confirm whether branding.whitelabel is a backend client_configs flag or a build-time config value.

Known limitations:

  • next-theme coverage. The brand override only restyles components on routes in NEXT_THEME_AVAILIBILITY (28 prefixes, nextThemeAvailibility.ts); legacy routes stay hard-coded blue.400 (design-doc §4b). Full coverage is gated on the separate next-theme migration. Until then, branded tenants will see mixed theming across routes.
  • Payload token naming. v1 overrides --mp-colors-brand-qontak; the payload is coupled to Pixel3 token names (accepted trade-off, design-doc Decision #1). A neutral --mp-colors-brand-primary alias is a future DS-squad change.
  • --mp-colors-text-inverse not referenced in hub-chat source. Confirmed 0 references in app code — the token is defined by Pixel3, so the override is valid, but its effect in hub-chat depends entirely on Pixel3 recipes using it under the next theme.

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-23RFC authorInitial draft from design doc + hub-chat grounding; 4 open questions pending before Ready: yes

7. Ready for agent execution

  • no
  • The FE consumption layer is fully specified and its chunks (4.C 1–8) are executable and verifiable against a local mock of GET /branding today. It is not yet yes because the following execution-readiness gates are open:
    • §1 Design References — no Figma frame (justified: no new UI), but the design-QA contact + contrast pass/fail rule is unresolved (§5 OQ1). A per-tenant color-legibility owner must be named before pilot.
    • §2.4 API contractGET /branding is needs-building; base URL + credential behavior + CSP allow-list are unresolved (§5 OQ2/OQ3). Chunks build against a mock, but end-to-end staging verification is blocked until the endpoint + edge route exist.
    • Detail 1.C stories — RFC-derived (the source is a design doc, not a PRD); the PM must confirm the story list and the flag provisioner (§5 OQ4).
    • Everything else is met: Repo Reading Guide + complete Source Verification table, mermaid architecture/state/sequence (with failure paths), typed UI Contract + 5-state UI Matrix, Asset Inventory, Failure/Error catalogs, Configuration Contract (flag named, default off), ordered Agent Execution Plan with repo-sourced commands + verifiable acceptance criteria, and a concrete Verification & Rollback Recipe.

Once OQ1–OQ4 are resolved and the /branding contract is frozen, flip this to yes. Optional next step: hand to rfc-reviewer for a second-pass score.