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.