Skip to main content

RFC: Unified Branding Service (Whitelabel) — hub (legacy Nuxt 2 SPA) 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. Sections marked N/A — reason are intentionally not deleted.

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 are the execution contract behind §7.

The YAML frontmatter is the machine-readable index; the Metadata table is the human-readable record. Both agree on every shared field.

Metadata

FieldValueNotes
StatusIDEAIDEA / RFC / ABANDON / AGREED
OwnerQontak ChatTeam owning the hub frontend
Author(s)engineering-qontak-chatPrimary author
Reviewershub FE lead · @mekari/pixel owner · qontak.com BE ownerTech reviewers across affected squads
Approver(s)FE tech leader · infosec approverApproval gate
Submitted Date2026-07-23Date RFC opened for discussion
Last Updated2026-07-23Bump on every material edit
Target ReleaseTBDBlocked on BE GET /branding + design tokens — see §5
Related DocumentsUnified Branding Service (Design)Architecture source (no formal PRD; no Figma)
DiscussionTBDSlack thread

Type: frontend Sub-type: new-feature

Sections at a Glance

  1. Overview (problem, scope, dependencies, Design References, traceability, decisions, per-story map)
  2. Technical Design (Repo Reading Guide → architecture → sequence → theming model → UI contracts → state)
  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

Make the hub app (repo name hub, package.json:2) whitelabel-capable: at boot it fetches a tenant branding payload from the Unified Branding Service (GET /branding, owned by qontak.com, per the design doc) and applies the tenant's logo, product name, favicon, page title, support/legal links, and brand color — replacing the hardcoded "Qontak" identity so the same build serves multiple tenants with distinct brands.

This is the hard consumer. The design doc's easy mechanism — "override @mekari/pixel3 --mp-colors-* CSS variables and the whole library re-themes" — does not apply to this repo. Verified against the code:

  • hub runs @mekari/pixel v1.1.1 (Chakra-Vue + @emotion/css), not Pixel3. There is no --mp-colors-* CSS-variable consumption anywhere (0 hits) and no setNextTheme / next-theme gating (0 hits).
  • The app's real brand color is #0274f5 (Qontak blue), expressed as the SCSS variable $q-primary (assets/stylesheets/abstracts/_variables.scss:6), used 124× across app styles, plus 93 literal #0274f5 occurrences that bypass the variable (53 in .vue, 10 .scss, 9 .svg, rest JS).
  • The pixel theme brand token brand.mekari (default #651FFF from @mekari/pixel-theme) is used in only 4 places.

So color whitelabel here is a runtime CSS-variable refactor of the app's own styles plus a pixel-theme override, not a token flip. This RFC scopes that work explicitly and honestly, phasing the long tail.

Success Criteria

  1. On a tenant domain, hub renders the tenant's logo, product name, favicon, browser tab title, and support/legal links from GET /brandingzero hardcoded "Qontak" identity on the primary authenticated surfaces (layouts/hub.vue, components/layouts/Header.vue, components/layouts/SideNav/index.vue, browser tab).
  2. Primary brand color ($q-primary and its 124 usages) is driven by --brand-primary at runtime; a tenant color change is visible without a code change.
  3. No flash of default brand (FODB) on reload for a returning visitor (cached branding applied before first paint).
  4. Default/fallback path: with no branding payload (fetch fails, unknown tenant), the app renders exactly today's Qontak brand — no regression.
  5. npm run lint and npm test pass; new store/branding.js and utils/applyBranding.js carry unit tests.

Out of Scope

  • The backend GET /branding service, data model, Redis cache, and edge allow-listing — owned by qontak.com; a separate BE RFC. This RFC consumes the contract as a dependency (§1 Dependencies).
  • Tenant admin UI to edit branding (separate initiative).
  • crm-fe-v3 and hub-chat consumers — those live in sibling repos (../fe/hub-chat) and are their own FE RFCs; the shared applyBranding contract is compatible but not delivered here.
  • Full elimination of all 405 Qontak string literals. Phase 1 covers high-visibility surfaces; the long tail is tracked (§5) and phased.
  • Recoloring raster/baked SVG assets by CSS. Tenant logos/icons come from CDN URLs in the payload; only inlined SVGs using currentColor recolor.
  • Font swapping beyond loading the tenant font.cssUrl + setting --brand-font-family (deep typographic theming is out).
  • Whitelabel — Unified Branding Service (Design) — the architecture, GET /branding JSON contract, data model, and caching strategy this consumer implements against. No formal PRD and no Figma exist (no-PRD — architecture doc is the requirement source; no-Figma — see §1 Design References).

Assumptions

  1. GET /branding returns the JSON shape in the design doc §4 (keys tenant, productName, colors, assets, font, links), is public/unauthenticated, and is reachable from the browser before login.
  2. Tenant resolution is done server-side (Host header — Plan 2 — or company claim — Plan 1); the FE sends no tenant id and trusts the resolved payload.
  3. Assets (logo, favicon, font.cssUrl) are absolute CDN URLs the browser can load directly.
  4. The colors object may include Pixel3-named keys (--mp-colors-*) that this repo does not consume; the FE reads only the keys it maps (§2 theming model) and ignores the rest — forward-compatible with the shared contract.
  5. Falling back to today's Qontak brand on any failure is acceptable product behavior.

Dependencies

DependencyTypeAvailabilityOwnerNotes
GET /branding endpointBackend endpointneeds-buildingqontak.comContract in design doc §4; consumed here as external. Base URL TBD (public, pre-login) — see §2.4 + §5.
Edge allow-list of /branding (unauth, per tenant domain)Infraneeds-buildingPlatform / qontak.comDesign doc §Decisions "Follow-through". Not a code dependency but a runtime prerequisite for Plan 2.
Tenant branding data (colors/logos/CDN) authored per tenantData/Opsblockedqontak.com / OpsNeeded for real tenants; dev can use a stub payload.
@nuxtjs/axiosnpm (present)existspackage.json; boot HTTP client (plugins/axios.js).
@mekari/pixel extendTheme colors overrideLibrary capabilityexists@mekari/pixelVue.use(Pixel, { extendTheme }) already used (plugins/pixel.js:201).
Neutral color-token alias (--mp-colors-brand-primary or equivalent) in payloadContract detailopenqontak.com + DSDesign doc flags Qontak-named tokens; not blocking this repo since we map to --brand-primary.

Design References (frontend-specific)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
All surfacesn/a — design pending@mekari/pixel@1.1.1 (installed)TBDNo Figma exists. The design source is the Confluence architecture doc, which specifies data/contract but not per-tenant visual frames. Visual QA is "matches today's layout with brand values swapped."

Per template rule: no Figma → surfaces are marked n/a — design pending and the gap is raised in §5 Open Questions. Because this RFC's visual goal is parity with today's layout (only brand values change), the absence of new frames is low-risk but is called out for infosec/design sign-off.

Detail 1.A — PRD Traceability Matrix

No formal PRD. "PRD requirement" below maps to a design-doc section.

Forward (design doc → RFC):

Design-doc requirementRFC sectionComponent / file
§4 GET /branding JSON consumed at boot§2.4, §2.B, §4.C ch.2–3requests/branding.js, store/branding.js
§5 applyBranding(b) core, reused everywhere§2.A, §4.C ch.1utils/applyBranding.js
§5 step 1 — inject color vars§2 theming model, §4.C ch.4–6utils/applyBranding.js, _variables.scss, plugins/pixel.js
§5 step 2 — swap favicon§2.A, §4.C ch.7utils/applyBranding.js
§5 step 3 — load tenant font§2.A, §4.C ch.7utils/applyBranding.js
§5 step 4 — logo + product name via store§2.E, §4.C ch.8–9store/branding.js, components/branding/BrandLogo.vue
§7 caching + localStorage instant paint§2.B, §4.C ch.3plugins/branding.js, plugins/pixel.js
§8 replace hub-chat/hub literals with {brandName}§2.D, §4.C ch.9nuxt.config.js, high-traffic .vue

Reverse (RFC → design doc):

New artifact / RFC decisionDesign-doc need it serves
--brand-primary CSS var + $q-primary refactor§4b — "does the override reach components?" — for pixel v1 the answer is only via app CSS vars + theme override, documented here
extendTheme.colors.brand.mekari override in plugins/pixel.js§4 colors → applied to the 4 brand.mekari usages
Synchronous localStorage read in plugins/pixel.js§7 "paint brand instantly on next load … avoids a flash of default theme"
Qontak-brand fallback§6 proof "same code, different brand" — default tenant = Qontak

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE endpoint)Required writesStatus surface
Browser tab (title + favicon)webGET /branding (productName, assets.favicon)nonehead() option + <link rel=icon>
App header logo (components/layouts/Header.vue:41,59)webGET /branding (assets.logo)nonebrandingStore.assets.logo
Side nav logo (components/layouts/SideNav/index.vue:20)webGET /branding (assets.logo)nonebrandingStore.assets.logo
Product name literals (405 Qontak; Phase-1 subset)webGET /branding (productName)nonebrandingStore.productName
Brand-colored UI (buttons/links/accents via $q-primary)webGET /branding (colors)none--brand-primary on :root
Support/legal linkswebGET /branding (links)nonebrandingStore.links

