Skip to main content

Theme — Single Base Theme for All Ghasi-eHealth Surfaces

Status: normative Owner: Frontend Platform / Design System Last updated: 2026-04-18 Applies to: every Ghasi-eHealth client surface — desktop (Electron), web (browser), and mobile (React Native).


1. Purpose

Define one base theme that governs visual design, motion, and direction across every Ghasi-eHealth client. The theme is token-driven, admin-configurable per tenant, and consumed uniformly by MUI (desktop + web), Tailwind (desktop + web), Motion (desktop + web), and React Native (mobile).

There is no separate "desktop theme," "web theme," or "mobile theme" — there is one theme, expressed as tokens, transformed per surface.


2. Scope of Surfaces

SurfaceClientPersonas
Desktop — Electron@ghasi/ehr-desktopLab, Pharmacy, Clinician, Public-health authority, Government authority
Web — Browser@ghasi/ehr-web (+ future public-health / gov web)Clinician, Public-health authority, Government authority
Mobile — React Native@ghasi/ehr-mobile (single app, role-switched)Patient, Caregiver, Guardian, CHW / local health worker

All three surfaces MUST render the same tenant's brand identity, status semantics, spacing rhythm, and RTL/LTR behaviour consistently — users moving between surfaces should recognise the product.


3. Stack Summary per Surface

ConcernDesktop (Electron)Web (Browser)Mobile (React Native)
Component foundationMUI (foundation + complex components)MUI (foundation + complex components)React Native Paper for foundation; custom tokens-driven components for the rest
Styling primaryTailwind CSS (utility classes)Tailwind CSSNativeWind (Tailwind semantics on RN)
Styling escape hatchMUI sx / styled (Emotion)MUI sx / styled (Emotion)StyleSheet (tokens-driven)
AnimationMotion (motion.dev)MotionReanimated + Moti
TypographyMUI typography scale + Tailwind text-*SameRN Text + token-driven scale
Icons@mui/icons-material + @tabler/icons-reactSamereact-native-svg + icon pack generated from same SVG set
Formsreact-hook-form + zod (or formik+yup per feature)SameSame
i18nreact-intl + @ghasi/i18nSameSame
RTLMUI direction + stylis-plugin-rtl + Tailwind logical utilities (ps-*, pe-*, start-*, end-*)SameRN I18nManager.forceRTL + logical StyleSheet helpers

Why hybrid (MUI + Tailwind)? MUI provides accessible, complex widgets (DataGrid, Autocomplete, DatePicker, dialogs, dense clinical tables) that would take years to rebuild. Tailwind provides a fast, consistent utility layer for layout, spacing, and one-off styling without the indirection cost of sx. The two are explicitly scoped (§7).


4. Design Token Architecture

4.1 Three token layers

Tokens are layered so that semantic meaning — not raw values — drives components.

Primitives ──► Semantic ──► Component
(raw values) (purpose) (per widget)
LayerExamplesConsumed by
Primitivescolor.blue.500 = #1F6FEB, fontSize.md = 14px, space.4 = 16px, radius.md = 8px, duration.md = 300msNever referenced directly by components
Semantictext.primary → color.neutral.900 / color.neutral.50 (light/dark), surface.background, brand.primary, status.success, status.warn, status.danger, border.default, focus.ringComponents and utilities
Componentbutton.primary.bg → brand.primary, card.elevation → shadow.md, input.border → border.default, chip.neutral.bg → surface.subtleSpecific widgets only

Rule: a component MAY read semantic or component tokens. A component MUST NOT read primitive tokens.

4.2 Tokens that exist

CategoryToken paths
Colorcolor.brand.*, color.neutral.*, color.status.*, color.surface.*, color.text.*, color.border.*, color.focus.*, color.overlay.*
Typographytypography.fontFamily.{sans,mono,serif,rtl}, typography.fontSize.{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl,5xl}, typography.lineHeight.*, typography.fontWeight.*, typography.letterSpacing.*
Spacingspace.{0,1,2,3,4,5,6,8,10,12,16,20,24,32} (4 px base)
Radiusradius.{none,xs,sm,md,lg,xl,pill,circle}
Shadow / Elevationshadow.{0,xs,sm,md,lg,xl,inner}
Z-indexz.{base,dropdown,sticky,overlay,modal,popover,tooltip,toast}
Motionduration.{instant,xs,sm,md,lg,xl}, easing.{standard,decelerate,accelerate,emphasized}
Breakpointsbreakpoint.{xs,sm,md,lg,xl,2xl}
Densitydensity.{compact,comfortable,spacious} (affects table row height, input padding, nav item height)

