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 — reasonare 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
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | IDEA / RFC / ABANDON / AGREED |
| Owner | Qontak Chat | Team owning the hub frontend |
| Author(s) | engineering-qontak-chat | Primary author |
| Reviewers | hub FE lead · @mekari/pixel owner · qontak.com BE owner | Tech reviewers across affected squads |
| Approver(s) | FE tech leader · infosec approver | Approval gate |
| Submitted Date | 2026-07-23 | Date RFC opened for discussion |
| Last Updated | 2026-07-23 | Bump on every material edit |
| Target Release | TBD | Blocked on BE GET /branding + design tokens — see §5 |
| Related Documents | Unified Branding Service (Design) | Architecture source (no formal PRD; no Figma) |
| Discussion | TBD | Slack thread |
Type: frontend Sub-type: new-feature
Sections at a Glance
- Overview (problem, scope, dependencies, Design References, traceability, decisions, per-story map)
- Technical Design (Repo Reading Guide → architecture → sequence → theming model → UI contracts → state)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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:
hubruns@mekari/pixelv1.1.1 (Chakra-Vue +@emotion/css), not Pixel3. There is no--mp-colors-*CSS-variable consumption anywhere (0 hits) and nosetNextTheme/ 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#0274f5occurrences that bypass the variable (53 in.vue, 10.scss, 9.svg, rest JS). - The pixel theme brand token
brand.mekari(default#651FFFfrom@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
- On a tenant domain,
hubrenders the tenant's logo, product name, favicon, browser tab title, and support/legal links fromGET /branding— zero hardcoded "Qontak" identity on the primary authenticated surfaces (layouts/hub.vue,components/layouts/Header.vue,components/layouts/SideNav/index.vue, browser tab). - Primary brand color (
$q-primaryand its 124 usages) is driven by--brand-primaryat runtime; a tenant color change is visible without a code change. - No flash of default brand (FODB) on reload for a returning visitor (cached branding applied before first paint).
- Default/fallback path: with no branding payload (fetch fails, unknown tenant), the app renders exactly today's Qontak brand — no regression.
npm run lintandnpm testpass; newstore/branding.jsandutils/applyBranding.jscarry unit tests.
Out of Scope
- The backend
GET /brandingservice, data model, Redis cache, and edge allow-listing — owned byqontak.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-v3andhub-chatconsumers — those live in sibling repos (../fe/hub-chat) and are their own FE RFCs; the sharedapplyBrandingcontract is compatible but not delivered here.- Full elimination of all 405
Qontakstring 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
currentColorrecolor. - Font swapping beyond loading the tenant
font.cssUrl+ setting--brand-font-family(deep typographic theming is out).
Related Documents
- Whitelabel — Unified Branding Service (Design)
— the architecture,
GET /brandingJSON 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
GET /brandingreturns the JSON shape in the design doc §4 (keystenant,productName,colors,assets,font,links), is public/unauthenticated, and is reachable from the browser before login.- 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.
- Assets (
logo,favicon,font.cssUrl) are absolute CDN URLs the browser can load directly. - The
colorsobject 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. - Falling back to today's Qontak brand on any failure is acceptable product behavior.
Dependencies
| Dependency | Type | Availability | Owner | Notes |
|---|---|---|---|---|
GET /branding endpoint | Backend endpoint | needs-building | qontak.com | Contract 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) | Infra | needs-building | Platform / qontak.com | Design doc §Decisions "Follow-through". Not a code dependency but a runtime prerequisite for Plan 2. |
| Tenant branding data (colors/logos/CDN) authored per tenant | Data/Ops | blocked | qontak.com / Ops | Needed for real tenants; dev can use a stub payload. |
@nuxtjs/axios | npm (present) | exists | — | package.json; boot HTTP client (plugins/axios.js). |
@mekari/pixel extendTheme colors override | Library capability | exists | @mekari/pixel | Vue.use(Pixel, { extendTheme }) already used (plugins/pixel.js:201). |
Neutral color-token alias (--mp-colors-brand-primary or equivalent) in payload | Contract detail | open | qontak.com + DS | Design doc flags Qontak-named tokens; not blocking this repo since we map to --brand-primary. |
Design References (frontend-specific)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| All surfaces | n/a — design pending | — | @mekari/pixel@1.1.1 (installed) | TBD | No 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 pendingand 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 requirement | RFC section | Component / file |
|---|---|---|
§4 GET /branding JSON consumed at boot | §2.4, §2.B, §4.C ch.2–3 | requests/branding.js, store/branding.js |
§5 applyBranding(b) core, reused everywhere | §2.A, §4.C ch.1 | utils/applyBranding.js |
| §5 step 1 — inject color vars | §2 theming model, §4.C ch.4–6 | utils/applyBranding.js, _variables.scss, plugins/pixel.js |
| §5 step 2 — swap favicon | §2.A, §4.C ch.7 | utils/applyBranding.js |
| §5 step 3 — load tenant font | §2.A, §4.C ch.7 | utils/applyBranding.js |
| §5 step 4 — logo + product name via store | §2.E, §4.C ch.8–9 | store/branding.js, components/branding/BrandLogo.vue |
§7 caching + localStorage instant paint | §2.B, §4.C ch.3 | plugins/branding.js, plugins/pixel.js |
§8 replace hub-chat/hub literals with {brandName} | §2.D, §4.C ch.9 | nuxt.config.js, high-traffic .vue |
Reverse (RFC → design doc):
| New artifact / RFC decision | Design-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 surface | Consumer | Required reads (BE endpoint) | Required writes | Status surface |
|---|---|---|---|---|
| Browser tab (title + favicon) | web | GET /branding (productName, assets.favicon) | none | head() option + <link rel=icon> |
App header logo (components/layouts/Header.vue:41,59) | web | GET /branding (assets.logo) | none | brandingStore.assets.logo |
Side nav logo (components/layouts/SideNav/index.vue:20) | web | GET /branding (assets.logo) | none | brandingStore.assets.logo |
Product name literals (405 Qontak; Phase-1 subset) | web | GET /branding (productName) | none | brandingStore.productName |
Brand-colored UI (buttons/links/accents via $q-primary) | web | GET /branding (colors) | none | --brand-primary on :root |
| Support/legal links | web | GET /branding (links) | none | brandingStore.links |
Role Coverage
| PRD role | UI surface visibility | Action buttons enabled | Auth scope expected from BE | Notes |
|---|---|---|---|---|
| Unauthenticated visitor (pre-login, Plan 2) | Sees tenant logo/colors on login screen | n/a | none — GET /branding is public | Requires 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 brand | unchanged by this RFC | existing session auth | Branding 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 § | Title | Where covered |
|---|---|---|
| 1 | Architecture | §2.1 (consumer slice only; BE n/a — BE RFC) |
| 2 | Tenant resolved & themed | §2.2 sequence |
| 3 | Data model | n/a — backend (BE RFC) |
| 4 | API contract GET /branding | §2.4 (consumed) |
| 4b | Does override reach components? | §2 theming model (this is the crux for pixel v1) |
| 5 | Consumer side applyBranding | §2.A, §4.C |
| 6 | Proof: two tenants | §2.C default-vs-tenant, §4.D verification |
| 7 | Caching & invalidation | §2.B (FE cache); server cache n/a — BE RFC |
| 8 | What this replaces, per service | §2.D scope boundaries (hub row) |
| Decisions | Owner / colors / public endpoint | §2 Technical Decisions (Decision 3 timing; rest n/a — BE RFC) |
Detail 1.B — Decisions Closed
| Decision | Chosen option | Alternatives rejected | Why rejected |
|---|---|---|---|
| How to theme brand color on pixel v1 | Hybrid: refactor $q-primary→var(--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 lives | New Vuex module store/branding.js + framework-agnostic utils/applyBranding.js | Pinia store | no alternative considered — repo is Vuex 3, no Pinia |
| Product-name delitteralization | Phase high-visibility surfaces via brandName getter; track the 405-literal tail | Big-bang replace all 405 now | Big-bang is high-risk churn across 40+ files with no test coverage; phase it |
| Default when payload absent | Fall back to today's Qontak values (SCSS defaults + bundled logo) | Show blank/spinner until branding resolves | Blank/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 title | Layer scope | Changes (concrete FE artifacts) | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| S1 | Framework-agnostic applyBranding core | FE-only | utils/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 |
| S2 | Branding fetch + store | FE + BE new | requests/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 /branding — blocked — BE RFC needed | §2.4 · §4.C ch.2 |
| S3 | Boot wiring + cached-first paint (no FODB) | FE-only | plugins/branding.js (registered nuxt.config.js:84-98), synchronous localStorage read in plugins/pixel.js | Manual/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 |
| S4 | App-style color via --brand-primary | FE-only | assets/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 |
| S5 | Pixel component brand override | FE-only | plugins/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 |
| S6 | Literal-#0274f5 sweep | FE-only | 53 .vue + 10 .scss inline #0274f5 → var(--brand-primary); JS hex (assets/mixins/messaging/conversation.js:324) → store/getter | grep #0274f5 in .vue/.scss (excl. svg) == 0 after sweep; existing specs still pass | §2.D · §4.C ch.6 |
| S7 | Favicon + tab title + font runtime | FE-only | dynamic head() in layouts/hub.vue; applyBranding favicon/font logic | E2E-lite: document.title and favicon href reflect productName; font <link> appended when font present | §2.A · §4.C ch.7 |
| S8 | BrandLogo component + logo swap | FE-only | new components/branding/BrandLogo.vue; replace inline logo <img> at Header.vue:41,59, SideNav/index.vue:20 | Snapshot: <BrandLogo> renders brandingStore.assets.logo; falls back to bundled logo-qontak-default.svg when unset | §2.E · §4.C ch.8 |
| S9 | Product-name delitteralization (Phase 1 surfaces) | FE-only | nuxt.config.js head literals → runtime; brandName getter used in Header.vue, SideNav, layouts/hub.vue | grep Qontak in the Phase-1 file set == 0; brandName getter drives them; long tail tracked (§5) | §2.D · §4.C ch.9 |
| S10 | Default/fallback safety | FE-only | fallback constants in utils/applyBranding.js + store/branding.js | Unit: 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
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
plugins/pixel.js | Where pixel is installed; the only place to override the pixel theme per tenant | Vue.use(Pixel, { extendTheme: { breakpoints } }) at L201; theme fixed at boot |
nuxt.config.js | head brand literals (L24–64), plugin registration list (L84–98), axios baseURL (L146) | static head, plugin ordering, SPA mode |
assets/stylesheets/abstracts/_variables.scss | Defines $q-primary/$q-primary-hover — the app's real brand color (124 usages) | SCSS variable → refactor target for --brand-primary |
store/preferences.js | Canonical Vuex 3 module shape (state/getters/mutations/actions, import requests) | module pattern for new store/branding.js |
requests/index.js | Aggregator of request modules | where to register branding |
assets/variables/endpoints.js | Endpoint constant convention | export const X_URL = process.env.HUB_SERVICE_URL + '/api/...' |
plugins/axios.js | Boot HTTP client + interceptors | context.$axios usage; auth header injection |
components/layouts/main/InitComponent.vue | Post-auth boot orchestration (mounted() L118+) | where authenticated boot fetches are triggered |
components/layouts/Header.vue | Inline logo <img> (L41, L59) + 10 Qontak literals | logo/product-name swap site |
components/inbox/information/WaGroupMembers.vue | Uses icon-color="brand.mekari" (L37, L67) | one of 4 pixel brand-token usages |
store/__test__/usman.spec.js | Store unit-test pattern | import { state, getters, mutations, actions } and assert |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
GET /branding | new-with-justification | No 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 var | extended | Keep the variable name; change its value source to var(--brand-primary, #0274f5) — 124 usages unchanged | hub FE |
plugins/pixel.js extendTheme | extended | Add colors.brand.mekari alongside existing breakpoints | hub FE |
nuxt.config.js head | extended | Static literals become runtime-overridable defaults | hub FE |
store/preferences.js module shape | reused | New store/branding.js follows it verbatim | hub FE |
Patterns to Follow
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| State management | Vuex 3 auto-namespaced module (export const state/getters/mutations/actions) | store/preferences.js | none |
| Folder convention | plugins in plugins/, registered in nuxt.config.js | nuxt.config.js:84-98 | new components/branding/ dir (minor) |
| Styling | global SCSS + SCSS variables in assets/stylesheets/abstracts/_variables.scss | _variables.scss:6,8 | introduce runtime CSS custom properties (new for this repo) |
| Error / boot resilience | boot fetches dispatched in InitComponent.mounted(); failures logged, don't block | InitComponent.vue:118+ | branding fetch must never block boot (fallback to defaults) |
| Data fetching | this.$axios (Nuxt axios) + requests/* modules | plugins/axios.js, requests/index.js | none |
| Theme | pixel theme fixed at Vue.use (extendTheme) | plugins/pixel.js:201 | read cached branding synchronously before Vue.use |
| Testing | Jest *.spec.js, import { ... } from '~/store/x' | store/__test__/usman.spec.js | none |
Reading Order for the Agent
plugins/pixel.js— understand the theme install point and the timing constraint.assets/stylesheets/abstracts/_variables.scss— see$q-primary(the color lever).store/preferences.js— the Vuex module shape to copy.requests/index.js+assets/variables/endpoints.js— how requests/endpoints are declared.plugins/axios.js— boot HTTP client.nuxt.config.js(L24–98, L146) — head literals, plugin registration, axios base.components/layouts/main/InitComponent.vue(L110–135) — boot orchestration.components/layouts/Header.vue(L41, L59) — logo + product-name swap site.store/__test__/usman.spec.js— the unit-test pattern.components/inbox/information/WaGroupMembers.vue(L37, L67) — abrand.mekariusage.
Source Verification (anti-hallucination)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
hub = Nuxt 2 / Vue 2.7 / pixel v1 | read 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 setNextTheme | grep repo | 0 hits for pixel3, --mp-colors, setNextTheme, NEXT_THEME across source |
| pixel is Chakra-Vue + emotion; theme is JS object | read node_modules/@mekari/pixel/package.json | "@emotion/css": "^11.0.0" dependency; main: dist/mekari-pixel.cjs.js |
brand.mekari default #651FFF | grep @mekari/pixel-theme/dist | mekari: '#651FFF' |
brand.mekari used 4× | grep components pages layouts | WaGroupMembers.vue:37,67, SmartAssistFooter.vue:10, email/create/index.vue:22 |
| pixel install point | read plugins/pixel.js | Vue.use(Pixel, { extendTheme: { breakpoints: customBreakpoints } }) L201-205 |
$q-primary = #0274f5, 124 usages | read _variables.scss + grep | $q-primary: #0274f5; L6, $q-primary-hover: #0364d1; L8; 124 q-primary refs outside the def |
93 literal #0274f5 | grep | 53 .vue, 10 .scss, 9 .svg, rest JS incl. assets/mixins/messaging/conversation.js:324 return '#0274f5' |
| head brand literals | read nuxt.config.js | title: 'Qontak Chat' L25, favicon href: '/qontak-favicon.ico' L63 |
| plugin registration list | read nuxt.config.js | plugins: [...] L84-98 (SPA mode L19, so all client-side) |
| Vuex 3 module pattern | read store/preferences.js | import requests from '../requests' L1; export const state = () => ({...}) L7 |
| endpoints convention | read assets/variables/endpoints.js | export const CALLS_URL = process.env.HUB_SERVICE_URL + '/api/core/v1/calls' L170 |
| axios boot client | read plugins/axios.js | context.$axios.onRequest/onResponse/onError L7-47 |
logo <img> sites | grep | Header.vue:41,59, SideNav/index.vue:20; asset assets/images/brands/new/logo-qontak-default.svg |
| boot orchestration | read InitComponent.vue | mounted() at L118+ with this.getX() boot calls; methods: L142 |
| test pattern | read store/__test__/usman.spec.js | import { PermissionName, state, getters, mutations, actions } from '~/store/usman' |
| test/lint commands | read 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.vue | grep | 0 hits for vue-i18n, composables/, useHead, app.vue |
Design ↔ Code Mapping
No Figma frames → this table maps the design-doc
applyBrandingspec (doc §5) to implementing files, since that is the only concrete "design" input.
| Design-doc artifact | Implementing file | Reuse vs new | Tokens/values used | Deviation |
|---|---|---|---|---|
applyBranding(b) (doc §5) | utils/applyBranding.js | new | writes --brand-primary, --brand-primary-hover, --brand-font-family | Deviation: repo has no --mp-colors-*, so we set app --brand-* vars, not pixel3 tokens |
brandingStore.set(b) (doc §5) | store/branding.js | new | productName, assets, links, colors | none |
<BrandLogo/> (doc §5) | components/branding/BrandLogo.vue | new | brandingStore.assets.logo | none |
| color override reaching components (doc §4b) | plugins/pixel.js extendTheme.colors.brand.mekari + _variables.scss | extended | brand.mekari, --brand-primary | Deviation 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
| Method | Path | Status | Contract authority | Notes |
|---|---|---|---|---|
| GET | /branding | needs-building | Design doc §4 | Public/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 emitscolors["--brand-primary"]andcolors["--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 bundledassets/images/brands/new/logo-qontak-default.svg. - Conditional rendering: if
assets.logounset → bundled default asset. - A11y:
<img alt="{productName} logo">; decorative variants getalt="".
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 arequests/branding.jsmodule. - Cache key:
localStorage['branding'](single tenant per browser origin). - TTL & refetch: fetched once per app boot (
plugins/branding.js); server owns freshness viaCache-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
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Whole-app brand | painted from cache or Qontak default (never blank) | no cache → Qontak default | fetch fail → keep current (cache or default) | some assets 404 → per-asset fallback, colors still apply | tenant colors + logo + name applied |
<BrandLogo> | bundled default until store set | bundled default | <img @error> → bundled default | n/a | tenant 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(registerbranding),nuxt.config.js(plugin registration + head defaults),components/layouts/Header.vue+components/layouts/SideNav/index.vue(BrandLogo + brandName),layouts/hub.vue(dynamichead()), the 53.vue+ 10.scssfiles with literal#0274f5,assets/mixins/messaging/conversation.js:324. - Files explicitly NOT touched: the 9
.svgassets with baked#0274f5(recolored only by swapping to CDN logo URLs, not by CSS), the ~390 non-Phase-1Qontakliterals (§5 tail),crm-fe-v3/hub-chat. - Shared surface impact:
_variables.scss$q-primaryfeeds 124 usages — changing its value source tovar(--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
| Entity | State field consumed | Default values | Source | Stale-tolerance |
|---|---|---|---|---|
| Brand identity | brandingStore.productName | "Qontak Chat" | GET /branding.productName | until next boot |
| Logo | brandingStore.assets.logo | bundled logo-qontak-default.svg | GET /branding.assets.logo | until next boot |
| Brand color | --brand-primary on :root | #0274f5 | GET /branding.colors (mapped) | reactive on fetch |
| Support/legal | brandingStore.links | Qontak URLs | GET /branding.links | until next boot |
Detail 2.F — Asset Inventory
| Asset | Type | Source | Format & sizes | Path in repo |
|---|---|---|---|---|
| Default fallback logo | image (SVG) | existing bundled | SVG | assets/images/brands/new/logo-qontak-default.svg (reused as fallback) |
| Tenant logo / favicon / appleTouchIcon | image | CDN URL from payload (not bundled) | SVG / ICO / PNG | runtime assets.* — no repo path |
| Tenant font | font | CDN CSS from payload | font.cssUrl | runtime — 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-hoverin_variables.scsstovar(--brand-primary, #0274f5)/var(--brand-primary-hover, #0364d1), set--brand-*at boot; (2) sweep literal#0274f5to the same var; (3) overrideextendTheme.colors.brand.mekariinplugins/pixel.jsfor the 4 token usages.- Pros: covers the 124
$q-primaryusages 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.
- Pros: covers the 124
- 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
hubto 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.
- Pros: unifies with
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']inplugins/pixel.jsbeforeVue.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 unauthGET /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 useslocalStorage(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;
loadingeager for header logo. - Browser support: unchanged (matches current Nuxt 2 target).
- Font: tenant
font.cssUrlloaded via<link>;font-display: swapexpected 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 actionbranding_fetch_failed(props: status,hadCache). Alert ifbranding_fetch_failedrate > 5% over 15 min (dashboard TBD with owner). - Success metric: % of sessions with
source != defaulton 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 injection —
colors/URLs are written into the DOM (style.setProperty,<img src>,<link href>). Mitigate: validate each color value against a strict hex/rgb()allowlist regex beforesetProperty; validate asset/font/link URLs arehttps:and (recommended) host-allowlisted before injecting. Never interpolate payload intoinnerHTML. (b) Open redirect vialinks— rendersupport/legalas plain anchors withrel="noopener noreferrer"; validatehttps:. 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) inimg-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 call | 401 | 403 | 404 | 429 | 500 | Timeout (s) | Offline | Retry |
|---|---|---|---|---|---|---|---|---|
GET /branding | treat as fail → keep cache/default | same | unknown tenant → default brand | back off → keep cache/default | keep cache/default | 5s → keep cache/default | keep cache/default | no 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 meaningfulalt; decorative variantsalt="".prefers-reduced-motion: n/a (no animation added).
4. Backwards Compatibility and Rollout Plan
Compatibility
- API contracts changed: none consumed today;
GET /brandingis additive. - Saved client state: new
localStorage['branding']key; absence = default brand (backward compatible).schemaVersionguard 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.jsfeature_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 / flag | Type | Default | Required | Provisioner |
|---|---|---|---|---|
whitelabel_branding (flag) | boolean | off | yes | toggle util / BE feature store (TBD) |
BRANDING_URL (endpoint const) | string | process.env.HUB_SERVICE_URL + '/api/core/v1/branding' (confirm base — public/pre-login may differ, §5) | yes | assets/variables/endpoints.js + env |
Detail 4.B — Test Plan (commands sourced from package.json)
| Layer | Command (source) | What it must prove |
|---|---|---|
| Unit | npm test → jest --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 |
| Build | npm run build (package.json:8) | SPA builds with new plugins |
| Bundle | npm run analyze (package.json:10) | bundle delta within budget |
| Visual regression | n/a — no VR tooling in repo (manual parity check for default tenant) | default-tenant pixel parity |
| E2E | n/a — no Playwright/Cypress in repo (manual + unit for DOM effects) | — |
Coverage note:
jest.config.jscollectCoverageFrom(L24–31) does not includestore/branding.js,utils/, orplugins/. Add these globs (or accept they're tested but uncounted). Flag to reviewer — see §5.
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|---|---|---|---|
| 1 | applyBranding core + fallbacks | create utils/applyBranding.js, utils/applyBranding.spec.js | npx jest utils/applyBranding.spec.js; npm run lint:js | spec: 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 |
| 2 | Endpoint + request + store | create requests/branding.js, store/branding.js, store/branding.spec.js; modify requests/index.js, assets/variables/endpoints.js | npx jest store/branding.spec.js; npm run lint:js | spec: fetch action commits payload; getters productName/assets/links/brandName return values then Qontak defaults when empty |
| 3 | Boot plugin + cached-first paint | create 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 |
| 4 | App color via --brand-primary | modify 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 build | stylelint passes; build passes; default-tenant renders #0274f5 (var falls back) |
| 5 | Pixel component brand override | modify plugins/pixel.js (extendTheme.colors.brand.mekari from cached branding, else #651FFF) | npm run build; npm test | the 4 brand.mekari usages render tenant color when cache present; default when absent |
| 6 | Literal #0274f5 sweep | modify 53 .vue + 10 .scss (inline #0274f5 → var(--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 lint | grep count (excl. .svg) == 0; existing specs incl. conversation.spec.js pass |
| 7 | Favicon + title + font runtime | modify layouts/hub.vue (dynamic head()), ensure applyBranding favicon/font logic | npm run build; manual load | document.title reflects productName; favicon href == payload; font <link> appended when font present |
| 8 | BrandLogo + logo swap | create components/branding/BrandLogo.vue, BrandLogo.spec.js; modify Header.vue:41,59, SideNav/index.vue:20 | npx jest components/branding; npm run lint | snapshot renders assets.logo; @error falls back to bundled default; no layout shift (fixed height) |
| 9 | Product-name delitteralization (Phase 1) | modify nuxt.config.js head, Header.vue, SideNav/index.vue, layouts/hub.vue to use brandName getter | grep -rn 'Qontak' nuxt.config.js components/layouts/Header.vue components/layouts/SideNav/index.vue layouts/hub.vue; npm test | grep in the Phase-1 set == 0; getter drives them; remaining ~390 tracked (§5) |
| 10 | Default/fallback hardening + flag gate | modify plugins/branding.js, utils/applyBranding.js, wire whitelabel_branding flag | npm test; npm run lint; npm run build | with 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):
npm run lint(package.json:19)npm test(package.json:21)npm run build(package.json:8)npm run analyze(package.json:10) — confirm bundle delta within budget- 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_failedrate < 5% (15-min window). - Analytics
branding.appliedwithsourcedistribution as expected. - No spike in JS errors from
plugins/branding.js/utils/applyBranding.js.
- Datadog RUM action
- Rollback (in order):
- Toggle
whitelabel_brandingoff → app renders today's Qontak brand. - If needed, revert the deploy PR(s) — defaults in
_variables.scssandnuxt.config.jsrestore original brand with zero data migration. - Confirm RUM error rate returns to baseline within 15 min.
- (Optional) instruct clients to clear
localStorage['branding']if a bad blob was cached (or bumpschemaVersionto auto-discard).
- Toggle
5. Concern, Questions, or Known Limitations
Open questions (blockers marked 🚫):
- 🚫
GET /brandingcontract for pixel-v1 consumers. The doc'scolorsare 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. - 🚫 Endpoint base URL / pre-login reachability. Is
/brandingunderHUB_SERVICE_URL, or a separate public host? Is the edge allow-list for unauth/brandingon tenant domains in place (Plan 2)? If not, pre-login branding is deferred and the login screen shows default brand until login. - No Figma / visual spec. Sign-off criterion is "parity with today's layout, brand values swapped." Confirm design/infosec accept this.
- Feature-flag mechanism. Cookie/query toggle vs BE feature store — confirm with FE lead for staged rollout.
jest.config.jscoverage globs excludestore/,utils/,plugins/(L24–31). Add globs or accept uncounted coverage.- Contrast/accessibility of tenant colors is not auto-corrected here; needs a guard at the admin/BE layer.
- CSP additions for tenant CDN hosts (img/style/font/connect-src) — Platform.
Known limitations:
- Long tail of ~390
Qontakliterals beyond Phase 1 — phased in follow-ups (grepQontakshows 405 total; top files:TicketingCrm.vue31,layouts/hub.vue24,InitComponent.vue19,constants/index.js8, etc.). - Baked-
#0274f5in 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.mekaripixel 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-23 | RFC author | Initial 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 contract —
GET /brandingmust emit a pixel-v1-usable brand hex (--brand-primaryor equivalent) and its base URL / pre-login reachability must be confirmed (§5 Q1, Q2). Until then S2's BE half isblocked — BE RFC needed. - §1 Design References —
n/a — design pending(no Figma); acceptable only if design/infosec confirm "layout-parity" is the visual bar (§5 Q3). - Detail 4.A flag mechanism —
whitelabel_brandingprovisioner 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-reviewerfor a second-pass score after this gate isyes.