@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/Configurator.d.ts.map +1 -1
- package/dist/bridge/toCSS.d.ts.map +1 -1
- package/dist/bridge/toThemeConfig.d.ts.map +1 -1
- package/dist/hex-conversion.d.ts +25 -0
- package/dist/hex-conversion.d.ts.map +1 -0
- package/dist/hooks/useWcagValidation.d.ts +20 -0
- package/dist/hooks/useWcagValidation.d.ts.map +1 -0
- package/dist/index.cjs +690 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +676 -39
- package/dist/index.js.map +1 -1
- package/dist/panels/DesignPanel.d.ts +10 -0
- package/dist/panels/DesignPanel.d.ts.map +1 -0
- package/dist/panels/PalettePanel.d.ts +3 -2
- package/dist/panels/PalettePanel.d.ts.map +1 -1
- package/dist/state/actions.d.ts +57 -1
- package/dist/state/actions.d.ts.map +1 -1
- package/dist/state/defaults.d.ts.map +1 -1
- package/dist/state/reducer.d.ts.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Configurator.tsx +10 -0
- package/src/bridge/toCSS.ts +4 -0
- package/src/bridge/toThemeConfig.ts +113 -3
- package/src/hex-conversion.ts +99 -0
- package/src/hooks/useWcagValidation.ts +111 -0
- package/src/index.ts +9 -2
- package/src/panels/DesignPanel.tsx +149 -0
- package/src/panels/PalettePanel.tsx +182 -6
- package/src/state/actions.ts +21 -1
- package/src/state/defaults.ts +31 -0
- package/src/state/reducer.ts +181 -0
- package/src/types.ts +35 -0
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
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/Configurator.tsx
CHANGED
|
@@ -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,
|
package/src/bridge/toCSS.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
});
|