4.3 Light and dark variants

Every semantic color token has both a light and dark value resolved at runtime. Primitives do not have variants — they are raw values. Semantic tokens pick the right primitive per mode.

// semantic/text.json (illustrative)
{
"text": {
"primary": {
"$value": {
"light": "{color.neutral.900}",
"dark": "{color.neutral.50}"
},
"$type": "color"
},
"secondary": {
"$value": {
"light": "{color.neutral.700}",
"dark": "{color.neutral.300}"
}
}
}
}

4.4 Tooling

Style Dictionary is the transformation pipeline.

packages/@ghasi/design-tokens/
├── src/tokens/
│ ├── primitives/
│ │ ├── colors.json
│ │ ├── typography.json
│ │ ├── space.json
│ │ ├── radius.json
│ │ ├── shadow.json
│ │ ├── motion.json
│ │ └── breakpoint.json
│ ├── semantic/
│ │ ├── color.json
│ │ ├── surface.json
│ │ ├── status.json
│ │ ├── text.json
│ │ ├── border.json
│ │ └── focus.json
│ └── component/
│ ├── button.json
│ ├── card.json
│ ├── input.json
│ ├── chip.json
│ └── nav.json
├── config.ts # Style Dictionary config with custom formats
└── build/ # generated — committed for reproducibility
├── mui-theme.ts
├── tailwind-preset.ts
├── rn-theme.ts
├── nativewind-preset.ts
├── css-variables.css
├── motion-tokens.ts
└── types.ts # typed access paths

Generated outputs:

OutputConsumed byPurpose
mui-theme.tsDesktop + webMUI createTheme(...) input with palette, typography, components overrides
tailwind-preset.tsDesktop + webTailwind presets: [ghasiPreset] — colors, spacing, radii, fonts
css-variables.cssDesktop + webRuntime CSS custom properties — enables per-tenant swap without bundle rebuild
rn-theme.tsMobileReact Native Paper theme + component tokens
nativewind-preset.tsMobileNativeWind preset (Tailwind-on-RN)
motion-tokens.tsDesktop + webMotion transition defaults
types.tsAllTypeScript types for token paths (autocomplete + safety)

4.5 CSS variables convention (desktop + web)

All semantic and component tokens emit CSS variables with a stable, flat naming scheme:

:root,
[data-theme="light"] {
--color-surface-bg: #ffffff;
--color-surface-subtle: #f6f8fa;
--color-text-primary: #101418;
--color-text-secondary: #475057;
--color-brand-primary: #1f6feb;
--color-status-success: #1a7f37;
--color-status-warn: #bf8700;
--color-status-danger: #cf222e;
--color-border-default: #d0d7de;
--color-focus-ring: #0969da;
/* …etc */

--radius-md: 8px;
--space-4: 16px;
--duration-md: 300ms;
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
}

[data-theme="dark"] {
--color-surface-bg: #0d1117;
--color-surface-subtle: #161b22;
--color-text-primary: #f0f6fc;
/* …etc */
}

MUI reads these via the CSS-variables mode (extendTheme / CssVarsProvider). Tailwind references them in the preset:

// tailwind-preset.ts (excerpt)
export default {
theme: {
extend: {
colors: {
'surface-bg': 'var(--color-surface-bg)',
'surface-subtle': 'var(--color-surface-subtle)',
'text-primary': 'var(--color-text-primary)',
'brand-primary': 'var(--color-brand-primary)',
'status-success': 'var(--color-status-success)',
/* … */
},
borderRadius: {
md: 'var(--radius-md)',
},
transitionDuration: {
md: 'var(--duration-md)',
},
},
},
};

Consequence: Tailwind class bg-brand-primary and MUI sx={{ bgcolor: 'brand.primary' }} resolve to the same CSS variable — tenant overrides hit both layers simultaneously without rebuilds.


5. Per-Tenant Configuration — Admin & Contract

5.1 What admins can configure