Role Coverage

PRD roleUI surface visibilityAction buttons enabledAuth scope expected from BENotes
Unauthenticated visitor (pre-login, Plan 2)Sees tenant logo/colors on login screenn/anoneGET /branding is publicRequires edge allow-list (dependency). If pre-login branding is deferred, this role degrades to default brand until login (see §5).
Any authenticated user (agent, admin, super-admin, read-only)Full app in tenant brandunchanged by this RFCexisting session authBranding is display-only; it grants/changes no permission. All roles get identical branding treatment.

Branding does not vary by role and creates no new authorization surface — see §3 Role × Endpoint note.

PRD Section Coverage (design-doc sections)

Doc §TitleWhere covered
1Architecture§2.1 (consumer slice only; BE n/a — BE RFC)
2Tenant resolved & themed§2.2 sequence
3Data modeln/a — backend (BE RFC)
4API contract GET /branding§2.4 (consumed)
4bDoes override reach components?§2 theming model (this is the crux for pixel v1)
5Consumer side applyBranding§2.A, §4.C
6Proof: two tenants§2.C default-vs-tenant, §4.D verification
7Caching & invalidation§2.B (FE cache); server cache n/a — BE RFC
8What this replaces, per service§2.D scope boundaries (hub row)
DecisionsOwner / colors / public endpoint§2 Technical Decisions (Decision 3 timing; rest n/a — BE RFC)

Detail 1.B — Decisions Closed

DecisionChosen optionAlternatives rejectedWhy rejected
How to theme brand color on pixel v1Hybrid: refactor $q-primaryvar(--brand-primary) (app styles) + override extendTheme.colors.brand.mekari (pixel components)(a) Pure --mp-colors-* override per design doc; (b) full migration to Pixel3/next-theme(a) repo consumes 0 --mp-colors-* — no effect; (b) DS migration is a multi-quarter effort, out of scope
When to know the color (timing)Cached-first: read localStorage['branding'] synchronously in plugins/pixel.js before Vue.use, revalidate async(a) Block boot on GET /branding; (b) apply only after mount(a) adds network latency to every cold start incl. pre-login; (b) causes visible recolor flash + pixel theme is fixed at Vue.use time
Where the applied state livesNew Vuex module store/branding.js + framework-agnostic utils/applyBranding.jsPinia storeno alternative considered — repo is Vuex 3, no Pinia
Product-name delitteralizationPhase high-visibility surfaces via brandName getter; track the 405-literal tailBig-bang replace all 405 nowBig-bang is high-risk churn across 40+ files with no test coverage; phase it
Default when payload absentFall back to today's Qontak values (SCSS defaults + bundled logo)Show blank/spinner until branding resolvesBlank/spinner regresses today's UX for the default tenant

Honesty rule applied: the store-library row is no alternative considered — the repo is Vuex 3 and introducing Pinia is unjustified churn.

Detail 1.C — Per-Story Change Map

No PRD §13b user stories exist; stories below are derived from the design doc's consumer requirements (§4b, §5, §7, §8) and this RFC's scope.

