@idealyst/theme 1.2.100 → 1.2.101

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/theme",
3
- "version": "1.2.100",
3
+ "version": "1.2.101",
4
4
  "description": "Theming system for Idealyst Framework",
5
5
  "readme": "README.md",
6
6
  "main": "src/index.ts",
@@ -63,7 +63,7 @@
63
63
  "publish:npm": "npm publish"
64
64
  },
65
65
  "dependencies": {
66
- "@idealyst/tooling": "^1.2.100"
66
+ "@idealyst/tooling": "^1.2.101"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "react-native-unistyles": ">=3.0.0"
package/src/builder.ts CHANGED
@@ -30,7 +30,9 @@ import {
30
30
  TableSizeValue,
31
31
  TooltipSizeValue,
32
32
  ViewSizeValue,
33
+ FontScaleConfig,
33
34
  } from './theme/structures';
35
+ import { applyFontScale, type ApplyFontScaleOptions } from './fontScale';
34
36
 
35
37
  /**
36
38
  * Mapping of component names to their size value types.
@@ -120,6 +122,14 @@ export type BuiltTheme<
120
122
  };
121
123
  interaction: InteractionConfig;
122
124
  breakpoints: Record<TBreakpoints, number>;
125
+ /** Font scale configuration. Undefined means no scaling (scale = 1.0). */
126
+ fontScaleConfig?: FontScaleConfig;
127
+ /**
128
+ * Unscaled base size values. Stored when fontScale != 1.0 so that
129
+ * runtime re-scaling can be done idempotently from the originals.
130
+ * @internal
131
+ */
132
+ __baseSizes?: Record<string, any>;
123
133
  };
124
134
 
125
135
  /**
@@ -135,7 +145,10 @@ type ThemeConfig<
135
145
  TBorder extends string,
136
146
  TSize extends string,
137
147
  TBreakpoints extends string,
138
- > = BuiltTheme<TIntents, TRadii, TShadows, TPallet, TSurface, TText, TBorder, TSize, TBreakpoints>;
148
+ > = BuiltTheme<TIntents, TRadii, TShadows, TPallet, TSurface, TText, TBorder, TSize, TBreakpoints> & {
149
+ /** @internal Pending font scale to apply at build() time. Stored on config so it survives builder chaining. */
150
+ __pendingFontScale?: { scale: number; options?: ApplyFontScaleOptions };
151
+ };
139
152
 
140
153
  /**
141
154
  * Fluent builder for creating themes with full TypeScript inference.
@@ -830,6 +843,54 @@ export class ThemeBuilder<
830
843
  return newBuilder;
831
844
  }
832
845
 
846
+ // =========================================================================
847
+ // Font Scale
848
+ // =========================================================================
849
+
850
+ /**
851
+ * Set a global font scale factor.
852
+ * When build() is called, all font-related size properties (fontSize, lineHeight,
853
+ * iconSize, etc.) will be multiplied by this factor.
854
+ *
855
+ * The unscaled base values are preserved on the built theme so that
856
+ * runtime re-scaling via applyFontScale() is idempotent.
857
+ *
858
+ * @param scale - The scale factor (1.0 = no change, 1.5 = 50% larger)
859
+ * @param options - Optional configuration
860
+ * @param options.scaleIcons - Whether to also scale iconSize properties (default: true)
861
+ * @param options.minScale - Minimum clamped scale (default: 0.5)
862
+ * @param options.maxScale - Maximum clamped scale (default: 3.0)
863
+ *
864
+ * @example
865
+ * ```typescript
866
+ * createTheme()
867
+ * .setSizes({ ... })
868
+ * .setFontScale(1.2)
869
+ * .build();
870
+ * ```
871
+ *
872
+ * @example Using OS font scale at init
873
+ * ```typescript
874
+ * import { UnistylesRuntime } from 'react-native-unistyles';
875
+ *
876
+ * createTheme()
877
+ * .setSizes({ ... })
878
+ * .setFontScale(UnistylesRuntime.fontScale)
879
+ * .build();
880
+ * ```
881
+ */
882
+ setFontScale(
883
+ scale: number,
884
+ options?: ApplyFontScaleOptions,
885
+ ): ThemeBuilder<TIntents, TRadii, TShadows, TPallet, TSurface, TText, TBorder, TSize, TBreakpoints> {
886
+ const newBuilder = new ThemeBuilder<TIntents, TRadii, TShadows, TPallet, TSurface, TText, TBorder, TSize, TBreakpoints>();
887
+ newBuilder.config = {
888
+ ...this.config,
889
+ __pendingFontScale: { scale, options },
890
+ };
891
+ return newBuilder;
892
+ }
893
+
833
894
  // =========================================================================
834
895
  // Build
835
896
  // =========================================================================