Tenant administrators configure the theme through the admin console. The configurable surface is a deliberate subset of the full token set, chosen to balance brand flexibility with clinical safety and accessibility.

ConfigurableAllowed valuesRationale
brand.primaryAny color that passes AA contrast against surface.bg (light + dark)Brand identity
brand.secondarySame contrast ruleBrand identity
brand.accentSame contrast ruleBrand identity
logo.headerUploaded SVG/PNG, max 200 KB, declared dimensionsBranding
logo.faviconUploaded ICO/PNGBranding
logo.mobileSplashUploaded SVG/PNG per platformMobile branding
typography.fontFamily.sansOne of an allowlist (Inter, Noto Sans, Noto Sans Arabic, IBM Plex Sans, Roboto)Licensing + fallback chain guarantees
density.defaultcompact / comfortable / spaciousDeployment-specific ergonomic preference
radius.scalesharp / default / roundedVisual style preference
defaultModeauto / light / darkTenant default; user can override

5.2 What admins CANNOT configure

These are locked by the base theme for safety, consistency, and accessibility reasons:

LockedWhy
status.success / status.warn / status.danger / status.infoClinical safety — status semantics must be stable across tenants so clinicians never misread a critical alert
focus.ringAccessibility — overridable focus leads to a11y regressions
space.* scaleLayout consistency
breakpoint.*Responsive consistency
z.*Layering contracts
Arbitrary CSS injectionSecurity (XSS), consistency
Fonts outside the allowlistLicensing, RTL rendering guarantees
Motion duration.* / easing.*Consistency and accessibility (reduced-motion handling)

5.3 Tenant override contract

Tenant overrides are a delta applied on top of the base theme. The contract:

// @ghasi/theme-runtime/types.ts
export interface TenantThemeOverride {
version: '1.0';
tenantId: string;
brand?: {
primary?: string; // hex
secondary?: string;
accent?: string;
};
logo?: {
header?: AssetRef;
favicon?: AssetRef;
mobileSplash?: { ios?: AssetRef; android?: AssetRef };
};
typography?: {
fontFamily?: {
sans?: AllowedSansFont; // enum
};
};
density?: 'compact' | 'comfortable' | 'spacious';
radiusScale?: 'sharp' | 'default' | 'rounded';
defaultMode?: 'auto' | 'light' | 'dark';
updatedAt: string; // ISO
updatedBy: string; // userId
}

interface AssetRef {
url: string; // served from object storage
width?: number;
height?: number;
sha256: string; // integrity check at load time
}

5.4 Config-resolver flow

Admin UI (web)


POST /v1/tenants/{tenantId}/theme


┌──────────────────────────────────┐
│ config-service │
│ 1. Validate against schema │
│ 2. AA contrast check (brand × surface, light + dark)
│ 3. Font allowlist check │
│ 4. Asset integrity (sha256, mime)
│ 5. Persist TenantThemeOverride │
│ 6. Bump config version │
│ 7. Publish NATS event: │
│ tenant.theme.changed │
└──────────────────────────────────┘


┌──────────────────────────────────┐
│ config-resolver │
│ Invalidates cached theme │
│ for (tenantId) │
└──────────────────────────────────┘

▼ (clients fetch on next load or reconnect)
┌──────────────────────────────────┐
│ Client (desktop / web / mobile) │
│ GET /v1/config/theme │
│ → { base, override, resolved } │
│ Apply resolved tokens to: │
│ • CSS variables (desktop/web) │
│ • RN theme + NativeWind (mob) │
└──────────────────────────────────┘

5.5 Validation rules enforced at save time

Rejections result in 422 Unprocessable Entity with a structured error list.

RuleError code
Brand color fails WCAG 2.2 AA against surface.bg.light (4.5:1 body, 3:1 large)CONTRAST_AA_FAIL_LIGHT
Brand color fails WCAG 2.2 AA against surface.bg.darkCONTRAST_AA_FAIL_DARK
Font family not in allowlistFONT_NOT_ALLOWED
Logo exceeds size limitASSET_TOO_LARGE
Logo MIME type not allowedASSET_MIME_INVALID
Logo SHA-256 integrity mismatchASSET_INTEGRITY_FAIL
Brand color hex malformedCOLOR_FORMAT_INVALID
Density / radius / mode not in enumVALUE_NOT_ENUM
Locked token attemptedTOKEN_LOCKED

