@newtonedev/configurator 0.1.0 → 0.1.1

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.
@@ -1,12 +1,15 @@
1
1
  import React from 'react';
2
- import { View, Text, StyleSheet } from 'react-native';
3
- import { Card, HueSlider, Slider, Select, Toggle, useTokens } from '@newtonedev/components';
2
+ import { View, Text, Pressable, StyleSheet } from 'react-native';
3
+ import { Card, HueSlider, Slider, Select, Toggle, ColorScaleSlider, TextInput, useTokens } from '@newtonedev/components';
4
4
  import { srgbToHex } from 'newtone';
5
- import type { ColorResult } from 'newtone';
6
- import type { PaletteState } from '../types';
5
+ import type { ColorResult, DynamicRange } from 'newtone';
6
+ import type { ConfiguratorState, PaletteState } from '../types';
7
7
  import type { ConfiguratorAction } from '../state/actions';
8
8
  import type { DesaturationStrength, HueGradingStrength } from 'newtone';
9
9
  import { SEMANTIC_HUE_RANGES } from '../constants';
10
+ import { useWcagValidation } from '../hooks/useWcagValidation';
11
+ import { hexToPaletteParams } from '../hex-conversion';
12
+ import { traditionalHueToOklch } from '../hue-conversion';
10
13
 
