Skip to main content

06 — Theming & Tenant Configuration

Scope: the frontend perspective on multi-tenant theming. How a single shared codebase (apps/web-meta, apps/web-tenant-booking, apps/mobile, and the Electron renderer) renders every tenant's unique brand at runtime — without per-tenant forks, without sacrificing accessibility, performance, or security.

Source-of-truth domain model: services/theme-config-service/DOMAIN_MODEL.md. This document is the consumer-side spec for that domain — the data shape, the resolution path, and the runtime application.

Companions: ../README.md · 01-product-overview-frontend.md · 02-architecture-overview-frontend.md · 03-design-system.md · 09-non-functional-requirements.md · services/bff-tenant-booking-service/API_CONTRACTS.md (/bootstrap)


1. Goal

One shared codebase serving every tenant with their unique brand at runtime.

Concretely:

  1. No per-tenant code forks. Every tenant runs the same Next.js bundle and the same React Native binary; nothing tenant-specific is compiled in.
  2. Bounded customization. Tenants choose tokens (colors, type, spacing, radius, motion), layout presets (per surface), content blocks (per page), navigation, booking-flow toggles, and email theme — and nothing else. No raw HTML, no arbitrary CSS, no per-tenant React components.
  3. Accessible by construction. Token contrast, RTL parity, and font safety are validated at publish in theme-config-service; the frontend trusts published bundles without re-validating.
  4. Performant by construction. A theme bundle is gzipped < 50 KiB and CDN-edge cached; SSR injection is hydration-safe; no client JS theme switch in steady state.
  5. Reversible. Every publish is a new immutable ThemeVersion; rollback is a single API call (re-publishing a previous version), with the CDN re-busted.

This document is the contract between the frontend and theme-config-service. Anything implementable on the frontend that contradicts what's in theme-config-service/DOMAIN_MODEL.md is a bug — that document wins.


2. Token model

The token model is flat-by-category, semantic-by-name. Tenants override values, never names. New tokens require a registry-bumping migration in theme-config-service and coordinated deploys of the consumer apps.

CategoryPurposeExample tokens
Color (semantic)Brand roles + on-color pairs + status + interactionprimary, primaryHover, primaryActive, textOnPrimary, secondary, textOnSecondary, accent, textOnAccent, surface, surfaceMuted, surfaceElevated, textOnSurface, textOnSurfaceMuted, border, divider, success, textOnSuccess, warning, textOnWarning, error, textOnError, info, textOnInfo, focusRing, overlay
Typography pairsHeading + body + mono families with a modular scalefontFamilyHeading, fontFamilyBody, fontFamilyMono, baseSizePx ∈ {14, 16, 18}, scaleRatio ∈ [1.067, 1.5], derived size.{xs,sm,md,lg,xl,xxl}, weight.*, lineHeight.*, letterSpacing.*
Spacing scale4-px base by default; tenant may pick unit ∈ {4, 6, 8}unit, values.{0,1,2,3,4,6,8,12,16} + RTL-aware start.* / end.* derivatives
Radius scaleCorner roundnessnone: 0, sm, md, lg, pill: 9999
Shadow scaleElevationnone, sm, md, lg, focus
Motion scaleDurations + easingsdurationMsFast: 120, durationMsBase: 200, durationMsSlow: 320, easingStandard, easingEntrance, easingExit
DirectionDefault directionltr | rtl | auto (resolves per locale)

RTL-aware tokens. spacing.start and spacing.end are computed at publish time from spacing.values and serialized into the bundle. The frontend consumes them via logical CSS properties (padding-inline-start: var(--space-start-3)); raw left/right keys are forbidden in token definitions and would be rejected by theme-config-service invariant MELMASTOON.THEME.RTL_VARIANT_MISSING.

2.1 Contrast invariants (enforced at publish)

PairRequired ratioNotes
(textOnPrimary, primary)≥ 4.5WCAG AA body
(textOnSecondary, secondary)≥ 4.5
(textOnAccent, accent)≥ 4.5
(textOnSurface, surface)≥ 4.5
(textOnError, error)≥ 4.5
(textOnSuccess, success)≥ 4.5
(textOnWarning, warning)≥ 4.5
(textOnInfo, info)≥ 4.5
Large text (≥ 24 px or ≥ 19 px bold)≥ 3.0Computed per usage

Failures block publish with MELMASTOON.THEME.TOKEN_INVALID carrying errors[] per violating pair.


3. Token JSON shape

Mirrors theme-config-service/DOMAIN_MODEL.md §6. Reproduced here for frontend implementers.

// packages/contracts-melmastoon/src/theme.ts
export type HexColor = string & { readonly __brand: 'HexColor' }; // /^#[0-9a-fA-F]{6}$/

