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
| Surface | Client | Personas |
|---|---|---|
| Desktop — Electron | @ghasi/ehr-desktop | Lab, 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
| Concern | Desktop (Electron) | Web (Browser) | Mobile (React Native) |
|---|---|---|---|
| Component foundation | MUI (foundation + complex components) | MUI (foundation + complex components) | React Native Paper for foundation; custom tokens-driven components for the rest |
| Styling primary | Tailwind CSS (utility classes) | Tailwind CSS | NativeWind (Tailwind semantics on RN) |
| Styling escape hatch | MUI sx / styled (Emotion) | MUI sx / styled (Emotion) | StyleSheet (tokens-driven) |
| Animation | Motion (motion.dev) | Motion | Reanimated + Moti |
| Typography | MUI typography scale + Tailwind text-* | Same | RN Text + token-driven scale |
| Icons | @mui/icons-material + @tabler/icons-react | Same | react-native-svg + icon pack generated from same SVG set |
| Forms | react-hook-form + zod (or formik+yup per feature) | Same | Same |
| i18n | react-intl + @ghasi/i18n | Same | Same |
| RTL | MUI direction + stylis-plugin-rtl + Tailwind logical utilities (ps-*, pe-*, start-*, end-*) | Same | RN 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)
| Layer | Examples | Consumed by |
|---|---|---|
| Primitives | color.blue.500 = #1F6FEB, fontSize.md = 14px, space.4 = 16px, radius.md = 8px, duration.md = 300ms | Never referenced directly by components |
| Semantic | text.primary → color.neutral.900 / color.neutral.50 (light/dark), surface.background, brand.primary, status.success, status.warn, status.danger, border.default, focus.ring | Components and utilities |
| Component | button.primary.bg → brand.primary, card.elevation → shadow.md, input.border → border.default, chip.neutral.bg → surface.subtle | Specific widgets only |
Rule: a component MAY read semantic or component tokens. A component MUST NOT read primitive tokens.
4.2 Tokens that exist
| Category | Token paths |
|---|---|
| Color | color.brand.*, color.neutral.*, color.status.*, color.surface.*, color.text.*, color.border.*, color.focus.*, color.overlay.* |
| Typography | typography.fontFamily.{sans,mono,serif,rtl}, typography.fontSize.{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl,5xl}, typography.lineHeight.*, typography.fontWeight.*, typography.letterSpacing.* |
| Spacing | space.{0,1,2,3,4,5,6,8,10,12,16,20,24,32} (4 px base) |
| Radius | radius.{none,xs,sm,md,lg,xl,pill,circle} |
| Shadow / Elevation | shadow.{0,xs,sm,md,lg,xl,inner} |
| Z-index | z.{base,dropdown,sticky,overlay,modal,popover,tooltip,toast} |
| Motion | duration.{instant,xs,sm,md,lg,xl}, easing.{standard,decelerate,accelerate,emphasized} |
| Breakpoints | breakpoint.{xs,sm,md,lg,xl,2xl} |
| Density | density.{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:
| Output | Consumed by | Purpose |
|---|---|---|
mui-theme.ts | Desktop + web | MUI createTheme(...) input with palette, typography, components overrides |
tailwind-preset.ts | Desktop + web | Tailwind presets: [ghasiPreset] — colors, spacing, radii, fonts |
css-variables.css | Desktop + web | Runtime CSS custom properties — enables per-tenant swap without bundle rebuild |
rn-theme.ts | Mobile | React Native Paper theme + component tokens |
nativewind-preset.ts | Mobile | NativeWind preset (Tailwind-on-RN) |
motion-tokens.ts | Desktop + web | Motion transition defaults |
types.ts | All | TypeScript 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.
| Configurable | Allowed values | Rationale |
|---|---|---|
brand.primary | Any color that passes AA contrast against surface.bg (light + dark) | Brand identity |
brand.secondary | Same contrast rule | Brand identity |
brand.accent | Same contrast rule | Brand identity |
logo.header | Uploaded SVG/PNG, max 200 KB, declared dimensions | Branding |
logo.favicon | Uploaded ICO/PNG | Branding |
logo.mobileSplash | Uploaded SVG/PNG per platform | Mobile branding |
typography.fontFamily.sans | One of an allowlist (Inter, Noto Sans, Noto Sans Arabic, IBM Plex Sans, Roboto) | Licensing + fallback chain guarantees |
density.default | compact / comfortable / spacious | Deployment-specific ergonomic preference |
radius.scale | sharp / default / rounded | Visual style preference |
defaultMode | auto / light / dark | Tenant default; user can override |
5.2 What admins CANNOT configure
These are locked by the base theme for safety, consistency, and accessibility reasons:
| Locked | Why |
|---|---|
status.success / status.warn / status.danger / status.info | Clinical safety — status semantics must be stable across tenants so clinicians never misread a critical alert |
focus.ring | Accessibility — overridable focus leads to a11y regressions |
space.* scale | Layout consistency |
breakpoint.* | Responsive consistency |
z.* | Layering contracts |
| Arbitrary CSS injection | Security (XSS), consistency |
| Fonts outside the allowlist | Licensing, 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.
| Rule | Error 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.dark | CONTRAST_AA_FAIL_DARK |
| Font family not in allowlist | FONT_NOT_ALLOWED |
| Logo exceeds size limit | ASSET_TOO_LARGE |
| Logo MIME type not allowed | ASSET_MIME_INVALID |
| Logo SHA-256 integrity mismatch | ASSET_INTEGRITY_FAIL |
| Brand color hex malformed | COLOR_FORMAT_INVALID |
| Density / radius / mode not in enum | VALUE_NOT_ENUM |
| Locked token attempted | TOKEN_LOCKED |
5.6 Live update strategy
| Surface | Update 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 |
| Web | Same as desktop — HTTP long-poll or SSE on /v1/config/stream?theme=true |
| Mobile | Check 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
CssBaselinefrom 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 for | Use 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 elements | Dense 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:
| Prefer | Avoid |
|---|---|
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-start | text-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:
| Preset | Use case |
|---|---|
fadeIn | List items, cards appearing |
slideInFromStart / slideInFromEnd | Drawers, side panels (RTL-aware — uses logical direction) |
scaleIn | Dialogs, popovers |
cardHover | Card elevation change on hover |
skeletonPulse | Loading skeletons |
toastEnter / toastExit | Snackbars / 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 overreact-native-reanimated) consumes the samedurationandeasingtokens@ghasi/design-tokens/build/rn-theme.tsre-exports Motion tokens asmotionDuration/motionEasing- The RN accessibility API
AccessibilityInfo.isReduceMotionEnabled()wires into auseReducedMotion()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) — neverleft/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
lightanddarkvalue. - Tenant
defaultModepicks the initial mode (autofollows OS). - Users may override on desktop + web via profile settings; persisted per user.
- On mobile, OS-level
Appearanceis 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 whendir="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
| Rule | Enforcement |
|---|---|
| WCAG 2.2 AA baseline across all surfaces | CI contrast tests on token generation + Playwright/Detox axe runs |
Focus ring is always visible and token-driven (focus.ring) — never outline: none without alternative | ESLint rule blocks outline: none / outline: 0 |
| Minimum touch target 44×44 pt (iOS HIG) / 48 dp (Material) on mobile | Design system components ship with compliant defaults |
| Dynamic type scaling respected on mobile | allowFontScaling={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 controls | ESLint 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
| Test | Scope | Tool |
|---|---|---|
| AA contrast validation at token build | Token generation | Style Dictionary custom transform + jest |
| Visual regression — canonical pages | Desktop + web, LTR + RTL, light + dark | Chromatic or Playwright screenshot diff |
| MUI + Tailwind integration — resolved values match | Desktop + web | Unit test reading CSS variables |
| Per-tenant override resolves correctly | All surfaces | Jest — seed override, assert resolved theme |
| Reduced-motion collapses durations | Desktop + web + mobile | Unit + E2E |
| RTL mirror of directional icons, nav, chevrons | All surfaces | Visual regression + snapshot tests |
| Admin theme save rejects out-of-contract values | config-service | Integration 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
- Desktop:
../desktop/desktop-electron/UI_AND_DESIGN_PARITY.md - Desktop technical:
../desktop/desktop-electron/TECHNICAL_REQUIREMENTS.md - Mobile:
../mobile/mobile-app/UI_UX_PATTERNS.md - Mobile personas:
../mobile/mobile-app/PERSONAS_AND_ROLES.md - Web (pending):
../web/— will reference this document as the canonical theme source
17. Change Management
- Token changes that affect semantic or component layers require:
- RFC with before/after screenshots of affected surfaces
- Visual regression sign-off
- Version bump on
@ghasi/design-tokens - 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
versionfield onTenantThemeOverridedrives backward-compatible migration inconfig-service.