11
14
  const STRENGTH_OPTIONS = [
12
15
  { label: 'None', value: 'none' },
@@ -20,11 +23,120 @@ interface PalettePanelProps {
20
23
  readonly index: number;
21
24
  readonly dispatch: (action: ConfiguratorAction) => void;
22
25
  readonly previewColors?: readonly ColorResult[];
26
+ readonly state: ConfiguratorState;
23
27
  }
24
28
 
25
- export function PalettePanel({ palette, index, dispatch, previewColors }: PalettePanelProps) {
29
+ /** Get the hex color at a normalizedValue from the preview colors array */
30
+ function getHexAtNv(previewColors: readonly ColorResult[], nv: number): string {
31
+ const idx = Math.round((1 - nv) * (previewColors.length - 1));
32
+ const clamped = Math.max(0, Math.min(previewColors.length - 1, idx));
33
+ return srgbToHex(previewColors[clamped].srgb);
34
+ }
35
+
36
+ export function PalettePanel({ palette, index, dispatch, previewColors, state }: PalettePanelProps) {
26
37
  const tokens = useTokens(1);
27
38
  const hueRange = SEMANTIC_HUE_RANGES[index];
39
+ const isNeutral = index === 0;
40
+ const mode = state.preview.mode;
41
+
42
+ // Resolve per-mode key color
43
+ const effectiveKeyColor = mode === 'dark' ? palette.keyColorDark : palette.keyColor;
44
+
45
+ // Mode-aware action types
46
+ const setKeyColorAction = mode === 'dark' ? 'SET_PALETTE_KEY_COLOR_DARK' as const : 'SET_PALETTE_KEY_COLOR' as const;
47
+ const clearKeyColorAction = mode === 'dark' ? 'CLEAR_PALETTE_KEY_COLOR_DARK' as const : 'CLEAR_PALETTE_KEY_COLOR' as const;
48
+ const hexActionType = mode === 'dark' ? 'SET_PALETTE_FROM_HEX_DARK' as const : 'SET_PALETTE_FROM_HEX' as const;
49
+
50
+ // WCAG validation for non-neutral palettes (already mode-aware)
51
+ const wcag = useWcagValidation(state, index);
52
+
53
+ // Hex input state
54
+ const [hexText, setHexText] = React.useState('');
55
+ const [hexError, setHexError] = React.useState('');
56
+ const [isEditingHex, setIsEditingHex] = React.useState(false);
57
+ const [isHexUserSet, setIsHexUserSet] = React.useState(false);
58
+
59
+ // Reset hex input state when mode changes
60
+ React.useEffect(() => {
61
+ setHexText('');
62
+ setHexError('');
63
+ setIsEditingHex(false);
64
+ setIsHexUserSet(false);
65
+ }, [mode]);
66
+
67
+ // Compute displayed hex from current key color position
68
+ const displayedHex = React.useMemo(() => {
69
+ if (!previewColors || previewColors.length === 0) return '';
70
+ const nv = effectiveKeyColor ?? wcag.autoNormalizedValue;
71
+ return getHexAtNv(previewColors, nv);
72
+ }, [previewColors, effectiveKeyColor, wcag.autoNormalizedValue]);
73
+
74
+ // Sync hex text when not actively editing and not user-submitted
75
+ React.useEffect(() => {
76
+ if (!isEditingHex && !isHexUserSet) {
77
+ setHexText(displayedHex);
78
+ }
79
+ }, [displayedHex, isEditingHex, isHexUserSet]);
80
+
81
+ // Build dynamic range for hex conversion
82
+ const dynamicRange = React.useMemo((): DynamicRange => {
83
+ const light = state.globalHueGrading.light.strength !== 'none'
84
+ ? { hue: traditionalHueToOklch(state.globalHueGrading.light.hue), strength: state.globalHueGrading.light.strength }
85
+ : undefined;
86
+ const dark = state.globalHueGrading.dark.strength !== 'none'
87
+ ? { hue: traditionalHueToOklch(state.globalHueGrading.dark.hue), strength: state.globalHueGrading.dark.strength }
88
+ : undefined;
89
+ const hueGrading = (light || dark) ? { light, dark } : undefined;
90
+ return {
91
+ lightest: state.dynamicRange.lightest,
92
+ darkest: state.dynamicRange.darkest,
93
+ ...(hueGrading ? { hueGrading } : {}),
94
+ };
95
+ }, [state.dynamicRange, state.globalHueGrading]);
96
+
97
+ const handleHexSubmit = React.useCallback(() => {
98
+ setIsEditingHex(false);
99
+ const trimmed = hexText.trim();
100
+ if (!trimmed) {
101
+ setHexError('');
102
+ return;
103
+ }
104
+
105
+ const hex = trimmed.startsWith('#') ? trimmed : `#${trimmed}`;
106
+ const params = hexToPaletteParams(hex, dynamicRange);
107
+
108
+ if (!params) {
109
+ setHexError('Invalid hex color');
110
+ return;
111
+ }
112
+
113
+ setHexError('');
114
+ setIsHexUserSet(true);
115
+ dispatch({
116
+ type: hexActionType,
117
+ index,
118
+ hue: params.hue,
119
+ saturation: params.saturation,
120
+ keyColor: params.normalizedValue,
121
+ });
122
+ }, [hexText, dynamicRange, dispatch, index, hexActionType]);
123
+
124
+ const handleClearKeyColor = React.useCallback(() => {
125
+ dispatch({ type: clearKeyColorAction, index });
126
+ setHexError('');
127
+ setIsHexUserSet(false);
128
+ }, [dispatch, index, clearKeyColorAction]);
129
+
130
+ // Build WCAG warning message
131
+ const wcagWarning = React.useMemo(() => {
132
+ if (effectiveKeyColor === undefined || wcag.keyColorContrast === null) return undefined;
133
+ if (wcag.passesAA) return undefined;
134
+ const ratio = wcag.keyColorContrast.toFixed(1);
135
+ if (wcag.passesAALargeText) {
136
+ return `Contrast ${ratio}:1 — passes large text (AA) but fails normal text (requires 4.5:1)`;
137
+ }
138
+ return `Contrast ${ratio}:1 — fails WCAG AA (requires 4.5:1 for normal text, 3:1 for large text)`;
139
+ }, [effectiveKeyColor, wcag]);
28
140
 
29
141
  return (
30
142
  <Card elevation={1} style={styles.container}>
@@ -32,7 +144,52 @@ export function PalettePanel({ palette, index, dispatch, previewColors }: Palett
32
144
  {palette.name}
33
145
  </Text>
34
146
 
35
- {previewColors && (
147
+ {/* Non-neutral: interactive luminosity slider + hex input */}
148
+ {!isNeutral && previewColors && (
149
+ <>
150
+ <ColorScaleSlider
151
+ colors={previewColors}
152
+ value={effectiveKeyColor ?? wcag.autoNormalizedValue}
153
+ onValueChange={(nv) => { setIsHexUserSet(false); dispatch({ type: setKeyColorAction, index, normalizedValue: nv }); }}
154
+ label="Key Color"
155
+ warning={wcagWarning}
156
+ trimEnds
157
+ snap
158
+ animateValue
159
+ />
160
+ <View style={styles.hexRow}>
161
+ <View style={styles.hexInputContainer}>
162
+ <TextInput
163
+ label="Hex"
164
+ value={hexText}
165
+ onChangeText={(text) => {
166
+ setIsEditingHex(true);
167
+ setHexText(text);
168
+ setHexError('');
169
+ }}
170
+ onBlur={handleHexSubmit}
171
+ onSubmitEditing={handleHexSubmit}
172
+ placeholder="#000000"
173
+ />
174
+ </View>
175
+ {effectiveKeyColor !== undefined && (
176
+ <Pressable onPress={handleClearKeyColor} style={styles.autoButton}>
177
+ <Text style={[styles.autoText, { color: srgbToHex(tokens.interactive.srgb) }]}>
178
+ Auto
179
+ </Text>
180
+ </Pressable>
181
+ )}
182
+ </View>
183
+ {hexError !== '' && (
184
+ <Text style={[styles.errorText, { color: srgbToHex(tokens.error.srgb) }]}>
185
+ {hexError}
186
+ </Text>
187
+ )}
188
+ </>
189
+ )}
190
+
191
+ {/* Neutral: static swatches (unchanged) */}
192
+ {isNeutral && previewColors && (
36
193
  <View style={styles.inlineSwatches}>
37
194
  {previewColors.map((color, i) => (
38
195
  <View
@@ -142,4 +299,23 @@ const styles = StyleSheet.create({
142
299
  toggleContainer: {
143
300
  paddingBottom: 2,
144
301
  },
302
+ hexRow: {
303
+ flexDirection: 'row',
304
+ alignItems: 'flex-end',
305
+ gap: 8,
306
+ },
307
+ hexInputContainer: {
308
+ flex: 1,
309
+ },
310
+ autoButton: {
311
+ paddingBottom: 6,
312
+ },
313
+ autoText: {
314
+ fontSize: 13,
315
+ fontWeight: '600',
316
+ },
317
+ errorText: {
318
+ fontSize: 12,
319
+ fontWeight: '500',
320
+ },
145
321
  });
@@ -1,6 +1,6 @@
1
1
  import type { DesaturationStrength, HueGradingStrength } from 'newtone';
2
2
  import type { ColorMode, ThemeName } from '@newtonedev/components';
3
- import type { ConfiguratorState } from '../types';
3
+ import type { ConfiguratorState, FontConfig } from '../types';
4
4
 
5
5
  export type ConfiguratorAction =
6
6
  // Palette actions
@@ -11,6 +11,12 @@ export type ConfiguratorAction =
11
11
  | { readonly type: 'SET_PALETTE_HUE_GRADE_STRENGTH'; readonly index: number; readonly strength: HueGradingStrength }
12
12
  | { readonly type: 'SET_PALETTE_HUE_GRADE_HUE'; readonly index: number; readonly hue: number }
13
13
  | { readonly type: 'SET_PALETTE_HUE_GRADE_DIRECTION'; readonly index: number; readonly direction: 'light' | 'dark' }
14
+ | { readonly type: 'SET_PALETTE_KEY_COLOR'; readonly index: number; readonly normalizedValue: number }
15
+ | { readonly type: 'CLEAR_PALETTE_KEY_COLOR'; readonly index: number }
16
+ | { readonly type: 'SET_PALETTE_FROM_HEX'; readonly index: number; readonly hue: number; readonly saturation: number; readonly keyColor: number }
17
+ | { readonly type: 'SET_PALETTE_KEY_COLOR_DARK'; readonly index: number; readonly normalizedValue: number }
18
+ | { readonly type: 'CLEAR_PALETTE_KEY_COLOR_DARK'; readonly index: number }
19
+ | { readonly type: 'SET_PALETTE_FROM_HEX_DARK'; readonly index: number; readonly hue: number; readonly saturation: number; readonly keyColor: number }
14
20
  // Dynamic range actions
15
21
  | { readonly type: 'SET_LIGHTEST'; readonly value: number }
16
22
  | { readonly type: 'SET_DARKEST'; readonly value: number }
@@ -19,6 +25,20 @@ export type ConfiguratorAction =
19
25
  | { readonly type: 'SET_GLOBAL_GRADE_LIGHT_HUE'; readonly hue: number }
20
26
  | { readonly type: 'SET_GLOBAL_GRADE_DARK_STRENGTH'; readonly strength: HueGradingStrength }
21
27
  | { readonly type: 'SET_GLOBAL_GRADE_DARK_HUE'; readonly hue: number }
28
+ // Spacing actions
29
+ | { readonly type: 'SET_SPACING_DENSITY'; readonly density: number }
30
+ // Roundness actions
31
+ | { readonly type: 'SET_ROUNDNESS_INTENSITY'; readonly intensity: number }
32
+ // Typography actions
33
+ | { readonly type: 'SET_TYPOGRAPHY_BASE_SIZE'; readonly baseSize: number }
34
+ | { readonly type: 'SET_TYPOGRAPHY_RATIO'; readonly ratio: number }
35
+ | { readonly type: 'SET_FONT_MONO'; readonly font: FontConfig }
36
+ | { readonly type: 'SET_FONT_DISPLAY'; readonly font: FontConfig }
37
+ | { readonly type: 'SET_FONT_DEFAULT'; readonly font: FontConfig }
38
+ // Icons actions
39
+ | { readonly type: 'SET_ICON_VARIANT'; readonly variant: 'outlined' | 'rounded' | 'sharp' }
40
+ | { readonly type: 'SET_ICON_WEIGHT'; readonly weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 }
41
+ | { readonly type: 'SET_ICON_AUTO_GRADE'; readonly autoGrade: boolean }
22
42
  // Preview actions
23
43
  | { readonly type: 'SET_PREVIEW_MODE'; readonly mode: ColorMode }
24
44
  | { readonly type: 'SET_PREVIEW_THEME'; readonly theme: ThemeName }
@@ -71,4 +71,35 @@ export const DEFAULT_CONFIGURATOR_STATE: ConfiguratorState = {
71
71
  mode: 'light',
72
72
  theme: 'neutral',
73
73
  },
74
+ spacing: {
75
+ density: 0.5, // 1.0x multiplier = preserves current hardcoded values
76
+ },
77
+ roundness: {
78
+ intensity: 0.5, // 1.0x multiplier = preserves current hardcoded values
79
+ },
80
+ typography: {
81
+ fonts: {
82
+ mono: {
83
+ type: 'system',
84
+ family: 'ui-monospace',
85
+ fallback: 'SFMono-Regular, Menlo, Monaco, Consolas, monospace',
86
+ },
87
+ display: {
88
+ type: 'system',
89
+ family: 'system-ui',
90
+ fallback: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
91
+ },
92
+ default: {
93
+ type: 'system',
94
+ family: 'system-ui',
95
+ fallback: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
96
+ },
97
+ },
98
+ scale: { baseSize: 16, ratio: 1.25 },
99
+ },
100
+ icons: {
101
+ variant: 'rounded', // Material Design 3 aesthetic
102
+ weight: 400, // Normal weight
103
+ autoGrade: true, // Enable mode-aware grade
104
+ },
74
105
  };
@@ -81,6 +81,58 @@ export function configuratorReducer(
81
81
  }),
82
82
  };
83
83
 
84
+ case 'SET_PALETTE_KEY_COLOR':
85
+ return {
86
+ ...state,
87
+ palettes: updatePalette(state.palettes, action.index, {
88
+ keyColor: clamp(action.normalizedValue, 0, 1),
89
+ }),
90
+ };
91
+
92
+ case 'CLEAR_PALETTE_KEY_COLOR':
93
+ return {
94
+ ...state,
95
+ palettes: updatePalette(state.palettes, action.index, {
96
+ keyColor: undefined,
97
+ }),
98
+ };
99
+
100
+ case 'SET_PALETTE_FROM_HEX':
101
+ return {
102
+ ...state,
103
+ palettes: updatePalette(state.palettes, action.index, {
104
+ hue: wrapHue(action.hue),
105
+ saturation: clamp(action.saturation, 0, 100),
106
+ keyColor: clamp(action.keyColor, 0, 1),
107
+ }),
108
+ };
109
+
110
+ case 'SET_PALETTE_KEY_COLOR_DARK':
111
+ return {
112
+ ...state,
113
+ palettes: updatePalette(state.palettes, action.index, {
114
+ keyColorDark: clamp(action.normalizedValue, 0, 1),
115
+ }),
116
+ };
117
+
118
+ case 'CLEAR_PALETTE_KEY_COLOR_DARK':
119
+ return {
120
+ ...state,
121
+ palettes: updatePalette(state.palettes, action.index, {
122
+ keyColorDark: undefined,
123
+ }),
124
+ };
125
+
126
+ case 'SET_PALETTE_FROM_HEX_DARK':
127
+ return {
128
+ ...state,
129
+ palettes: updatePalette(state.palettes, action.index, {
130
+ hue: wrapHue(action.hue),
131
+ saturation: clamp(action.saturation, 0, 100),
132
+ keyColorDark: clamp(action.keyColor, 0, 1),
133
+ }),
134
+ };
135
+
84
136
  // Dynamic range actions
85
137
  case 'SET_LIGHTEST':
86
138
  return {
@@ -137,6 +189,135 @@ export function configuratorReducer(
137
189
  },
138
190
  };
139
191
 
192
+ // Spacing actions
193
+ case 'SET_SPACING_DENSITY':
194
+ return {
195
+ ...state,
196
+ spacing: {
197
+ density: clamp(action.density, 0, 1),
198
+ },
199
+ };
200
+
201
+ // Roundness actions
202
+ case 'SET_ROUNDNESS_INTENSITY':
203
+ return {
204
+ ...state,
205
+ roundness: {
206
+ intensity: clamp(action.intensity, 0, 1),
207
+ },
208
+ };
209
+
210
+ // Typography actions
211
+ case 'SET_TYPOGRAPHY_BASE_SIZE': {
212
+ const defaultTypography = DEFAULT_CONFIGURATOR_STATE.typography!;
213
+ return {
214
+ ...state,
215
+ typography: {
216
+ fonts: state.typography?.fonts ?? defaultTypography.fonts,
217
+ scale: {
218
+ baseSize: clamp(action.baseSize, 12, 24),
219
+ ratio: state.typography?.scale.ratio ?? defaultTypography.scale.ratio,
220
+ },
221
+ },
222
+ };
223
+ }
224
+
225
+ case 'SET_TYPOGRAPHY_RATIO': {
226
+ const defaultTypography = DEFAULT_CONFIGURATOR_STATE.typography!;
227
+ return {
228
+ ...state,
229
+ typography: {
230
+ fonts: state.typography?.fonts ?? defaultTypography.fonts,
231
+ scale: {
232
+ baseSize: state.typography?.scale.baseSize ?? defaultTypography.scale.baseSize,
233
+ ratio: clamp(action.ratio, 1.1, 1.5),
234
+ },
235
+ },
236
+ };
237
+ }
238
+
239
+ case 'SET_FONT_MONO': {
240
+ const defaultTypography = DEFAULT_CONFIGURATOR_STATE.typography!;
241
+ return {
242
+ ...state,
243
+ typography: {
244
+ fonts: {
245
+ mono: action.font,
246
+ display: state.typography?.fonts.display ?? defaultTypography.fonts.display,
247
+ default: state.typography?.fonts.default ?? defaultTypography.fonts.default,
248
+ },
249
+ scale: state.typography?.scale ?? defaultTypography.scale,
250
+ },
251
+ };
252
+ }
253
+
254
+ case 'SET_FONT_DISPLAY': {
255
+ const defaultTypography = DEFAULT_CONFIGURATOR_STATE.typography!;
256
+ return {
257
+ ...state,
258
+ typography: {
259
+ fonts: {
260
+ mono: state.typography?.fonts.mono ?? defaultTypography.fonts.mono,
261
+ display: action.font,
262
+ default: state.typography?.fonts.default ?? defaultTypography.fonts.default,
263
+ },
264
+ scale: state.typography?.scale ?? defaultTypography.scale,
265
+ },
266
+ };
267
+ }
268
+
269
+ case 'SET_FONT_DEFAULT': {
270
+ const defaultTypography = DEFAULT_CONFIGURATOR_STATE.typography!;
271
+ return {
272
+ ...state,
273
+ typography: {
274
+ fonts: {
275
+ mono: state.typography?.fonts.mono ?? defaultTypography.fonts.mono,
276
+ display: state.typography?.fonts.display ?? defaultTypography.fonts.display,
277
+ default: action.font,
278
+ },
279
+ scale: state.typography?.scale ?? defaultTypography.scale,
280
+ },
281
+ };
282
+ }
283
+
284
+ // Icons actions
285
+ case 'SET_ICON_VARIANT': {
286
+ const defaultIcons = DEFAULT_CONFIGURATOR_STATE.icons!;
287
+ return {
288
+ ...state,
289
+ icons: {
290
+ variant: action.variant,
291
+ weight: state.icons?.weight ?? defaultIcons.weight,
292
+ autoGrade: state.icons?.autoGrade ?? defaultIcons.autoGrade,
293
+ },
294
+ };
295
+ }
296
+
297
+ case 'SET_ICON_WEIGHT': {
298
+ const defaultIcons = DEFAULT_CONFIGURATOR_STATE.icons!;
299
+ return {
300
+ ...state,
301
+ icons: {
302
+ variant: state.icons?.variant ?? defaultIcons.variant,
303
+ weight: action.weight,
304
+ autoGrade: state.icons?.autoGrade ?? defaultIcons.autoGrade,
305
+ },
306
+ };
307
+ }
308
+
309
+ case 'SET_ICON_AUTO_GRADE': {
310
+ const defaultIcons = DEFAULT_CONFIGURATOR_STATE.icons!;
311
+ return {
312
+ ...state,
313
+ icons: {
314
+ variant: state.icons?.variant ?? defaultIcons.variant,
315
+ weight: state.icons?.weight ?? defaultIcons.weight,
316
+ autoGrade: action.autoGrade,
317
+ },
318
+ };
319
+ }
320
+
140
321
  // Preview actions
141
322
  case 'SET_PREVIEW_MODE':
142
323
  return {
package/src/types.ts CHANGED
@@ -11,6 +11,8 @@ export interface PaletteState {
11
11
  readonly hueGradeStrength: HueGradingStrength; // 'none' | 'low' | 'medium' | 'hard'
12
12
  readonly hueGradeHue: number; // Traditional HSL hue [0, 359]
13
13
  readonly hueGradeDirection: 'light' | 'dark';
14
+ readonly keyColor?: number; // Light mode normalizedValue [0, 1] — undefined = auto
15
+ readonly keyColorDark?: number; // Dark mode normalizedValue [0, 1] — undefined = auto
14
16
  }
15
17
 
16
18
  /** Global hue grading endpoint state */
@@ -19,6 +21,14 @@ export interface HueGradingEndpointState {
19
21
  readonly hue: number; // Traditional HSL hue [0, 359]
20
22
  }
21
23
 
24
+ /** Font configuration for a single font slot */
25
+ export interface FontConfig {
26
+ readonly type: 'system' | 'google' | 'custom';
27
+ readonly family: string; // 'ui-monospace' | 'Roboto' | custom name
28
+ readonly customUrl?: string; // Supabase Storage URL for custom fonts
29
+ readonly fallback: string; // CSS fallback stack
30
+ }
31
+
22
32
  /** Complete configurator state */
23
33
  export interface ConfiguratorState {
24
34
  readonly palettes: readonly PaletteState[];
@@ -34,4 +44,26 @@ export interface ConfiguratorState {
34
44
  readonly mode: ColorMode;
35
45
  readonly theme: ThemeName;
36
46
  };
47
+ readonly spacing?: {
48
+ readonly density: number; // [0, 1] where 0=tight, 1=relaxed
49
+ };
50
+ readonly roundness?: {
51
+ readonly intensity: number; // [0, 1] where 0=rectangle, 1=pill
52
+ };
53
+ readonly typography?: {
54
+ readonly fonts: {
55
+ readonly mono: FontConfig; // currencies, code, amounts
56
+ readonly display: FontConfig; // headlines
57
+ readonly default: FontConfig; // fallback
58
+ };
59
+ readonly scale: {
60
+ readonly baseSize: number; // px (default: 16)
61
+ readonly ratio: number; // scale ratio (default: 1.25)
62
+ };
63
+ };
64
+ readonly icons?: {
65
+ readonly variant: 'outlined' | 'rounded' | 'sharp';
66
+ readonly weight: 100 | 200 | 300 | 400 | 500 | 600 | 700;
67
+ readonly autoGrade: boolean; // true = mode-aware grade (light=-25, dark=200)
68
+ };
37
69
  }