export interface ColorTokens {
primary: HexColor;
primaryHover: HexColor;
primaryActive: HexColor;
textOnPrimary: HexColor;

secondary: HexColor;
secondaryHover: HexColor;
textOnSecondary: HexColor;

accent: HexColor;
textOnAccent: HexColor;

surface: HexColor;
surfaceMuted: HexColor;
surfaceElevated: HexColor;
textOnSurface: HexColor;
textOnSurfaceMuted: HexColor;
border: HexColor;
divider: HexColor;

success: HexColor; textOnSuccess: HexColor;
warning: HexColor; textOnWarning: HexColor;
error: HexColor; textOnError: HexColor;
info: HexColor; textOnInfo: HexColor;

focusRing: HexColor;
overlay: HexColor; // rgba(0,0,0,.5) form allowed here
}

export interface TypographyTokens {
fontFamilyHeading: string; // CSS family stack
fontFamilyBody: string;
fontFamilyMono: string;
baseSizePx: 14 | 16 | 18;
scaleRatio: number; // [1.067, 1.5]
size: { xs: number; sm: number; md: number; lg: number; xl: number; xxl: number };
weight: { regular: 400; medium: 500; semibold: 600; bold: 700 };
lineHeight: { tight: 1.2; snug: 1.35; normal: 1.5; relaxed: 1.7 };
letterSpacing: { tight: number; normal: number; wide: number };
}

export interface SpacingScale {
unit: 4 | 6 | 8;
values: { 0: 0; 1: number; 2: number; 3: number; 4: number; 6: number; 8: number; 12: number; 16: number };
start: SpacingScale['values']; // logical-property start (RTL-aware)
end: SpacingScale['values']; // logical-property end
}

export interface RadiusScale { none: 0; sm: number; md: number; lg: number; pill: 9999 }
export interface ShadowScale { none: 'none'; sm: string; md: string; lg: string; focus: string }
export interface MotionScale {
durationMsFast: number; durationMsBase: number; durationMsSlow: number;
easingStandard: string; easingEntrance: string; easingExit: string;
}

export interface DesignTokens {
color: ColorTokens;
typography: TypographyTokens;
spacing: SpacingScale;
radius: RadiusScale;
shadow: ShadowScale;
motion: MotionScale;
direction: 'ltr' | 'rtl' | 'auto';
}

3.1 Concrete JSON example (Kabul Grand Hotel — ps-AF default)

{
"color": {
"primary": "#1F6F4A",
"primaryHover": "#185A3C",
"primaryActive": "#11422C",
"textOnPrimary": "#FFFFFF",
"secondary": "#C2A04E",
"secondaryHover": "#A88838",
"textOnSecondary":"#1A1A1A",
"accent": "#0E3B27",
"textOnAccent": "#FFFFFF",
"surface": "#F8F4EC",
"surfaceMuted": "#EFE9DC",
"surfaceElevated":"#FFFFFF",
"textOnSurface": "#1A1A1A",
"textOnSurfaceMuted":"#5C5C5C",
"border": "#D9D2C0",
"divider": "#E8E2D0",
"success":"#16A34A","textOnSuccess":"#FFFFFF",
"warning":"#D97706","textOnWarning":"#1A1A1A",
"error": "#DC2626","textOnError": "#FFFFFF",
"info": "#2563EB","textOnInfo": "#FFFFFF",
"focusRing": "#1F6F4A",
"overlay": "rgba(15,23,42,0.55)"
},
"typography": {
"fontFamilyHeading": "\"Vazirmatn\", \"Noto Naskh Arabic\", system-ui, sans-serif",
"fontFamilyBody": "\"Vazirmatn\", \"Noto Naskh Arabic\", system-ui, sans-serif",
"fontFamilyMono": "\"JetBrains Mono\", ui-monospace, monospace",
"baseSizePx": 16, "scaleRatio": 1.2,
"size": { "xs": 12, "sm": 14, "md": 16, "lg": 19, "xl": 23, "xxl": 28 },
"weight": { "regular": 400, "medium": 500, "semibold": 600, "bold": 700 },
"lineHeight": { "tight": 1.2, "snug": 1.35, "normal": 1.5, "relaxed": 1.7 },
"letterSpacing": { "tight": -0.01, "normal": 0, "wide": 0.02 }
},
"spacing": {
"unit": 4,
"values": { "0": 0, "1": 4, "2": 8, "3": 12, "4": 16, "6": 24, "8": 32, "12": 48, "16": 64 },
"start": { "0": 0, "1": 4, "2": 8, "3": 12, "4": 16, "6": 24, "8": 32, "12": 48, "16": 64 },
"end": { "0": 0, "1": 4, "2": 8, "3": 12, "4": 16, "6": 24, "8": 32, "12": 48, "16": 64 }
},
"radius": { "none": 0, "sm": 4, "md": 8, "lg": 16, "pill": 9999 },
"shadow": {
"none": "none",
"sm": "0 1px 2px rgba(0,0,0,.05), 0 1px 4px rgba(0,0,0,.06)",
"md": "0 4px 16px rgba(0,0,0,.08)",
"lg": "0 12px 32px rgba(0,0,0,.12)",
"focus":"0 0 0 3px rgba(31,111,74,.35)"
},
"motion": {
"durationMsFast": 120, "durationMsBase": 200, "durationMsSlow": 320,
"easingStandard": "cubic-bezier(0.2, 0, 0, 1)",
"easingEntrance": "cubic-bezier(0.0, 0, 0.2, 1)",
"easingExit": "cubic-bezier(0.4, 0, 1, 1)"
},
"direction": "auto"
}