Story #Story titleLayer scopeChanges (concrete FE artifacts)Acceptance criteria (verifiable)RFC anchors
S1Framework-agnostic applyBranding coreFE-onlyutils/applyBranding.js (writes --brand-* vars, favicon, font, calls store)utils/applyBranding.spec.js passes: given a payload, documentElement.style has --brand-primary; <link rel=icon>.href == payload favicon§2.A · §4.C ch.1
S2Branding fetch + storeFE + BE newrequests/branding.js, store/branding.js, assets/variables/endpoints.js (+BRANDING_URL)store/branding.spec.js: fetch action commits payload; state getters expose productName/assets/links. BE: GET /brandingblocked — BE RFC needed§2.4 · §4.C ch.2
S3Boot wiring + cached-first paint (no FODB)FE-onlyplugins/branding.js (registered nuxt.config.js:84-98), synchronous localStorage read in plugins/pixel.jsManual/E2E-lite: reload with cached branding → no default-brand frame; unit: plugins/pixel.js applies cached brand.mekari when cache present§2.2 · §2.B · §4.C ch.3
S4App-style color via --brand-primaryFE-onlyassets/stylesheets/abstracts/_variables.scss ($q-primary: var(--brand-primary, #0274f5))Build passes; DOM getComputedStyle(:root).--brand-primary reflects payload; visual: primary buttons/links recolor§2 theming · §4.C ch.4
S5Pixel component brand overrideFE-onlyplugins/pixel.js (extendTheme.colors.brand.mekari from cached branding)Unit: extendTheme.colors.brand.mekari == cached value; the 4 brand.mekari usages render tenant color§2 theming · §4.C ch.5
S6Literal-#0274f5 sweepFE-only53 .vue + 10 .scss inline #0274f5var(--brand-primary); JS hex (assets/mixins/messaging/conversation.js:324) → store/gettergrep #0274f5 in .vue/.scss (excl. svg) == 0 after sweep; existing specs still pass§2.D · §4.C ch.6
S7Favicon + tab title + font runtimeFE-onlydynamic head() in layouts/hub.vue; applyBranding favicon/font logicE2E-lite: document.title and favicon href reflect productName; font <link> appended when font present§2.A · §4.C ch.7
S8BrandLogo component + logo swapFE-onlynew components/branding/BrandLogo.vue; replace inline logo <img> at Header.vue:41,59, SideNav/index.vue:20Snapshot: <BrandLogo> renders brandingStore.assets.logo; falls back to bundled logo-qontak-default.svg when unset§2.E · §4.C ch.8
S9Product-name delitteralization (Phase 1 surfaces)FE-onlynuxt.config.js head literals → runtime; brandName getter used in Header.vue, SideNav, layouts/hub.vuegrep Qontak in the Phase-1 file set == 0; brandName getter drives them; long tail tracked (§5)§2.D · §4.C ch.9
S10Default/fallback safetyFE-onlyfallback constants in utils/applyBranding.js + store/branding.jsUnit: with null/failed payload, getters return Qontak defaults; no thrown error on boot§2.C · §3 · §4.C ch.10

Every story appears once. S2's BE half is blocked — BE RFC needed (GET /branding); the FE half proceeds against a stub payload.


2. Technical Design

Detail 2.0 — Repo Reading Guide (read this first)

Repo Map (mermaid)

flowchart LR
subgraph boot["boot (nuxt.config.js plugins[])"]
pixel["plugins/pixel.js\n(Vue.use Pixel, extendTheme)"]
axios["plugins/axios.js"]
branding["plugins/branding.js\n(NEW)"]
end
subgraph state["store/ (Vuex 3, auto-namespaced)"]
brandingStore["store/branding.js (NEW)"]
prefs["store/preferences.js (pattern)"]
end
subgraph data["requests/ + api/"]
reqIdx["requests/index.js"]
brandReq["requests/branding.js (NEW)"]
ep["assets/variables/endpoints.js\n(+BRANDING_URL)"]
end
subgraph ui["components / layouts"]
initc["layouts/hub.vue → InitComponent.vue\n(boot orchestration)"]
header["components/layouts/Header.vue"]
sidenav["components/layouts/SideNav/index.vue"]
brandlogo["components/branding/BrandLogo.vue (NEW)"]
end
subgraph styles["assets/stylesheets"]
vars["abstracts/_variables.scss\n($q-primary → var(--brand-primary))"]
end
util["utils/applyBranding.js (NEW)"]

branding --> brandReq --> ep
branding --> util --> brandingStore
pixel --> util
brandingStore --> header
brandingStore --> sidenav
brandingStore --> brandlogo
util --> vars

Existing Code Anchors

PathWhy the agent reads itWhat pattern it teaches
plugins/pixel.jsWhere pixel is installed; the only place to override the pixel theme per tenantVue.use(Pixel, { extendTheme: { breakpoints } }) at L201; theme fixed at boot
nuxt.config.jshead brand literals (L24–64), plugin registration list (L84–98), axios baseURL (L146)static head, plugin ordering, SPA mode
assets/stylesheets/abstracts/_variables.scssDefines $q-primary/$q-primary-hover — the app's real brand color (124 usages)SCSS variable → refactor target for --brand-primary
store/preferences.jsCanonical Vuex 3 module shape (state/getters/mutations/actions, import requests)module pattern for new store/branding.js
requests/index.jsAggregator of request moduleswhere to register branding
assets/variables/endpoints.jsEndpoint constant conventionexport const X_URL = process.env.HUB_SERVICE_URL + '/api/...'
plugins/axios.jsBoot HTTP client + interceptorscontext.$axios usage; auth header injection
components/layouts/main/InitComponent.vuePost-auth boot orchestration (mounted() L118+)where authenticated boot fetches are triggered
components/layouts/Header.vueInline logo <img> (L41, L59) + 10 Qontak literalslogo/product-name swap site
components/inbox/information/WaGroupMembers.vueUses icon-color="brand.mekari" (L37, L67)one of 4 pixel brand-token usages
store/__test__/usman.spec.jsStore unit-test patternimport { state, getters, mutations, actions } and assert

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
GET /brandingnew-with-justificationNo branding endpoint exists in this repo or its known BE; required by design doc §4. Consumed here, built in BE RFC.qontak.com
$q-primary SCSS varextendedKeep the variable name; change its value source to var(--brand-primary, #0274f5) — 124 usages unchangedhub FE
plugins/pixel.js extendThemeextendedAdd colors.brand.mekari alongside existing breakpointshub FE
nuxt.config.js headextendedStatic literals become runtime-overridable defaultshub FE
store/preferences.js module shapereusedNew store/branding.js follows it verbatimhub FE

Patterns to Follow

ConcernPattern in repoReference fileDeviation in this RFC?
State managementVuex 3 auto-namespaced module (export const state/getters/mutations/actions)store/preferences.jsnone
Folder conventionplugins in plugins/, registered in nuxt.config.jsnuxt.config.js:84-98new components/branding/ dir (minor)
Stylingglobal SCSS + SCSS variables in assets/stylesheets/abstracts/_variables.scss_variables.scss:6,8introduce runtime CSS custom properties (new for this repo)
Error / boot resilienceboot fetches dispatched in InitComponent.mounted(); failures logged, don't blockInitComponent.vue:118+branding fetch must never block boot (fallback to defaults)
Data fetchingthis.$axios (Nuxt axios) + requests/* modulesplugins/axios.js, requests/index.jsnone
Themepixel theme fixed at Vue.use (extendTheme)plugins/pixel.js:201read cached branding synchronously before Vue.use
TestingJest *.spec.js, import { ... } from '~/store/x'store/__test__/usman.spec.jsnone

Reading Order for the Agent

  1. plugins/pixel.js — understand the theme install point and the timing constraint.
  2. assets/stylesheets/abstracts/_variables.scss — see $q-primary (the color lever).
  3. store/preferences.js — the Vuex module shape to copy.
  4. requests/index.js + assets/variables/endpoints.js — how requests/endpoints are declared.
  5. plugins/axios.js — boot HTTP client.
  6. nuxt.config.js (L24–98, L146) — head literals, plugin registration, axios base.
  7. components/layouts/main/InitComponent.vue (L110–135) — boot orchestration.
  8. components/layouts/Header.vue (L41, L59) — logo + product-name swap site.
  9. store/__test__/usman.spec.js — the unit-test pattern.
  10. components/inbox/information/WaGroupMembers.vue (L37, L67) — a brand.mekari usage.

Source Verification (anti-hallucination)

Anchor / pattern / contractVerified byEvidence
hub = Nuxt 2 / Vue 2.7 / pixel v1read package.json"nuxt": "^2.17.0" (L69), "vue": "2.7" (L81), "@mekari/pixel": "^1.1.14" (L42); installed 1.1.1 (node_modules/@mekari/pixel/package.json)
No Pixel3 / no --mp-colors-* / no setNextThemegrep repo0 hits for pixel3, --mp-colors, setNextTheme, NEXT_THEME across source
pixel is Chakra-Vue + emotion; theme is JS objectread node_modules/@mekari/pixel/package.json"@emotion/css": "^11.0.0" dependency; main: dist/mekari-pixel.cjs.js
brand.mekari default #651FFFgrep @mekari/pixel-theme/distmekari: '#651FFF'
brand.mekari used 4×grep components pages layoutsWaGroupMembers.vue:37,67, SmartAssistFooter.vue:10, email/create/index.vue:22
pixel install pointread plugins/pixel.jsVue.use(Pixel, { extendTheme: { breakpoints: customBreakpoints } }) L201-205
$q-primary = #0274f5, 124 usagesread _variables.scss + grep$q-primary: #0274f5; L6, $q-primary-hover: #0364d1; L8; 124 q-primary refs outside the def
93 literal #0274f5grep53 .vue, 10 .scss, 9 .svg, rest JS incl. assets/mixins/messaging/conversation.js:324 return '#0274f5'
head brand literalsread nuxt.config.jstitle: 'Qontak Chat' L25, favicon href: '/qontak-favicon.ico' L63
plugin registration listread nuxt.config.jsplugins: [...] L84-98 (SPA mode L19, so all client-side)
Vuex 3 module patternread store/preferences.jsimport requests from '../requests' L1; export const state = () => ({...}) L7
endpoints conventionread assets/variables/endpoints.jsexport const CALLS_URL = process.env.HUB_SERVICE_URL + '/api/core/v1/calls' L170
axios boot clientread plugins/axios.jscontext.$axios.onRequest/onResponse/onError L7-47
logo <img> sitesgrepHeader.vue:41,59, SideNav/index.vue:20; asset assets/images/brands/new/logo-qontak-default.svg
boot orchestrationread InitComponent.vuemounted() at L118+ with this.getX() boot calls; methods: L142
test patternread store/__test__/usman.spec.jsimport { PermissionName, state, getters, mutations, actions } from '~/store/usman'
test/lint commandsread package.json scripts"test": "jest --coverage" L21; "lint": "npm-run-all --parallel lint:js lint:style lint:prettier" L19
no i18n / no composables / no useHead / no app.vuegrep0 hits for vue-i18n, composables/, useHead, app.vue

Design ↔ Code Mapping

No Figma frames → this table maps the design-doc applyBranding spec (doc §5) to implementing files, since that is the only concrete "design" input.

Design-doc artifactImplementing fileReuse vs newTokens/values usedDeviation
applyBranding(b) (doc §5)utils/applyBranding.jsnewwrites --brand-primary, --brand-primary-hover, --brand-font-familyDeviation: repo has no --mp-colors-*, so we set app --brand-* vars, not pixel3 tokens
brandingStore.set(b) (doc §5)store/branding.jsnewproductName, assets, links, colorsnone
<BrandLogo/> (doc §5)components/branding/BrandLogo.vuenewbrandingStore.assets.logonone
color override reaching components (doc §4b)plugins/pixel.js extendTheme.colors.brand.mekari + _variables.scssextendedbrand.mekari, --brand-primaryDeviation documented in §2 theming model

Detail 2.1 — Architecture (mermaid)

Component diagram

flowchart TB
boot([App boot / SPA]) --> pixelP["plugins/pixel.js"]
boot --> brandP["plugins/branding.js"]
pixelP -->|"sync read"| ls[("localStorage['branding']")]
pixelP -->|"extendTheme.colors.brand.mekari"| pixelLib["@mekari/pixel theme"]
brandP --> apply["utils/applyBranding.js"]
brandP --> req["requests/branding.js"]
req --> axios["$axios / api client"]
axios --> be[/"GET /branding (qontak.com)"/]
apply -->|"setProperty --brand-*"| root[(":root / documentElement")]
apply -->|"favicon, font, title"| head["document.head"]
apply --> store[("store/branding.js")]
store --> logo["components/branding/BrandLogo.vue"]
store --> header["Header.vue / SideNav"]
root --> scss["$q-primary → var(--brand-primary)"]
apply --> ls

State machine (branding lifecycle at boot)

stateDiagram-v2
[*] --> cached_check: boot
cached_check --> painted_cached: localStorage hit
cached_check --> painted_default: no cache (Qontak defaults)
painted_cached --> revalidating: fetch GET /branding
painted_default --> revalidating: fetch GET /branding
revalidating --> applied: 2xx (apply + cache)
revalidating --> painted_cached: fetch fail & had cache (keep)
revalidating --> painted_default: fetch fail & no cache (stay default)
applied --> [*]

Detail 2.2 — Sequence (mermaid)

Happy path — returning visitor on tenant domain (no FODB)

sequenceDiagram
actor U as Browser
participant PX as plugins/pixel.js
participant LS as localStorage
participant BP as plugins/branding.js
participant AP as applyBranding
participant AX as $axios
participant BE as GET /branding (qontak.com)
participant ST as store/branding

Note over PX,LS: BEFORE Vue mount
PX->>LS: getItem('branding') (sync)
LS-->>PX: cached payload
PX->>PX: Vue.use(Pixel, extendTheme.colors.brand.mekari = cached)
Note over U: first paint already in tenant color
BP->>AP: applyBranding(cached) (vars, favicon, font, store)
AP->>ST: commit set(cached)
BP->>AX: GET /branding
AX->>BE: HTTPS (public, no auth)
Note right of BE: cacheable at edge; p99 target <300ms
BE-->>AX: 200 { colors, assets, font, links }
AX-->>BP: fresh payload
BP->>AP: applyBranding(fresh)
AP->>LS: setItem('branding', fresh)
AP->>ST: commit set(fresh)

Failure path — GET /branding timeout / 5xx

sequenceDiagram
participant BP as plugins/branding.js
participant AX as $axios
participant BE as GET /branding
participant AP as applyBranding
participant LS as localStorage

BP->>AX: GET /branding (timeout 5s)
AX->>BE: HTTPS
Note right of BE: no response / 500
BE--xAX: timeout / 5xx
AX-->>BP: error
BP->>BP: log warn (datadog), do NOT throw
alt had cached branding
BP->>AP: keep cached (already applied)
else no cache
BP->>AP: keep Qontak defaults (already painted)
end
Note over BP: boot continues; app fully usable

Failure path — asset (logo/font) load fails

sequenceDiagram
participant AP as applyBranding
participant CDN as Asset CDN
participant DOM as document

AP->>DOM: set <BrandLogo> src = assets.logo
DOM->>CDN: GET logo.svg
CDN--xDOM: 404 / network error
DOM->>DOM: <img @error> → fallback bundled logo-qontak-default.svg
Note over AP,DOM: colors already applied; only the asset falls back

Detail 2.3 — Database Model

n/a — pure frontend RFC. Client-side persistence: localStorage['branding'] stores the last successful payload (JSON) for instant paint. Shape = the GET /branding response. Eviction: overwritten on each successful fetch; no TTL client-side (server Cache-Control governs freshness). Migration: if the shape changes, a schemaVersion field (added by BE) lets the consumer discard an incompatible cached blob and fall back to defaults (see §5).

Detail 2.4 — APIs Consumed

MethodPathStatusContract authorityNotes
GET/brandingneeds-buildingDesign doc §4Public/unauth; tenant resolved server-side (Host or claim); base URL TBD (§5). New const BRANDING_URL in assets/variables/endpoints.js.

Expected response (design doc §4, consumed subset in bold = keys this repo maps):

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

Contract note (raise with BE): this repo cannot use --mp-colors-* keys. It needs a primary brand hex and a hover hex. Proposal: BE also emits colors["--brand-primary"] and colors["--brand-primary-hover"] (neutral, not Pixel3-named), OR the consumer derives hover from primary. Tracked in §5.

Detail 2.A — UI Contract

<BrandLogo/> (new)

  • Figma frame URL: n/a — design pending (parity with today's inline <img>).
  • Implementation file: components/branding/BrandLogo.vue.
  • Props:
interface BrandLogoProps {
variant?: 'default' | 'white' | 'mono-white'; // default 'default'
height?: string; // e.g. '24px'; passthrough style
alt?: string; // default = brandingStore.productName
}
  • State ownership: reads brandingStore.assets.logo (Vuex getter). No local state.
  • Events: none. On <img> error, swaps to bundled assets/images/brands/new/logo-qontak-default.svg.
  • Conditional rendering: if assets.logo unset → bundled default asset.
  • A11y: <img alt="{productName} logo">; decorative variants get alt="".

applyBranding(b) (module, not a component) — see §2.A data-fetching + Execution Plan ch.1.

Detail 2.B — Data-Fetching Strategy

  • Library: @nuxtjs/axios (this.$axios) via a requests/branding.js module.
  • Cache key: localStorage['branding'] (single tenant per browser origin).
  • TTL & refetch: fetched once per app boot (plugins/branding.js); server owns freshness via Cache-Control. No focus/interval refetch.
  • Stale-while-revalidate: yes — cached payload painted immediately, then replaced by the fresh fetch (design doc §7).
  • Optimistic updates: n/a (read-only consumer; no writes).

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Whole-app brandpainted from cache or Qontak default (never blank)no cache → Qontak defaultfetch fail → keep current (cache or default)some assets 404 → per-asset fallback, colors still applytenant colors + logo + name applied
<BrandLogo>bundled default until store setbundled default<img @error> → bundled defaultn/atenant logo from CDN

Detail 2.D — Scope Boundaries

  • Files to create: utils/applyBranding.js, store/branding.js, requests/branding.js, plugins/branding.js, components/branding/BrandLogo.vue, plus co-located *.spec.js.
  • Files to modify: plugins/pixel.js (extendTheme colors), assets/stylesheets/abstracts/_variables.scss ($q-primary → CSS var), assets/variables/endpoints.js (+BRANDING_URL), requests/index.js (register branding), nuxt.config.js (plugin registration + head defaults), components/layouts/Header.vue + components/layouts/SideNav/index.vue (BrandLogo + brandName), layouts/hub.vue (dynamic head()), the 53 .vue + 10 .scss files with literal #0274f5, assets/mixins/messaging/conversation.js:324.
  • Files explicitly NOT touched: the 9 .svg assets with baked #0274f5 (recolored only by swapping to CDN logo URLs, not by CSS), the ~390 non-Phase-1 Qontak literals (§5 tail), crm-fe-v3 / hub-chat.
  • Shared surface impact: _variables.scss $q-primary feeds 124 usages — changing its value source to var(--brand-primary, #0274f5) is transparent (same default) but touches every brand-colored element. High blast radius → gated behind the feature flag and validated by visual parity for the default tenant.

Detail 2.E — State Surface Contract

EntityState field consumedDefault valuesSourceStale-tolerance
Brand identitybrandingStore.productName"Qontak Chat"GET /branding.productNameuntil next boot
LogobrandingStore.assets.logobundled logo-qontak-default.svgGET /branding.assets.logountil next boot
Brand color--brand-primary on :root#0274f5GET /branding.colors (mapped)reactive on fetch
Support/legalbrandingStore.linksQontak URLsGET /branding.linksuntil next boot

Detail 2.F — Asset Inventory

AssetTypeSourceFormat & sizesPath in repo
Default fallback logoimage (SVG)existing bundledSVGassets/images/brands/new/logo-qontak-default.svg (reused as fallback)
Tenant logo / favicon / appleTouchIconimageCDN URL from payload (not bundled)SVG / ICO / PNGruntime assets.* — no repo path
Tenant fontfontCDN CSS from payloadfont.cssUrlruntime — no repo path

No new bundled assets are introduced; tenant assets are runtime CDN URLs. The only repo asset used is the existing default logo, as fallback.


Technical Decisions (ADR-format)

Decision 1: Theme brand color via hybrid CSS-var refactor + pixel extendTheme

Context The design doc assumes overriding --mp-colors-* re-themes the library. This repo has zero --mp-colors-* usage, runs pixel v1 (Chakra-Vue + emotion; theme is a JS object resolved at render), and expresses brand as $q-primary: #0274f5 (124 usages) plus 93 literal hex and 4 brand.mekari token usages. There is no single lever.

Options considered

  • Option A — Hybrid: (1) refactor $q-primary/$q-primary-hover in _variables.scss to var(--brand-primary, #0274f5) / var(--brand-primary-hover, #0364d1), set --brand-* at boot; (2) sweep literal #0274f5 to the same var; (3) override extendTheme.colors.brand.mekari in plugins/pixel.js for the 4 token usages.
    • Pros: covers the 124 $q-primary usages with one variable; app CSS vars update reactively at runtime; small, well-understood change.
    • Cons: two mechanisms (CSS var for app styles, JS theme for pixel); the literal-hex sweep touches ~63 files; SVG-baked color needs asset swap.
  • Option B — Pure design-doc path (--mp-colors-* override): a no-op here.
    • Pros: matches sibling repos' contract.
    • Cons: 0% effect — this repo doesn't consume those tokens.
  • Option C — Migrate hub to Pixel3 + next-theme first, then use the clean path.
    • Pros: unifies with crm-fe-v3/hub-chat; future-proof.
    • Cons: multi-quarter DS migration; far beyond whitelabel scope; high risk.

Decision: Option A (Hybrid).

Rationale $q-primary's 124 usages mean one CSS variable delivers the bulk of color parity immediately; the pixel extendTheme override is a 1-line change for the 4 token spots. Option B is verifiably inert here; Option C is an unrelated, much larger program. Hybrid is the only option that actually themes this codebase without a rewrite.

Consequences Two color mechanisms coexist (documented). A literal-#0274f5 sweep is required and is regression-prone across untested .vue files → gated by flag + default-tenant visual parity. Baked-SVG color is out of reach for CSS and is handled by logo/asset URL swaps only.

Reversibility Fully reversible: $q-primary default stays #0274f5, so removing the --brand-primary writes (or disabling the flag) restores today's brand exactly. Revert is "delete the 5 new files + flag off."

Decision 2: Cached-first application to avoid flash-of-default-brand (FODB)

Context Pixel v1's theme is fixed at Vue.use(Pixel, …) (plugins/pixel.js:201), which runs before mount. GET /branding is async. If we only apply after the fetch resolves, pixel components (and app colors) paint in the default brand first, then visibly recolor — and the pixel theme cannot be re-created after install without remount.

Options considered

  • Option A — Cached-first + revalidate: synchronously read localStorage['branding'] in plugins/pixel.js before Vue.use, seed the theme + CSS vars from it, then fetch fresh and re-apply (CSS vars reactively; pixel token only fully updates next load).
    • Pros: no FODB for returning visitors; boot never blocks on network.
    • Cons: first-ever visit (empty cache) shows default brand until the fetch, and pixel's 4 token spots update only on the next load for that first visit.
  • Option B — Block boot on GET /branding: await the fetch before mount.
    • Pros: always-correct first paint incl. first visit.
    • Cons: adds network RTT to every cold start, including the pre-login screen; a slow/down branding service delays the whole app.
  • Option C — Apply only post-mount (store-driven, no sync cache).
    • Pros: simplest.
    • Cons: guaranteed recolor flash; pixel token spots never update without remount.

Decision: Option A (cached-first + revalidate).

Rationale Returning visitors (the common case) get zero flash and zero added latency. The first-visit degradation is bounded to app colors briefly + 4 pixel token spots until next load — acceptable given only 4 usages. Blocking boot (Option B) penalizes every user and couples app availability to a non-critical service.

Consequences First-visit users may see one default-brand paint. A schemaVersion guard is needed so a stale cached blob from an old contract is discarded, not misapplied.

Reversibility Remove the synchronous read; the store-driven post-mount path (Option C behavior) remains as the fallback. No data migration.

Decision 3: Fetch owner & timing — dedicated boot plugin, non-blocking, public

Context Where does the fetch live, and does it wait for auth? Plan 2 (pre-login, Host-resolved) needs branding before auth; Plan 1 could wait for the company claim. InitComponent.mounted() runs only post-auth (layouts/hub.vue mounts it behind $auth.user).

Options considered

  • Option A — Dedicated plugins/branding.js, runs at boot regardless of auth, public unauth GET /branding.
    • Pros: supports pre-login branding (Plan 2); decoupled from auth; single responsibility.
    • Cons: one more plugin in the boot list.
  • Option B — Fetch inside InitComponent.mounted() (post-auth).
    • Pros: reuses existing boot orchestration.
    • Cons: no pre-login branding; couples branding to auth lifecycle.

Decision: Option A.

Rationale The design doc's endpoint is explicitly public and pre-login (Plan 2). A dedicated plugin matches that and keeps branding independent of the auth/permissions boot chain.

Consequences Requires the edge to allow-list /branding unauth on tenant domains (dependency). The plugin must be resilient: log-and-continue on failure.

Reversibility Move the call into InitComponent and drop the plugin; low-cost.

Decision 4: Caching (client)

Context Design doc §7 suggests localStorage for instant paint. Decision: cache the last successful payload in localStorage['branding']; overwrite on each success; rely on server Cache-Control for HTTP-level caching. No client TTL. Rationale: enables Decision 2; server owns freshness. Consequences: a schemaVersion field is needed to invalidate incompatible blobs. Reversibility: clear the key; no migration.

Decision 5: Multi-tenancy isolation

n/a — display-only, read-only public data. The FE sends no tenant id (resolved server-side) and branding grants no access. No cross-tenant data risk (payload carries no PII per design doc §4).

Decision 6: Storage / sync-vs-async / third-party

  • Storage: n/a — no server DB in this RFC (BE RFC owns it); client uses localStorage (Decision 4).
  • Sync vs async: fetch is async + non-blocking (Decision 2/3).
  • Third-party: n/a — no new third-party SDK; assets/fonts are plain CDN URLs loaded via <link>/<img>.

3. High-Availability & Security

HA narrative. Branding is non-critical: every failure path degrades to the last-known or default brand and the app stays fully usable (§2.2 failure diagrams). The fetch has a 5s timeout and never blocks boot. No retry storm — one attempt per boot; server + edge caching absorb load.

Performance Requirement

  • LCP target: no regression vs today (branding adds one small async GET; cached paint is synchronous from localStorage).
  • INP / CLS: CLS risk = logo dimensions; mitigate with fixed logo height in <BrandLogo> to avoid layout shift on swap. Target CLS < 0.1.
  • Bundle delta: < ~3KB (5 small modules; no new deps). Verify with npm run analyze.
  • Code-splitting: n/a — boot plugins are in the main chunk by necessity.
  • Image strategy: tenant logo is a CDN SVG/PNG; loading eager for header logo.
  • Browser support: unchanged (matches current Nuxt 2 target).
  • Font: tenant font.cssUrl loaded via <link>; font-display: swap expected from the CDN CSS.
  • i18n/RTL: n/a — no i18n in repo.

Monitoring & Alerting

  • Analytics: emit branding.applied (props: tenant, source: cache|network|default) via existing mixpanel plugin (plugins/mixpanel.js).
  • Error monitoring: Datadog RUM is present (plugins/datadog-rum.ts); log a warn action branding_fetch_failed (props: status, hadCache). Alert if branding_fetch_failed rate > 5% over 15 min (dashboard TBD with owner).
  • Success metric: % of sessions with source != default on tenant domains.

Logging

  • Fields: event, tenant, source, httpStatus. Level: warn on failure, info on apply.
  • PII: none — payload is public brand data only (design doc §4). Do not log auth tokens.

Security Implications

  • Threat model: (a) payload injectioncolors/URLs are written into the DOM (style.setProperty, <img src>, <link href>). Mitigate: validate each color value against a strict hex/rgb() allowlist regex before setProperty; validate asset/font/link URLs are https: and (recommended) host-allowlisted before injecting. Never interpolate payload into innerHTML. (b) Open redirect via links — render support/legal as plain anchors with rel="noopener noreferrer"; validate https:.
  • dangerouslySetInnerHTML / v-html: none introduced.
  • Font cssUrl: loaded as stylesheet <link>; scope risk is a malicious CDN — mitigated by host allowlist + it being tenant-owned config.
  • Auth token storage: unchanged; branding endpoint is unauth so sends no token.
  • CSP: adding runtime <link href=cdn> / <img src=cdn> / stylesheet requires the tenant CDN host(s) in img-src/style-src/font-src/connect-src. Coordinate CSP with Platform (§5).
  • PII handling: none (public data).
  • Secrets: none; no keys in payload or client.

Detail 3.A — Failure Mode Catalog

API call401403404429500Timeout (s)OfflineRetry
GET /brandingtreat as fail → keep cache/defaultsameunknown tenant → default brandback off → keep cache/defaultkeep cache/default5s → keep cache/defaultkeep cache/defaultno retry this boot

Narrative: rapid reloads are absorbed by cache; navigation-during-fetch is safe (apply is idempotent); asset load failure falls back per-asset (<img @error>); malformed JSON → caught, treated as failure (keep cache/default).

Detail 3.B — Error Message Catalog

n/a — branding failures are silent to the user by design (degrade to default/cached brand). No user-facing error surface; failures go to logs/RUM only.

Detail 3.C — Accessibility

  • WCAG AA. Contrast risk is real: a tenant may pick a brand color with poor contrast against white text on buttons. Mitigation: validate/adjust or warn at the BE/admin layer (out of scope here) and document the risk (§5). This RFC does not auto-correct contrast.
  • Keyboard/focus: unchanged (no new interactive flows besides links).
  • <BrandLogo> has meaningful alt; decorative variants alt="".
  • prefers-reduced-motion: n/a (no animation added).

4. Backwards Compatibility and Rollout Plan

Compatibility

  • API contracts changed: none consumed today; GET /branding is additive.
  • Saved client state: new localStorage['branding'] key; absence = default brand (backward compatible). schemaVersion guard discards incompatible blobs.
  • Bundle/CDN cache: standard app deploy; no special invalidation.

Rollout Strategy

  • Feature flag: whitelabel_branding. Default off. Provisioner: reuse the repo's cookie/query-toggle pattern (utils/toggle.js, e.g. ?dev-whitelabel=1 + cookie) for dev/internal, and/or the backend feature-flag store (store/preferences.js feature_flag_state) for staged audiences. Confirm mechanism with FE lead (§5).
  • Stages: internal (flag on for team) → 1 pilot tenant → all tenants. Go/no-go: visual parity for the default (Qontak) tenant at each stage.
  • Stop conditions: branding_fetch_failed > 5% over 15 min, or any default-tenant visual regression, or contrast/AX complaint.
  • Rollback: flag off → app renders exactly today's Qontak brand (defaults intact).
  • Blast radius: with the flag off, zero change. With it on, every brand-colored element + logos + title. Default tenant must be pixel-identical.
  • PIC + timeline: TBD (§5).

Detail 4.A — Configuration Contract

Env var / flagTypeDefaultRequiredProvisioner
whitelabel_branding (flag)booleanoffyestoggle util / BE feature store (TBD)
BRANDING_URL (endpoint const)stringprocess.env.HUB_SERVICE_URL + '/api/core/v1/branding' (confirm base — public/pre-login may differ, §5)yesassets/variables/endpoints.js + env

Detail 4.B — Test Plan (commands sourced from package.json)

LayerCommand (source)What it must prove
Unitnpm testjest --coverage (package.json:21)applyBranding, store/branding, BrandLogo specs pass
Unit (scoped, dev)npx jest utils/applyBranding.spec.js store/branding.spec.js (jest present)fast iteration on new modules
Lint (JS)npm run lint:js (package.json:15)no eslint errors in new/changed files
Lint (style)npm run lint:style (package.json:17)SCSS var refactor passes stylelint
Lint (all)npm run lint (package.json:19)full parallel lint gate
Buildnpm run build (package.json:8)SPA builds with new plugins
Bundlenpm run analyze (package.json:10)bundle delta within budget
Visual regressionn/a — no VR tooling in repo (manual parity check for default tenant)default-tenant pixel parity
E2En/a — no Playwright/Cypress in repo (manual + unit for DOM effects)

Coverage note: jest.config.js collectCoverageFrom (L24–31) does not include store/branding.js, utils/, or plugins/. Add these globs (or accept they're tested but uncounted). Flag to reviewer — see §5.

Detail 4.C — Agent Execution Plan

OrderChunkFiles to modify/createCommandsAcceptance criteria
1applyBranding core + fallbackscreate utils/applyBranding.js, utils/applyBranding.spec.jsnpx jest utils/applyBranding.spec.js; npm run lint:jsspec: given payload, documentElement.style['--brand-primary'] set, <link rel=icon>.href == favicon; given null, no throw + Qontak defaults; color values failing hex regex are skipped
2Endpoint + request + storecreate requests/branding.js, store/branding.js, store/branding.spec.js; modify requests/index.js, assets/variables/endpoints.jsnpx jest store/branding.spec.js; npm run lint:jsspec: fetch action commits payload; getters productName/assets/links/brandName return values then Qontak defaults when empty
3Boot plugin + cached-first paintcreate plugins/branding.js; modify nuxt.config.js (register plugin), plugins/pixel.js (sync localStorage read → extendTheme.colors.brand.mekari)npm run build; npx jest plugins (if spec added)build succeeds; unit: when localStorage['branding'] set, extendTheme.colors.brand.mekari == cached color; fetch failure does not throw
4App color via --brand-primarymodify assets/stylesheets/abstracts/_variables.scss ($q-primary: var(--brand-primary, #0274f5), $q-primary-hover: var(--brand-primary-hover, #0364d1))npm run lint:style; npm run buildstylelint passes; build passes; default-tenant renders #0274f5 (var falls back)
5Pixel component brand overridemodify plugins/pixel.js (extendTheme.colors.brand.mekari from cached branding, else #651FFF)npm run build; npm testthe 4 brand.mekari usages render tenant color when cache present; default when absent
6Literal #0274f5 sweepmodify 53 .vue + 10 .scss (inline #0274f5var(--brand-primary)), assets/mixins/messaging/conversation.js:324 (→ store getter)grep -rniI '#0274f5' components pages layouts assets --include=*.vue --include=*.scss | grep -v images; npm test; npm run lintgrep count (excl. .svg) == 0; existing specs incl. conversation.spec.js pass
7Favicon + title + font runtimemodify layouts/hub.vue (dynamic head()), ensure applyBranding favicon/font logicnpm run build; manual loaddocument.title reflects productName; favicon href == payload; font <link> appended when font present
8BrandLogo + logo swapcreate components/branding/BrandLogo.vue, BrandLogo.spec.js; modify Header.vue:41,59, SideNav/index.vue:20npx jest components/branding; npm run lintsnapshot renders assets.logo; @error falls back to bundled default; no layout shift (fixed height)
9Product-name delitteralization (Phase 1)modify nuxt.config.js head, Header.vue, SideNav/index.vue, layouts/hub.vue to use brandName gettergrep -rn 'Qontak' nuxt.config.js components/layouts/Header.vue components/layouts/SideNav/index.vue layouts/hub.vue; npm testgrep in the Phase-1 set == 0; getter drives them; remaining ~390 tracked (§5)
10Default/fallback hardening + flag gatemodify plugins/branding.js, utils/applyBranding.js, wire whitelabel_branding flagnpm test; npm run lint; npm run buildwith flag off: no branding writes, app == today's Qontak; with flag on + no payload: Qontak defaults, no error

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge (in order):
    1. npm run lint (package.json:19)
    2. npm test (package.json:21)
    3. npm run build (package.json:8)
    4. npm run analyze (package.json:10) — confirm bundle delta within budget
    5. Manual: default tenant (flag off) is pixel-identical to production; flag on with a stub payload recolors header/buttons/logo/title.
  • Post-deploy signals:
    • Datadog RUM action branding_fetch_failed rate < 5% (15-min window).
    • Analytics branding.applied with source distribution as expected.
    • No spike in JS errors from plugins/branding.js / utils/applyBranding.js.
  • Rollback (in order):
    1. Toggle whitelabel_branding off → app renders today's Qontak brand.
    2. If needed, revert the deploy PR(s) — defaults in _variables.scss and nuxt.config.js restore original brand with zero data migration.
    3. Confirm RUM error rate returns to baseline within 15 min.
    4. (Optional) instruct clients to clear localStorage['branding'] if a bad blob was cached (or bump schemaVersion to auto-discard).

5. Concern, Questions, or Known Limitations

Open questions (blockers marked 🚫):

  1. 🚫 GET /branding contract for pixel-v1 consumers. The doc's colors are Pixel3-named (--mp-colors-*), which this repo can't use. Need a primary brand hex + hover hex (proposal: colors["--brand-primary"] / ["--brand-primary-hover"], or derive hover client-side). Confirm with BE.
  2. 🚫 Endpoint base URL / pre-login reachability. Is /branding under HUB_SERVICE_URL, or a separate public host? Is the edge allow-list for unauth /branding on tenant domains in place (Plan 2)? If not, pre-login branding is deferred and the login screen shows default brand until login.
  3. No Figma / visual spec. Sign-off criterion is "parity with today's layout, brand values swapped." Confirm design/infosec accept this.
  4. Feature-flag mechanism. Cookie/query toggle vs BE feature store — confirm with FE lead for staged rollout.
  5. jest.config.js coverage globs exclude store/, utils/, plugins/ (L24–31). Add globs or accept uncounted coverage.
  6. Contrast/accessibility of tenant colors is not auto-corrected here; needs a guard at the admin/BE layer.
  7. CSP additions for tenant CDN hosts (img/style/font/connect-src) — Platform.

Known limitations:

  • Long tail of ~390 Qontak literals beyond Phase 1 — phased in follow-ups (grep Qontak shows 405 total; top files: TicketingCrm.vue 31, layouts/hub.vue 24, InitComponent.vue 19, constants/index.js 8, etc.).
  • Baked-#0274f5 in 9 SVG assets cannot be CSS-recolored; only logo/icon assets swapped via CDN URLs are tenant-colored. Non-logo decorative SVGs stay blue unless re-exported per tenant (out of scope).
  • First-ever visit (empty cache) may show one default-brand paint; the 4 brand.mekari pixel spots update on next load for that first visit (Decision 2 consequence).
  • Deep typography/spacing theming is out of scope (only brand color + font family/CSS URL).

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-23RFC authorInitial draft — grounded against hub @ ../fe/hub; flagged pixel-v1 divergence from design doc

7. Ready for agent execution

Ready for agent execution: no — two contract dependencies block a clean end-to-end run (the FE build/unit work can proceed against a stub payload today).

Blocking gates outstanding:

  • §2.4 API contractGET /branding must emit a pixel-v1-usable brand hex (--brand-primary or equivalent) and its base URL / pre-login reachability must be confirmed (§5 Q1, Q2). Until then S2's BE half is blocked — BE RFC needed.
  • §1 Design Referencesn/a — design pending (no Figma); acceptable only if design/infosec confirm "layout-parity" is the visual bar (§5 Q3).
  • Detail 4.A flag mechanismwhitelabel_branding provisioner to be confirmed (§5 Q4).

Gates already green:

  • Repo Reading Guide (Detail 2.0) with Source Verification — complete, every anchor verified against opened files with line-level evidence.
  • Mermaid diagrams — Repo Map, component, state machine, 1 happy + 2 failure sequences.
  • Detail 1.C Per-Story Change Map — 10 stories, each with layer scope, concrete artifacts, verifiable AC, RFC anchors; BE-blocked half labeled.
  • UI Contract, UI State Matrix (5 states), Failure Mode Catalog, Asset Inventory, Configuration Contract — complete.
  • Agent Execution Plan — 10 ordered chunks with files, repo-sourced commands, and assertable acceptance criteria; Verification & Rollback Recipe concrete.

Once §5 Q1/Q2 are resolved (BE contract + endpoint), flip S2's BE half to available and set this gate to yes.

Optional: hand off to rfc-reviewer for a second-pass score after this gate is yes.