5.6 Live update strategy

SurfaceUpdate mechanism
Desktop (Electron)Poll on focus (every 60 s max) + listen to tenant.theme.changed via WebSocket if enabled; apply by swapping CSS variables — no app restart
WebSame as desktop — HTTP long-poll or SSE on /v1/config/stream?theme=true
MobileCheck on app foreground; apply on next navigation transition (gracefully — avoid mid-screen flash)

Users never see a mid-flight jarring theme swap: the swap is smoothed by a 200 ms cross-fade where feasible, and avoided entirely during active form entry.


6. MUI Integration

6.1 Theme construction

// @ghasi/theme-runtime/ThemeProvider.tsx
import { experimental_extendTheme as extendTheme, CssVarsProvider } from '@mui/material/styles';
import { muiThemeTokens } from '@ghasi/design-tokens';

export function ThemeProvider({ tenantOverride, children }) {
const theme = useMemo(
() => extendTheme({
cssVarPrefix: '', // '' → bare --color-... variables
colorSchemes: muiThemeTokens.colorSchemes(tenantOverride),
typography: muiThemeTokens.typography(tenantOverride),
shape: muiThemeTokens.shape(tenantOverride),
components: muiThemeTokens.components(tenantOverride),
direction: useDirection(), // 'ltr' | 'rtl'
}),
[tenantOverride, direction],
);

return (
<CssVarsProvider theme={theme} defaultMode={tenantOverride?.defaultMode ?? 'auto'}>
{children}
</CssVarsProvider>
);
}

6.2 Component overrides

Component tokens feed directly into MUI's components.MuiButton.styleOverrides (and friends). Because both MUI and Tailwind reference the same CSS variables, a Tailwind className="bg-brand-primary" and MUI variant="contained" color="primary" render with exactly the same background — there is no "two versions of the brand" problem.

6.3 Global baseline

  • CssBaseline from MUI is enabled
  • Tailwind Preflight is disabled to prevent double-reset
  • Margin/padding resets come from Tailwind Preflight's replacement (manual list) that does not conflict with MUI's CssBaseline

7. Tailwind Integration

7.1 Scoping rules

Tailwind is explicitly scoped so MUI components remain the source of truth for complex widgets:

Use Tailwind forUse MUI (sx / components) for
Layout (flex, grid, spacing, gaps)DataGrid, Autocomplete, DatePicker, Dialog, Menu, Tabs
Typography utilities (text-lg, font-semibold, leading-tight)Complex interactive components with accessibility requirements
Color utilities on neutral surfaces (bg-surface-subtle, text-text-secondary)Form control primitives (TextField, Select, Checkbox) that need RTL + theming + validation state
Simple hover / focus states on custom elementsDense clinical tables (MUI X DataGrid)
One-off composition (cards, banners, empty states)Any component with intricate a11y (ARIA roles, keyboard interactions)
Responsive layout (md:grid-cols-3)

7.2 Tailwind config

// apps/ehr-desktop/tailwind.config.ts
import ghasiPreset from '@ghasi/design-tokens/build/tailwind-preset';

export default {
content: ['./renderer/src/**/*.{ts,tsx}'],
presets: [ghasiPreset],
corePlugins: {
preflight: false, // MUI CssBaseline is the reset
},
plugins: [
require('@tailwindcss/forms')({ strategy: 'class' }), // opt-in via class="form-*"
require('@tailwindcss/typography'),
require('tailwindcss-logical'), // ps-* / pe-* / start-* / end-* for RTL
],
};

7.3 RTL utilities

Use Tailwind logical properties — never left/right:

PreferAvoid
ps-4 (padding-inline-start)pl-4
pe-4 (padding-inline-end)pr-4
ms-auto (margin-inline-start)ml-auto
start-0 (inset-inline-start)left-0
text-starttext-left
border-s (border-inline-start)border-l

The tailwindcss-logical plugin provides these. ESLint rule blocks physical left/right utilities in repo.


8. Motion Integration

8.1 Motion tokens

Motion values are tokens, not magic numbers, so admin-facing reduced-motion and tenant-scale overrides both work consistently.