4. Layout presets

Tenants pick one preset per surface; presets are platform-registered, validated for RTL, and contractually declare which content slots they accept.

SurfacePreset keyDescriptionSlots accepted
homehero-with-searchFull-bleed hero (image or video) + overlaid search widget; below-fold: about / amenities / featured rooms / testimonialsabout_us, amenities_grid, gallery, testimonials, faq, contact, policies, location_map, custom_markdown
listingmosaic-gridPhoto-forward 12-up mosaic of room types with overlay inforoom cards (system) + custom_markdown between rows
listinglist-with-mapTwo-column results: list left, sticky map rightroom cards + system filters
detailgallery-top-info-bottomHero photo + 4-up gallery; tabs (Overview / Rooms / Amenities / Reviews / Policies / Location)gallery, amenities_grid, policies, location_map, testimonials
detailstory-pageLong-form scrollable narrative with anchored photos and inline room CTAscustom_markdown, gallery, amenities_grid
bookingvertical-stepperOne step per screen; visible progress(system)
bookingsingle-pageAll steps stacked on one scrollable page(system)
post_staythank-you-with-review-ctaSummary card + review promptcustom_markdown

4.1 Preset registry contract

export interface LayoutPreset {
readonly id: LayoutPresetId; // 'lpr_HERO_WITH_SEARCH'
readonly key: string; // 'hero-with-search'
readonly title: string; // for the authoring console
readonly applicableSurfaces: SurfaceKey[]; // ['home']
readonly supportsRtl: boolean; // must be true for RTL tenants
readonly previewImageUrl: string;
readonly minTokens: ReadonlyArray<keyof DesignTokens['color']>; // tokens this preset *uses*
readonly acceptedBlockKinds: ReadonlyArray<ContentBlockKind>;
readonly version: number;
readonly createdAt: ISODate;
readonly deprecatedAt: ISODate | null;
}

A preset that's deprecated is still rendered for tenants currently using it; new selections of it are rejected.


5. Content blocks

Typed, schema-validated, multi-language-aware blocks. Tenants attach blocks to surfaces; the preset determines what's allowed where. Body content is sanitized against an allow-list (no <script>, no inline event handlers, no javascript: URLs).

KindPurposeRequired metaMulti-language fieldsRendered by primitive
about_usBrand storyheroImage?, cta?body<AboutBlock />
amenities_gridAmenity tilesitems[] of { iconKey, titleKey, bodyKey }resolved from LocalePack<AmenitiesGrid />
galleryPhoto gallerymedia[]: MediaRef, layout: 'mosaic'|'carousel'|'grid'alt text per locale<GalleryGrid />
testimonialsGuest quotesitems[] of { author, quoteI18n, rating? }quote per locale<TestimonialCard /> (×N)
faqFAQ listitems[] of { questionI18n, answerI18n }per locale<FAQ /> (accordion)
contactContact infophone? email? whatsapp? addressI18n?address per locale<ContactBlock />
policiesTenant policiesdocuments[] of { typeKey, bodyI18n }per locale<PoliciesBlock />
location_mapMap embedlat, lng, zoom, markerLabelKey?label via LocalePack<PropertyMap />
custom_markdownFree-form sanitized markdowntitleI18n?body<MarkdownBlock />

5.1 Schema sketch

type Locale = string; // BCP-47

export interface I18nMarkup {
entries: Map<Locale, { format: 'markdown'|'html_safe'; content: string; aiProvenance?: AIProvenance; updatedAt: ISODate; updatedBy: UserId | null; }>;
}

export type ContentBlockKind =
| 'about_us' | 'amenities_grid' | 'gallery' | 'testimonials'
| 'faq' | 'contact' | 'policies' | 'location_map' | 'custom_markdown';

