@newtonedev/configurator 0.1.0 → 0.1.2

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/dist/types.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { DesaturationStrength, HueGradingStrength } from 'newtone';
2
2
  import type { ColorMode, ThemeName } from '@newtonedev/components';
3
+ /** Spacing preset options */
4
+ export type SpacingPreset = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
3
5
  /** Per-palette state in the configurator (human-readable, traditional hues) */
4
6
  export interface PaletteState {
5
7
  readonly name: string;
@@ -10,12 +12,21 @@ export interface PaletteState {
10
12
  readonly hueGradeStrength: HueGradingStrength;
11
13
  readonly hueGradeHue: number;
12
14
  readonly hueGradeDirection: 'light' | 'dark';
15
+ readonly keyColor?: number;
16
+ readonly keyColorDark?: number;
13
17
  }
14
18
  /** Global hue grading endpoint state */
15
19
  export interface HueGradingEndpointState {
16
20
  readonly strength: HueGradingStrength;
17
21
  readonly hue: number;
18
22
  }
23
+ /** Font configuration for a single font slot */
24
+ export interface FontConfig {
25
+ readonly type: 'system' | 'google' | 'custom';
26
+ readonly family: string;
27
+ readonly customUrl?: string;
28
+ readonly fallback: string;
29
+ }
19
30
  /** Complete configurator state */
20
31
  export interface ConfiguratorState {
21
32
  readonly palettes: readonly PaletteState[];
@@ -31,5 +42,27 @@ export interface ConfiguratorState {
31
42
  readonly mode: ColorMode;
32
43
  readonly theme: ThemeName;
33
44
  };
45
+ readonly spacing?: {
46
+ readonly preset: SpacingPreset;
47
+ };
48
+ readonly roundness?: {
49
+ readonly intensity: number;
50
+ };
51
+ readonly typography?: {
52
+ readonly fonts: {
53
+ readonly mono: FontConfig;
54
+ readonly display: FontConfig;
55
+ readonly default: FontConfig;
56
+ };
57
+ readonly scale: {
58
+ readonly baseSize: number;
59
+ readonly ratio: number;
60
+ };
61
+ };
62
+ readonly icons?: {
63
+ readonly variant: 'outlined' | 'rounded' | 'sharp';
64
+ readonly weight: 100 | 200 | 300 | 400 | 500 | 600 | 700;
65
+ readonly autoGrade: boolean;
66
+ };
34
67
  }
35
68
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEnE,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,oBAAoB,EAAE,oBAAoB,CAAC;IACpD,QAAQ,CAAC,qBAAqB,EAAE,OAAO,GAAG,MAAM,CAAC;IACjD,QAAQ,CAAC,gBAAgB,EAAE,kBAAkB,CAAC;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,iBAAiB,EAAE,OAAO,GAAG,MAAM,CAAC;CAC9C;AAED,wCAAwC;AACxC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,kCAAkC;AAClC,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,CAAC;IAC3C,QAAQ,CAAC,YAAY,EAAE;QACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;KAC1B,CAAC;IACF,QAAQ,CAAC,gBAAgB,EAAE;QACzB,QAAQ,CAAC,KAAK,EAAE,uBAAuB,CAAC;QACxC,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;KACxC,CAAC;IACF,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;QACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;KAC3B,CAAC;CACH"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newtonedev/configurator",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Embeddable palette builder widget for Newtone color systems",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,7 @@ import { PalettePanel } from './panels/PalettePanel';
9
9
  import { GlobalPanel } from './panels/GlobalPanel';
10
10
  import { PreviewPanel } from './panels/PreviewPanel';
11
11
  import { ExportPanel } from './panels/ExportPanel';
12
+ import { DesignPanel } from './panels/DesignPanel';
12
13
 
13
14
  /**
14
15
  * Newtone Configurator — embeddable palette builder widget.
@@ -74,6 +75,11 @@ export function Configurator({
74
75
  )}
75
76
  </View>
76
77
 
78
+ {/* Design System controls */}
79
+ <View style={styles.designRow}>
80
+ <DesignPanel state={state} dispatch={dispatch} />
81
+ </View>
82
+
77
83
  {/* Palette tab bar */}
78
84
  <View style={styles.tabBar}>