// motion-tokens.ts (generated)
export const duration = {
instant: 0,
xs: 100, // micro-interactions (hover feedback)
sm: 200, // simple transitions
md: 300, // component enter/exit (default)
lg: 500, // page transitions
xl: 700, // emphasized sequences
};

export const easing = {
standard: [0.2, 0, 0, 1], // most UI
decelerate: [0, 0, 0.2, 1], // entering
accelerate: [0.3, 0, 1, 1], // exiting
emphasized: [0.2, 0, 0, 1.4], // moderate overshoot
};

8.2 Motion library usage (desktop + web)

import { motion } from 'motion/react';
import { duration, easing } from '@ghasi/design-tokens/build/motion-tokens';

<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: duration.md / 1000, ease: easing.decelerate }}
>

</motion.div>

8.3 Shared animation presets

Published from @ghasi/theme-runtime/motion:

PresetUse case
fadeInList items, cards appearing
slideInFromStart / slideInFromEndDrawers, side panels (RTL-aware — uses logical direction)
scaleInDialogs, popovers
cardHoverCard elevation change on hover
skeletonPulseLoading skeletons
toastEnter / toastExitSnackbars / toasts

8.4 Reduced-motion

import { useReducedMotion } from 'motion/react';

// All shared presets internally check useReducedMotion() and collapse to opacity-only.

At the token level, when prefers-reduced-motion: reduce is active, every duration token becomes 0 except duration.instant (which is already 0). Easings are ignored. This is enforced at the motion/react transition wrapper level — individual components don't need to check.

8.5 Mobile motion

On React Native:

  • moti (declarative animations over react-native-reanimated) consumes the same duration and easing tokens
  • @ghasi/design-tokens/build/rn-theme.ts re-exports Motion tokens as motionDuration / motionEasing
  • The RN accessibility API AccessibilityInfo.isReduceMotionEnabled() wires into a useReducedMotion() hook parallel to web

9. React Native Integration

9.1 Stack

expo / bare RN
├── react-native-paper (foundation components — cards, buttons, dialogs, menus)
├── nativewind (Tailwind semantics for RN)
├── react-native-reanimated (animation runtime)
├── moti (declarative layer over Reanimated)
├── react-native-svg (icons)
└── @ghasi/design-tokens (tokens, theme, motion)

9.2 Theme application

// App.tsx
import { PaperProvider } from 'react-native-paper';
import { rnTheme } from '@ghasi/design-tokens/build/rn-theme';
import { useTenantTheme } from '@ghasi/theme-runtime';

export default function App() {
const { resolved, isRTL } = useTenantTheme();
const theme = useMemo(() => rnTheme(resolved, { isRTL }), [resolved, isRTL]);

useEffect(() => {
I18nManager.allowRTL(true);
if (I18nManager.isRTL !== isRTL) {
I18nManager.forceRTL(isRTL);
// Prompt reload (RN requires restart for RTL flip)
}
}, [isRTL]);

return (
<PaperProvider theme={theme}>
{/* app */}
</PaperProvider>
);
}

9.3 NativeWind

// tailwind.config.ts (mobile)
import ghasiPreset from '@ghasi/design-tokens/build/nativewind-preset';

export default {
content: ['./src/**/*.{ts,tsx}'],
presets: [ghasiPreset],
};
// Usage
<View className="bg-surface-bg p-4 rounded-md">
<Text className="text-text-primary font-semibold">Hello</Text>
</View>

9.4 RTL on mobile

  • RTL flip requires an app restart on RN — this is an RN constraint
  • UI surfaces a confirm prompt: "Language change requires app restart — restart now?"
  • All RN styles MUST use logical spacing (paddingStart, paddingEnd, marginStart, marginEnd) — never left/right
  • Icon mirror logic for directional icons lives in the shared icon registry (per §11)

10. Dark Mode

  • First-class. Every semantic token has a light and dark value.
  • Tenant defaultMode picks the initial mode (auto follows OS).
  • Users may override on desktop + web via profile settings; persisted per user.
  • On mobile, OS-level Appearance is honoured by default; user may override in app settings.
  • Switching mode is a CSS-variable swap on desktop + web (no restart); on RN it's a re-render.

