RFC: Unified Branding Service (Whitelabel) — hub-chat frontend consumption
Document Conventions (do not remove)
This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Replace placeholder values; mark sections
N/A — reasonwhen truly inapplicable rather than deleting them.It is also agent-execution-ready: the §1 Design References, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan
- Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.
The YAML frontmatter at the very top is the machine-readable index agents parse. The metadata table below is the human-readable governance record. Both must agree on every shared field (status, owner, type, dates).
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | IDEA / RFC / ABANDON / AGREED |
| Owner | Qontak Chat | Team owning the RFC |
| Author(s) | Engineering (hub-chat) | Primary author(s) |
| Reviewers | hub-chat tech lead · qontak.com backend owner · Pixel3/DS owner | Tech reviewers across affected squads |
| Approver(s) | hub-chat tech leader · infosec approver (TBD) | Tech leaders + infosec approver |
| Submitted Date | 2026-07-23 | ISO-8601; date RFC opened for discussion |
| Last Updated | 2026-07-23 | ISO-8601; bump on every material edit |
| Target Release | 2026-Q4 | Depends on GET /branding availability (§1 Dependencies) |
| Related Documents | Unified Branding Service (Design) · rfc-unified-branding-service-be.md (backend RFC — the GET /branding producer) | Design doc is not a full PRD (no §13b story list; Detail 1.C stories are RFC-derived). BE RFC is the contract authority for §2.4. |
| Discussion | #TBD | Set the Slack thread before promoting to RFC |
Type: frontend Sub-type: new-feature
Sections at a Glance
- Overview (incl. Dependencies, Communication & Stakeholders, §1 Design References — the
GET /brandingcontract + next-theme prerequisite) - Technical Design (Repo Reading Guide → mermaid architecture → UI/state contracts → Asset Inventory)
- 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
Scope of this RFC. This is the hub-chat frontend half of the Unified Branding
initiative. It implements the consumption layer described in the design doc §5/§8:
at app boot, hub-chat fetches GET /branding and applies the tenant's brand — Pixel3
--mp-colors-* color tokens, favicon, product name, and optional web font — with no
per-component color logic, because Pixel3 components already read --mp-colors-* at
render time (verified: assets/styles/pixel.css:38 consumes --mp-colors-border-brand).
The branding service itself (GET /branding, branding_config store, Redis cache,
tenant resolver) is out of scope here — the design doc assigns it to qontak.com. This
RFC treats GET /branding as an external dependency and pins the FE against the
contract documented in design-doc §4. The crm-fe-v3 and legacy hub consumers are
separate efforts (crm-fe-v3 is not checked out in this workspace; legacy hub uses
bootstrap-vue + old pixel and cannot be themed by CSS-variable override at all — design
doc §4b). Both are tracked as n/a — covered elsewhere in Detail 1.C.
Success Criteria
- On any hub-chat route running the next theme, setting the tenant's brand tokens on
:rootrestyles Pixel3 components (buttons, banners, inputs, …) to the tenant color with zero component edits. Verifiable: a themed snapshot/visual check ofMpButtonrenders the tenant brand fill, notblue.400. - Favicon, browser/tab title, and in-app product name reflect the tenant's brand from the
/brandingpayload rather than the hard-coded"Qontak Chat"(nuxt.config.ts:27,configs/*.jsonMETA). - The tenant brand paints on first render with no flash of default theme (FODT) for
returning users (last payload cached in
localStorage, revalidated on boot — design doc §7). - The feature is fully behind a flag defaulting off; with the flag off, hub-chat renders exactly today's Qontak brand (no regression).
Out of Scope
- Building
GET /branding, thebranding_config/tenant_domainstore, the tenant resolver, the Redis cache, or the edge/ingress allow-list — all owned byqontak.com(design-doc Decisions #2/#3). This RFC only consumes the endpoint. - crm-fe-v3 and legacy hub consumption (separate RFCs / repos).
- Backend email/PDF brand rendering (design-doc §8, backend-owned).
- Completing hub-chat's next-theme migration for legacy routes — this RFC depends on that migration for full component coverage but does not perform it (design-doc §4b).
- A tenant-admin UI to edit branding (this is a consumer, not an authoring surface).
- Introducing a neutral
--mp-colors-brand-primaryalias in Pixel3 (design-doc §4 raises it; it is a design-system change, owned by the DS squad — tracked as an Open Question).
Related Documents
- Whitelabel — Unified Branding Service (Design) — source design/architecture doc. Note: it is a design + worked-example doc, not a full PRD; it has no §13b user-story list, so Detail 1.C stories are RFC-derived from its numbered sections and must be confirmed by the PM.
- Design-system reference (in-repo):
docs/mekari-pixel-design-system.md,docs/architecture/flows/cross-cutting/design-system/README.md.
Assumptions
GET /brandingwill return exactly the shape in design-doc §4 (keys:tenant,productName,colorsas Pixel3 token names,assets.{logo,favicon,appleTouchIcon},font.{family,cssUrl},links.{support,legal}). Any drift is a blocker (Detail 2.G-style contract check → §5).GET /brandingis public + unauthenticated and reachable before login on the tenant domain (design-doc Decision #3). hub-chat can therefore fetch it during boot without a token, and must not send credentials to it.- The endpoint returns only non-PII brand data (color, URLs, product name) — design-doc §4. hub-chat treats the payload as world-readable and never stores anything sensitive from it.
- Pixel3's next theme (
data-panda-theme=next) is the coverage boundary: brand-token override styles a component only where the next theme is active (design-doc §4b, verified againstNEXT_THEME_AVAILIBILITY= 28 route prefixes,app.vue:104). - Asset URLs are CDN-hosted and CORS-safe for
<link rel=icon>/<link rel=stylesheet>font loading.
Dependencies
| Dependency | Type | Status | Owner | Notes |
|---|---|---|---|---|
GET /branding endpoint + contract | Backend endpoint | needs-building | qontak.com | FE builds against a local mock matching design-doc §4 until the endpoint ships; contract must be frozen before the boot-fetch chunk (4.C-6) can be verified against staging. |
Edge/ingress allow-list of /branding as unauthenticated route | Infra | needs-building | Platform / qontak.com | Not an FE task; blocks end-to-end verification only. |
| hub-chat next-theme migration of remaining routes | FE (this repo, separate track) | in-progress (28/‹all› routes migrated) | Qontak Chat | Full component coverage of the brand override is gated on this. Legacy routes stay hard-coded blue.400 until migrated (design-doc §4b). |
Pixel3 neutral --mp-colors-brand-primary alias (optional) | Design system | not-started | DS squad | Optional — lets the payload not be named after Qontak. Not required for v1 (override --mp-colors-brand-qontak). |
@mekari/pixel3 / @mekari/pixel3-utils (already deps) | Package | exists | DS squad | usePixelTheme, PixelPlugin; no version bump required. |
Communication & Stakeholders
Whitelabel branding for hub-chat spans multiple squads: hub-chat consumes a contract owned by another team, depends on a design-system capability, and shares the initiative with the other frontend consumers. This table names each stakeholder, what they own for this RFC, and the channel through which decisions and blockers are raised.
| Squad / role | Owns (for this RFC) | Contact / channel | Coordination cadence |
|---|---|---|---|
| Qontak Chat (hub-chat FE) — RFC owner | The consumption layer: BrandingStore, applyBranding, <BrandLogo/>, boot wiring, flag, literal de-hardcoding | hub-chat tech lead · #TBD (set the whitelabel thread) | Drives this RFC; resolves OQ1–OQ4 owners |
| qontak.com backend | GET /branding endpoint + contract, branding_config/tenant_domain store, tenant resolver, Redis cache | qontak.com backend owner (reviewer) | Contract freeze before chunk 4.C-6 staging verification (OQ2) |
| Design System (Pixel3) | Next-theme rollout guidance; optional neutral --mp-colors-brand-primary alias | Pixel3/DS owner (reviewer) | Consulted on token-naming decision (design-doc §1/§4b) |
| Design QA | WCAG-AA contrast / legibility sign-off for arbitrary tenant brand colors | TBD — name before pilot (OQ1) | Gate before pilot-tenant stage |
| Platform / Infra | Edge/ingress allow-list of /branding as an unauthenticated route; CSP origin allow-list | Platform (reviewer) | Blocks end-to-end verification (OQ2/OQ3) |
| crm-fe-v3 squad | Parallel consumer RFC (separate repo, not in this workspace) | crm-fe-v3 squad | Keep payload contract aligned; not a hub-chat blocker |
| Product (PM) | Confirm/renumber the RFC-derived stories (BR-1…BR-7); flag-provisioner decision | PM (design-doc author: A. Firdha Shafridhi) | Before promoting this RFC to RFC status (OQ4) |
Channels of record. Design decisions and blockers are raised in the Slack discussion
thread (frontmatter discussion — currently #TBD, set before promoting to RFC); durable
decisions and reviewer feedback land in §6 Comment logs; the frozen API contract lives in
the backend RFC rfc-unified-branding-service-be.md referenced from §2.4. Cross-squad blockers (OQ1–OQ4)
are tracked in §5 until an owner and resolution are recorded here.
Design References (frontend-specific)
This feature introduces no new UI screen or component layout — it re-themes the existing Pixel3 component library via CSS-variable override and swaps three brand artifacts (logo, favicon, product name). There is therefore no Figma frame to implement pixel-for-pixel; the "design" is the per-tenant brand payload plus a design-QA judgement on how arbitrary tenant colors look against the existing layout.
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| App shell re-themed by brand tokens (navbar, sidebar, buttons, banners) | n/a — no new UI surface; re-themes existing Pixel3 components via --mp-colors-* override | n/a | @mekari/pixel3 (current repo dep — see package.json) | TBD — see §5 Open Question 1 | Visual QA is a per-tenant brand/contrast review (esp. text-on-brand contrast for dark/light tenant colors), not a frame comparison. |
| Favicon / browser title / product name | n/a — asset + string swap, no layout | n/a | n/a | TBD | Driven by assets.favicon + productName from payload. |
Because there is no Figma frame, the design-QA gate here is contrast/legibility of arbitrary tenant brand colors against the existing surfaces (WCAG AA). That gate is an Open Question, not a resolved reference — see §5.
Detail 1.A — PRD Traceability Matrix
Forward (Design-doc → RFC):
| Design-doc requirement | RFC section | Component / file |
|---|---|---|
§5 applyBranding(b) core: set --mp-colors-* on :root, swap favicon, load font, store logo/name | §2.A UI Contract · §4.C chunk 3–4 | common/utils/applyBranding.ts (new) |
§5 reactive brandingStore consumed by logo + useHead | §2.E State Surface Contract · §4.C chunk 2 | common/store/BrandingStore.ts (new) |
§2 boot-time fetch of GET /branding then apply | §2.2 Sequence · §4.C chunk 5–6 | app.vue onMounted (extend, alongside getAppConfig() at app.vue:205) |
§4 payload shape (colors/assets/font/productName/links) | §2.4 APIs Consumed · §2.A | common/store/BrandingStore.ts types |
§4b next-theme prerequisite (override only works under data-panda-theme=next) | §1 Dependencies · §3 Known Limitations | common/constants/nextThemeAvailibility.ts (read-only anchor) |
§7 caching: localStorage last payload, paint instantly, revalidate | §2.B Data-Fetching Strategy · §4.C chunk 5 | common/store/BrandingStore.ts |
§8 replace "Qontak Chat" literals + favicon/title in nuxt.config | §2.D Scope Boundaries · §4.C chunk 7 | nuxt.config.ts, navbar/sidebar components |
Reverse (RFC → Design-doc):
| New component / RFC decision | Design-doc requirement driving it |
|---|---|
common/utils/applyBranding.ts (framework-agnostic core) | §5 "one small composable, reused everywhere" |
common/store/BrandingStore.ts (Pinia setup store) | §5 "reactive store consumed by <BrandLogo/> / useHead" |
BrandLogo.vue component | §5 step-4 logo binding + §6 per-tenant logo |
Feature flag branding.whitelabel default-off | RFC success criterion #4 (no-regression safety) — not in design doc, added for safe rollout |
localStorage cache + revalidate | §7 "avoid a flash of default theme" |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE endpoint) | Required writes (BE endpoint) | Status surface (which field/event reflects state) |
|---|---|---|---|---|
| App shell (navbar/sidebar/buttons re-themed) | web (hub-chat SPA) | GET /branding | none (read-only consumer) | BrandingStore.branding.colors applied to :root; BrandingStore.status |
| Favicon + tab title | web | GET /branding | none | BrandingStore.branding.assets.favicon, .productName via useHead |
In-app product name (<BrandLogo/>, titles) | web | GET /branding | none | BrandingStore.branding.productName |
| Tenant font (optional) | web | GET /branding (font.cssUrl) | none | BrandingStore.branding.font |
Role Coverage
| PRD role | UI surface visibility | Action buttons enabled | Auth scope expected from BE | Notes |
|---|---|---|---|---|
| Anonymous / pre-login visitor on tenant domain | Themed login/boot screen | n/a (no actions) | none — /branding is public/unauthenticated (design-doc Decision #3) | Primary reason the fetch runs before auth. |
| Authenticated agent | Full app, themed | existing app buttons (unchanged) | none for branding; app auth unchanged | Branding is orthogonal to the user's app permissions. |
| Super admin | Same as agent for branding | unchanged | none for branding | isSuperAdmin path in app.vue:54 unaffected. |
| Support / read-only internal | Same themed shell | unchanged | none for branding | Branding does not gate on role. |
All roles see the same brand; branding carries no per-role authorization. The only auth-relevant fact is that the endpoint is unauthenticated — enforced by not attaching credentials (§3 Security).
PRD Section Coverage
Source is the design doc's numbered sections (it has no PRD-style numbering beyond these).
| Design-doc section # | Title | Where covered (RFC section) or n/a — reason |
|---|---|---|
| 1 | Architecture | §1 Overview (scope split) · §2.1 (consumer slice only; service topology is BE-owned) |
| 2 | How a tenant gets resolved and themed | §2.2 Sequence (FE half: fetch → apply); resolver itself n/a — BE-owned |
| 3 | Data model | n/a — BE-owned (branding_config/tenant_domain tables); FE consumes payload only (§2.4) |
| 4 | API contract GET /branding | §2.4 APIs Consumed · §2.A UI Contract (payload type) |
| 4b | Does the override reach components? (next-theme) | §1 Dependencies · §3 Known Limitations · §5 |
| 5 | Consumer side — applyBranding composable | §2.A · §2.B · §4.C chunks 3–5 |
| 6 | Proof: two tenants, same code | §2.C UI State Matrix · §4.C chunk 8 (multi-tenant test) |
| 7 | Caching & invalidation | §2.B (FE localStorage half); Redis/HTTP cache n/a — BE-owned |
| 8 | What this replaces, per service | §2.D Scope Boundaries (hub-chat row only) |
| Decisions | Colors-as-tokens / owner / public-endpoint | Assumptions (§1) · §3 Security · §5 |
Detail 1.B — Decisions Closed
| Decision | Chosen option | Alternatives rejected | Why rejected |
|---|---|---|---|
| Where the brand is applied | Framework-agnostic applyBranding() util + a Pinia setup store, invoked from app.vue boot | (a) a Nuxt plugin that fetches at plugin-init | Plugins load alphabetically before customFetch.ts provides $customFetch (verified: plugin list, no numeric prefixes); AppConfig already fetches in onMounted (app.vue:205) — mirror that proven pattern. |
| How colors are applied | Write the payload's --mp-colors-* keys verbatim to document.documentElement.style | a per-app token-mapping table | Design-doc §4 returns Pixel3 token names on purpose; a mapping table re-introduces the per-app logic the design explicitly removes. |
| Store shape | Setup-style defineStore("Branding", () => {...}) with status ref + action | Options API store | Matches every existing store (AppConfigStore.ts:71, OrganizationStore.ts:168). |
| Fetch client | $customFetch (useNuxtApp().$customFetch) with no auth requirement | raw $fetch | Reuse the repo's one HTTP wrapper (retry, error-toast shape). Endpoint is public, but $customFetch only adds an auth header when a token cookie exists — acceptable for a public endpoint. See §3 for the credential caveat. |
| First-paint strategy | localStorage cache → paint → revalidate | fetch-then-paint (block on network) | Design-doc §7 explicitly calls for no flash of default theme. |
| Safety mechanism | Feature flag branding.whitelabel, default off | ship unflagged | 286 Qontak literal sites + global :root mutation = high blast radius; flag gives an instant kill-switch. no alternative considered — safety is non-negotiable for a global re-theme. |
Honesty rule: if no alternative was seriously considered, the row says so.
Detail 1.C — Per-Story Change Map (RFC-derived — the design doc has no §13b story list)
The linked doc is a design doc, not a PRD; it contains no user-story list. The stories below are RFC-derived from its numbered sections so traceability is complete — the PM must confirm/renumber them before this RFC is promoted to
RFCstatus (§5 Open Question 4).
| Story # | Story title | Layer scope | Changes (concrete FE artifacts + cross-layer references) | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| BR-1 | Branding store + typed payload | FE + BE existing (consumes GET /branding, status needs-building — build vs mock) | • new common/store/BrandingStore.ts (defineStore("Branding", …), status ref, branding ref, fetchBranding() action via $customFetch) • Branding TS interface matching design-doc §4 • unit spec BrandingStore.spec.ts | pnpm test common/store/BrandingStore.spec.ts passes: store resolves a mocked payload; status transitions idle→pending→resolved; malformed payload → rejected without throwing to boot | §2.A · §2.4 · §4.C chunk 2 |
| BR-2 | applyBranding() core util | FE-only (Runtime/behavior) | • new common/utils/applyBranding.ts (set --mp-colors-* on :root, swap favicon <link>, inject font <link>, set brandingStore) • unit spec | pnpm test on the util: after applyBranding(fixture), document.documentElement.style.getPropertyValue('--mp-colors-background-brand-bold') equals fixture value; favicon href updated; --brand-font-family set | §2.A · §4.C chunk 3 |
| BR-3 | <BrandLogo/> + head/title from store | FE-only | • new common/components/BrandLogo.vue reading brandingStore.branding.productName/logo • useHead title/favicon bound to store (extend the existing useHead pattern, e.g. pages/customers/index.vue:9) | Component test: <BrandLogo/> renders tenant productName and logo src; falls back to "Qontak Chat" + default logo when store empty | §2.A · §2.E · §4.C chunk 4 |
| BR-4 | Boot fetch + cache-first paint | FE + BE existing | • extend app.vue onMounted (beside getAppConfig() at app.vue:205) to read localStorage cache → applyBranding → fetchBranding() → re-apply + re-cache | Test (mounted app / mocked fetch): cached payload applies synchronously on boot; network payload re-applies; no unhandled rejection when /branding errors (falls back to default brand) | §2.2 · §2.B · §4.C chunk 5–6 |
| BR-5 | Feature flag gate | Config | • flag branding.whitelabel (see §4.A — sourced from AppConfigStore/runtimeConfig) wraps BR-4 boot logic | With flag off, boot does not fetch /branding and :root is unmutated (snapshot identical to today); with flag on, BR-4 runs | §4.A · §4.C chunk 1 |
| BR-6 | De-hardcode brand literals | FE-only | • replace nuxt.config.ts head title/favicon literals + configs/*.json META with store-driven values where runtime-settable; audit the 286 Qontak occurrences and route the user-facing ones through the store/i18n | Grep shows user-facing product-name renders come from the store, not string literals, on themed routes; pnpm build succeeds; title reflects productName when flag on | §2.D · §4.C chunk 7 |
| BR-7 | Two-tenant proof (design-doc §6) | FE-only (test) | • integration/visual test applying two payloads (purple #7A2FF2 / green #0E9F6E), asserting identical code path, different rendered brand | Test: same applyBranding call with two fixtures yields two distinct :root color sets; a themed MpButton snapshot differs by brand color only | §2.C · §4.C chunk 8 |
| — | crm-fe-v3 consumption | Cross-squad | n/a — covered in separate crm-fe-v3 RFC (repo not in this workspace) | n/a | design-doc §8 |
| — | legacy hub consumption | Cross-squad | n/a — covered elsewhere; design-doc §4b: bootstrap-vue + old pixel, not themeable by token override | n/a | design-doc §4b |
| — | GET /branding service + store + resolver + edge allow-list | Cross-squad | n/a — covered in qontak.com backend RFC (design-doc Decisions #2/#3) | n/a | design-doc §1–4,7 |
Coverage rule satisfied: every design-doc section maps to a story or an explicit
n/a — covered elsewhere. Cross-check against §2 — every artifact named above appears in §2.A / §2.D / §4.C.
2. Technical Design
Detail 2.0 — Repo Reading Guide (read this first; understand the existing code as documentation before writing any new code)
Repo Map (mermaid)
flowchart LR
subgraph boot["hub-chat boot"]
appvue["app.vue\n(onMounted: getAppConfig)"]
end
subgraph plugins["plugins/"]
cf["customFetch.ts\n($customFetch)"]
px["pixel.ts\n(PixelPlugin)"]
end
subgraph store["common/store/"]
acs["AppConfigStore.ts\n(pattern to copy)"]
bs["BrandingStore.ts\n(NEW)"]
end
subgraph util["common/utils/ (NEW)"]
ab["applyBranding.ts\n(NEW)"]
end
subgraph comp["common/components/"]
bl["BrandLogo.vue\n(NEW)"]
end
subgraph theme["theming"]
nta["constants/nextThemeAvailibility.ts"]
pcss["assets/styles/pixel.css"]
end
ext[/"GET /branding (qontak.com, external)"/]
appvue --> bs --> cf --> ext
bs --> ab
ab --> pcss
ab --> bl
px --> theme
appvue --> nta
Existing Code Anchors
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
common/store/AppConfigStore.ts | The store BrandingStore.ts is modelled on | Setup defineStore + status state-machine + $customFetch action + idempotency guard (L71–104) |
common/store/OrganizationStore.ts | Second store example (boot fetch + computed getter) | defineStore("organization", …) with getDetail + storeToRefs consumption |
plugins/customFetch.ts | How to call an API + error shape | $fetch.create → provide.customFetch; retry/onResponseError toast shape (L154, L227) |
app.vue | Where boot-time fetches and theme wiring live | onMounted(() => getAppConfig()) (L205) is the branding-fetch hook; next-theme watch (L86–112) |
common/constants/nextThemeAvailibility.ts | The coverage boundary of the brand override | 28 route prefixes gate setNextTheme (design-doc §4b) |
plugins/pixel.ts | Confirms Pixel3 theme is enabled | PixelPlugin { pixelTheme: true } (L4–9) |
assets/styles/pixel.css | Confirms app consumes --mp-colors-* tokens | var(--mp-colors-border-brand) at L38 (consume-only; no override exists yet) |
nuxt.config.ts | Brand literals + runtimeConfig source | head title/favicon (L27, L50); runtimeConfig.public from configs/*.json (L419–478) |
configs/production.json | Where brand strings/URLs live today | META.title, API_BASE_URL (L5, L11); no /branding key yet |
common/components/__tests__/InputPeriod.spec.ts | The component-test pattern to copy | // @vitest-environment nuxt + vi.mock("@mekari/pixel3-utils", …) |
Patterns to Follow (and where to find them)
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| State management | Pinia setup store, status ref + async action | common/store/AppConfigStore.ts:71 | none |
| Folder convention | stores in common/store/, utils auto-imported from common/utils/** | nuxt.config.ts:18-20 (imports.dirs) | none — new util lands in common/utils/ |
| API call | useNuxtApp().$customFetch(path) | common/store/AppConfigStore.ts:76,84 | Endpoint is public/base-URL differs — may need a brandingApiBaseUrl (see §2.4) |
| Error / toast / retry | onResponseError normalizes response._data.message | plugins/customFetch.ts:181-224 | Branding fetch must not toast on failure — it silently falls back to default brand |
| Theme wiring | usePixelTheme().setNextTheme per-route watch | app.vue:84-112 | none — read-only; RFC does not change next-theme logic |
| Head/title | per-page useHead({...}) | pages/customers/index.vue:9 | Extend to bind title/favicon to brandingStore |
| Component test | // @vitest-environment nuxt, mount, mock pixel utils | common/components/__tests__/InputPeriod.spec.ts:1-13 | none |
Reading Order for the Agent
common/store/AppConfigStore.ts— the exact store shape to copy forBrandingStore.app.vue(L83–112, L203–239) — next-theme watch +onMountedboot fetches (the hook point).plugins/customFetch.ts(L146–228) — how$customFetchis provided and errors normalized.common/constants/nextThemeAvailibility.ts— the routes where a brand override actually takes effect.assets/styles/pixel.css(L1, L38) — confirms--mp-colors-*are consumed (so:rootoverride works).nuxt.config.ts(L15–68, L419–478) — head literals + runtimeConfig/configs/*.jsonwiring.common/components/__tests__/InputPeriod.spec.ts— the component-test harness to reuse.docs/mekari-pixel-design-system.md— Pixel3 next-theme background.
Existing API check
GET /branding — new-with-justification: no /branding route or config key exists in
hub-chat (verified: no brandingApiBaseUrl//branding in nuxt.config.ts or
configs/*.json; store grep shows only client_configs/config, sso_oauth/token, etc.).
Reuse is impossible because no endpoint returns tenant brand tokens today. The endpoint is
owned by qontak.com (design-doc Decision #2), so from hub-chat's side it is a consumed
external contract, not something this RFC creates.
Source Verification (anti-hallucination)
| Anchor / pattern / contract | Verified by | Evidence (1-line) |
|---|---|---|
common/store/AppConfigStore.ts | read | export const useAppConfigStore = defineStore("AppConfig", () => { L71; await $customFetch("/api/core/v1/client_configs/config") L84; status idle/pending/resolved/rejected L72,82,88,91 |
common/store/OrganizationStore.ts | read (Explore) | defineStore("organization", () => { L168; getDetail L173; computed organizationSettings L213 |
plugins/customFetch.ts | read | const $customFetch = $fetch.create({ … baseURL: config.public.apiBaseUrl … }) L154; return { provide: { customFetch: $customFetch } } L227; error-normalize L217-224 |
app.vue boot hook | read | onMounted(() => { … getAppConfig(); … }) L203-205 |
app.vue next-theme | read | const { setNextTheme } = usePixelTheme(); L84; NEXT_THEME_AVAILIBILITY.some((base) => …) L104; setNextTheme(isNextTheme) L109 |
common/constants/nextThemeAvailibility.ts | read | export const NEXT_THEME_AVAILIBILITY = [ "/inbox", … ] — 28 entries L1-30 |
plugins/pixel.ts | read | nuxtApp.vueApp.use(PixelPlugin, { … pixelTheme: true }) L4-9 |
assets/styles/pixel.css consumes tokens | read/grep | border: 1.5px solid var(--mp-colors-border-brand); L38 |
nuxt.config.ts head literals | read | title: "Qontak Chat" L27; { rel: "icon", href: "/qontak-favicon.ico" } L50; runtimeConfig.public L423, apiBaseUrl: CONFIGENVIRONMENT.env.API_BASE_URL L427 |
configs/production.json | read | "title": "Qontak Chat" L5; "API_BASE_URL": "https://chat-service.qontak.com" L11; no /branding key |
Qontak literal count | grep | 286 matches across app common configs i18n pages layouts (*.ts/*.vue/*.json); top clusters TheNavbar.vue 37, OneNavbar.vue 31, useSidebar.ts 22 |
--mp-colors*brand* override absent | grep | 2 matches, only 1 in code (pixel.css:38 consume); no existing :root override |
--mp-colors-text-inverse in repo | grep | 0 matches — the design-doc "48 uses" span all repos/Pixel3, not hub-chat app code (payload still valid; token is defined by Pixel3, just not referenced in hub-chat source) |
| Test commands | read package.json | test: "vitest --dom --pool=forks", type-check: "vue-tsc --noEmit", lint: "pnpm lint:js && pnpm lint:prettier", build: "nuxt build" |
| Component test pattern | read | common/components/__tests__/InputPeriod.spec.ts:1 // @vitest-environment nuxt; vi.mock("@mekari/pixel3-utils", …) L10-13 |
BrandLogo/brandingStore/applyBranding are new | grep | 0 matches repo-wide — all greenfield |
Design ↔ Code Mapping (frontend-specific)
No Figma frames exist (§1 Design References). This maps the design-doc's code artifacts (its §5 is literally TypeScript) to their hub-chat implementation files and tokens.
| Design-doc artifact | Implementing file (path) | Reuse vs new | Design tokens used | Deviation from design |
|---|---|---|---|---|
applyBranding(b) (design-doc §5 TS) | common/utils/applyBranding.ts | new | writes payload's --mp-colors-* keys verbatim + --brand-font-family | none — code faithful; adds a null-guard for missing assets/font |
Branding interface (design-doc §5) | common/store/BrandingStore.ts (exported type) | new | n/a | none |
brandingStore.set(b) (design-doc §5 step 4) | common/store/BrandingStore.ts action | new | n/a | store is Pinia (repo convention) rather than a bare module singleton |
<BrandLogo/> (design-doc §5 step 4) | common/components/BrandLogo.vue | new | consumes --mp-colors-* via Pixel3 | none |
There is no design QA sign-off attached (no Figma); the deviation that does need design approval is legibility of arbitrary tenant colors — tracked in §5 Open Question 1.
Detail 2.1 — Architecture (mermaid)
Component diagram
flowchart TB
user([User on tenant domain]) --> app[app.vue boot]
app --> flag{branding.whitelabel on?}
flag -- no --> default[render default Qontak brand]
flag -- yes --> store[BrandingStore]
store --> lscache[(localStorage cache)]
store --> client[$customFetch]
client --> ext[/GET /branding — qontak.com/]
store --> apply[applyBranding util]
apply --> root["document.documentElement\n(:root --mp-colors-*)"]
apply --> head[useHead: favicon + title]
apply --> logo[BrandLogo.vue]
root --> pixel[Pixel3 components re-render in tenant colors]
State machine (branding boot flow)
stateDiagram-v2
[*] --> flag_off
flag_off --> [*]: render default brand (no fetch)
[*] --> cache_check: flag on
cache_check --> painted_cached: localStorage hit -> applyBranding
cache_check --> default_pending: localStorage miss
painted_cached --> revalidating: fetchBranding()
default_pending --> revalidating: fetchBranding()
revalidating --> painted_fresh: 2xx -> applyBranding + cache
revalidating --> painted_cached: network error & had cache (keep cached)
revalidating --> default_final: network error & no cache (default brand)
painted_fresh --> [*]
painted_cached --> [*]
default_final --> [*]
Detail 2.2 — Sequence (mermaid, incl. failure paths)
Happy path — returning user, cache-first paint then revalidate
sequenceDiagram
actor U as User
participant APP as app.vue (onMounted)
participant ST as BrandingStore
participant LS as localStorage
participant CF as $customFetch
participant EXT as GET /branding (qontak.com)
participant ROOT as :root / Pixel3
U->>APP: load hub-chat on acme.com
APP->>ST: if flag on -> initBranding()
ST->>LS: read branding:cache
LS-->>ST: cached payload (last visit)
ST->>ROOT: applyBranding(cached) %% instant paint, no FODT
ROOT-->>U: app shell already in tenant colors
ST->>CF: GET /branding (no credentials)
CF->>EXT: HTTPS GET (Host: acme.com)
EXT-->>CF: 200 { colors, assets, font, productName }
CF-->>ST: payload
ST->>ROOT: applyBranding(fresh) %% idempotent re-apply
ST->>LS: write branding:cache (fresh)
Note over ROOT: only next-theme routes restyle fully (design-doc §4b)
Failure path — /branding unreachable / 5xx / timeout
sequenceDiagram
participant APP as app.vue
participant ST as BrandingStore
participant LS as localStorage
participant CF as $customFetch
participant EXT as GET /branding
APP->>ST: initBranding() (flag on)
ST->>LS: read cache
alt cache present
LS-->>ST: cached payload
ST->>ST: applyBranding(cached)
else no cache
ST->>ST: keep default Qontak brand
end
ST->>CF: GET /branding
CF->>EXT: HTTPS GET
Note right of EXT: timeout ~5s / 5xx
EXT--xCF: no usable response
CF-->>ST: error (retry disabled for this call)
ST->>ST: status=rejected; DO NOT toast; keep current brand
Note over ST: boot never blocks on branding; app remains fully usable
Detail 2.3 — Database Model
N/A for this frontend RFC. Client-side persistence used:
localStoragekeybranding:cache— stores the last successful/brandingpayload (JSON). Shape = theBrandinginterface (§2.A). Eviction: overwritten on each successful fetch; cleared when the feature flag is off (BR-5) and on logout is not required (payload is public, non-PII). No migration needed (new key); if the shape ever changes, guard the parse and treat a parse failure as a cache miss.
Detail 2.4 — APIs Consumed
| Method | Path | Status | Contract authority | Notes |
|---|---|---|---|---|
| GET | /branding | needs-building (build FE against local mock until live) | Design-doc §4 + rfc-unified-branding-service-be.md (the producer RFC) | Public, unauthenticated, cacheable. Base URL TBD: hub-chat's apiBaseUrl is chat-service.qontak.com (configs/production.json:11) but the service is qontak.com-owned; a dedicated brandingApiBaseUrl config key or an edge route on the tenant domain is required — §5 Open Question 2. |
Payload contract (design-doc §4, verbatim keys the FE writes to :root):
{
"tenant": "acme",
"productName": "Acme Chat",
"colors": {
"--mp-colors-brand-qontak": "#7A2FF2",
"--mp-colors-background-brand": "#7A2FF2",
"--mp-colors-background-brand-hovered": "#6A1FE0",
"--mp-colors-background-brand-bold": "#7A2FF2",
"--mp-colors-border-brand": "#7A2FF2",
"--mp-colors-icon-brand": "#7A2FF2",
"--mp-colors-text-inverse": "#FFFFFF"
},
"assets": {
"logo": "https://cdn.brand.example/acme/logo.svg",
"favicon": "https://cdn.brand.example/acme/favicon.ico",
"appleTouchIcon": "https://cdn.brand.example/acme/apple-touch-icon.png"
},
"font": { "family": "Inter", "cssUrl": "https://cdn.brand.example/acme/fonts/inter.css" },
"links": { "support": "https://help.acme.com", "legal": "https://acme.com/terms" }
}
The FE iterates
colorsgenerically (writes every key/value to:root) — it does not hard-code the token list, so a payload adding/removing a token needs no FE change.linksare consumed only if/where the app has a support/legal surface (else ignored).
Detail 2.A — UI Contract
applyBranding core util — common/utils/applyBranding.ts (design-doc §5)
- Implementation file path:
common/utils/applyBranding.ts - No Figma frame (see §1).
- Type:
export interface Branding {
tenant?: string
productName: string
colors: Record<string, string> // Pixel3 token names -> hex; written verbatim to :root
assets: { logo: string; favicon: string; appleTouchIcon?: string }
font?: { family: string; cssUrl: string }
links?: { support?: string; legal?: string }
}
export function applyBranding(b: Branding): void
- State shape & ownership:
BrandingStore(Pinia) ownsbranding+status; the util is pure DOM side-effect +brandingStore.set(). - Event payloads: analytics event
whitelabel.branding_appliedwith{ tenant, source: "cache" | "network", flag: true }fired once per boot after first successful apply. (New event — naming mirrors existing mixpanel usage; register in the mixpanel plugin.) - Conditional rendering:
assets.appleTouchIconandfontare optional — skip the DOM write when absent.linksoptional. - Slots / children contract: n/a (util).
- A11y notes: after applying colors, the contrast of
text-inverseon brand fills must meet WCAG AA — validated at design-QA time per tenant (§5 OQ1), not enforced in code v1.
<BrandLogo/> — common/components/BrandLogo.vue
- Type:
interface BrandLogoProps {
variant?: 'full' | 'mark' // default 'full'
alt?: string // default productName from store
}
- State shape & ownership: reads
useBrandingStore().branding(logo URL + productName). - Event payloads: none.
- Conditional rendering: store empty / flag off → render the existing default Qontak logo asset (
public/asset) so the component is safe pre-fetch. - A11y:
<img alt>defaults to product name; decorative mark usesalt="".
Detail 2.B — Data-Fetching Strategy
- Library:
$customFetch(ofetch wrapper) viauseNuxtApp().$customFetch— repo standard. - Cache key structure:
localStorage["branding:cache"]= serializedBranding; in-memory truth isBrandingStore.branding. - TTL & refetch triggers: fetch once on boot (
onMounted). No interval. Server-side freshness is the backend's Redis + HTTPCache-Control(design-doc §7, BE-owned). - Stale-while-revalidate: yes — paint the
localStoragepayload immediately, then fetch and re-apply. This is the FODT-avoidance behavior from design-doc §7. - Optimistic updates + rollback: n/a (read-only consumer). On fetch failure, keep whatever is already applied (cached or default) — never revert a good cached brand to default.
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| App shell colors | Cached brand painted (or default if no cache) — never a blank/unstyled shell | Flag off → default Qontak brand | Fetch failed → keep cached/default brand, no toast | next-theme routes themed; legacy routes stay blue.400 (design-doc §4b) | All next-theme routes render tenant colors |
| Favicon / title | Default until store resolves, then swapped | Default /qontak-favicon.ico + "Qontak Chat" | Keep current | favicon may swap before font loads (independent) | Tenant favicon + productName title |
<BrandLogo/> | Default logo until store set | Default Qontak logo | Default logo (broken tenant URL → onerror fallback) | logo shown, product name still default until store set | Tenant logo + name |
| Tenant font | System/default font until cssUrl loads | No font in payload → default font | <link> load fails → default font (FOUT acceptable) | text reflows once font loads | Tenant font applied via --brand-font-family |
Detail 2.D — Scope Boundaries
- Files to create:
common/store/BrandingStore.tscommon/utils/applyBranding.tscommon/components/BrandLogo.vuecommon/store/BrandingStore.spec.ts,common/utils/__tests__/applyBranding.spec.ts,common/components/__tests__/BrandLogo.spec.ts
- Files to modify (+reason):
app.vue— add flag-gated branding boot inonMountedbesidegetAppConfig()(L203-205).nuxt.config.ts— make headtitle/favicon default-only (still "Qontak Chat" when flag off); addbrandingApiBaseUrlruntimeConfig key +branding.whitelabelflag source if config-driven.configs/{development,staging-alpha,production}.json— add branding base URL + default flag value.- Navbar/sidebar brand-name renders:
layouts/components/TheNavbar/TheNavbar.vue,layouts/components/OneNavbar/OneNavbar.vue,common/composables/useSidebar.ts— route user-facing product-name strings through the store (biggest literal clusters: 37/31/22).
- Files explicitly NOT touched (non-goals):
common/constants/nextThemeAvailibility.tsandapp.vuenext-themewatch(L86-112) — next-theme migration is a separate track.plugins/customFetch.tsauth logic — branding reuses it as-is.- Any
qontak.com/ crm-fe-v3 / legacy-hub code.
- Shared components touched + impact:
<BrandLogo/>is new (no existing usages to break). Navbar/sidebar edits touch high-traffic layout components used on every page — gate behind the flag and snapshot-test before/after.
Detail 2.E — State Surface Contract
| Entity | State field / event consumed | Default values | Source endpoint / event | Stale-tolerance window |
|---|---|---|---|---|
| Brand colors | BrandingStore.branding.colors → :root vars | Pixel3 default Qontak palette | GET /branding | Until next boot (cache-first; backend controls freshness via Redis/Cache-Control) |
| Product name | BrandingStore.branding.productName | "Qontak Chat" (nuxt.config.ts:27) | GET /branding | Until next boot |
| Logo / favicon | BrandingStore.branding.assets.* | /qontak-favicon.ico, default logo asset | GET /branding | Until next boot |
| Font | BrandingStore.branding.font | System/default font stack | GET /branding | Until next boot |
Detail 2.F — Asset Inventory (frontend-specific)
| Asset name | Type | Source | Format & sizes | Path in repo |
|---|---|---|---|---|
| Tenant logo | image/logo | Runtime from CDN (assets.logo in payload) | SVG/PNG (tenant-provided) | none — fetched at runtime, never committed |
| Tenant favicon | favicon | Runtime from CDN (assets.favicon) | .ico/PNG | none — runtime |
| Tenant apple-touch-icon | icon | Runtime from CDN (assets.appleTouchIcon) | PNG 180×180 | none — runtime |
| Tenant font | font | Runtime from CDN (font.cssUrl) | @font-face CSS | none — runtime |
| Default Qontak logo fallback | image/logo | existing repo asset | existing | public/ (existing default; confirm/keep) |
This feature imports no new committed assets — all tenant assets are runtime CDN URLs. The only static asset is the existing default Qontak logo/favicon used as fallback.
3. High-Availability & Security
HA narrative. Branding is a non-blocking enhancement: the app boots and is fully
usable regardless of /branding availability. Failure modes degrade gracefully to the
cached brand, then to the default Qontak brand. The branding fetch has retry disabled
(a slow branding call must never delay app interactivity) and never surfaces an error toast.
Performance Requirement
- LCP target: no regression vs current hub-chat baseline; cache-first paint avoids a re-theme repaint on returning-user boot.
- INP target: unaffected (one-time boot-side effect).
- CLS target: font swap may cause minor reflow → use
font-display: swapsemantics; keep logo dimensions fixed in<BrandLogo/>to avoid layout shift. - Bundle size budget + delta: target < ~3 KB gzip added (one util + one small store + one component; no new dependency). Verify with the build output.
- Code-splitting strategy: none needed — boot-critical, ships in the entry chunk.
- Image strategy: tenant logo/favicon are external URLs;
<BrandLogo/>sets explicit width/height. - Browser support matrix: same as hub-chat baseline (CSS custom properties are universally supported in that matrix).
- Responsive breakpoints / touch targets: unchanged (no new layout).
- i18n / l10n / RTL: product name is now dynamic; ensure i18n strings that embedded
"Qontak" use an interpolated
{brandName}where user-facing (BR-6).
Monitoring & Alerting
- Analytics event:
whitelabel.branding_applied{ tenant, source: "cache"|"network", flag }— fired once after first apply. Alsowhitelabel.branding_fetch_failed{ status }on fetch error (for silent-failure visibility). - Error monitoring: Datadog RUM is already initialized (
app.vue:19,datadog.client.ts). Log a RUM action/errorbranding_fetch_failed(non-fatal, low sample is fine). - Core Web Vitals: watch LCP on themed routes post-rollout via the existing Datadog RUM dashboard.
- User-facing success metric: % of tenant sessions where
branding_applied.sourceis set (adoption), and absence of FODT (source=cachefor returning users).
Logging
- Frontend log fields:
event,tenant,source,status(on failure). Level: info for applied, warn for fetch failure. - PII removed: payload is non-PII by contract; nonetheless log only
tenantslug + status, never full URLs or headers.
Security Implications
- Threat model:
- Malicious/compromised
/brandingpayload — because the FE writescolorsvalues to:rootand injects afont.cssUrl<link>and a favicon URL, a hostile payload could attempt CSS-value injection or load attacker CSS. Mitigations: (a) validate eachcolorsvalue against a strict color pattern (#hex/rgb()/hsl()), rejecting anything else beforesetProperty; (b) validateassets.*/font.cssUrlarehttps:absolute URLs on an allow-listed host set (CDN); (c) rely on CSP (see below). Do not inject raw CSS text — only individual property values and a stylesheet<link href>. - CSP — adding tenant CDN origins to
style-src/font-src/img-srcis required if a CSP is enforced; coordinate the allow-list (§5 OQ3). Prefer a single known branding-CDN origin over arbitrary tenant URLs. - XSS via product name — render
productNameas text (Vue escapes by default); never viav-html.
- Malicious/compromised
- Input sanitization per field:
colors[*]→ color-syntax regex;productName→ length cap + plain text;assets.*/font.cssUrl→https+ host allow-list. dangerouslySetInnerHTML/v-html: none — do not use for any branding value.- Auth token storage: unchanged. The branding call is public; ensure it does not require a token.
$customFetchattachesAuthorizationonly when a token cookie exists — acceptable (server ignores it), but confirm the backend does not treat a present-but-foreign token as an error. If it might, use raw$fetchfor/brandinginstead. (§5 OQ2) - HTTPS-only resources + CSP additions: all branding asset URLs must be
https. - PII handling: none — payload is non-PII by contract (design-doc §4/Decision #3).
- Secret management: no secrets involved.
Detail 3.A — Failure Mode Catalog
| API call | 401 | 403 | 404 | 429 | 500 | Timeout (s) | Offline | Retry mechanism |
|---|---|---|---|---|---|---|---|---|
GET /branding | keep cached/default brand (endpoint is public; 401 is unexpected → treat as failure) | same | keep default brand (tenant not branded) | keep cached/default; no retry | keep cached/default | ~5s → keep cached/default | use localStorage cache if present, else default | retry disabled for this call (options.retry = 0 pattern, customFetch.ts:174-177) |
Narrative: rapid re-mounts (SPA nav) do not refetch (boot-once guard, mirroring
AppConfigStore's idempotency guard at L78). Font-load failure → default font, no error.
Broken logo URL → <img onerror> falls back to default logo.
Detail 3.B — Error Message Catalog
| Error code | User-facing message (i18n key) | Surface (toast/inline/banner) | User-facing? |
|---|---|---|---|
| branding_fetch_failed | (none) | (silent — no UI) | no |
| branding_invalid_payload | (none — log + default brand) | (silent) | no |
By design, branding failures are never shown to the user; the app simply stays on its current brand.
Detail 3.C — Accessibility
- WCAG level: AA.
- Keyboard navigation: unchanged (no new interactive elements beyond a logo image).
- Focus management: unchanged.
- ARIA labels:
<BrandLogo/>image has meaningful/emptyaltper variant. - Color contrast: the key a11y risk — arbitrary tenant brand colors vs
text-inversemust stay AA-legible. v1 relies on a per-tenant design-QA check (§5 OQ1); a future enhancement could compute contrast and warn. prefers-reduced-motion: n/a (no animation introduced).
4. Backwards Compatibility and Rollout Plan
Compatibility
- API contracts changed: none (new consumed endpoint).
- Saved client state: new
localStorage["branding:cache"]key; guarded parse — a missing or malformed value is a cache miss, never an error. Removing the feature simply orphans the key. - Old bundle / CDN cache invalidation: standard hub-chat deploy; no special handling.
Rollout Strategy
- Feature flag:
branding.whitelabel, default off. Preferred provisioner: backendAppConfigStore(/api/core/v1/client_configs/config) so it can be enabled per-org without a redeploy (mirrors existing flags likeseamless_auth); a build-timeconfigs/*.jsonfallback is acceptable for the first internal stage. (§5 OQ4 — confirm which.) - Rollout stages:
- Internal / staging — flag on for a test tenant with a mocked/real
/branding; verify themedMpButton+ favicon + title on a next-theme route (/inbox). - 1 pilot tenant — real
/branding, real CDN assets; design-QA contrast sign-off. - 10% / selected tenants — monitor
branding_fetch_failedrate + LCP. - 100% of branded tenants — non-branded tenants keep flag off → default Qontak brand.
- Internal / staging — flag on for a test tenant with a mocked/real
- Stop conditions:
branding_fetch_failedrate > 2% of branded sessions; any FODT regression; any contrast/legibility complaint; LCP regression on themed routes. - Rollback mechanism: toggle
branding.whitelabeloff → next boot renders default brand; mid-session users are unaffected until reload. - Blast radius: worst case = a bad global
:rootoverride on every themed route for branded tenants. Mitigated by the flag (instant off) + color-value validation + cache-keeps-last-good. - PIC + timeline per stage: TBD with the Chat squad lead.
Detail 4.A — Configuration Contract
| Env var / build config / flag | Type | Default | Required | Provisioner |
|---|---|---|---|---|
branding.whitelabel (AppConfig flag) | boolean | false | yes | backend client_configs (preferred) or configs/*.json |
BRANDING_API_BASE_URL → runtimeConfig.public.brandingApiBaseUrl | string (URL) | (unset → use edge /branding on current origin) | yes if service is not same-origin | configs/*.json (mirrors API_BASE_URL at configs/production.json:11) |
Detail 4.B — Test Plan (commands sourced from the repo)
| Layer | Command (source) | What it must prove |
|---|---|---|
| Unit — store | pnpm test common/store/BrandingStore.spec.ts (source: package.json test: "vitest --dom --pool=forks") | fetch resolves/rejects; status state-machine; cache read/write; no throw to boot on error |
| Unit — util | pnpm test common/utils/__tests__/applyBranding.spec.ts | :root gets each --mp-colors-*; favicon/font/link side-effects; invalid color rejected |
| Component | pnpm test common/components/__tests__/BrandLogo.spec.ts (pattern: common/components/__tests__/InputPeriod.spec.ts, // @vitest-environment nuxt) | renders tenant name/logo; falls back when store empty |
| Typecheck | pnpm type-check (source: package.json type-check: "vue-tsc --noEmit") | Branding types compile; no any leaks |
| Lint | pnpm lint (source: package.json lint: "pnpm lint:js && pnpm lint:prettier") | eslint + prettier clean |
| Build | pnpm build (source: package.json build: "nuxt build") | bundle builds; head defaults intact |
| Coverage | pnpm coverage (source: Makefile:12 → pnpm coverage = vitest run --dom --pool=forks --coverage) | new files covered (config include: common/**) |
| Visual regression | n/a — no visual-regression harness in repo (verified: no Playwright/Chromatic script in package.json); use component snapshot in the two-tenant test (BR-7) instead | themed button differs by brand color only |
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands to run | Acceptance criteria (verifiable) |
|---|---|---|---|---|
| 1 | Feature flag + config scaffolding | nuxt.config.ts (add brandingApiBaseUrl), configs/*.json (base URL + default flag), a useWhitelabelFlag read via AppConfigStore | pnpm type-check | flag reads false by default; runtimeConfig.public.brandingApiBaseUrl resolvable |
| 2 | BrandingStore + types + spec | common/store/BrandingStore.ts, common/store/BrandingStore.spec.ts | pnpm test common/store/BrandingStore.spec.ts | store models AppConfigStore; fetchBranding() resolves mock, status transitions; error → rejected, no throw |
| 3 | applyBranding util + spec (incl. color validation) | common/utils/applyBranding.ts, common/utils/__tests__/applyBranding.spec.ts | pnpm test common/utils/__tests__/applyBranding.spec.ts | :root receives each valid --mp-colors-*; invalid color value rejected; favicon/font <link> created; missing font/assets handled |
| 4 | <BrandLogo/> + head binding + spec | common/components/BrandLogo.vue, common/components/__tests__/BrandLogo.spec.ts | pnpm test common/components/__tests__/BrandLogo.spec.ts | renders store name/logo; default fallback when empty |
| 5 | Cache-first paint (localStorage) | common/store/BrandingStore.ts (cache read/write) | pnpm test common/store/BrandingStore.spec.ts | cached payload applied before network; fresh payload re-applied + re-cached; malformed cache = miss |
| 6 | Boot wiring behind flag | app.vue (onMounted, beside getAppConfig() L205) | pnpm test; pnpm build | flag off → no fetch, :root unmutated (snapshot == baseline); flag on → applies mock; fetch error never blocks boot |
| 7 | De-hardcode user-facing brand literals | nuxt.config.ts head, configs/*.json META, TheNavbar.vue, OneNavbar.vue, useSidebar.ts | pnpm lint; pnpm build; pnpm test | user-facing product-name renders come from store when flag on; default "Qontak Chat" when off; build/title intact |
| 8 | Two-tenant proof test (design-doc §6) | common/utils/__tests__/applyBranding.spec.ts (add cases) or a dedicated spec | pnpm test | same applyBranding call, two fixtures (#7A2FF2 / #0E9F6E) → two distinct :root color sets; identical code path |
Detail 4.D — Verification & Rollback Recipe
- Pre-merge verification commands (run in order):
pnpm lintpnpm type-checkpnpm testpnpm build
- Post-deploy verification signals:
- Datadog RUM:
branding_fetch_failedaction rate < 2% of branded sessions over first 30 min. - Analytics:
whitelabel.branding_appliedevents present withsourceset for the pilot tenant. - Manual: load the pilot tenant domain on a next-theme route (
/inbox) →MpButtonrenders brand color, favicon + tab title match tenant.
- Datadog RUM:
- Rollback recipe (in order):
- Toggle
branding.whitelabeloff (backendclient_configsfor the affected org, or config redeploy). - Confirm a fresh boot renders default Qontak brand (
:roothas no--mp-colors-*override; title = "Qontak Chat"). - If shipped via code and the flag is insufficient, revert the feature PR.
- Confirm
branding_fetch_failedreturns to ~0 and LCP on themed routes matches baseline.
- Toggle
5. Concern, Questions, or Known Limitations
Open Questions (blockers for Ready: yes):
- Design-QA owner + contrast rule. No Figma frame exists; who signs off that arbitrary
tenant brand colors stay WCAG-AA legible (esp.
text-inverseon brand fills)? Name a design-QA contact and a pass/fail rule (§1 Design References, §3.C). /brandingbase URL + credential behavior. Is/brandingserved same-origin via the edge on each tenant domain, or at a dedicated host needingbrandingApiBaseUrl? And will the backend tolerate$customFetchsending anAuthorizationheader when a token cookie happens to exist, or must hub-chat use raw$fetchwith no credentials? (§2.4, §3)- CSP allow-list. If a CSP is enforced, which origins go in
img-src/font-src/style-srcfor tenant CDN assets? Prefer one branding-CDN origin over arbitrary hosts. - Story list + flag provisioner. The linked doc is a design doc, not a PRD — confirm the
RFC-derived stories (BR-1…BR-7) and numbering with the PM, and confirm whether
branding.whitelabelis a backendclient_configsflag or a build-time config value.
Known limitations:
- next-theme coverage. The brand override only restyles components on routes in
NEXT_THEME_AVAILIBILITY(28 prefixes,nextThemeAvailibility.ts); legacy routes stay hard-codedblue.400(design-doc §4b). Full coverage is gated on the separate next-theme migration. Until then, branded tenants will see mixed theming across routes. - Payload token naming. v1 overrides
--mp-colors-brand-qontak; the payload is coupled to Pixel3 token names (accepted trade-off, design-doc Decision #1). A neutral--mp-colors-brand-primaryalias is a future DS-squad change. --mp-colors-text-inversenot referenced in hub-chat source. Confirmed 0 references in app code — the token is defined by Pixel3, so the override is valid, but its effect in hub-chat depends entirely on Pixel3 recipes using it under the next theme.
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-23 | RFC author | Initial draft from design doc + hub-chat grounding; 4 open questions pending before Ready: yes |
7. Ready for agent execution
- no
- The FE consumption layer is fully specified and its chunks (4.C 1–8) are executable and
verifiable against a local mock of
GET /brandingtoday. It is not yetyesbecause the following execution-readiness gates are open:- §1 Design References — no Figma frame (justified: no new UI), but the design-QA contact + contrast pass/fail rule is unresolved (§5 OQ1). A per-tenant color-legibility owner must be named before pilot.
- §2.4 API contract —
GET /brandingis needs-building; base URL + credential behavior + CSP allow-list are unresolved (§5 OQ2/OQ3). Chunks build against a mock, but end-to-end staging verification is blocked until the endpoint + edge route exist. - Detail 1.C stories — RFC-derived (the source is a design doc, not a PRD); the PM must confirm the story list and the flag provisioner (§5 OQ4).
- Everything else is met: Repo Reading Guide + complete Source Verification table, mermaid architecture/state/sequence (with failure paths), typed UI Contract + 5-state UI Matrix, Asset Inventory, Failure/Error catalogs, Configuration Contract (flag named, default off), ordered Agent Execution Plan with repo-sourced commands + verifiable acceptance criteria, and a concrete Verification & Rollback Recipe.
Once OQ1–OQ4 are resolved and the
/brandingcontract is frozen, flip this toyes. Optional next step: hand torfc-reviewerfor a second-pass score.