export interface ContentBlock {
id: ContentBlockId;
themeVersionId: ThemeVersionId;
surface: 'home' | 'listing' | 'detail' | 'booking' | 'post_stay';
kind: ContentBlockKind;
ordinal: number; // unique per (themeVersionId, surface)
visible: boolean;
body: I18nMarkup;
meta: ContentBlockMeta; // discriminated union (see theme-config-service DOMAIN_MODEL §8.2)
}

5.2 Rendering rules

  • The renderer resolves locale → falls back per the chain → renders the matched MarkupEntry.
  • Markdown is parsed by a single shared parser (@ghasi/markdown-safe) wrapping markdown-it with the platform allow-list. No app-level dangerouslySetInnerHTML outside of this primitive.
  • Missing locale entries fall back; if even the default-locale entry is missing, the block does not render and a theme.broken_block telemetry event is emitted (non-blocking; non-user-visible).
  • Blocks marked visible: false are SSR-skipped (not just CSS-hidden) to keep the DOM clean.

6. Navigation

One NavigationConfig per (themeVersionId, surface ∈ {header, footer, mobile_drawer}). Tree depth ≤ 3, ≤ 8 top-level items per surface.

export interface MenuItem {
id: string; // stable across saves
labelI18n: I18nMarkup; // typically plain text per locale
target:
| { kind: 'route'; routeId: string; params?: Record<string, string> }
| { kind: 'external'; href: string; openInNewTab: boolean }
| { kind: 'anchor'; surface: SurfaceKey; blockOrdinal: number };
icon?: string; // icon-key from @ghasi/icons
visible: boolean;
children: MenuItem[]; // recursive; ≤ depth 3
}
SurfaceRendererRTL behavior
header<HeaderNav /> (web), <TabHeader /> (mobile)Items reverse; chevron icons mirror
footer<FooterNav />Multi-column on web; single accordion on mobile
mobile_drawer<MobileDrawer />Slide-in from end-side; overlay scrim

Per-locale label overrides live in labelI18n.entries; missing locales fall back to the chain.


7. Booking flow config

Per theme-config-service/DOMAIN_MODEL.md §10. The frontend honors what bootstrap.flowConfig returns — it never independently decides what step to show.

export type BookingStepKey =
| 'select_dates' | 'select_room' | 'select_rate' | 'guest_details'
| 'add_ons' | 'review' | 'payment' | 'confirmation';

export interface BookingFlowConfig {
steps: BookingStepKey[]; // ordered subset; mandatory: select_dates, select_room, review, payment, confirmation
fieldRequirements: Record<BookingStepKey, FieldRequirement[]>;
toggles: BookingFlowToggles;
}

export interface FieldRequirement {
field: string; // 'guest.passportNumber', 'guest.dateOfBirth', ...
required: boolean;
visibleByDefault: boolean;
helperTextKey?: string; // resolves via LocalePack
}

export interface BookingFlowToggles {
captureGuestPassport: boolean;
captureGuestNationalId: boolean;
requestAirportTransport: boolean;
allowGiftBooking: boolean;
requireGuestSignature: boolean;
showCancellationPolicySummary: boolean;
showPriceBreakdown: 'always' | 'on_review' | 'never';
allowCashOnArrival: boolean;
allowPayLaterByLink: boolean;
promptForReviewOnPostStay: boolean;
}

7.1 Per-tenant override examples

ToggleUse case
captureGuestPassport: trueAF-tax-resident bookings need passport capture for invoicing
requestAirportTransport: trueHotels with shuttle service surface a free check-box
allowCashOnArrival: falseTenants who refuse cash (rare; requires explicit override)
requireGuestSignature: trueBoutique properties wanting a signed liability waiver — surfaces a native signature pad on mobile / canvas on web
allowGiftBooking: trueBooker pays; guest receives credentials; UI adds a "Gift to" section
showPriceBreakdown: 'on_review'Less commitment-heavy presentation

7.2 Step inclusion examples

// Minimal flow (cash-only guesthouse)
{ "steps": ["select_dates", "select_room", "review", "confirmation"],
"toggles": { "allowCashOnArrival": true, "showPriceBreakdown": "always", ... } }

// Full flow with signature
{ "steps": ["select_dates", "select_room", "select_rate", "add_ons", "guest_details", "review", "payment", "confirmation"],
"toggles": { "requireGuestSignature": true, ... } }

Invariants enforced by theme-config-service (frontend trusts):

  • Mandatory steps cannot be removed.
  • requireGuestSignature: truereview step present.
  • allowCashOnArrival: falsepayment step present and online.

8. Email theme

notification-service consumes the published EmailTheme to render transactional emails (booking confirmation, reminder, receipt, post-stay review request). Email-safe constraints differ from web/native:

export interface EmailTheme {
brand: {
logoUrl: MediaRef; // height target 48 px in email
logoAltKey: string;
headerBackground: HexColor;
headerForeground: HexColor;
primary: HexColor; // CTA button background
textOnPrimary: HexColor;
surface: HexColor;
textOnSurface: HexColor;
};
typography: { fontFamilyEmail: string; baseSizePx: 14 | 15 | 16 };
footer: {
addressI18n: I18nMarkup;
socialLinks: Array<{ platform: 'whatsapp'|'facebook'|'instagram'|'x'|'tiktok'|'youtube'; href: string }>;
legalI18n: I18nMarkup;
showPoweredByMelmastoon: boolean;
};
}

Constraints:

  • No @import web fonts. Email-safe stacks only. Validated at publish (MELMASTOON.THEME.EMAIL_FONT_UNSAFE).
  • Logo ≤ 1 MiB, 2× resolution required for retina; failed asset → MELMASTOON.THEME.ASSET_TOO_LARGE.
  • Contrast ≥ 4.5 on (textOnPrimary, primary) and (textOnSurface, surface).
  • Locale-aware footer. Address and legal text in every enabled locale; default-locale entry mandatory.

The notification-service template renderer interpolates these tokens into MJML templates; the frontend code does not render email — listed here for completeness so the theming surface is coherent end-to-end.


9. Token resolution path

Browser request: GET https://kabul-grand-hotel.melmastoon.app/


Cloud CDN (cache HIT? serve)
│ MISS

Cloud Run: bff-tenant-booking-service
┌──────────────────────────────────────────────────────────┐
│ TenantContextGuard: slug → tenantId │
│ BootstrapAssembler: │
│ ├─ tenant-service: tenant meta │
│ ├─ theme-config-service: active ThemeVersion │
│ │ (Memorystore-cached; bundle URL on Cloud CDN) │
│ ├─ pricing-service: currencies │
│ ├─ payment-gateway-service: payment methods │
│ ├─ policies-service: policy refs │
│ └─ optional handoff consume │
│ Compose ──▶ BootstrapResponse │
└──────────────────────────────────────────────────────────┘


Next.js SSR layout reads bootstrap, emits:
<html dir lang>
<link rel="stylesheet" href="https://cdn..../themes/{thv}.css">
<style>:root { --color-primary: #...; ... }</style>
<ThemeProvider value={tokens}>{children}</ThemeProvider>


Hydrated React tree
Cache layerKeyTTLInvalidation
Cloud CDN edgetenant-booking:bootstrap:{slug}:{locale}:{currency}30 s + SWR 60 smelmastoon.theme.cdn_cache_invalidated.v1 triggers tag-based purge
Memorystore (BFF tier)bff:tenant:bootstrap:{tenantId}:{themeVersion}:{tenantConfigVersion}5 minSame event + new themeVersion/tenantConfigVersion value
Theme bundle (CSS, JSON)themes/{themeVersionId}.css on GCS + Cloud CDNimmutable; long max-ageNever — versions are immutable; new publish = new id
Browser HTTP cacheper Cache-Control headerper-routeStandard

The mobile app calls GET /bootstrap directly; the same Memorystore-tier cache is the source of truth. There is no service worker cache for bootstrap on mobile (we want freshness on app foreground).


10. Token application on web

10.1 SSR injection

In app/layout.tsx:

import { getBootstrap } from '@ghasi/api-clients';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
const slug = await resolveTenantSlug(); // from headers (host or path)
const bootstrap = await getBootstrap(slug); // server fetch
const dir = bootstrap.theme.isRtl ? 'rtl' : 'ltr';

return (
<html dir={dir} lang={bootstrap.theme.locale}>
<head>
<link rel="stylesheet" href={bootstrap.theme.designTokensCssUrl} />
<style nonce={bootstrap.csp.nonce}>{`:root { ${cssVarsFromTokens(bootstrap.theme.brandColors)} }`}</style>
</head>
<body>
<ThemeProvider value={bootstrap}>{children}</ThemeProvider>
</body>
</html>
);
}

The <link> is the published, immutable token bundle (CSS + a JSON sidecar for client-side primitives that need to read raw values). The inline <style> is the override layer allowing per-request cookie-overridable values (e.g., a future high-contrast mode toggle).

10.2 Tailwind preset

packages/ui-melmastoon/tailwind.preset.ts maps Tailwind utilities to CSS variables:

export const preset = {
theme: {
colors: {
primary: 'rgb(var(--color-primary) / <alpha-value>)',
'primary-hover':'rgb(var(--color-primary-hover) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
'on-surface': 'rgb(var(--color-text-on-surface) / <alpha-value>)',
// ...
},
spacing: { 0: '0', 1: 'var(--space-1)', 2: 'var(--space-2)', /* ... */ },
fontFamily: { sans: 'var(--font-family-body)', heading: 'var(--font-family-heading)', mono: 'var(--font-family-mono)' },
borderRadius: { none: '0', sm: 'var(--radius-sm)', md: 'var(--radius-md)', lg: 'var(--radius-lg)', pill: '9999px' },
boxShadow: { sm: 'var(--shadow-sm)', md: 'var(--shadow-md)', lg: 'var(--shadow-lg)', focus: 'var(--shadow-focus)' },
},
};