@@ -838,7 +899,11 @@ export class ThemeBuilder<
838
899
  * Build the final theme object.
839
900
  */
840
901
  build(): BuiltTheme<TIntents, TRadii, TShadows, TPallet, TSurface, TText, TBorder, TSize, TBreakpoints> {
841
- return this.config;
902
+ const { __pendingFontScale, ...config } = this.config as any;
903
+ if (__pendingFontScale) {
904
+ return applyFontScale(config, __pendingFontScale.scale, __pendingFontScale.options);
905
+ }
906
+ return config;
842
907
  }
843
908
  }
844
909
 
@@ -0,0 +1,241 @@
1
+ import type { BuiltTheme } from './builder';
2
+ import type { FontScaleConfig, SizeValue, TypographyValue } from './theme/structures';
3
+
4
+ // =============================================================================
5
+ // Property Classification
6
+ // =============================================================================
7
+
8
+ /**
9
+ * Property names that represent font metrics and are always scaled.
10
+ */
11
+ const FONT_PROPERTIES = new Set([
12
+ 'fontSize',
13
+ 'lineHeight',
14
+ 'headerFontSize',
15
+ 'labelFontSize',
16
+ 'labelLineHeight',
17
+ 'titleFontSize',
18
+ 'titleLineHeight',
19
+ 'messageFontSize',
20
+ 'messageLineHeight',
21
+ 'circularLabelFontSize',
22
+ ]);
23
+
24
+ /**
25
+ * Property names that represent icon dimensions and are scaled when scaleIcons is true.
26
+ */
27
+ const ICON_PROPERTIES = new Set([
28
+ 'iconSize',
29
+ 'thumbIconSize',
30
+ 'closeIconSize',
31
+ ]);
32
+
33
+ /**
34
+ * The 'icon' component's width/height represent icon glyph dimensions (not layout),
35
+ * so they are scaled when scaleIcons is true.
36
+ */
37
+ const ICON_COMPONENT_KEY = 'icon';
38
+
39
+ // =============================================================================
40
+ // Scaling Helpers
41
+ // =============================================================================
42
+
43
+ function scaleSizeValue(value: SizeValue, scale: number): SizeValue {
44
+ if (typeof value === 'number') {
45
+ return Math.round(value * scale * 100) / 100;
46
+ }
47
+ return value;
48
+ }
49
+
50
+ function shouldScaleProperty(
51
+ key: string,
52
+ componentKey: string,
53
+ scaleIcons: boolean,
54
+ ): boolean {
55
+ if (FONT_PROPERTIES.has(key)) {
56
+ return true;
57
+ }
58
+ if (scaleIcons && ICON_PROPERTIES.has(key)) {
59
+ return true;
60
+ }
61
+ // Icon component's width/height are icon dimensions
62
+ if (scaleIcons && componentKey === ICON_COMPONENT_KEY && (key === 'width' || key === 'height')) {
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function scaleSizeRecord(
69
+ record: Record<string, SizeValue>,
70
+ scale: number,
71
+ componentKey: string,
72
+ scaleIcons: boolean,
73
+ ): Record<string, SizeValue> {
74
+ const result: Record<string, SizeValue> = {};
75
+ for (const [key, value] of Object.entries(record)) {
76
+ result[key] = shouldScaleProperty(key, componentKey, scaleIcons)
77
+ ? scaleSizeValue(value, scale)
78
+ : value;
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function scaleTypography(
84
+ typography: Record<string, TypographyValue>,
85
+ scale: number,
86
+ ): Record<string, TypographyValue> {
87
+ const result: Record<string, TypographyValue> = {};
88
+ for (const [key, value] of Object.entries(typography)) {
89
+ result[key] = {
90
+ fontSize: scaleSizeValue(value.fontSize, scale),
91
+ lineHeight: scaleSizeValue(value.lineHeight, scale),
92
+ fontWeight: value.fontWeight,
93
+ };
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // =============================================================================
99
+ // Public API
100
+ // =============================================================================
101
+
102
+ export type ApplyFontScaleOptions = {
103
+ /** Whether to scale icon sizes alongside fonts (default: true) */
104
+ scaleIcons?: boolean;
105
+ /** Minimum allowed scale factor (default: 0.5) */
106
+ minScale?: number;
107
+ /** Maximum allowed scale factor (default: 3.0) */
108
+ maxScale?: number;
109
+ };
110
+
111
+ /**
112
+ * Apply a font scale factor to a theme, returning a new theme with all
113
+ * font-related size properties scaled.
114
+ *
115
+ * Uses `__baseSizes` (if present) as the source for scaling, making this
116
+ * function idempotent — calling it multiple times with different scales
117
+ * always computes from the original unscaled values.
118
+ *
119
+ * @param theme - The theme to scale
120
+ * @param scale - The scale factor (1.0 = no change)
121
+ * @param options - Configuration
122
+ * @returns A new theme with scaled sizes, fontScaleConfig, and __baseSizes set
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * import { applyFontScale } from '@idealyst/theme';
127
+ *
128
+ * const scaledTheme = applyFontScale(lightTheme, 1.2);
129
+ * // scaledTheme.sizes.button.md.fontSize === 19.2 (was 16)
130
+ * ```
131
+ */
132
+ export function applyFontScale<T extends BuiltTheme<any, any, any, any, any, any, any, any, any>>(
133
+ theme: T,
134
+ scale: number,
135
+ options?: ApplyFontScaleOptions,
136
+ ): T {
137
+ const scaleIcons = options?.scaleIcons ?? true;
138
+ const minScale = options?.minScale ?? 0.5;
139
+ const maxScale = options?.maxScale ?? 3.0;
140
+ const clampedScale = Math.min(maxScale, Math.max(minScale, scale));
141
+
142
+ // Use __baseSizes if available (idempotent rescaling), otherwise current sizes
143
+ const baseSizes = theme.__baseSizes ?? theme.sizes;
144
+
145
+ const fontScaleConfig: FontScaleConfig = {
146
+ fontScale: clampedScale,
147
+ scaleIcons,
148
+ minScale,
149
+ maxScale,
150
+ };
151
+
152
+ if (clampedScale === 1.0) {
153
+ return {
154
+ ...theme,
155
+ sizes: baseSizes,
156
+ fontScaleConfig,
157
+ __baseSizes: baseSizes,
158
+ };
159
+ }
160
+
161
+ // Scale each component's size records
162
+ const scaledSizes: Record<string, any> = {};
163
+ for (const [componentKey, sizeVariants] of Object.entries(baseSizes)) {
164
+ if (componentKey === 'typography') {
165
+ scaledSizes.typography = scaleTypography(
166
+ sizeVariants as Record<string, TypographyValue>,
167
+ clampedScale,
168
+ );
169
+ } else {
170
+ const scaledVariants: Record<string, any> = {};
171
+ for (const [variantKey, variantValue] of Object.entries(sizeVariants as Record<string, Record<string, SizeValue>>)) {
172
+ scaledVariants[variantKey] = scaleSizeRecord(
173
+ variantValue,
174
+ clampedScale,
175
+ componentKey,
176
+ scaleIcons,
177
+ );
178
+ }
179
+ scaledSizes[componentKey] = scaledVariants;
180
+ }
181
+ }
182
+
183
+ return {
184
+ ...theme,
185
+ sizes: scaledSizes as T['sizes'],
186
+ fontScaleConfig,
187
+ __baseSizes: baseSizes,
188
+ };
189
+ }
190
+
191
+ // =============================================================================
192
+ // Content Size Category Mapping
193
+ // =============================================================================
194
+
195
+ /**
196
+ * Maps iOS/Android contentSizeCategory strings to numeric font scale factors.
197
+ *
198
+ * iOS values are based on Apple's Dynamic Type scale ratios for the default
199
+ * body text style. Android values use approximate equivalents.
200
+ *
201
+ * Returns 1.0 for unknown categories.
202
+ *
203
+ * @param category - The contentSizeCategory string from UnistylesRuntime
204
+ * @returns A numeric scale factor
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * import { contentSizeCategoryToScale } from '@idealyst/theme';
209
+ * import { UnistylesRuntime } from 'react-native-unistyles';
210
+ *
211
+ * const scale = contentSizeCategoryToScale(UnistylesRuntime.contentSizeCategory);
212
+ * ```
213
+ */
214
+ export function contentSizeCategoryToScale(category: string): number {
215
+ const mapping: Record<string, number> = {
216
+ // iOS categories (runtime string values from IOSContentSizeCategory enum)
217
+ xSmall: 0.82,
218
+ Small: 0.88,
219
+ Medium: 0.94,
220
+ Large: 1.0, // iOS default
221
+ xLarge: 1.12,
222
+ xxLarge: 1.24,
223
+ xxxLarge: 1.35,
224
+ accessibilityMedium: 1.6,
225
+ accessibilityLarge: 1.9,
226
+ accessibilityExtraLarge: 2.35,
227
+ accessibilityExtraExtraLarge: 2.75,
228
+ accessibilityExtraExtraExtraLarge: 3.1,
229
+ // Android categories (runtime string values from AndroidContentSizeCategory enum)
230
+ Default: 1.0,
231
+ ExtraLarge: 1.24,
232
+ Huge: 1.35,
233
+ ExtraHuge: 1.6,
234
+ ExtraExtraHuge: 1.9,
235
+ // Web / unspecified
236
+ 'web-unspecified': 1.0,
237
+ unspecified: 1.0,
238
+ };
239
+
240
+ return mapping[category] ?? 1.0;
241
+ }
@@ -0,0 +1,72 @@
1
+ import { UnistylesRuntime } from 'react-native-unistyles';
2
+ import { applyFontScale, type ApplyFontScaleOptions } from './fontScale';
3
+ import type { FontScaleConfig } from './theme/structures';
4
+
5
+ /**
6
+ * Options for updateFontScale.
7
+ */
8
+ export type UpdateFontScaleOptions = ApplyFontScaleOptions & {
9
+ /** Theme names to update. Defaults to ['light', 'dark']. */
10
+ themes?: string[];
11
+ };
12
+
13
+ /**
14
+ * Update the font scale at runtime across registered Unistyles themes.
15
+ *
16
+ * Uses UnistylesRuntime.updateTheme() to recompute all font-related sizes,
17
+ * triggering automatic re-renders of all components that reference theme
18
+ * size values via stylesheets.
19
+ *
20
+ * @param scale - The new font scale factor (1.0 = no change)
21
+ * @param options - Configuration
22
+ *
23
+ * @example React to OS font size changes
24
+ * ```typescript
25
+ * import { updateFontScale, contentSizeCategoryToScale } from '@idealyst/theme';
26
+ * import { UnistylesRuntime } from 'react-native-unistyles';
27
+ *
28
+ * const osScale = contentSizeCategoryToScale(UnistylesRuntime.contentSizeCategory);
29
+ * updateFontScale(osScale);
30
+ * ```
31
+ *
32
+ * @example User preference from settings
33
+ * ```typescript
34
+ * function onFontScaleChange(newScale: number) {
35
+ * updateFontScale(newScale);
36
+ * }
37
+ * ```
38
+ */
39
+ export function updateFontScale(scale: number, options?: UpdateFontScaleOptions): void {
40
+ const { themes: themeNames = ['light', 'dark'], ...scaleOptions } = options ?? {};
41
+
42
+ for (const themeName of themeNames) {
43
+ try {
44
+ UnistylesRuntime.updateTheme(themeName as any, (currentTheme: any) => {
45
+ const existingConfig = currentTheme.fontScaleConfig as FontScaleConfig | undefined;
46
+ return applyFontScale(currentTheme, scale, {
47
+ scaleIcons: scaleOptions.scaleIcons ?? existingConfig?.scaleIcons ?? true,
48
+ minScale: scaleOptions.minScale ?? existingConfig?.minScale,
49
+ maxScale: scaleOptions.maxScale ?? existingConfig?.maxScale,
50
+ });
51
+ });
52
+ } catch (error) {
53
+ // Theme may not be registered — silently skip
54
+ if (__DEV__) {
55
+ console.warn(`[idealyst/theme] Unable to update font scale for theme "${themeName}":`, error);
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get the current font scale from the active theme.
63
+ * Returns 1.0 if no font scale has been applied.
64
+ */
65
+ export function getFontScale(): number {
66
+ try {
67
+ const theme = UnistylesRuntime.getTheme() as any;
68
+ return theme?.fontScaleConfig?.fontScale ?? 1.0;
69
+ } catch {
70
+ return 1.0;
71
+ }
72
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,10 @@ export * from './useResponsiveStyle';
28
28
  // Color scheme utilities
29
29
  export * from './colorScheme';
30
30
 
31
+ // Font scale utilities
32
+ export { applyFontScale, contentSizeCategoryToScale, type ApplyFontScaleOptions } from './fontScale';
33
+ export { updateFontScale, getFontScale, type UpdateFontScaleOptions } from './fontScaleRuntime';
34
+
31
35
  // Theme hook
32
36
  export { useTheme } from './useTheme';
33
37
 
@@ -49,4 +49,5 @@ export type {
49
49
  TableSizeValue,
50
50
  TooltipSizeValue,
51
51
  ViewSizeValue,
52
+ FontScaleConfig,
52
53
  } from "./structures";
@@ -259,3 +259,18 @@ export type ViewSizeValue = {
259
259
  padding: SizeValue;
260
260
  spacing: SizeValue;
261
261
  };
262
+
263
+ /**
264
+ * Font scale configuration stored on the built theme.
265
+ * Allows runtime utilities to recompute scaled values from base values.
266
+ */
267
+ export type FontScaleConfig = {
268
+ /** The current font scale factor (1.0 = no scaling) */
269
+ fontScale: number;
270
+ /** Whether icon sizes should be scaled alongside fonts */
271
+ scaleIcons: boolean;
272
+ /** Minimum allowed scale factor */
273
+ minScale: number;
274
+ /** Maximum allowed scale factor */
275
+ maxScale: number;
276
+ };