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:
- 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.
- 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.
- 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. - 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.
- 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.
| Category | Purpose | Example tokens |
|---|---|---|
| Color (semantic) | Brand roles + on-color pairs + status + interaction | primary, 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 pairs | Heading + body + mono families with a modular scale | fontFamilyHeading, fontFamilyBody, fontFamilyMono, baseSizePx ∈ {14, 16, 18}, scaleRatio ∈ [1.067, 1.5], derived size.{xs,sm,md,lg,xl,xxl}, weight.*, lineHeight.*, letterSpacing.* |
| Spacing scale | 4-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 scale | Corner roundness | none: 0, sm, md, lg, pill: 9999 |
| Shadow scale | Elevation | none, sm, md, lg, focus |
| Motion scale | Durations + easings | durationMsFast: 120, durationMsBase: 200, durationMsSlow: 320, easingStandard, easingEntrance, easingExit |
| Direction | Default direction | ltr | rtl | auto (resolves per locale) |
RTL-aware tokens.
spacing.startandspacing.endare computed at publish time fromspacing.valuesand serialized into the bundle. The frontend consumes them via logical CSS properties (padding-inline-start: var(--space-start-3)); rawleft/rightkeys are forbidden in token definitions and would be rejected bytheme-config-serviceinvariantMELMASTOON.THEME.RTL_VARIANT_MISSING.
2.1 Contrast invariants (enforced at publish)
| Pair | Required ratio | Notes |
|---|---|---|
(textOnPrimary, primary) | ≥ 4.5 | WCAG 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.0 | Computed 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.
| Surface | Preset key | Description | Slots accepted |
|---|---|---|---|
home | hero-with-search | Full-bleed hero (image or video) + overlaid search widget; below-fold: about / amenities / featured rooms / testimonials | about_us, amenities_grid, gallery, testimonials, faq, contact, policies, location_map, custom_markdown |
listing | mosaic-grid | Photo-forward 12-up mosaic of room types with overlay info | room cards (system) + custom_markdown between rows |
listing | list-with-map | Two-column results: list left, sticky map right | room cards + system filters |
detail | gallery-top-info-bottom | Hero photo + 4-up gallery; tabs (Overview / Rooms / Amenities / Reviews / Policies / Location) | gallery, amenities_grid, policies, location_map, testimonials |
detail | story-page | Long-form scrollable narrative with anchored photos and inline room CTAs | custom_markdown, gallery, amenities_grid |
booking | vertical-stepper | One step per screen; visible progress | (system) |
booking | single-page | All steps stacked on one scrollable page | (system) |
post_stay | thank-you-with-review-cta | Summary card + review prompt | custom_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).
| Kind | Purpose | Required meta | Multi-language fields | Rendered by primitive |
|---|---|---|---|---|
about_us | Brand story | heroImage?, cta? | body | <AboutBlock /> |
amenities_grid | Amenity tiles | items[] of { iconKey, titleKey, bodyKey } | resolved from LocalePack | <AmenitiesGrid /> |
gallery | Photo gallery | media[]: MediaRef, layout: 'mosaic'|'carousel'|'grid' | alt text per locale | <GalleryGrid /> |
testimonials | Guest quotes | items[] of { author, quoteI18n, rating? } | quote per locale | <TestimonialCard /> (×N) |
faq | FAQ list | items[] of { questionI18n, answerI18n } | per locale | <FAQ /> (accordion) |
contact | Contact info | phone? email? whatsapp? addressI18n? | address per locale | <ContactBlock /> |
policies | Tenant policies | documents[] of { typeKey, bodyI18n } | per locale | <PoliciesBlock /> |
location_map | Map embed | lat, lng, zoom, markerLabelKey? | label via LocalePack | <PropertyMap /> |
custom_markdown | Free-form sanitized markdown | titleI18n? | 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) wrappingmarkdown-itwith the platform allow-list. No app-leveldangerouslySetInnerHTMLoutside 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_blocktelemetry event is emitted (non-blocking; non-user-visible). - Blocks marked
visible: falseare 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
}
| Surface | Renderer | RTL 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
| Toggle | Use case |
|---|---|
captureGuestPassport: true | AF-tax-resident bookings need passport capture for invoicing |
requestAirportTransport: true | Hotels with shuttle service surface a free check-box |
allowCashOnArrival: false | Tenants who refuse cash (rare; requires explicit override) |
requireGuestSignature: true | Boutique properties wanting a signed liability waiver — surfaces a native signature pad on mobile / canvas on web |
allowGiftBooking: true | Booker 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⇒reviewstep present.allowCashOnArrival: false⇒paymentstep 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
@importweb 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 layer | Key | TTL | Invalidation |
|---|---|---|---|
| Cloud CDN edge | tenant-booking:bootstrap:{slug}:{locale}:{currency} | 30 s + SWR 60 s | melmastoon.theme.cdn_cache_invalidated.v1 triggers tag-based purge |
| Memorystore (BFF tier) | bff:tenant:bootstrap:{tenantId}:{themeVersion}:{tenantConfigVersion} | 5 min | Same event + new themeVersion/tenantConfigVersion value |
| Theme bundle (CSS, JSON) | themes/{themeVersionId}.css on GCS + Cloud CDN | immutable; long max-age | Never — versions are immutable; new publish = new id |
| Browser HTTP cache | per Cache-Control header | per-route | Standard |
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:
BookingStackis fully unmounted (navigation.popToTop(); navigation.navigate('Discover')).- New
BootstrapTenantScreenfetchesGET /bootstrapfor B. ThemeProviderswaps;BookingStackis 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
| Concern | Mechanism |
|---|---|
| Direction at root | <html dir> (web), I18nManager.forceRTL (mobile) |
| Logical CSS properties | padding-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 icons | Each 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 numerals | Numerals 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 flips | Rows reverse via flex-direction: row-reverse only when explicitly intended (e.g., custom carousel arrows); default reliance is on logical properties + direction. |
| Forms | Inputs 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
| Layer | Source | Resolution |
|---|---|---|
| UI strings | @ghasi/i18n (next-intl-compatible JSON per locale) | useTranslations('ns') / useTranslations('booking') etc. |
| Tenant content (blocks) | theme-config-service LocalePack + I18nMarkup.entries | requested locale → tenant.fallbackChain → platform en/en-US |
| Server-formatted strings | BFF display.formatted (totals, dates) | server-side Intl |
| Calendars | Server emits ISO-8601 Gregorian; presentational variants computed client-side | Intl.DateTimeFormat(locale + '-u-ca-' + calendar) |
| Numerals | UI: 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, nofollowand<meta name="robots" content="noindex">). - Preview state never affects analytics or reservation creation (preview mode rejects
POST /quotewithMELMASTOON.BFF.TENANT.PREVIEW_MODE_READ_ONLY). - Edits to the draft revoke all outstanding
PreviewTokens for that version atomically (pertheme-config-serviceinvariant 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:
- Backoffice publishes via
POST /theme-versions/{id}/publish(server-side workflow insidetheme-config-service). theme-config-serviceruns final validation (token contrast, RTL parity, asset HEAD checks, AI-content HITL approval if applicable, booking-flow consistency).- On success, the platform emits
melmastoon.theme.published.v1(andmelmastoon.theme.cdn_cache_invalidated.v1). bff-tenant-booking-serviceconsumes the event and rotates its in-memory(themeVersion, tenantConfigVersion)cache key.- Cloud CDN purges by tag
theme:{themeId}. - 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.
| Step | Where | Effect |
|---|---|---|
| Operator opens Versions tab | Backoffice authoring console | Lists published versions with publishedAt, publishedBy, ordinal, summary diff |
| Operator clicks Restore this version | UI → 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 busts | Same chain as publish | CDN tag purge + Memorystore rotation |
| Audit | Yes — 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:
| Toggle | Default | Surfaces affected | Behavior |
|---|---|---|---|
showHalalKitchenIndicator | true (tenants in MENA / SCA markets) | listing card, detail | Renders the halal-kitchen badge; gated to properties with the amenity |
showWomenOnlyFloorIndicator | false | listing card, detail | Surfaces women-only-floor badge if the property declares it |
showPrayerRoomIndicator | true (MENA / SCA) | listing card, detail | Same |
requestTransportFromAirport | false | booking → add_ons / guest_details | Adds an opt-in checkbox plus a free-text "preferred pickup time" |
allowGiftBooking | false | booking → guest_details | Adds "Booking on behalf of" section; recipient receives confirmation |
requireGuestSignature | false | booking → review | Adds signature pad (canvas web / native pad mobile) |
enableMobileKey | tenant-specific (per their lock vendor) | post-confirmation, mobile push | Adds KeyCredentialBadge to confirmation view; triggers push when issued |
showPoweredByMelmastoon | true | tenant booking footer + email theme | Off requires paid white-label tier |
enableHighContrastTheme | platform-default true | global | Settings/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
| Concern | Target | Mechanism |
|---|---|---|
| Theme bundle (CSS + sidecar JSON, gzipped) | < 50 KiB | Token-only bundle; no fonts inlined; aggressive sweep of unused tokens at publish |
| CDN edge cache hit-rate | ≥ 95% post-warm | Immutable bundle URLs (/themes/{themeVersionId}.css); Cache-Control: public, max-age=31536000, immutable |
| Cold first byte (post-publish) | < 350 ms p95 | Memorystore preload on publish event; cross-AZ replication |
| SSR theme injection cost | < 5 ms p95 added to TTFB | Token JSON parse cached per-process; <style> emit is template literal |
| Mobile bootstrap parse + apply | < 80 ms p95 on Pixel 4a | Single JSON parse; ThemeProvider memoizes once |
| Tenant switch (mobile) | < 200 ms p95 | Pre-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
| Concern | Tool | Frequency |
|---|---|---|
| Visual regression per major theme combo | Chromatic — fixtures: 6 token presets × 4 locales (en, ps-AF, ar-SA, fa-IR) × 2 directions | Every PR touching packages/ui-melmastoon, theme-config-service token shape, or content-block primitives |
| Contrast checks per token combination | Custom CI step using the same contrastRatio function as theme-config-service | Every PR; gates on AA failures |
| RTL screenshot tests | Playwright (web), Detox + ScreenShoter (mobile) — every primary screen in dir=rtl | Every PR touching renderer or token plumbing |
| Locale completeness audit | Script reads LocalePackKeyRegistry, verifies every required key present per enabled locale across fixture themes | Nightly cron; PR comment with diff |
| Bundle size budget | size-limit against the published bundle artifact | Every publish in pre-prod |
| Hydration mismatch detection | React strict + a Playwright assertion that console.error is empty on first paint | Every e2e run |
| Preview-mode read-only invariant | E2E asserts that POST /quote in preview mode returns MELMASTOON.BFF.TENANT.PREVIEW_MODE_READ_ONLY | Every release |
| Publish-then-rollback dry run | Synthetic test publishes a known-good version, rolls back, asserts CDN serves the previous bundle within 60 s | Pre-prod nightly |
20. Anti-patterns
| Anti-pattern | Why it's banned | Correct approach |
|---|---|---|
Hardcoded hex colors anywhere in apps/* or packages/feature-* | Defeats tenant theming; trips contrast invariants | Use semantic tokens via Tailwind utilities (web) or useTheme() (mobile) |
Tenant-specific React components (KabulGrandHero.tsx) in shared codebase | Doesn't scale; per-tenant code requires deploys | Compose 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 guard | Always 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 invariants | Theme switching is a server-side new-version publish |
Storing raw HTML from tenant input and rendering with dangerouslySetInnerHTML | XSS surface; bypasses sanitization | Only render I18nMarkup through <MarkdownBlock /> (which uses the platform-shared sanitizer) |
| Per-tenant route paths or per-tenant Next.js pages | Tenant-coupled code; routing surface explodes | Routing is identical per tenant; variation comes from content blocks and presets |
| Embedding tenant assets (logos, photos) directly in the bundle | Defeats CDN; bloat; per-tenant invalidation impossible | Always reference via MediaRef URLs; never bundled |
Using left/right CSS properties or marginLeft/marginRight style props | Breaks RTL silently | Use logical properties / ms-* / me-* Tailwind utilities |
Relying on prefers-color-scheme for tenant theming | Conflicts with tenant brand intent | Tenant 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 environments | Run the same validators in pre-commit hooks (provided by @ghasi/ui-melmastoon) |
Writing custom email templates outside the EmailTheme shape | Breaks transactional consistency, locale fallback, audit trail | Always use the templates in notification-service; tenant control is the EmailTheme block |
Using useEffect to fetch the bootstrap from the client on every page load | Hydration-mismatch + perceived flash + double round-trip | Bootstrap is fetched in the SSR Server Component; client reads from context |
21. References
- Domain model (source of truth):
services/theme-config-service/DOMAIN_MODEL.md - Application logic (publish, rollback, preview):
services/theme-config-service/APPLICATION_LOGIC.md - BFF bootstrap surface:
services/bff-tenant-booking-service/API_CONTRACTS.md§5/bootstrap - Web/mobile spec (where the bootstrap lands):
01-web-and-mobile-specification.md§8, §11 - Design system:
03-design-system.md - Naming + error code registry:
docs/standards/NAMING.md,docs/standards/ERROR_CODES.md - Notification rendering (consumes
EmailTheme):services/notification-service/