App authors write bg-primary text-on-primary rounded-md shadow-md p-4. There is no client-side JS theme switch in steady state — full SSR avoids hydration flicker entirely.

10.3 Where JS reads tokens

For primitives that need raw values (motion scale for Framer Motion, color for canvas/SVG fills), useTheme() returns the JSON sidecar parsed once during bootstrap. Identity-stable to keep useMemo dependents quiet.


11. Token application on mobile

React Native does not have CSS variables; tokens flow through React context.

// apps/mobile/App.tsx
const App = () => {
const { data: bootstrap, isLoading } = useBootstrap();
if (isLoading || !bootstrap) return <SplashScreen />;
return (
<ThemeProvider value={bootstrap}>
<DirectionProvider isRtl={bootstrap.theme.isRtl}>
<RootStack />
</DirectionProvider>
</ThemeProvider>
);
};

Primitives consume useTheme():

const Button = ({ children, ...props }: ButtonProps) => {
const t = useTheme();
return (
<Pressable
style={({ pressed }) => ({
backgroundColor: pressed ? t.color.primaryHover : t.color.primary,
paddingHorizontal: t.spacing.values[4],
paddingVertical: t.spacing.values[2],
borderRadius: t.radius.md,
})}
{...props}
>
<Text style={{ color: t.color.textOnPrimary, fontFamily: t.typography.fontFamilyBody }}>{children}</Text>
</Pressable>
);
};

11.1 Tenant switching

Switching from tenant A to tenant B (e.g., user backs out of one booking, picks another property from search) triggers:

  1. BookingStack is fully unmounted (navigation.popToTop(); navigation.navigate('Discover')).
  2. New BootstrapTenantScreen fetches GET /bootstrap for B.
  3. ThemeProvider swaps; BookingStack is re-mounted with B's theme.

This explicit unmount avoids any token-mismatch flash mid-screen at the cost of ~120 ms of perceived loading on tenant switch — an acceptable trade.

11.2 RTL on mobile

I18nManager.forceRTL(isRtl) is called once at app start, before any rendering. Changing direction at runtime requires Updates.reloadAsync() (Expo) to take full effect. The settings screen warns the user before language change.


12. RTL/LTR derivation

ConcernMechanism
Direction at root<html dir> (web), I18nManager.forceRTL (mobile)
Logical CSS propertiespadding-inline-start, margin-inline-end, inset-inline-start, border-start-start-radius. Tailwind preset exposes ps-*, pe-*, ms-*, me-*, start-*, end-*. App code never uses raw left/right.
Mirrored iconsEach icon in @ghasi/icons declares mirrorOnRtl: boolean. <Icon name="chevron-right" /> flips automatically when dir==='rtl'. Brand glyphs and media playback do not mirror.
Stable numeralsNumerals rule (F4): Money, dates, quantities, IDs, and financial confirmations MUST use Latin numerals across all locales and all surfaces (finance audit invariant enforced at the BFF serialization layer). Tenant locale-packs MAY opt in to Persian-Indic or Arabic-Indic numerals for narrative body copy only (e.g., blog posts, room descriptions). The opt-in is per numeral_variant field in the locale-pack manifest; the BFF validates the field at publish. Canonical reference: 09-non-functional-requirements.md §2 i18n.
Bi-directional text<BidiText value={...} /> injects Unicode bidi control marks per the locale and content type.
Layout flipsRows reverse via flex-direction: row-reverse only when explicitly intended (e.g., custom carousel arrows); default reliance is on logical properties + direction.
FormsInputs render with text-alignment matching the locale of their value, not the page direction (so an English email field on a Pashto page is left-aligned).

13. Multi-language strategy

LayerSourceResolution
UI strings@ghasi/i18n (next-intl-compatible JSON per locale)useTranslations('ns') / useTranslations('booking') etc.
Tenant content (blocks)theme-config-service LocalePack + I18nMarkup.entriesrequested locale → tenant.fallbackChain → platform en/en-US
Server-formatted stringsBFF display.formatted (totals, dates)server-side Intl
CalendarsServer emits ISO-8601 Gregorian; presentational variants computed client-sideIntl.DateTimeFormat(locale + '-u-ca-' + calendar)
NumeralsUI: Latin (locked). Narrative: per locale-pack opt-in.Intl.NumberFormat({ numberingSystem: 'latn' }) for prices

Missing translations behave as follows: at runtime we fall back through the chain; we never show the raw key (cta.book_now literally) — fallbacks always end at a real string in the platform default locale. CI scans for missing default-locale entries and blocks PRs that introduce them.


