@chipmobilesdk/rn-theme 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-06-06
4
+
5
+ ### Added
6
+
7
+ - Documented single-entrypoint public API for reusable theme generation and token types.
8
+ - Added consumer setup guidance for host dependencies, setup order, Android/iOS expectations, and theme-start strategy.
9
+ - Added package validation coverage for theme generation, config validation, public API imports, and package manifest readiness.
10
+ - Added package publish-readiness metadata and dry-run validation expectations.
11
+
12
+ ### Changed
13
+
14
+ - Classified host integrations as peer dependencies and kept runtime color generation in package dependencies.
15
+ - Kept app-specific brand colors and typography outside the package.
16
+
17
+ ### Migration
18
+
19
+ - This is a backward-compatible minor release for existing workspace consumers importing from `@chipmobilesdk/rn-theme`.
20
+ - Consumers importing private subpaths must migrate to the package root entrypoint.
21
+
22
+ ### Deprecations
23
+
24
+ - Private subpath imports are unsupported and should not be used by consuming apps.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @chipmobilesdk/rn-theme
2
+
3
+ Reusable React Native theme generation and token contracts for ChipMobileSdk-family mobile apps.
4
+
5
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
6
+ > applications; no open-source license is granted.
7
+
8
+ - **Version**: `0.2.0`
9
+ - **Entrypoint**: `@chipmobilesdk/rn-theme`
10
+ - **Demo**: `src/screens/ExampleScreen.tsx`
11
+
12
+ ## Install
13
+
14
+ Install the package from the public npm registry, then install the host styling dependencies in the consuming app:
15
+
16
+ ```bash
17
+ npm install @chipmobilesdk/rn-theme react-native-unistyles
18
+ ```
19
+
20
+ The host app owns `react`, `react-native`, and `react-native-unistyles`. If the app uses a safe-area shell like the ChipMobileSdk demo, install `react-native-safe-area-context` in the app as an app dependency. The theme package itself does not import safe-area APIs.
21
+
22
+ ## Compatibility
23
+
24
+ The current ChipMobileSdk demo validates against:
25
+
26
+ - React `19.2.3`
27
+ - React Native `0.85.3`
28
+ - React Native Unistyles `^3.2.5`
29
+ - TypeScript `^5.8.3`
30
+ - Node.js `>=22.11.0`
31
+ - Android and iOS host apps supported by the current React Native/Unistyles setup
32
+
33
+ The package is TypeScript-only and does not include native Android or iOS code, permissions, entitlements, analytics, tracking, billing, storage, or network behavior.
34
+
35
+ ## Setup Order
36
+
37
+ Create app-owned brand values:
38
+
39
+ ```ts
40
+ import type { BrandColors, TypographyConfig } from '@chipmobilesdk/rn-theme';
41
+
42
+ export const BRAND_COLORS: BrandColors = {
43
+ primary: '#046b63',
44
+ secondary: '#625b71',
45
+ tertiary: '#7d5260',
46
+ };
47
+
48
+ export const BRAND_TYPOGRAPHY: TypographyConfig = {
49
+ fontFamily: 'System',
50
+ baseFontSize: 16,
51
+ fontScale: 1,
52
+ };
53
+ ```
54
+
55
+ Configure Unistyles before any themed component renders:
56
+
57
+ ```ts
58
+ import { StyleSheet } from 'react-native-unistyles';
59
+ import { createUnistylesConfig } from '@chipmobilesdk/rn-theme';
60
+ import type { UnistylesThemeFromBrand } from '@chipmobilesdk/rn-theme';
61
+ import { BRAND_COLORS, BRAND_TYPOGRAPHY } from './brand';
62
+
63
+ declare module 'react-native-unistyles' {
64
+ export interface UnistylesThemes extends UnistylesThemeFromBrand {}
65
+ }
66
+
67
+ StyleSheet.configure(
68
+ createUnistylesConfig(
69
+ {
70
+ brandColors: BRAND_COLORS,
71
+ typographyConfig: BRAND_TYPOGRAPHY,
72
+ },
73
+ { initialTheme: 'dark' },
74
+ ),
75
+ );
76
+ ```
77
+
78
+ Import the setup module first in the app entrypoint.
79
+
80
+ ## Theme Start Strategy
81
+
82
+ Choose exactly one startup strategy.
83
+
84
+ Manual initial theme:
85
+
86
+ ```ts
87
+ createUnistylesConfig(config, { initialTheme: 'light' });
88
+ ```
89
+
90
+ System adaptive themes:
91
+
92
+ ```ts
93
+ createUnistylesConfig(config, { adaptiveThemes: true });
94
+ ```
95
+
96
+ Using `initialTheme` and `adaptiveThemes: true` together is invalid. Persisting a user theme preference, if needed, belongs in the consuming app.
97
+
98
+ ## Public Runtime Exports
99
+
100
+ - `createUnistylesConfig(config, options?)`
101
+ - `generateLightPalette(brandColors)`
102
+ - `generateDarkPalette(brandColors)`
103
+
104
+ ## Public Type Exports
105
+
106
+ - `BrandColors`
107
+ - `ThemeColors`
108
+ - `TypographyConfig`
109
+ - `TypographyToken`
110
+ - `Typography`
111
+ - `SpacingTokens`
112
+ - `RadiusTokens`
113
+ - `ShadowToken`
114
+ - `ShadowTokens`
115
+ - `ZIndexTokens`
116
+ - `DeepPartial`
117
+ - `StyleConfig`
118
+ - `StyleTheme`
119
+ - `UnistylesThemes`
120
+ - `UnistylesConfigOptions`
121
+ - `UnistylesConfig`
122
+ - `UnistylesThemeFromBrand`
123
+
124
+ ## Private Modules
125
+
126
+ Only `@chipmobilesdk/rn-theme` is public. Imports from `@chipmobilesdk/rn-theme/src/...`, `@chipmobilesdk/rn-theme/utils/...`, `@chipmobilesdk/rn-theme/tokens/...`, `@chipmobilesdk/rn-theme/types/...`, or `@chipmobilesdk/rn-theme/unistyles/...` are unsupported.
127
+
128
+ ## Configuration Validation
129
+
130
+ The package validates required brand colors, positive typography values, valid color strings, and invalid theme-start combinations before returning a Unistyles config. Missing optional typography fields use documented defaults.
131
+
132
+ ## Dependency Classification
133
+
134
+ - `dependencies`: runtime implementation packages imported by package code, currently `tinycolor2`.
135
+ - `peerDependencies`: host framework/integration packages supplied by each app: `react`, `react-native`, and `react-native-unistyles`.
136
+ - `devDependencies`: package-local type and validation tooling such as `@types/tinycolor2`.
137
+ - `react-native-safe-area-context`: app/demo dependency only, because the package does not import it.
138
+
139
+ ## Release Validation
140
+
141
+ Run from the repository root:
142
+
143
+ ```bash
144
+ npm run lint
145
+ npm test
146
+ npm run typecheck
147
+ npm run typecheck:theme
148
+ npm run pack:theme
149
+ ```
150
+
151
+ Record Android and iOS demo evidence under `specs/001-enhance-theme-package/validation/` before another app adopts the package.
152
+
153
+ ## Semver and Migration
154
+
155
+ - Patch: internal fix with no public shape or behavior change.
156
+ - Minor: backward-compatible public additions or documentation/package metadata improvements.
157
+ - Major: removed export, renamed token, changed token shape, changed default semantics, or changed required consumer setup.
158
+
159
+ Breaking changes require migration notes and a deprecation decision before release.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@chipmobilesdk/rn-theme",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/index.ts",
17
+ "src/tokens",
18
+ "src/types",
19
+ "src/unistyles",
20
+ "src/utils",
21
+ "README.md",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "sideEffects": false,
25
+ "scripts": {
26
+ "typecheck": "tsc -p tsconfig.json --noEmit",
27
+ "pack:dry-run": "npm pack --dry-run"
28
+ },
29
+ "dependencies": {
30
+ "tinycolor2": "^1.6.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/tinycolor2": "^1.4.6"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=19",
37
+ "react-native": ">=0.85",
38
+ "react-native-unistyles": ">=3.2"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react-native-unistyles": {
42
+ "optional": false
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // ─── Color utilities (advanced use / custom palette overrides) ──────────────
2
+ export { generateLightPalette, generateDarkPalette } from './utils/colorGeneration';
3
+
4
+ // ─── Unistyles helpers ──────────────────────────────────────────────────────
5
+ export { createUnistylesConfig } from './unistyles/createUnistylesConfig';
6
+ export type {
7
+ UnistylesThemes,
8
+ UnistylesConfigOptions,
9
+ UnistylesConfig,
10
+ } from './unistyles/createUnistylesConfig';
11
+ export type { UnistylesThemeFromBrand } from './unistyles/types';
12
+
13
+ // ─── Types ──────────────────────────────────────────────────────────────────
14
+ export type { BrandColors } from './types/brand';
15
+ export type { ThemeColors } from './types/colors';
16
+ export type { TypographyConfig, TypographyToken, Typography } from './types/typography';
17
+ export type {
18
+ SpacingTokens,
19
+ RadiusTokens,
20
+ ShadowToken,
21
+ ShadowTokens,
22
+ ZIndexTokens,
23
+ } from './types/tokens';
24
+ export type { DeepPartial, StyleConfig, StyleTheme } from './types/style';
@@ -0,0 +1,52 @@
1
+ import type { SpacingTokens, RadiusTokens, ShadowTokens, ZIndexTokens } from '../types/tokens';
2
+
3
+ export const DEFAULT_SPACING: SpacingTokens = {
4
+ xs: 4,
5
+ sm: 8,
6
+ md: 16,
7
+ lg: 24,
8
+ xl: 32,
9
+ xxl: 48,
10
+ };
11
+
12
+ export const DEFAULT_RADIUS: RadiusTokens = {
13
+ sm: 4,
14
+ md: 8,
15
+ lg: 16,
16
+ xl: 24,
17
+ full: 9999,
18
+ };
19
+
20
+ export function buildShadows(shadowColor: string): ShadowTokens {
21
+ return {
22
+ sm: {
23
+ shadowColor,
24
+ shadowOffset: { width: 0, height: 1 },
25
+ shadowOpacity: 0.12,
26
+ shadowRadius: 2,
27
+ elevation: 2,
28
+ },
29
+ md: {
30
+ shadowColor,
31
+ shadowOffset: { width: 0, height: 2 },
32
+ shadowOpacity: 0.16,
33
+ shadowRadius: 6,
34
+ elevation: 4,
35
+ },
36
+ lg: {
37
+ shadowColor,
38
+ shadowOffset: { width: 0, height: 4 },
39
+ shadowOpacity: 0.22,
40
+ shadowRadius: 12,
41
+ elevation: 8,
42
+ },
43
+ };
44
+ }
45
+
46
+ export const DEFAULT_Z_INDEX: ZIndexTokens = {
47
+ base: 0,
48
+ raised: 10,
49
+ modal: 100,
50
+ overlay: 200,
51
+ toast: 300,
52
+ };
@@ -0,0 +1,64 @@
1
+ import type {
2
+ TypographyConfig,
3
+ TypographyToken,
4
+ Typography,
5
+ RawTypography,
6
+ } from '../types/typography';
7
+
8
+ interface ScaleEntry {
9
+ size: number;
10
+ weight: TypographyToken['fontWeight'];
11
+ lineHeightRatio: number;
12
+ letterSpacing?: number;
13
+ }
14
+
15
+ const DEFAULT_BODY_SIZE = 16;
16
+
17
+ const SCALE: Record<keyof RawTypography, ScaleEntry> = {
18
+ display: {
19
+ size: 57,
20
+ weight: '700',
21
+ lineHeightRatio: 1.12,
22
+ letterSpacing: -0.25,
23
+ },
24
+ headline: { size: 32, weight: '600', lineHeightRatio: 1.25 },
25
+ title: { size: 22, weight: '600', lineHeightRatio: 1.27 },
26
+ body: { size: 16, weight: '400', lineHeightRatio: 1.5 },
27
+ label: { size: 14, weight: '500', lineHeightRatio: 1.43, letterSpacing: 0.1 },
28
+ caption: {
29
+ size: 12,
30
+ weight: '400',
31
+ lineHeightRatio: 1.33,
32
+ letterSpacing: 0.4,
33
+ },
34
+ };
35
+
36
+ export function buildTypography(config?: TypographyConfig): Typography {
37
+ const fontFamily = config?.fontFamily ?? 'System';
38
+ const baseScale =
39
+ (config?.baseFontSize ?? DEFAULT_BODY_SIZE) / DEFAULT_BODY_SIZE;
40
+ const scale = baseScale * (config?.fontScale ?? 1);
41
+
42
+ function makeToken(entry: ScaleEntry): TypographyToken {
43
+ const fontSize = Math.round(entry.size * scale);
44
+ return {
45
+ fontSize,
46
+ fontWeight: entry.weight,
47
+ lineHeight: Math.round(fontSize * entry.lineHeightRatio),
48
+ fontFamily,
49
+ ...(entry.letterSpacing != null && {
50
+ letterSpacing: entry.letterSpacing,
51
+ }),
52
+ };
53
+ }
54
+
55
+ return {
56
+ fontFamily,
57
+ display: makeToken(SCALE.display),
58
+ headline: makeToken(SCALE.headline),
59
+ title: makeToken(SCALE.title),
60
+ body: makeToken(SCALE.body),
61
+ label: makeToken(SCALE.label),
62
+ caption: makeToken(SCALE.caption),
63
+ };
64
+ }
@@ -0,0 +1,5 @@
1
+ export interface BrandColors {
2
+ primary: string;
3
+ secondary: string;
4
+ tertiary: string;
5
+ }
@@ -0,0 +1,23 @@
1
+ export interface ThemeColors {
2
+ primary: string;
3
+ primaryContainer: string;
4
+ onPrimary: string;
5
+ secondary: string;
6
+ secondaryContainer: string;
7
+ onSecondary: string;
8
+ tertiary: string;
9
+ tertiaryContainer: string;
10
+ onTertiary: string;
11
+ background: string;
12
+ surface: string;
13
+ surfaceVariant: string;
14
+ onBackground: string;
15
+ onSurface: string;
16
+ border: string;
17
+ error: string;
18
+ success: string;
19
+ warning: string;
20
+ info: string;
21
+ disabled: string;
22
+ shadow: string;
23
+ }
@@ -0,0 +1,11 @@
1
+ export type { BrandColors } from './brand';
2
+ export type { ThemeColors } from './colors';
3
+ export type { TypographyConfig, TypographyToken, Typography } from './typography';
4
+ export type {
5
+ SpacingTokens,
6
+ RadiusTokens,
7
+ ShadowToken,
8
+ ShadowTokens,
9
+ ZIndexTokens,
10
+ } from './tokens';
11
+ export type { DeepPartial, StyleConfig, StyleTheme } from './style';
@@ -0,0 +1,26 @@
1
+ import type { BrandColors } from './brand';
2
+ import type { ThemeColors } from './colors';
3
+ import type { RadiusTokens, ShadowTokens, SpacingTokens, ZIndexTokens } from './tokens';
4
+ import type { Typography, TypographyConfig } from './typography';
5
+
6
+ export type DeepPartial<T> = {
7
+ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
8
+ };
9
+
10
+ export interface StyleTheme {
11
+ color: ThemeColors;
12
+ typography: Typography;
13
+ spacing: SpacingTokens;
14
+ radius: RadiusTokens;
15
+ shadow: ShadowTokens;
16
+ zIndex: ZIndexTokens;
17
+ }
18
+
19
+ export interface StyleConfig {
20
+ brandColors: BrandColors;
21
+ typographyConfig?: TypographyConfig;
22
+ overrides?: {
23
+ light?: DeepPartial<StyleTheme>;
24
+ dark?: DeepPartial<StyleTheme>;
25
+ };
26
+ }
@@ -0,0 +1,38 @@
1
+ export interface SpacingTokens {
2
+ xs: number;
3
+ sm: number;
4
+ md: number;
5
+ lg: number;
6
+ xl: number;
7
+ xxl: number;
8
+ }
9
+
10
+ export interface RadiusTokens {
11
+ sm: number;
12
+ md: number;
13
+ lg: number;
14
+ xl: number;
15
+ full: number;
16
+ }
17
+
18
+ export interface ShadowToken {
19
+ shadowColor: string;
20
+ shadowOffset: { width: number; height: number };
21
+ shadowOpacity: number;
22
+ shadowRadius: number;
23
+ elevation: number;
24
+ }
25
+
26
+ export interface ShadowTokens {
27
+ sm: ShadowToken;
28
+ md: ShadowToken;
29
+ lg: ShadowToken;
30
+ }
31
+
32
+ export interface ZIndexTokens {
33
+ base: number;
34
+ raised: number;
35
+ modal: number;
36
+ overlay: number;
37
+ toast: number;
38
+ }
@@ -0,0 +1,26 @@
1
+ export interface TypographyConfig {
2
+ fontFamily?: string;
3
+ baseFontSize?: number;
4
+ fontScale?: number;
5
+ }
6
+
7
+ export interface TypographyToken {
8
+ fontSize: number;
9
+ fontWeight: '400' | '500' | '600' | '700' | '800' | '900';
10
+ lineHeight: number;
11
+ fontFamily?: string;
12
+ letterSpacing?: number;
13
+ }
14
+
15
+ export interface RawTypography {
16
+ display: TypographyToken;
17
+ headline: TypographyToken;
18
+ title: TypographyToken;
19
+ body: TypographyToken;
20
+ label: TypographyToken;
21
+ caption: TypographyToken;
22
+ }
23
+
24
+ export interface Typography extends RawTypography {
25
+ fontFamily: string;
26
+ }
@@ -0,0 +1,177 @@
1
+ import {
2
+ generateDarkPalette,
3
+ generateLightPalette,
4
+ } from '../utils/colorGeneration';
5
+ import type { DeepPartial, StyleConfig, StyleTheme } from '../types';
6
+ import {
7
+ buildShadows,
8
+ DEFAULT_RADIUS,
9
+ DEFAULT_SPACING,
10
+ DEFAULT_Z_INDEX,
11
+ } from '../tokens/defaults';
12
+ import { buildTypography } from '../tokens/typography';
13
+ import {
14
+ validateStyleConfig,
15
+ validateThemeOptions,
16
+ } from './validateThemeConfig';
17
+
18
+ export interface UnistylesThemes {
19
+ light: StyleTheme;
20
+ dark: StyleTheme;
21
+ }
22
+
23
+ type ThemeName = keyof UnistylesThemes;
24
+
25
+ interface UnistylesBaseSettings {
26
+ CSSVars?: boolean;
27
+ nativeBreakpointsMode?: 'pixels' | 'points';
28
+ }
29
+
30
+ export type UnistylesConfigOptions = UnistylesBaseSettings &
31
+ (
32
+ | {
33
+ /** Which theme should be active on first launch. Defaults to 'light'. */
34
+ initialTheme?: ThemeName | (() => ThemeName);
35
+ adaptiveThemes?: false;
36
+ }
37
+ | {
38
+ /** Let Unistyles switch between light/dark from the system color scheme. */
39
+ adaptiveThemes: boolean;
40
+ initialTheme?: never;
41
+ }
42
+ );
43
+
44
+ type UnistylesThemeSettings =
45
+ | {
46
+ initialTheme: ThemeName | (() => ThemeName);
47
+ adaptiveThemes?: false;
48
+ }
49
+ | {
50
+ adaptiveThemes: true;
51
+ initialTheme?: never;
52
+ };
53
+
54
+ type UnistylesSettings = UnistylesBaseSettings & UnistylesThemeSettings;
55
+
56
+ export interface UnistylesConfig {
57
+ themes: UnistylesThemes;
58
+ settings: UnistylesSettings;
59
+ }
60
+
61
+ /**
62
+ * Generates a unistyles-compatible configuration object from brand colors.
63
+ *
64
+ * Usage in your app (e.g. src/unistyles/setup.ts):
65
+ * ```ts
66
+ * import { StyleSheet } from 'react-native-unistyles';
67
+ * import { createUnistylesConfig } from '@chipmobilesdk/rn-theme';
68
+ * StyleSheet.configure(createUnistylesConfig(BRAND_COLORS));
69
+ * ```
70
+ *
71
+ * Use `initialTheme` for manual theme switching via `UnistylesRuntime.setTheme()`,
72
+ * or `adaptiveThemes` when Unistyles should follow the system color scheme.
73
+ */
74
+ export function createUnistylesConfig(
75
+ config: StyleConfig,
76
+ options?: UnistylesConfigOptions,
77
+ ): UnistylesConfig {
78
+ validateStyleConfig(config);
79
+ validateThemeOptions(options);
80
+
81
+ const lightPalette = generateLightPalette(config.brandColors);
82
+ const darkPalette = generateDarkPalette(config.brandColors);
83
+ const defaultStyling = {
84
+ typography: buildTypography(config.typographyConfig),
85
+ spacing: DEFAULT_SPACING,
86
+ radius: DEFAULT_RADIUS,
87
+ zIndex: DEFAULT_Z_INDEX,
88
+ };
89
+
90
+ const lightTheme = mergeTheme(
91
+ {
92
+ color: lightPalette,
93
+ shadow: buildShadows(lightPalette.shadow),
94
+ ...defaultStyling,
95
+ },
96
+ config.overrides?.light,
97
+ );
98
+
99
+ const darkTheme = mergeTheme(
100
+ {
101
+ color: darkPalette,
102
+ shadow: buildShadows(darkPalette.shadow),
103
+ ...defaultStyling,
104
+ },
105
+ config.overrides?.dark,
106
+ );
107
+
108
+ return {
109
+ themes: {
110
+ light: lightTheme,
111
+ dark: darkTheme,
112
+ },
113
+ settings: createSettings(options),
114
+ };
115
+ }
116
+
117
+ function createSettings(options?: UnistylesConfigOptions): UnistylesSettings {
118
+ const baseSettings = {
119
+ ...(options?.CSSVars != null && { CSSVars: options.CSSVars }),
120
+ ...(options?.nativeBreakpointsMode != null && {
121
+ nativeBreakpointsMode: options.nativeBreakpointsMode,
122
+ }),
123
+ };
124
+
125
+ if (options?.adaptiveThemes === true) {
126
+ return {
127
+ ...baseSettings,
128
+ adaptiveThemes: options.adaptiveThemes,
129
+ };
130
+ }
131
+
132
+ return {
133
+ ...baseSettings,
134
+ initialTheme: options?.initialTheme ?? 'light',
135
+ };
136
+ }
137
+
138
+ function mergeTheme(
139
+ theme: StyleTheme,
140
+ overrides?: DeepPartial<StyleTheme>,
141
+ ): StyleTheme {
142
+ if (!overrides) {
143
+ return theme;
144
+ }
145
+
146
+ return mergeDeep(theme, overrides);
147
+ }
148
+
149
+ function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
150
+ const result = { ...base } as Record<PropertyKey, unknown>;
151
+
152
+ for (const key of Object.keys(overrides) as Array<keyof T>) {
153
+ const overrideValue = overrides[key];
154
+
155
+ if (overrideValue == null) {
156
+ continue;
157
+ }
158
+
159
+ const baseValue = base[key];
160
+
161
+ if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
162
+ result[key] = mergeDeep(
163
+ baseValue,
164
+ overrideValue as DeepPartial<typeof baseValue>,
165
+ );
166
+ continue;
167
+ }
168
+
169
+ result[key] = overrideValue;
170
+ }
171
+
172
+ return result as T;
173
+ }
174
+
175
+ function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
176
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
177
+ }
@@ -0,0 +1,24 @@
1
+ import 'react-native-unistyles';
2
+ import type { StyleTheme } from '../types';
3
+
4
+ /**
5
+ * Use this interface for module augmentation in your host application
6
+ * so that react-native-unistyles is aware of the theme token shapes.
7
+ *
8
+ * Example (src/unistyles/setup.ts):
9
+ * ```ts
10
+ * import type { UnistylesThemeFromBrand } from '@chipmobilesdk/rn-theme';
11
+ *
12
+ * declare module 'react-native-unistyles' {
13
+ * export interface UnistylesThemes extends UnistylesThemeFromBrand {}
14
+ * }
15
+ * ```
16
+ */
17
+ export interface UnistylesThemeFromBrand {
18
+ light: StyleTheme;
19
+ dark: StyleTheme;
20
+ }
21
+
22
+ declare module 'react-native-unistyles' {
23
+ export interface UnistylesThemes extends UnistylesThemeFromBrand { }
24
+ }
@@ -0,0 +1,154 @@
1
+ import tinycolor from 'tinycolor2';
2
+ import type { BrandColors } from '../types/brand';
3
+ import type { StyleConfig } from '../types/style';
4
+ import type { TypographyConfig } from '../types/typography';
5
+
6
+ const BRAND_COLOR_KEYS = ['primary', 'secondary', 'tertiary'] as const;
7
+
8
+ type ThemeName = 'light' | 'dark';
9
+
10
+ interface ThemeOptionsShape {
11
+ CSSVars?: unknown;
12
+ nativeBreakpointsMode?: unknown;
13
+ initialTheme?: unknown;
14
+ adaptiveThemes?: unknown;
15
+ }
16
+
17
+ export function validateStyleConfig(config: StyleConfig): void {
18
+ if (!isRecord(config)) {
19
+ throw new Error('Theme config must be an object.');
20
+ }
21
+
22
+ validateBrandColors(config.brandColors);
23
+ validateTypographyConfig(config.typographyConfig);
24
+ }
25
+
26
+ export function validateBrandColors(brandColors: BrandColors): void {
27
+ if (!isRecord(brandColors)) {
28
+ throw new Error('Theme config must include brandColors.');
29
+ }
30
+
31
+ for (const key of BRAND_COLOR_KEYS) {
32
+ const color = brandColors[key];
33
+
34
+ if (typeof color !== 'string' || color.trim().length === 0) {
35
+ throw new Error(`brandColors.${key} must be a non-empty color string.`);
36
+ }
37
+
38
+ if (!tinycolor(color).isValid()) {
39
+ throw new Error(`brandColors.${key} must be a valid color string.`);
40
+ }
41
+ }
42
+ }
43
+
44
+ export function validateTypographyConfig(config?: TypographyConfig): void {
45
+ if (config == null) {
46
+ return;
47
+ }
48
+
49
+ if (!isRecord(config)) {
50
+ throw new Error('typographyConfig must be an object when provided.');
51
+ }
52
+ const typographyConfig = config as {
53
+ fontFamily?: unknown;
54
+ baseFontSize?: unknown;
55
+ fontScale?: unknown;
56
+ };
57
+
58
+ if (typographyConfig.fontFamily != null) {
59
+ if (
60
+ typeof typographyConfig.fontFamily !== 'string' ||
61
+ typographyConfig.fontFamily.trim().length === 0
62
+ ) {
63
+ throw new Error(
64
+ 'typographyConfig.fontFamily must be a non-empty string.',
65
+ );
66
+ }
67
+ }
68
+
69
+ if (typographyConfig.baseFontSize != null) {
70
+ assertPositiveFiniteNumber(
71
+ typographyConfig.baseFontSize,
72
+ 'typographyConfig.baseFontSize',
73
+ );
74
+ }
75
+
76
+ if (typographyConfig.fontScale != null) {
77
+ assertPositiveFiniteNumber(
78
+ typographyConfig.fontScale,
79
+ 'typographyConfig.fontScale',
80
+ );
81
+ }
82
+ }
83
+
84
+ export function validateThemeOptions(options?: ThemeOptionsShape): void {
85
+ if (options == null) {
86
+ return;
87
+ }
88
+
89
+ if (!isRecord(options)) {
90
+ throw new Error('Theme options must be an object when provided.');
91
+ }
92
+
93
+ const hasInitialTheme = options.initialTheme != null;
94
+ const hasAdaptiveThemes = options.adaptiveThemes === true;
95
+
96
+ if (hasInitialTheme && hasAdaptiveThemes) {
97
+ throw new Error('Use either initialTheme or adaptiveThemes, not both.');
98
+ }
99
+
100
+ if (options.initialTheme != null) {
101
+ validateInitialTheme(options.initialTheme);
102
+ }
103
+
104
+ if (
105
+ options.adaptiveThemes != null &&
106
+ typeof options.adaptiveThemes !== 'boolean'
107
+ ) {
108
+ throw new Error('adaptiveThemes must be a boolean when provided.');
109
+ }
110
+
111
+ if (options.CSSVars != null && typeof options.CSSVars !== 'boolean') {
112
+ throw new Error('CSSVars must be a boolean when provided.');
113
+ }
114
+
115
+ if (
116
+ options.nativeBreakpointsMode != null &&
117
+ options.nativeBreakpointsMode !== 'pixels' &&
118
+ options.nativeBreakpointsMode !== 'points'
119
+ ) {
120
+ throw new Error("nativeBreakpointsMode must be 'pixels' or 'points'.");
121
+ }
122
+ }
123
+
124
+ function validateInitialTheme(initialTheme: unknown): void {
125
+ if (typeof initialTheme === 'function') {
126
+ const resolvedTheme = initialTheme();
127
+
128
+ if (!isThemeName(resolvedTheme)) {
129
+ throw new Error("initialTheme resolver must return 'light' or 'dark'.");
130
+ }
131
+
132
+ return;
133
+ }
134
+
135
+ if (!isThemeName(initialTheme)) {
136
+ throw new Error(
137
+ "initialTheme must be 'light', 'dark', or a resolver function.",
138
+ );
139
+ }
140
+ }
141
+
142
+ function assertPositiveFiniteNumber(value: unknown, fieldName: string): void {
143
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
144
+ throw new Error(`${fieldName} must be a positive finite number.`);
145
+ }
146
+ }
147
+
148
+ function isThemeName(value: unknown): value is ThemeName {
149
+ return value === 'light' || value === 'dark';
150
+ }
151
+
152
+ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
153
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
154
+ }
@@ -0,0 +1,87 @@
1
+ import type { BrandColors } from '../types/brand';
2
+ import type { ThemeColors } from '../types/colors';
3
+ import {
4
+ getContrastColor,
5
+ adjustLightness,
6
+ adjustSaturation,
7
+ toContainerColor,
8
+ } from './colorMath';
9
+
10
+ export function generateLightPalette(brand: BrandColors): ThemeColors {
11
+ const primaryContainer = toContainerColor(brand.primary, false);
12
+ const secondaryContainer = toContainerColor(brand.secondary, false);
13
+ const tertiaryContainer = toContainerColor(brand.tertiary, false);
14
+
15
+ return {
16
+ primary: brand.primary,
17
+ primaryContainer,
18
+ onPrimary: getContrastColor(brand.primary),
19
+
20
+ secondary: brand.secondary,
21
+ secondaryContainer,
22
+ onSecondary: getContrastColor(brand.secondary),
23
+
24
+ tertiary: brand.tertiary,
25
+ tertiaryContainer,
26
+ onTertiary: getContrastColor(brand.tertiary),
27
+
28
+ background: '#F8F9FA',
29
+ surface: '#FFFFFF',
30
+ surfaceVariant: adjustSaturation(adjustLightness(brand.primary, 48), -20),
31
+
32
+ onBackground: '#1A1A1A',
33
+ onSurface: '#1A1A1A',
34
+
35
+ border: '#E0E0E0',
36
+
37
+ // Semantic — WCAG-compliant accessible values for light backgrounds
38
+ error: '#B3261E',
39
+ success: '#2E7D32',
40
+ warning: '#E65100',
41
+ info: '#0277BD',
42
+ disabled: '#9E9E9E',
43
+ shadow: '#000000',
44
+ };
45
+ }
46
+
47
+ export function generateDarkPalette(brand: BrandColors): ThemeColors {
48
+ // Brighten brand colors slightly so they read clearly on dark backgrounds
49
+ const darkPrimary = adjustLightness(brand.primary, 10);
50
+ const darkSecondary = adjustLightness(brand.secondary, 10);
51
+ const darkTertiary = adjustLightness(brand.tertiary, 10);
52
+
53
+ const primaryContainer = toContainerColor(brand.primary, true);
54
+ const secondaryContainer = toContainerColor(brand.secondary, true);
55
+ const tertiaryContainer = toContainerColor(brand.tertiary, true);
56
+
57
+ return {
58
+ primary: darkPrimary,
59
+ primaryContainer,
60
+ onPrimary: getContrastColor(darkPrimary),
61
+
62
+ secondary: darkSecondary,
63
+ secondaryContainer,
64
+ onSecondary: getContrastColor(darkSecondary),
65
+
66
+ tertiary: darkTertiary,
67
+ tertiaryContainer,
68
+ onTertiary: getContrastColor(darkTertiary),
69
+
70
+ background: '#121212',
71
+ surface: '#1E1E1E',
72
+ surfaceVariant: '#2D2D2D',
73
+
74
+ onBackground: '#E6E6E6',
75
+ onSurface: '#E6E6E6',
76
+
77
+ border: '#3A3A3A',
78
+
79
+ // Semantic — dark surfaces that keep light text readable
80
+ error: '#8C1D18',
81
+ success: '#1B5E20',
82
+ warning: '#8A4B00',
83
+ info: '#01579B',
84
+ disabled: '#4A4A4A',
85
+ shadow: '#000000',
86
+ };
87
+ }
@@ -0,0 +1,55 @@
1
+ import tinycolor from 'tinycolor2';
2
+
3
+ /** Returns the relative luminance of a color (0–1). */
4
+ export function getLuminance(color: string): number {
5
+ return tinycolor(color).getLuminance();
6
+ }
7
+
8
+ /**
9
+ * Returns '#FFFFFF' or '#000000' — whichever has higher contrast
10
+ * against the given background color (WCAG AA).
11
+ */
12
+ export function getContrastColor(background: string): '#FFFFFF' | '#000000' {
13
+ return tinycolor.isReadable('#FFFFFF', background, { level: 'AA', size: 'small' })
14
+ ? '#FFFFFF'
15
+ : '#000000';
16
+ }
17
+
18
+ /**
19
+ * Adjusts lightness.
20
+ * Positive delta = lighten, negative delta = darken.
21
+ */
22
+ export function adjustLightness(color: string, delta: number): string {
23
+ const tc = tinycolor(color);
24
+ return delta >= 0
25
+ ? tc.lighten(delta).toHexString()
26
+ : tc.darken(Math.abs(delta)).toHexString();
27
+ }
28
+
29
+ /**
30
+ * Adjusts saturation.
31
+ * Positive delta = saturate, negative delta = desaturate.
32
+ */
33
+ export function adjustSaturation(color: string, delta: number): string {
34
+ const tc = tinycolor(color);
35
+ return delta >= 0
36
+ ? tc.saturate(delta).toHexString()
37
+ : tc.desaturate(Math.abs(delta)).toHexString();
38
+ }
39
+
40
+ /** Returns whether two colors meet WCAG AA contrast requirements. */
41
+ export function isReadable(foreground: string, background: string): boolean {
42
+ return tinycolor.isReadable(foreground, background, { level: 'AA', size: 'small' });
43
+ }
44
+
45
+ /**
46
+ * Produces a container shade from a brand color.
47
+ * For dark themes: darkens + desaturates.
48
+ * For light themes: lightens + desaturates.
49
+ */
50
+ export function toContainerColor(color: string, isDark: boolean): string {
51
+ const tc = tinycolor(color);
52
+ return isDark
53
+ ? tc.darken(25).desaturate(10).toHexString()
54
+ : tc.lighten(35).desaturate(15).toHexString();
55
+ }