79
85
  {state.palettes.map((palette, index) => (
@@ -115,6 +121,7 @@ export function Configurator({
115
121
  index={activePaletteIndex}
116
122
  dispatch={dispatch}
117
123
  previewColors={previews[activePaletteIndex]}
124
+ state={state}
118
125
  />
119
126
 
120
127
  {showExport && <ExportPanel state={state} />}
@@ -160,6 +167,9 @@ const styles = StyleSheet.create({
160
167
  topRowPanel: {
161
168
  flex: 1,
162
169
  },
170
+ designRow: {
171
+ marginBottom: 4,
172
+ },
163
173
  tabBar: {
164
174
  flexDirection: 'row',
165
175
  gap: 4,
@@ -20,6 +20,10 @@ export function toCSS(state: ConfiguratorState): string {
20
20
  config.themes.neutral,
21
21
  1,
22
22
  config.elevation.offsets,
23
+ config.spacing,
24
+ config.radius,
25
+ config.typography,
26
+ config.icons
23
27
  );
24
28
 
25
29
  const selector = mode === 'light' ? ':root' : '[data-theme="dark"]';
@@ -1,8 +1,72 @@
1
- import type { ConfiguratorState } from '../types';
2
- import type { NewtoneThemeConfig } from '@newtonedev/components';
1
+ import type { ConfiguratorState, SpacingPreset } from '../types';
2
+ import type { NewtoneThemeConfig, FontConfig } from '@newtonedev/components';
3
3
  import type { PaletteConfig, DynamicRange, HueGrading, HueGradingEndpoint } from 'newtone';
4
4
  import { traditionalHueToOklch } from '../hue-conversion';
5
5
 
6
+ /**
7
+ * Spacing preset to base pixel value mapping
8
+ */
9
+ const SPACING_PRESET_TO_BASE: Record<SpacingPreset, number> = {
10
+ xs: 6, // Extra Small: compact/dense UI
11
+ sm: 7, // Small: tighter spacing
12
+ md: 8, // Medium: default/balanced
13
+ lg: 9, // Large: more spacious
14
+ xl: 10, // Extra Large: maximum spacing
15
+ };
16
+
17
+ /**
18
+ * Convert roundness intensity [0, 1] to radius multiplier [0, 2.0]
19
+ */
20
+ function roundnessToMultiplier(intensity: number): number {
21
+ return intensity * 2.0; // lerp(0, 2.0, intensity)
22
+ }
23
+
24
+ /**
25
+ * Compute typography scale from base size and ratio.
26
+ * Uses modular scale: baseSize * ratio^n
27
+ */
28
+ function computeTypographyScale(baseSize: number, ratio: number) {
29
+ return {
30
+ xs: Math.round(baseSize / (ratio ** 2)),
31
+ sm: Math.round(baseSize / ratio),
32
+ base: baseSize,
33
+ md: Math.round(baseSize * ratio),
34
+ lg: Math.round(baseSize * (ratio ** 2)),
35
+ xl: Math.round(baseSize * (ratio ** 3)),
36
+ xxl: Math.round(baseSize * (ratio ** 4)),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Default font configurations using system fonts
42
+ */
43
+ const DEFAULT_FONTS: { readonly mono: FontConfig; readonly display: FontConfig; readonly default: FontConfig } = {
44
+ mono: {
45
+ type: 'system',
46
+ family: 'ui-monospace',
47
+ fallback: 'SFMono-Regular, Menlo, Monaco, Consolas, monospace',
48
+ },
49
+ display: {
50
+ type: 'system',
51
+ family: 'system-ui',
52
+ fallback: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
53
+ },
54
+ default: {
55
+ type: 'system',
56
+ family: 'system-ui',
57
+ fallback: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Default icon configuration using Material Symbols Rounded
63
+ */
64
+ const DEFAULT_ICONS = {
65
+ variant: 'rounded' as const, // Material Design 3 aesthetic
66
+ weight: 400 as const, // Normal weight
67
+ autoGrade: true, // Enable mode-aware grade
68
+ };
69
+
6
70
  /**
7
71
  * Convert configurator state (traditional hues, human-readable controls)
8
72
  * to a NewtoneThemeConfig (OKLCH hues, engine-ready format).
@@ -23,7 +87,14 @@ export function toThemeConfig(state: ConfiguratorState): NewtoneThemeConfig {
23
87
  } as const
24
88
  : undefined;
25
89
 
26
- return { hue: oklchHue, saturation: p.saturation, desaturation, paletteHueGrading };
90
+ return {
91
+ hue: oklchHue,
92
+ saturation: p.saturation,
93
+ desaturation,
94
+ paletteHueGrading,
95
+ ...(p.keyColor !== undefined ? { keyNormalizedValue: p.keyColor } : {}),
96
+ ...(p.keyColorDark !== undefined ? { keyNormalizedValueDark: p.keyColorDark } : {}),
97
+ };
27
98
  });
28
99
 
29
100
  // Build global hue grading
@@ -43,6 +114,12 @@ export function toThemeConfig(state: ConfiguratorState): NewtoneThemeConfig {
43
114
  ...(hueGrading ? { hueGrading } : {}),
44
115
  };
45
116
 
117
+ // Compute spacing base from preset (default 'md' = 8px base)
118
+ const spacingBase = SPACING_PRESET_TO_BASE[state.spacing?.preset ?? 'md'];
119
+
120
+ // Compute radius multiplier from roundness intensity slider (default 0.5 = 1.0x multiplier)
121
+ const radiusMultiplier = roundnessToMultiplier(state.roundness?.intensity ?? 0.5);
122
+
46
123
  return {
47
124
  colorSystem: { dynamicRange, palettes },
48
125
  themes: {
@@ -54,5 +131,38 @@ export function toThemeConfig(state: ConfiguratorState): NewtoneThemeConfig {
54
131
  elevation: {
55
132
  offsets: [-0.02, 0, 0.04] as const,
56
133
  },
134
+ spacing: {
135
+ '00': Math.round(spacingBase * 0), // Always 0
136
+ '02': Math.round(spacingBase * 0.25),
137
+ '04': Math.round(spacingBase * 0.5),
138
+ '06': Math.round(spacingBase * 0.75),
139
+ '08': Math.round(spacingBase * 1), // Equals base
140
+ '10': Math.round(spacingBase * 1.25),
141
+ '12': Math.round(spacingBase * 1.5),
142
+ '16': Math.round(spacingBase * 2),
143
+ '20': Math.round(spacingBase * 2.5),
144
+ '24': Math.round(spacingBase * 3),
145
+ '32': Math.round(spacingBase * 4),
146
+ '40': Math.round(spacingBase * 5),
147
+ '48': Math.round(spacingBase * 6),
148
+ },
149
+ radius: {
150
+ none: 0,
151
+ sm: Math.round(4 * radiusMultiplier),
152
+ md: Math.round(6 * radiusMultiplier),
153
+ lg: Math.round(8 * radiusMultiplier),
154
+ xl: Math.round(12 * radiusMultiplier),
155
+ pill: 999,
156
+ },
157
+ typography: {
158
+ fonts: state.typography?.fonts ?? DEFAULT_FONTS,
159
+ scale: computeTypographyScale(
160
+ state.typography?.scale.baseSize ?? 16,
161
+ state.typography?.scale.ratio ?? 1.25
162
+ ),
163
+ lineHeight: { tight: 1.25, normal: 1.5, relaxed: 1.75 },
164
+ fontWeight: { regular: 400, medium: 500, semibold: 600, bold: 700 },
165
+ },
166
+ icons: state.icons ?? DEFAULT_ICONS,
57
167
  };
58
168
  }
@@ -0,0 +1,99 @@
1
+ import {
2
+ hexToSrgb,
3
+ srgbToOklch,
4
+ findMaxChromaInGamut,
5
+ lightnessToNormalizedValue,
6
+ } from 'newtone';
7
+ import type { DynamicRange } from 'newtone';
8
+ import { traditionalHueToOklch } from './hue-conversion';
9
+
10
+ /** Result of decomposing a hex color into palette parameters */
11
+ export interface HexPaletteParams {
12
+ /** Traditional HSL hue [0, 359] */
13
+ readonly hue: number;
14
+ /** Saturation as % of max in-gamut chroma [0, 100] */
15
+ readonly saturation: number;
16
+ /** Position on the luminosity scale [0, 1] */
17
+ readonly normalizedValue: number;
18
+ }
19
+
20
+ // Achromatic threshold — below this chroma, the color has no meaningful hue
21
+ const ACHROMATIC_THRESHOLD = 0.005;
22
+
23
+ // Lookup table for OKLCH hue → traditional hue (built once, lazily)
24
+ let traditionalHueLut: readonly number[] | null = null;
25
+
26
+ function buildTraditionalHueLut(): readonly number[] {
27
+ const lut: number[] = new Array(360);
28
+ for (let h = 0; h < 360; h++) {
29
+ lut[h] = traditionalHueToOklch(h);
30
+ }
31
+ return lut;
32
+ }
33
+
34
+ /**
35
+ * Convert an OKLCH hue back to the nearest traditional HSL hue.
36
+ * Uses a precomputed lookup table with 1° resolution.
37
+ */
38
+ export function oklchHueToTraditional(oklchHue: number): number {
39
+ if (!traditionalHueLut) {
40
+ traditionalHueLut = buildTraditionalHueLut();
41
+ }
42
+
43
+ const target = ((oklchHue % 360) + 360) % 360;
44
+ let bestHue = 0;
45
+ let bestDelta = Infinity;
46
+
47
+ for (let h = 0; h < 360; h++) {
48
+ // Shortest angular distance
49
+ const raw = Math.abs(traditionalHueLut[h] - target);
50
+ const delta = Math.min(raw, 360 - raw);
51
+ if (delta < bestDelta) {
52
+ bestDelta = delta;
53
+ bestHue = h;
54
+ }
55
+ }
56
+
57
+ return bestHue;
58
+ }
59
+
60
+ /**
61
+ * Decompose a hex color string into palette parameters (hue, saturation, luminosity).
62
+ *
63
+ * Returns null if the hex string is invalid.
64
+ *
65
+ * @param hex - Hex color string (e.g., "#FF0000", "#f00", "FF0000")
66
+ * @param dynamicRange - Current dynamic range for normalizedValue mapping
67
+ */
68
+ export function hexToPaletteParams(
69
+ hex: string,
70
+ dynamicRange: DynamicRange,
71
+ ): HexPaletteParams | null {
72
+ // Validate hex format
73
+ const cleaned = hex.startsWith('#') ? hex.slice(1) : hex;
74
+ if (!/^[0-9a-fA-F]{3}$/.test(cleaned) && !/^[0-9a-fA-F]{6}$/.test(cleaned)) {
75
+ return null;
76
+ }
77
+
78
+ const srgb = hexToSrgb(hex);
79
+ const oklch = srgbToOklch(srgb);
80
+
81
+ // Map OKLCH lightness to normalizedValue within the dynamic range
82
+ const normalizedValue = lightnessToNormalizedValue(dynamicRange, oklch.L);
83
+
84
+ // Achromatic case: no meaningful hue
85
+ if (oklch.C < ACHROMATIC_THRESHOLD) {
86
+ return { hue: 0, saturation: 0, normalizedValue };
87
+ }
88
+
89
+ // Reverse-map OKLCH hue to traditional hue
90
+ const hue = oklchHueToTraditional(oklch.h);
91
+
92
+ // Compute saturation as percentage of max available chroma at this L and h
93
+ const maxChroma = findMaxChromaInGamut(oklch.L, oklch.h);
94
+ const saturation = maxChroma > 0
95
+ ? Math.min(100, Math.round(oklch.C / maxChroma * 100))
96
+ : 0;
97
+
98
+ return { hue, saturation, normalizedValue };
99
+ }
@@ -0,0 +1,111 @@
1
+ import { useMemo } from 'react';
2
+ import {
3
+ getColor,
4
+ getColorByContrast,
5
+ getWcagContrastRatio,
6
+ lightnessToNormalizedValue,
7
+ } from 'newtone';
8
+ import type { DynamicRange } from 'newtone';
9
+ import type { ConfiguratorState } from '../types';
10
+ import { traditionalHueToOklch } from '../hue-conversion';
11
+
12
+ export interface WcagValidation {
13
+ /** WCAG contrast ratio at the key color position (null if no keyColor set) */
14
+ readonly keyColorContrast: number | null;
15
+ /** Whether the key color passes WCAG AA for normal text (>= 4.5:1) */
16
+ readonly passesAA: boolean;
17
+ /** Whether the key color passes WCAG AA for large text (>= 3:1) */
18
+ readonly passesAALargeText: boolean;
19
+ /** The normalizedValue where auto contrast search would place the key color */
20
+ readonly autoNormalizedValue: number;
21
+ }
22
+
23
+ /**
24
+ * Compute WCAG validation info for a non-neutral palette's key color.
25
+ *
26
+ * Validates the palette's key color against the neutral background
27
+ * in the current preview mode. Also computes where the auto contrast
28
+ * search would place the key color (for showing the default position).
29
+ */
30
+ export function useWcagValidation(
31
+ state: ConfiguratorState,
32
+ paletteIndex: number,
33
+ ): WcagValidation {
34
+ return useMemo(() => {
35
+ const palette = state.palettes[paletteIndex];
36
+ const neutral = state.palettes[0];
37
+ if (!palette || !neutral) {
38
+ return { keyColorContrast: null, passesAA: true, passesAALargeText: true, autoNormalizedValue: 0.5 };
39
+ }
40
+
41
+ const mode = state.preview.mode;
42
+
43
+ // Build dynamic range with global hue grading
44
+ const light = state.globalHueGrading.light.strength !== 'none'
45
+ ? { hue: traditionalHueToOklch(state.globalHueGrading.light.hue), strength: state.globalHueGrading.light.strength }
46
+ : undefined;
47
+ const dark = state.globalHueGrading.dark.strength !== 'none'
48
+ ? { hue: traditionalHueToOklch(state.globalHueGrading.dark.hue), strength: state.globalHueGrading.dark.strength }
49
+ : undefined;
50
+ const hueGrading = (light || dark) ? { light, dark } : undefined;
51
+
52
+ const dynamicRange: DynamicRange = {
53
+ lightest: state.dynamicRange.lightest,
54
+ darkest: state.dynamicRange.darkest,
55
+ ...(hueGrading ? { hueGrading } : {}),
56
+ };
57
+
58
+ // Compute neutral background for the current preview mode
59
+ const neutralOklchHue = traditionalHueToOklch(neutral.hue);
60
+ const neutralDesat = neutral.desaturationStrength !== 'none'
61
+ ? { direction: neutral.desaturationDirection, strength: neutral.desaturationStrength } as const
62
+ : undefined;
63
+ const neutralPhg = neutral.hueGradeStrength !== 'none'
64
+ ? { hue: traditionalHueToOklch(neutral.hueGradeHue), strength: neutral.hueGradeStrength, direction: neutral.hueGradeDirection } as const
65
+ : undefined;
66
+
67
+ const backgroundNv = mode === 'light' ? 0.95 : 0.1;
68
+ const background = getColor(neutralOklchHue, neutral.saturation, dynamicRange, backgroundNv, neutralDesat, neutralPhg);
69
+
70
+ // Build palette engine params
71
+ const paletteOklchHue = traditionalHueToOklch(palette.hue);
72
+ const effectiveTextMode = mode === 'light' ? 'light' as const : 'dark' as const;
73
+ const paletteDesat = palette.desaturationStrength !== 'none'
74
+ ? { direction: palette.desaturationDirection, strength: palette.desaturationStrength } as const
75
+ : undefined;
76
+ const palettePhg = palette.hueGradeStrength !== 'none'
77
+ ? { hue: traditionalHueToOklch(palette.hueGradeHue), strength: palette.hueGradeStrength, direction: palette.hueGradeDirection } as const
78
+ : undefined;
79
+
80
+ // Compute auto position: where contrast search would place the key color
81
+ const autoColor = getColorByContrast(
82
+ paletteOklchHue, palette.saturation, dynamicRange,
83
+ 4.5, effectiveTextMode, paletteDesat, palettePhg, background,
84
+ );
85
+ const autoNormalizedValue = lightnessToNormalizedValue(dynamicRange, autoColor.oklch.L);
86
+
87
+ // Resolve per-mode key color: light mode uses keyColor, dark uses keyColorDark
88
+ const effectiveKeyColor = mode === 'dark' ? palette.keyColorDark : palette.keyColor;
89
+
90
+ // If no key color set for this mode, everything passes (auto is always WCAG-compliant)
91
+ if (effectiveKeyColor === undefined) {
92
+ return { keyColorContrast: null, passesAA: true, passesAALargeText: true, autoNormalizedValue };
93
+ }
94
+
95
+ // Compute the actual color at the user-chosen key position for this mode
96
+ const keyColor = getColor(
97
+ paletteOklchHue, palette.saturation, dynamicRange,
98
+ effectiveKeyColor, paletteDesat, palettePhg,
99
+ );
100
+
101
+ // Compute WCAG contrast against the neutral background
102
+ const contrast = getWcagContrastRatio(keyColor.srgb, background.srgb);
103
+
104
+ return {
105
+ keyColorContrast: contrast,
106
+ passesAA: contrast >= 4.5,
107
+ passesAALargeText: contrast >= 3.0,
108
+ autoNormalizedValue,
109
+ };
110
+ }, [state, paletteIndex]);
111
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export { Configurator } from './Configurator';
3
3
  export type { ConfiguratorProps } from './Configurator.types';
4
4
 
5
5
  // State types
6
- export type { ConfiguratorState, PaletteState, HueGradingEndpointState } from './types';
6
+ export type { ConfiguratorState, PaletteState, HueGradingEndpointState, SpacingPreset } from './types';
7
7
  export type { ConfiguratorAction } from './state/actions';
8
8
 
9
9
  // Bridge functions (for headless usage)
@@ -12,13 +12,20 @@ export { toCSS } from './bridge/toCSS';
12
12
  export { toJSON } from './bridge/toJSON';
13
13
  export type { ConfiguratorExport } from './bridge/toJSON';
14
14
 
15
- // Hue conversion utility
15
+ // Hue conversion utilities
16
16
  export { traditionalHueToOklch } from './hue-conversion';
17
+ export { oklchHueToTraditional, hexToPaletteParams } from './hex-conversion';
18
+ export type { HexPaletteParams } from './hex-conversion';
17
19
 
18
20
  // Defaults
19
21
  export { DEFAULT_CONFIGURATOR_STATE } from './state/defaults';
20
22
 
23
+ // Constants
24
+ export { SEMANTIC_HUE_RANGES } from './constants';
25
+
21
26
  // Hooks (for custom configurator UIs)
22
27
  export { useConfigurator } from './hooks/useConfigurator';
23
28
  export type { UseConfiguratorResult } from './hooks/useConfigurator';
24
29
  export { usePreviewColors } from './hooks/usePreviewColors';
30
+ export { useWcagValidation } from './hooks/useWcagValidation';
31
+ export type { WcagValidation } from './hooks/useWcagValidation';
@@ -0,0 +1,149 @@
1
+ import React from 'react';
2
+ import { View, Text, StyleSheet } from 'react-native';
3
+ import { Card, Slider, Select, useTokens } from '@newtonedev/components';
4
+ import { srgbToHex } from 'newtone';
5
+ import type { ConfiguratorState, SpacingPreset } from '../types';
6
+ import type { ConfiguratorAction } from '../state/actions';
7
+
8
+ const ICON_VARIANT_OPTIONS = [
9
+ { label: 'Outlined', value: 'outlined' },
10
+ { label: 'Rounded', value: 'rounded' },
11
+ { label: 'Sharp', value: 'sharp' },
12
+ ];
13
+
14
+ const ICON_WEIGHT_OPTIONS = [
15
+ { label: '100', value: '100' },
16
+ { label: '200', value: '200' },
17
+ { label: '300', value: '300' },
18
+ { label: '400', value: '400' },
19
+ { label: '500', value: '500' },
20
+ { label: '600', value: '600' },
21
+ { label: '700', value: '700' },
22
+ ];
23
+
24
+ interface DesignPanelProps {
25
+ readonly state: ConfiguratorState;
26
+ readonly dispatch: (action: ConfiguratorAction) => void;
27
+ }
28
+
29
+ export function DesignPanel({ state, dispatch }: DesignPanelProps) {
30
+ const tokens = useTokens(1);
31
+
32
+ const spacingPreset = state.spacing?.preset ?? 'md';
33
+ const intensity = state.roundness?.intensity ?? 0.5;
34
+ const baseSize = state.typography?.scale.baseSize ?? 16;
35
+ const ratio = state.typography?.scale.ratio ?? 1.25;
36
+ const variant = state.icons?.variant ?? 'rounded';
37
+ const weight = state.icons?.weight ?? 400;
38
+ return (
39
+ <Card elevation={1} style={styles.container}>
40
+ <Text style={[styles.title, { color: srgbToHex(tokens.textPrimary.srgb) }]}>
41
+ Design System
42
+ </Text>
43
+
44
+ {/* Spacing Section */}
45
+ <Text style={[styles.subtitle, { color: srgbToHex(tokens.textSecondary.srgb) }]}>
46
+ Spacing
47
+ </Text>
48
+ <Select
49
+ value={spacingPreset}
50
+ onValueChange={(preset) => dispatch({ type: 'SET_SPACING_PRESET', preset: preset as SpacingPreset })}
51
+ options={[
52
+ { value: 'xs', label: 'Extra Small' },
53
+ { value: 'sm', label: 'Small' },
54
+ { value: 'md', label: 'Medium' },
55
+ { value: 'lg', label: 'Large' },
56
+ { value: 'xl', label: 'Extra Large' },
57
+ ]}
58
+ label="Preset"
59
+ />
60
+
61
+ {/* Roundness Section */}
62
+ <Text style={[styles.subtitle, { color: srgbToHex(tokens.textSecondary.srgb) }]}>
63
+ Roundness
64
+ </Text>
65
+ <Slider
66
+ value={Math.round(intensity * 100)}
67
+ onValueChange={(v) => dispatch({ type: 'SET_ROUNDNESS_INTENSITY', intensity: v / 100 })}
68
+ min={0}
69
+ max={100}
70
+ label="Intensity"
71
+ showValue
72
+ />
73
+
74
+ {/* Typography Section */}
75
+ <Text style={[styles.subtitle, { color: srgbToHex(tokens.textSecondary.srgb) }]}>
76
+ Typography
77
+ </Text>
78
+ <View style={styles.row}>
79
+ <View style={styles.flex}>
80
+ <Slider
81
+ value={baseSize}
82
+ onValueChange={(v) => dispatch({ type: 'SET_TYPOGRAPHY_BASE_SIZE', baseSize: v })}
83
+ min={12}
84
+ max={24}
85
+ step={1}
86
+ label="Base Size"
87
+ showValue
88
+ />
89
+ </View>
90
+ <View style={styles.flex}>
91
+ <Slider
92
+ value={Math.round(ratio * 100)}
93
+ onValueChange={(v) => dispatch({ type: 'SET_TYPOGRAPHY_RATIO', ratio: v / 100 })}
94
+ min={110}
95
+ max={150}
96
+ step={5}
97
+ label="Scale Ratio"
98
+ showValue
99
+ />
100
+ </View>
101
+ </View>
102
+
103
+ {/* Icons Section */}
104
+ <Text style={[styles.subtitle, { color: srgbToHex(tokens.textSecondary.srgb) }]}>
105
+ Icons
106
+ </Text>
107
+ <View style={styles.row}>
108
+ <View style={styles.flex}>
109
+ <Select
110
+ options={ICON_VARIANT_OPTIONS}
111
+ value={variant}
112
+ onValueChange={(v) => dispatch({ type: 'SET_ICON_VARIANT', variant: v as 'outlined' | 'rounded' | 'sharp' })}
113
+ label="Variant"
114
+ />
115
+ </View>
116
+ <View style={styles.flex}>
117
+ <Select
118
+ options={ICON_WEIGHT_OPTIONS}
119
+ value={weight.toString()}
120
+ onValueChange={(v) => dispatch({ type: 'SET_ICON_WEIGHT', weight: parseInt(v) as 100 | 200 | 300 | 400 | 500 | 600 | 700 })}
121
+ label="Weight"
122
+ />
123
+ </View>
124
+ </View>
125
+ </Card>
126
+ );
127
+ }
128
+
129
+ const styles = StyleSheet.create({
130
+ container: {
131
+ gap: 12,
132
+ },
133
+ title: {
134
+ fontSize: 16,
135
+ fontWeight: '700',
136
+ },
137
+ subtitle: {
138
+ fontSize: 13,
139
+ fontWeight: '600',
140
+ marginTop: 4,
141
+ },
142
+ row: {
143
+ flexDirection: 'row',
144
+ gap: 12,
145
+ },
146
+ flex: {
147
+ flex: 1,
148
+ },
149
+ });