14. Preview workflow

A tenant administrator working in the backoffice or the control plane can preview an unpublished ThemeVersion (status draft or preview_ready) and share it with stakeholders without publishing.

┌──────────────────────────────────────────────────────────────────────┐
│ Tenant admin (theme-config-service authoring console) │
│ 1. Edit draft ThemeVersion (tokens / blocks / nav / flow) │
│ 2. POST /theme-versions/{id}/preview │
│ 3. Receive PreviewToken (TTL ≤ 7 days) │
│ 4. Share signed URL: https://<tenant>.melmastoon.app/?preview=pvt_XYZ
└──────────────────────────────────────────────────────────────────────┘


Browser hits the preview URL


bff-tenant-booking-service:
├─ Verify PreviewToken (sha256 hash + not revoked + not expired)
├─ Fetch the *draft* ThemeVersion bundle (NOT the active publication)
├─ Compose bootstrap with draft tokens, blocks, nav, flow
└─ Set Cache-Control: no-store; X-Preview-Mode: pvt_XYZ


SSR renders with draft theme; banner pinned: "Preview — not live"

Constraints:

  • Preview URLs are not indexable (X-Robots-Tag: noindex, nofollow and <meta name="robots" content="noindex">).
  • Preview state never affects analytics or reservation creation (preview mode rejects POST /quote with MELMASTOON.BFF.TENANT.PREVIEW_MODE_READ_ONLY).
  • Edits to the draft revoke all outstanding PreviewTokens for that version atomically (per theme-config-service invariant P4).

15. Publishing workflow

draft ──▶ preview_ready ──▶ published (atomic flip; OCC-checked)

├─ previous published version → archived (reason: superseded)
├─ CDN cache busted (tag: theme:{themeId})
├─ Memorystore key rotated (themeVersion bumped)
└─ analytics records publish event

Sequence for the frontend:

  1. Backoffice publishes via POST /theme-versions/{id}/publish (server-side workflow inside theme-config-service).
  2. theme-config-service runs final validation (token contrast, RTL parity, asset HEAD checks, AI-content HITL approval if applicable, booking-flow consistency).
  3. On success, the platform emits melmastoon.theme.published.v1 (and melmastoon.theme.cdn_cache_invalidated.v1).
  4. bff-tenant-booking-service consumes the event and rotates its in-memory (themeVersion, tenantConfigVersion) cache key.
  5. Cloud CDN purges by tag theme:{themeId}.
  6. Next user request gets the new bundle. Cold cache; ~30 ms additional latency on first request post-publish.

Failures roll back; the previous publication remains active. Authoring console surfaces each failure with its MELMASTOON.THEME.* code.


16. Rollback workflow

The active publication can be rolled back to any prior ThemeVersion (status archived with reason superseded) in O(1) — no edit, no diff, just re-publish.

StepWhereEffect
Operator opens Versions tabBackoffice authoring consoleLists published versions with publishedAt, publishedBy, ordinal, summary diff
Operator clicks Restore this versionUI → POST /themes/{id}/rollback (body: { versionId })Server-side: new ThemePublication activated pointing at the chosen version; previous active version transitions to archived (reason: rolled_back)
Cache bustsSame chain as publishCDN tag purge + Memorystore rotation
AuditYes — melmastoon.theme.rolled_back.v1 event with previousVersionId, restoredVersionId, actorUserId, reasonText?Always logged; visible in the audit timeline

Rollback is idempotent — clicking twice produces one rollback then a no-op.


17. Per-tenant feature toggles

Beyond the booking-flow toggles in §7, tenants can flip these display-level features:

ToggleDefaultSurfaces affectedBehavior
showHalalKitchenIndicatortrue (tenants in MENA / SCA markets)listing card, detailRenders the halal-kitchen badge; gated to properties with the amenity
showWomenOnlyFloorIndicatorfalselisting card, detailSurfaces women-only-floor badge if the property declares it
showPrayerRoomIndicatortrue (MENA / SCA)listing card, detailSame
requestTransportFromAirportfalsebooking → add_ons / guest_detailsAdds an opt-in checkbox plus a free-text "preferred pickup time"
allowGiftBookingfalsebooking → guest_detailsAdds "Booking on behalf of" section; recipient receives confirmation
requireGuestSignaturefalsebooking → reviewAdds signature pad (canvas web / native pad mobile)
enableMobileKeytenant-specific (per their lock vendor)post-confirmation, mobile pushAdds KeyCredentialBadge to confirmation view; triggers push when issued
showPoweredByMelmastoontruetenant booking footer + email themeOff requires paid white-label tier
enableHighContrastThemeplatform-default trueglobalSettings/preferences toggle exposed to guests

