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: true ⇒ review step present.
  • allowCashOnArrival: false ⇒ payment 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​