11. Icons and Mirroring

  • Icon set is shared across surfaces (SVG source under @ghasi/design-tokens/icons/)
  • Generated outputs:
    • @ghasi/icons-react — React components (desktop + web)
    • @ghasi/icons-rn — React Native components
  • Each icon has metadata:
    {
    "name": "chevron-end",
    "category": "navigation",
    "mirrorInRTL": true
    }
  • Directional icons (chevrons, arrows, back) have mirrorInRTL: true — automatically transformed when dir="rtl" / I18nManager.isRTL
  • Semantic icons (warning triangle, heart, pill, syringe) have mirrorInRTL: false — never flipped
  • Design-time lint rule flags icons used without reviewing the mirror metadata

12. Accessibility

RuleEnforcement
WCAG 2.2 AA baseline across all surfacesCI contrast tests on token generation + Playwright/Detox axe runs
Focus ring is always visible and token-driven (focus.ring) — never outline: none without alternativeESLint rule blocks outline: none / outline: 0
Minimum touch target 44×44 pt (iOS HIG) / 48 dp (Material) on mobileDesign system components ship with compliant defaults
Dynamic type scaling respected on mobileallowFontScaling={true} default with max multiplier cap per surface to prevent layout explosion
Keyboard navigation consistent with native platform conventions
Screen reader labels on all icon-only controlsESLint rule requires aria-label on <IconButton> without children
Contrast validated for brand overrides at save time (§5.5)config-service validation + runtime double-check
Reduced-motion honoured globally (§8.4)Motion provider auto-collapses durations

13. Package Layout

packages/
├── @ghasi/design-tokens/ # Tokens + generated artefacts
│ ├── src/tokens/
│ ├── config.ts
│ └── build/ # committed
│ ├── mui-theme.ts
│ ├── tailwind-preset.ts
│ ├── css-variables.css
│ ├── rn-theme.ts
│ ├── nativewind-preset.ts
│ ├── motion-tokens.ts
│ └── types.ts

├── @ghasi/theme-runtime/ # Runtime theme application
│ ├── src/
│ │ ├── ThemeProvider.tsx (web/desktop)
│ │ ├── MobileThemeProvider.tsx (RN)
│ │ ├── useTenantTheme.ts
│ │ ├── useDirection.ts
│ │ ├── applyTenantOverrides.ts
│ │ └── motion/
│ │ ├── presets.ts
│ │ └── useReducedMotion.ts
│ └── index.ts

├── @ghasi/icons-react/ # React icon components
├── @ghasi/icons-rn/ # RN icon components

└── @ghasi/design-system/ # Opinionated shared components built on MUI + tokens
├── src/
│ ├── Button/
│ ├── Card/
│ ├── DataTable/ (thin MUI DataGrid wrapper)
│ ├── FormField/
│ ├── EmptyState/
│ ├── StatusBadge/
│ └── ThemedShell/
└── index.ts

14. Testing

TestScopeTool
AA contrast validation at token buildToken generationStyle Dictionary custom transform + jest
Visual regression — canonical pagesDesktop + web, LTR + RTL, light + darkChromatic or Playwright screenshot diff
MUI + Tailwind integration — resolved values matchDesktop + webUnit test reading CSS variables
Per-tenant override resolves correctlyAll surfacesJest — seed override, assert resolved theme
Reduced-motion collapses durationsDesktop + web + mobileUnit + E2E
RTL mirror of directional icons, nav, chevronsAll surfacesVisual regression + snapshot tests
Admin theme save rejects out-of-contract valuesconfig-serviceIntegration test against validation rules §5.5

15. Out of Scope / Non-Goals

  • Allowing tenants to inject custom CSS, JavaScript, or fonts outside the allowlist
  • Separate theme packages per surface — there is one source of tokens
  • Allowing tenant overrides of status colors, focus rings, spacing scale, or motion durations
  • Allowing tenants to ship their own React components to the platform
  • Runtime theme compilation — all tokens are pre-compiled at build time; runtime overrides happen via CSS variables only

16. Companion Documents


17. Change Management

  • Token changes that affect semantic or component layers require:
    1. RFC with before/after screenshots of affected surfaces
    2. Visual regression sign-off
    3. Version bump on @ghasi/design-tokens
    4. Coordinated release with consuming apps
  • Primitive additions (e.g., a new palette scale) are non-breaking.
  • Tenant override schema changes (§5.3) are versioned — the version field on TenantThemeOverride drives backward-compatible migration in config-service.