Toggles live in theme-config-service BookingFlowToggles (where applicable) or in tenant-service settings (general display); the BFF aggregates both into bootstrap.


18. Performance

ConcernTargetMechanism
Theme bundle (CSS + sidecar JSON, gzipped)< 50 KiBToken-only bundle; no fonts inlined; aggressive sweep of unused tokens at publish
CDN edge cache hit-rate≥ 95% post-warmImmutable bundle URLs (/themes/{themeVersionId}.css); Cache-Control: public, max-age=31536000, immutable
Cold first byte (post-publish)< 350 ms p95Memorystore preload on publish event; cross-AZ replication
SSR theme injection cost< 5 ms p95 added to TTFBToken JSON parse cached per-process; <style> emit is template literal
Mobile bootstrap parse + apply< 80 ms p95 on Pixel 4aSingle JSON parse; ThemeProvider memoizes once
Tenant switch (mobile)< 200 ms p95Pre-warm next tenant bootstrap when user opens detail view from search

Bundle composition guarantee (validated at publish): tokens-only ≤ 30 KiB; layout/blocks/nav/flow JSON ≤ 18 KiB. Locale packs are separate bundles, requested per active locale only.


19. Testing

ConcernToolFrequency
Visual regression per major theme comboChromatic — fixtures: 6 token presets × 4 locales (en, ps-AF, ar-SA, fa-IR) × 2 directionsEvery PR touching packages/ui-melmastoon, theme-config-service token shape, or content-block primitives
Contrast checks per token combinationCustom CI step using the same contrastRatio function as theme-config-serviceEvery PR; gates on AA failures
RTL screenshot testsPlaywright (web), Detox + ScreenShoter (mobile) — every primary screen in dir=rtlEvery PR touching renderer or token plumbing
Locale completeness auditScript reads LocalePackKeyRegistry, verifies every required key present per enabled locale across fixture themesNightly cron; PR comment with diff
Bundle size budgetsize-limit against the published bundle artifactEvery publish in pre-prod
Hydration mismatch detectionReact strict + a Playwright assertion that console.error is empty on first paintEvery e2e run
Preview-mode read-only invariantE2E asserts that POST /quote in preview mode returns MELMASTOON.BFF.TENANT.PREVIEW_MODE_READ_ONLYEvery release
Publish-then-rollback dry runSynthetic test publishes a known-good version, rolls back, asserts CDN serves the previous bundle within 60 sPre-prod nightly

20. Anti-patterns

Anti-patternWhy it's bannedCorrect approach
Hardcoded hex colors anywhere in apps/* or packages/feature-*Defeats tenant theming; trips contrast invariantsUse semantic tokens via Tailwind utilities (web) or useTheme() (mobile)
Tenant-specific React components (KabulGrandHero.tsx) in shared codebaseDoesn't scale; per-tenant code requires deploysCompose a hero-with-search layout + gallery content block driven by tenant config
Bypassing the BFF theme bootstrap (e.g., direct call to theme-config-service from the SPA)Skips Memorystore cache, CSP nonce, locale negotiation, suspended-tenant guardAlways use GET /bootstrap from bff-tenant-booking-service
Mutating tokens at runtime in JS (document.documentElement.style.setProperty('--color-primary', '#abc'))Hydration mismatch; visual jank; bypasses invariantsTheme switching is a server-side new-version publish
Storing raw HTML from tenant input and rendering with dangerouslySetInnerHTMLXSS surface; bypasses sanitizationOnly render I18nMarkup through <MarkdownBlock /> (which uses the platform-shared sanitizer)
Per-tenant route paths or per-tenant Next.js pagesTenant-coupled code; routing surface explodesRouting is identical per tenant; variation comes from content blocks and presets
Embedding tenant assets (logos, photos) directly in the bundleDefeats CDN; bloat; per-tenant invalidation impossibleAlways reference via MediaRef URLs; never bundled
Using left/right CSS properties or marginLeft/marginRight style propsBreaks RTL silentlyUse logical properties / ms-* / me-* Tailwind utilities
Relying on prefers-color-scheme for tenant themingConflicts with tenant brand intentTenant themes are explicit; light/dark variants are tenant-published, not OS-driven
Skipping the contrast / preset validation locally because "publish will catch it"Slows feedback; surfaces broken builds in shared environmentsRun the same validators in pre-commit hooks (provided by @ghasi/ui-melmastoon)
Writing custom email templates outside the EmailTheme shapeBreaks transactional consistency, locale fallback, audit trailAlways use the templates in notification-service; tenant control is the EmailTheme block
Using useEffect to fetch the bootstrap from the client on every page loadHydration-mismatch + perceived flash + double round-tripBootstrap is fetched in the SSR Server Component; client reads from context

21. References