@ankhorage/zora 0.16.2 → 1.0.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.
- package/CHANGELOG.md +74 -0
- package/README.md +27 -22
- package/dist/components/heading/resolveHeadingRecipe.d.ts +2 -2
- package/dist/components/heading/resolveHeadingRecipe.d.ts.map +1 -1
- package/dist/components/heading/resolveHeadingRecipe.js.map +1 -1
- package/dist/components/text/resolveTextRecipe.d.ts +2 -2
- package/dist/components/text/resolveTextRecipe.d.ts.map +1 -1
- package/dist/components/text/resolveTextRecipe.js.map +1 -1
- package/dist/patterns/theme-composer/ThemeComposer.d.ts.map +1 -1
- package/dist/patterns/theme-composer/ThemeComposer.js +101 -86
- package/dist/patterns/theme-composer/ThemeComposer.js.map +1 -1
- package/dist/patterns/theme-composer/index.d.ts +1 -1
- package/dist/patterns/theme-composer/index.d.ts.map +1 -1
- package/dist/patterns/theme-composer/index.js.map +1 -1
- package/dist/patterns/theme-composer/types.d.ts +3 -13
- package/dist/patterns/theme-composer/types.d.ts.map +1 -1
- package/dist/patterns/theme-composer/types.js.map +1 -1
- package/dist/theme/createZoraThemeConfig.d.ts +1 -1
- package/dist/theme/createZoraThemeConfig.d.ts.map +1 -1
- package/dist/theme/createZoraThemeConfig.js +5 -6
- package/dist/theme/createZoraThemeConfig.js.map +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/types.d.ts +16 -11
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js +1 -20
- package/dist/theme/types.js.map +1 -1
- package/dist/theme/useZoraTheme.d.ts +1 -1
- package/dist/theme/zoraDefaultTheme.js +1 -1
- package/dist/theme/zoraDefaultTheme.js.map +1 -1
- package/package.json +4 -4
- package/src/components/heading/resolveHeadingRecipe.test.ts +30 -5
- package/src/components/heading/resolveHeadingRecipe.ts +6 -6
- package/src/components/text/resolveTextRecipe.test.ts +30 -5
- package/src/components/text/resolveTextRecipe.ts +6 -6
- package/src/patterns/theme-composer/ThemeComposer.test.ts +128 -114
- package/src/patterns/theme-composer/ThemeComposer.tsx +130 -128
- package/src/patterns/theme-composer/index.ts +1 -6
- package/src/patterns/theme-composer/types.ts +4 -15
- package/src/showcaseCoverage.test.ts +14 -0
- package/src/theme/createZoraThemeConfig.test.ts +51 -26
- package/src/theme/createZoraThemeConfig.ts +7 -7
- package/src/theme/index.ts +1 -3
- package/src/theme/types.ts +22 -34
- package/src/theme/zoraDefaultTheme.ts +1 -1
- package/dist/internal/color/colorToneRecipes.d.ts +0 -23
- package/dist/internal/color/colorToneRecipes.d.ts.map +0 -1
- package/dist/internal/color/colorToneRecipes.js +0 -139
- package/dist/internal/color/colorToneRecipes.js.map +0 -1
- package/dist/internal/color/harmony.d.ts +0 -12
- package/dist/internal/color/harmony.d.ts.map +0 -1
- package/dist/internal/color/harmony.js +0 -69
- package/dist/internal/color/harmony.js.map +0 -1
- package/dist/internal/color/hue.d.ts +0 -3
- package/dist/internal/color/hue.d.ts.map +0 -1
- package/dist/internal/color/hue.js +0 -7
- package/dist/internal/color/hue.js.map +0 -1
- package/dist/internal/color/index.d.ts +0 -10
- package/dist/internal/color/index.d.ts.map +0 -1
- package/dist/internal/color/index.js +0 -10
- package/dist/internal/color/index.js.map +0 -1
- package/dist/internal/color/oklch.d.ts +0 -6
- package/dist/internal/color/oklch.d.ts.map +0 -1
- package/dist/internal/color/oklch.js +0 -50
- package/dist/internal/color/oklch.js.map +0 -1
- package/dist/internal/color/primary.d.ts +0 -3
- package/dist/internal/color/primary.d.ts.map +0 -1
- package/dist/internal/color/primary.js +0 -44
- package/dist/internal/color/primary.js.map +0 -1
- package/dist/internal/color/roleHues.d.ts +0 -15
- package/dist/internal/color/roleHues.d.ts.map +0 -1
- package/dist/internal/color/roleHues.js +0 -103
- package/dist/internal/color/roleHues.js.map +0 -1
- package/dist/internal/color/roleScales.d.ts +0 -20
- package/dist/internal/color/roleScales.d.ts.map +0 -1
- package/dist/internal/color/roleScales.js +0 -79
- package/dist/internal/color/roleScales.js.map +0 -1
- package/dist/internal/color/scales.d.ts +0 -19
- package/dist/internal/color/scales.d.ts.map +0 -1
- package/dist/internal/color/scales.js +0 -135
- package/dist/internal/color/scales.js.map +0 -1
- package/dist/internal/color/semanticTokens.d.ts +0 -28
- package/dist/internal/color/semanticTokens.d.ts.map +0 -1
- package/dist/internal/color/semanticTokens.js +0 -84
- package/dist/internal/color/semanticTokens.js.map +0 -1
- package/dist/internal/color/types.d.ts +0 -10
- package/dist/internal/color/types.d.ts.map +0 -1
- package/dist/internal/color/types.js +0 -4
- package/dist/internal/color/types.js.map +0 -1
- package/dist/patterns/theme-composer/recommendations.d.ts +0 -14
- package/dist/patterns/theme-composer/recommendations.d.ts.map +0 -1
- package/dist/patterns/theme-composer/recommendations.js +0 -58
- package/dist/patterns/theme-composer/recommendations.js.map +0 -1
- package/src/internal/color/colorToneRecipes.test.ts +0 -89
- package/src/internal/color/colorToneRecipes.ts +0 -167
- package/src/internal/color/harmony.test.ts +0 -145
- package/src/internal/color/harmony.ts +0 -96
- package/src/internal/color/hue.test.ts +0 -28
- package/src/internal/color/hue.ts +0 -7
- package/src/internal/color/index.ts +0 -44
- package/src/internal/color/oklch.ts +0 -65
- package/src/internal/color/primary.test.ts +0 -105
- package/src/internal/color/primary.ts +0 -64
- package/src/internal/color/roleHues.test.ts +0 -197
- package/src/internal/color/roleHues.ts +0 -142
- package/src/internal/color/roleScales.test.ts +0 -220
- package/src/internal/color/roleScales.ts +0 -127
- package/src/internal/color/scales.test.ts +0 -151
- package/src/internal/color/scales.ts +0 -194
- package/src/internal/color/semanticTokens.test.ts +0 -170
- package/src/internal/color/semanticTokens.ts +0 -114
- package/src/internal/color/types.ts +0 -15
- package/src/patterns/theme-composer/recommendations.ts +0 -85
|
@@ -1,25 +1,24 @@
|
|
|
1
|
+
import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';
|
|
2
|
+
import { APP_CATEGORIES } from '@ankhorage/contracts';
|
|
1
3
|
import { describe, expect, test } from 'bun:test';
|
|
2
4
|
|
|
3
|
-
import { ZORA_COLOR_HARMONIES, ZORA_COLOR_TONES } from '../../theme/types';
|
|
4
5
|
import { zoraDefaultTheme } from '../../theme/zoraDefaultTheme';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import type { ThemeComposerProps, ThemeComposerRecommendation } from './types';
|
|
12
|
-
|
|
13
|
-
// Validate the exported types compile correctly by asserting shape
|
|
6
|
+
import type { ThemeComposerProps } from './types';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// ThemeComposerProps shape tests
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
14
12
|
describe('ThemeComposerProps', () => {
|
|
15
13
|
test('accepts a valid ZoraTheme as value', () => {
|
|
16
14
|
const props: ThemeComposerProps = {
|
|
17
15
|
value: zoraDefaultTheme,
|
|
18
16
|
onChange: () => undefined,
|
|
19
17
|
};
|
|
20
|
-
expect(props.value.primaryColor).toBe(
|
|
18
|
+
expect(props.value.primaryColor).toBe(zoraDefaultTheme.primaryColor);
|
|
21
19
|
expect(props.value.harmony).toBe('analogous');
|
|
22
|
-
expect(props.value.
|
|
20
|
+
expect(props.value.appCategory).toBe('developer_tools');
|
|
21
|
+
expect(props.value.name).toBe('ZORA');
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
test('accepts optional mode and onModeChange', () => {
|
|
@@ -45,58 +44,71 @@ describe('ThemeComposerProps', () => {
|
|
|
45
44
|
expect(submitted).toBe(true);
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
test('accepts optional
|
|
49
|
-
const
|
|
50
|
-
appCategory: 'finance_money',
|
|
51
|
-
appMood: 'trustworthy',
|
|
52
|
-
suggestedColorTone: 'mineral',
|
|
53
|
-
suggestedHarmony: 'analogous',
|
|
54
|
-
suggestedPrimaryHueDegrees: 195,
|
|
55
|
-
};
|
|
47
|
+
test('accepts optional appCategories as option list override', () => {
|
|
48
|
+
const narrow = ['developer_tools', 'utilities_tools'] as const;
|
|
56
49
|
const props: ThemeComposerProps = {
|
|
57
50
|
value: zoraDefaultTheme,
|
|
58
51
|
onChange: () => undefined,
|
|
59
|
-
|
|
60
|
-
appMood: 'trustworthy',
|
|
61
|
-
recommendations: [recommendation],
|
|
52
|
+
appCategories: narrow,
|
|
62
53
|
};
|
|
63
|
-
|
|
64
|
-
expect(props.recommendations?.[0]?.suggestedColorTone).toBe('mineral');
|
|
54
|
+
expect(props.appCategories).toEqual(narrow);
|
|
65
55
|
});
|
|
66
56
|
});
|
|
67
57
|
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// COLOR_HARMONIES coverage
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('COLOR_HARMONIES coverage for ThemeComposer', () => {
|
|
63
|
+
test('all canonical harmony values are present for the Select options', () => {
|
|
70
64
|
const expected = [
|
|
71
65
|
'monochromatic',
|
|
72
66
|
'analogous',
|
|
73
67
|
'complementary',
|
|
74
|
-
'splitComplementary',
|
|
75
68
|
'triadic',
|
|
76
69
|
'tetradic',
|
|
70
|
+
'splitComplementary',
|
|
77
71
|
] as const;
|
|
78
|
-
expect(
|
|
72
|
+
expect(COLOR_HARMONIES).toEqual(expected);
|
|
79
73
|
});
|
|
80
74
|
});
|
|
81
75
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// APP_CATEGORIES default behavior
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('APP_CATEGORIES defaults', () => {
|
|
81
|
+
test('APP_CATEGORIES from contracts contains expected entries', () => {
|
|
82
|
+
expect(APP_CATEGORIES).toContain('developer_tools');
|
|
83
|
+
expect(APP_CATEGORIES).toContain('utilities_tools');
|
|
84
|
+
expect(APP_CATEGORIES).toContain('lifestyle');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('ThemeComposer appCategories prop is optional — falls back to APP_CATEGORIES', () => {
|
|
88
|
+
const props: ThemeComposerProps = {
|
|
89
|
+
value: zoraDefaultTheme,
|
|
90
|
+
onChange: () => undefined,
|
|
91
|
+
};
|
|
92
|
+
// No appCategories provided — component uses APP_CATEGORIES internally.
|
|
93
|
+
expect(props.appCategories).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('ThemeComposer appCategories narrows the available options when provided', () => {
|
|
97
|
+
const narrow = ['developer_tools', 'utilities_tools'] as const;
|
|
98
|
+
const props: ThemeComposerProps = {
|
|
99
|
+
value: zoraDefaultTheme,
|
|
100
|
+
onChange: () => undefined,
|
|
101
|
+
appCategories: narrow,
|
|
102
|
+
};
|
|
103
|
+
expect(props.appCategories).toHaveLength(2);
|
|
104
|
+
expect(props.appCategories).toContain('developer_tools');
|
|
97
105
|
});
|
|
98
106
|
});
|
|
99
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// onChange propagation contracts
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
100
112
|
describe('onChange propagation contract', () => {
|
|
101
113
|
test('onChange receives updated theme with new harmony', () => {
|
|
102
114
|
const received: (typeof zoraDefaultTheme)[] = [];
|
|
@@ -110,93 +122,95 @@ describe('onChange propagation contract', () => {
|
|
|
110
122
|
expect(received[0]?.harmony).toBe('triadic');
|
|
111
123
|
});
|
|
112
124
|
|
|
113
|
-
test('onChange receives updated theme with new
|
|
125
|
+
test('onChange receives updated theme with new appCategory', () => {
|
|
114
126
|
const received: (typeof zoraDefaultTheme)[] = [];
|
|
115
127
|
const props: ThemeComposerProps = {
|
|
116
128
|
value: zoraDefaultTheme,
|
|
117
129
|
onChange: (t) => received.push(t),
|
|
118
130
|
};
|
|
119
|
-
props.onChange({ ...zoraDefaultTheme,
|
|
120
|
-
expect(received
|
|
131
|
+
props.onChange({ ...zoraDefaultTheme, appCategory: 'lifestyle' });
|
|
132
|
+
expect(received).toHaveLength(1);
|
|
133
|
+
expect(received[0]?.appCategory).toBe('lifestyle');
|
|
121
134
|
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('ThemeComposer recommendation helpers', () => {
|
|
125
|
-
const recommendations: readonly ThemeComposerRecommendation[] = [
|
|
126
|
-
{
|
|
127
|
-
appCategory: 'finance_money',
|
|
128
|
-
appMood: 'trustworthy',
|
|
129
|
-
suggestedColorTone: 'mineral',
|
|
130
|
-
suggestedHarmony: 'analogous',
|
|
131
|
-
suggestedPrimaryHueDegrees: 195,
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
appCategory: 'graphics_design',
|
|
135
|
-
appMood: 'creative',
|
|
136
|
-
suggestedColorTone: 'vaporwave',
|
|
137
|
-
suggestedHarmony: 'splitComplementary',
|
|
138
|
-
},
|
|
139
|
-
];
|
|
140
|
-
|
|
141
|
-
test('finds a recommendation by app category and mood', () => {
|
|
142
|
-
const found = findThemeComposerRecommendation({
|
|
143
|
-
appCategory: 'finance_money',
|
|
144
|
-
appMood: 'trustworthy',
|
|
145
|
-
recommendations,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
expect(found?.suggestedColorTone).toBe('mineral');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('does not find a recommendation with mismatched mood', () => {
|
|
152
|
-
const found = findThemeComposerRecommendation({
|
|
153
|
-
appCategory: 'finance_money',
|
|
154
|
-
appMood: 'creative',
|
|
155
|
-
recommendations,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
expect(found).toBeUndefined();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('creates an updated theme only when recommendation is explicitly applied', () => {
|
|
162
|
-
const [recommendation] = recommendations;
|
|
163
|
-
if (recommendation === undefined) {
|
|
164
|
-
throw new Error('Expected fixture recommendation.');
|
|
165
|
-
}
|
|
166
135
|
|
|
167
|
-
|
|
136
|
+
test('onChange receives updated theme with new valid name', () => {
|
|
137
|
+
const received: (typeof zoraDefaultTheme)[] = [];
|
|
138
|
+
const props: ThemeComposerProps = {
|
|
168
139
|
value: zoraDefaultTheme,
|
|
169
|
-
|
|
170
|
-
}
|
|
140
|
+
onChange: (t) => received.push(t),
|
|
141
|
+
};
|
|
142
|
+
props.onChange({ ...zoraDefaultTheme, name: 'My Custom Theme' });
|
|
143
|
+
expect(received).toHaveLength(1);
|
|
144
|
+
expect(received[0]?.name).toBe('My Custom Theme');
|
|
145
|
+
});
|
|
171
146
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
147
|
+
test('onChange is not called with an empty name', () => {
|
|
148
|
+
// Simulate the component's name validation logic
|
|
149
|
+
const received: (typeof zoraDefaultTheme)[] = [];
|
|
150
|
+
const onChange = (t: typeof zoraDefaultTheme) => received.push(t);
|
|
151
|
+
|
|
152
|
+
const newName = '';
|
|
153
|
+
if (newName.trim().length > 0) {
|
|
154
|
+
onChange({ ...zoraDefaultTheme, name: newName });
|
|
155
|
+
}
|
|
156
|
+
// Empty name should not propagate
|
|
157
|
+
expect(received).toHaveLength(0);
|
|
176
158
|
});
|
|
177
159
|
|
|
178
|
-
test('
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
160
|
+
test('onChange is not called with invalid primary color', () => {
|
|
161
|
+
// Simulate the component's hex validation logic
|
|
162
|
+
const received: (typeof zoraDefaultTheme)[] = [];
|
|
163
|
+
const onChange = (t: typeof zoraDefaultTheme) => received.push(t);
|
|
164
|
+
|
|
165
|
+
const badColor = '#xyz';
|
|
166
|
+
try {
|
|
167
|
+
parseHexColorOrThrow(badColor);
|
|
168
|
+
// Should not reach here for an invalid color
|
|
169
|
+
onChange({ ...zoraDefaultTheme, primaryColor: badColor });
|
|
170
|
+
} catch {
|
|
171
|
+
// Expected: invalid color — do not call onChange
|
|
182
172
|
}
|
|
173
|
+
expect(received).toHaveLength(0);
|
|
174
|
+
});
|
|
183
175
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
176
|
+
test('onChange is called with a valid primary color', () => {
|
|
177
|
+
const received: (typeof zoraDefaultTheme)[] = [];
|
|
178
|
+
const onChange = (t: typeof zoraDefaultTheme) => received.push(t);
|
|
179
|
+
|
|
180
|
+
const goodColor = '#c084fc';
|
|
181
|
+
try {
|
|
182
|
+
parseHexColorOrThrow(goodColor);
|
|
183
|
+
onChange({ ...zoraDefaultTheme, primaryColor: goodColor });
|
|
184
|
+
} catch {
|
|
185
|
+
// Should not reach here for a valid color
|
|
186
|
+
}
|
|
187
|
+
expect(received).toHaveLength(1);
|
|
188
|
+
expect(received[0]?.primaryColor).toBe(goodColor);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Legacy API absence checks
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe('No legacy APIs', () => {
|
|
197
|
+
test('ThemeComposerProps value carries no colorTone field', () => {
|
|
198
|
+
const theme = zoraDefaultTheme;
|
|
199
|
+
expect('colorTone' in theme).toBe(false);
|
|
200
|
+
});
|
|
188
201
|
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
test('ThemeComposerProps value carries no appMood field', () => {
|
|
203
|
+
const theme = zoraDefaultTheme;
|
|
204
|
+
expect('appMood' in theme).toBe(false);
|
|
191
205
|
});
|
|
192
206
|
|
|
193
|
-
test('
|
|
194
|
-
expect(
|
|
195
|
-
expect(
|
|
207
|
+
test('value.name is required — zoraDefaultTheme.name is a non-empty string', () => {
|
|
208
|
+
expect(typeof zoraDefaultTheme.name).toBe('string');
|
|
209
|
+
expect(zoraDefaultTheme.name.length).toBeGreaterThan(0);
|
|
196
210
|
});
|
|
197
211
|
|
|
198
|
-
test('
|
|
199
|
-
expect(
|
|
200
|
-
expect(
|
|
212
|
+
test('value.appCategory is required — zoraDefaultTheme.appCategory is set', () => {
|
|
213
|
+
expect(typeof zoraDefaultTheme.appCategory).toBe('string');
|
|
214
|
+
expect(zoraDefaultTheme.appCategory.length).toBeGreaterThan(0);
|
|
201
215
|
});
|
|
202
216
|
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';
|
|
2
|
+
import type { AppCategory } from '@ankhorage/contracts';
|
|
3
|
+
import { APP_CATEGORIES } from '@ankhorage/contracts';
|
|
1
4
|
import { Box, Stack } from '@ankhorage/surface';
|
|
2
5
|
import React from 'react';
|
|
3
6
|
|
|
@@ -9,63 +12,37 @@ import { Input } from '../../components/input';
|
|
|
9
12
|
import { Select } from '../../components/select';
|
|
10
13
|
import { Tabs } from '../../components/tabs';
|
|
11
14
|
import { Text } from '../../components/text';
|
|
12
|
-
import {
|
|
13
|
-
ZORA_COLOR_HARMONIES,
|
|
14
|
-
ZORA_COLOR_TONES,
|
|
15
|
-
type ZoraColorTone,
|
|
16
|
-
type ZoraTheme,
|
|
17
|
-
type ZoraThemeMode,
|
|
18
|
-
} from '../../theme/types';
|
|
15
|
+
import type { ZoraThemeMode } from '../../theme/types';
|
|
19
16
|
import { useZoraTheme } from '../../theme/useZoraTheme';
|
|
20
17
|
import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
|
|
21
|
-
import {
|
|
22
|
-
createThemeFromThemeComposerRecommendation,
|
|
23
|
-
findThemeComposerRecommendation,
|
|
24
|
-
formatThemeComposerLabel,
|
|
25
|
-
} from './recommendations';
|
|
26
18
|
import type { ThemeComposerProps } from './types';
|
|
27
19
|
|
|
28
|
-
const
|
|
20
|
+
const HEX_ERROR_MESSAGE = 'Enter a valid 6-digit hex color (e.g. #0f766e).';
|
|
21
|
+
const NAME_ERROR_MESSAGE = 'Theme name cannot be empty.';
|
|
29
22
|
|
|
30
23
|
function isValidHex(value: string): boolean {
|
|
31
|
-
|
|
24
|
+
try {
|
|
25
|
+
parseHexColorOrThrow(value);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
|
|
32
|
+
function formatAppCategoryLabel(category: AppCategory): string {
|
|
33
|
+
return category
|
|
34
|
+
.split('_')
|
|
35
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
36
|
+
.join(' ');
|
|
37
|
+
}
|
|
35
38
|
|
|
36
|
-
const
|
|
39
|
+
const HARMONY_OPTIONS = COLOR_HARMONIES.map((h) => ({ value: h, label: h }));
|
|
37
40
|
|
|
38
41
|
const MODE_TABS = [
|
|
39
42
|
{ value: 'light' as ZoraThemeMode, label: 'Light' },
|
|
40
43
|
{ value: 'dark' as ZoraThemeMode, label: 'Dark' },
|
|
41
44
|
];
|
|
42
45
|
|
|
43
|
-
const COLOR_TONE_BACKGROUND_TONE: Record<ZoraColorTone, string> = {
|
|
44
|
-
neutral: 'Neutral',
|
|
45
|
-
pastel: 'Pastel',
|
|
46
|
-
earth: 'Earth',
|
|
47
|
-
mineral: 'Mineral',
|
|
48
|
-
muted: 'Muted',
|
|
49
|
-
jewel: 'Neutral',
|
|
50
|
-
fluorescent: 'Obsidian',
|
|
51
|
-
obsidian: 'Obsidian',
|
|
52
|
-
vaporwave: 'Pastel',
|
|
53
|
-
monochromeAccent: 'Neutral',
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const COLOR_TONE_FOREGROUND_TONE: Record<ZoraColorTone, string> = {
|
|
57
|
-
neutral: 'Jewel',
|
|
58
|
-
pastel: 'Jewel',
|
|
59
|
-
earth: 'Mineral',
|
|
60
|
-
mineral: 'Jewel',
|
|
61
|
-
muted: 'Jewel',
|
|
62
|
-
jewel: 'Jewel',
|
|
63
|
-
fluorescent: 'Fluorescent',
|
|
64
|
-
obsidian: 'Fluorescent',
|
|
65
|
-
vaporwave: 'Fluorescent',
|
|
66
|
-
monochromeAccent: 'Jewel',
|
|
67
|
-
};
|
|
68
|
-
|
|
69
46
|
function ThemeComposerInner({
|
|
70
47
|
themeId: _themeId,
|
|
71
48
|
value,
|
|
@@ -73,9 +50,7 @@ function ThemeComposerInner({
|
|
|
73
50
|
mode,
|
|
74
51
|
onModeChange,
|
|
75
52
|
onSubmit,
|
|
76
|
-
|
|
77
|
-
appMood,
|
|
78
|
-
recommendations,
|
|
53
|
+
appCategories,
|
|
79
54
|
testID,
|
|
80
55
|
}: ThemeComposerProps) {
|
|
81
56
|
const { theme } = useZoraTheme();
|
|
@@ -83,12 +58,30 @@ function ThemeComposerInner({
|
|
|
83
58
|
const [hexInput, setHexInput] = React.useState<string>(value.primaryColor);
|
|
84
59
|
const [hexError, setHexError] = React.useState<string | undefined>(undefined);
|
|
85
60
|
|
|
86
|
-
|
|
61
|
+
const [nameInput, setNameInput] = React.useState<string>(value.name);
|
|
62
|
+
const [nameError, setNameError] = React.useState<string | undefined>(undefined);
|
|
63
|
+
|
|
64
|
+
// Keep local inputs in sync when value changes externally
|
|
87
65
|
React.useEffect(() => {
|
|
88
66
|
setHexInput(value.primaryColor);
|
|
89
67
|
setHexError(undefined);
|
|
90
68
|
}, [value.primaryColor]);
|
|
91
69
|
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
setNameInput(value.name);
|
|
72
|
+
setNameError(undefined);
|
|
73
|
+
}, [value.name]);
|
|
74
|
+
|
|
75
|
+
function handleNameChange(text: string) {
|
|
76
|
+
setNameInput(text);
|
|
77
|
+
if (text.trim().length === 0) {
|
|
78
|
+
setNameError(NAME_ERROR_MESSAGE);
|
|
79
|
+
} else {
|
|
80
|
+
setNameError(undefined);
|
|
81
|
+
onChange({ ...value, name: text });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
92
85
|
function handleHexChange(text: string) {
|
|
93
86
|
// Ensure leading hash
|
|
94
87
|
const normalized = text.startsWith('#') ? text : `#${text}`;
|
|
@@ -96,69 +89,87 @@ function ThemeComposerInner({
|
|
|
96
89
|
|
|
97
90
|
if (isValidHex(normalized)) {
|
|
98
91
|
setHexError(undefined);
|
|
99
|
-
onChange({ ...value, primaryColor: normalized
|
|
92
|
+
onChange({ ...value, primaryColor: normalized });
|
|
100
93
|
} else {
|
|
101
|
-
setHexError(
|
|
94
|
+
setHexError(HEX_ERROR_MESSAGE);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleSubmit() {
|
|
99
|
+
const hasValidName = nameInput.trim().length > 0;
|
|
100
|
+
const hasValidHex = isValidHex(hexInput);
|
|
101
|
+
|
|
102
|
+
if (!hasValidName) {
|
|
103
|
+
setNameError(NAME_ERROR_MESSAGE);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!hasValidHex) {
|
|
107
|
+
setHexError(HEX_ERROR_MESSAGE);
|
|
102
108
|
}
|
|
109
|
+
|
|
110
|
+
if (!hasValidName || !hasValidHex) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onSubmit?.({
|
|
115
|
+
...value,
|
|
116
|
+
name: nameInput,
|
|
117
|
+
primaryColor: hexInput,
|
|
118
|
+
});
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
const activeMode = mode ?? 'light';
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
122
|
+
const categoryOptions = (appCategories ?? APP_CATEGORIES).map((c) => ({
|
|
123
|
+
value: c,
|
|
124
|
+
label: formatAppCategoryLabel(c),
|
|
125
|
+
}));
|
|
111
126
|
|
|
112
127
|
return (
|
|
113
128
|
<Stack gap="l" testID={testID}>
|
|
114
|
-
{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
{/* Section: Theme identity */}
|
|
130
|
+
<Card
|
|
131
|
+
title="Theme identity"
|
|
132
|
+
description="Name your theme. The ID is assigned automatically and shown for reference."
|
|
133
|
+
>
|
|
134
|
+
<Stack gap="m">
|
|
135
|
+
<Stack gap="xs">
|
|
136
|
+
<Text variant="label">Name</Text>
|
|
137
|
+
<Input
|
|
138
|
+
value={nameInput}
|
|
139
|
+
onChangeText={handleNameChange}
|
|
140
|
+
placeholder="My theme"
|
|
141
|
+
autoCorrect={false}
|
|
142
|
+
invalid={nameError !== undefined}
|
|
143
|
+
testID={testID ? `${testID}-name-input` : undefined}
|
|
144
|
+
/>
|
|
145
|
+
{nameError ? (
|
|
146
|
+
<Text tone="danger" variant="bodySmall">
|
|
147
|
+
{nameError}
|
|
148
|
+
</Text>
|
|
149
|
+
) : null}
|
|
150
|
+
</Stack>
|
|
151
|
+
<Stack gap="xs">
|
|
152
|
+
<Text variant="label">ID</Text>
|
|
153
|
+
<Text
|
|
154
|
+
tone="muted"
|
|
155
|
+
variant="bodySmall"
|
|
156
|
+
testID={testID ? `${testID}-id-display` : undefined}
|
|
132
157
|
>
|
|
133
|
-
|
|
134
|
-
</Button>
|
|
135
|
-
}
|
|
136
|
-
>
|
|
137
|
-
<Stack gap="s">
|
|
138
|
-
<Stack direction="row" gap="s" wrap="wrap">
|
|
139
|
-
<Badge tone="primary" emphasis="soft">
|
|
140
|
-
{formatThemeComposerLabel(recommendation.appMood)} mood
|
|
141
|
-
</Badge>
|
|
142
|
-
<Badge tone="neutral" emphasis="soft">
|
|
143
|
-
{formatThemeComposerLabel(recommendation.suggestedColorTone)} color tone
|
|
144
|
-
</Badge>
|
|
145
|
-
<Badge tone="neutral" emphasis="soft">
|
|
146
|
-
{formatThemeComposerLabel(recommendation.suggestedHarmony)} harmony
|
|
147
|
-
</Badge>
|
|
148
|
-
{recommendation.suggestedPrimaryHueDegrees === undefined ? null : (
|
|
149
|
-
<Badge tone="neutral" emphasis="soft">
|
|
150
|
-
{recommendation.suggestedPrimaryHueDegrees}° hue
|
|
151
|
-
</Badge>
|
|
152
|
-
)}
|
|
153
|
-
</Stack>
|
|
154
|
-
<Text tone="muted" variant="bodySmall">
|
|
155
|
-
Suggested for {formatThemeComposerLabel(recommendation.appCategory)}. The color tone
|
|
156
|
-
controls palette character, harmony controls accent relationships, and hue sets the
|
|
157
|
-
starting primary color when available.
|
|
158
|
+
{value.id}
|
|
158
159
|
</Text>
|
|
159
160
|
</Stack>
|
|
160
|
-
</
|
|
161
|
-
|
|
161
|
+
</Stack>
|
|
162
|
+
</Card>
|
|
163
|
+
|
|
164
|
+
{/* Section: App category */}
|
|
165
|
+
<Card title="App category" description="Choose the category that best describes this app.">
|
|
166
|
+
<Select
|
|
167
|
+
value={value.appCategory}
|
|
168
|
+
options={categoryOptions}
|
|
169
|
+
onValueChange={(c) => onChange({ ...value, appCategory: c })}
|
|
170
|
+
testID={testID ? `${testID}-category-select` : undefined}
|
|
171
|
+
/>
|
|
172
|
+
</Card>
|
|
162
173
|
|
|
163
174
|
{/* Section: Primary Color */}
|
|
164
175
|
<Card title="Primary color" description="Set the seed color for your theme palette.">
|
|
@@ -209,35 +220,6 @@ function ThemeComposerInner({
|
|
|
209
220
|
/>
|
|
210
221
|
</Card>
|
|
211
222
|
|
|
212
|
-
{/* Section: Color tone */}
|
|
213
|
-
<Card
|
|
214
|
-
title="Color tone"
|
|
215
|
-
description="Controls the vibrancy and saturation style of the palette."
|
|
216
|
-
>
|
|
217
|
-
<Stack gap="s">
|
|
218
|
-
<Select
|
|
219
|
-
value={value.colorTone}
|
|
220
|
-
options={TONE_OPTIONS}
|
|
221
|
-
onValueChange={(t) => onChange({ ...value, colorTone: t })}
|
|
222
|
-
testID={testID ? `${testID}-tone-select` : undefined}
|
|
223
|
-
/>
|
|
224
|
-
<Stack direction="row" gap="s" align="center">
|
|
225
|
-
<Text tone="muted" variant="caption">
|
|
226
|
-
Background:
|
|
227
|
-
</Text>
|
|
228
|
-
<Badge tone="neutral" emphasis="soft">
|
|
229
|
-
{COLOR_TONE_BACKGROUND_TONE[value.colorTone]}
|
|
230
|
-
</Badge>
|
|
231
|
-
<Text tone="muted" variant="caption">
|
|
232
|
-
Foreground:
|
|
233
|
-
</Text>
|
|
234
|
-
<Badge tone="neutral" emphasis="soft">
|
|
235
|
-
{COLOR_TONE_FOREGROUND_TONE[value.colorTone]}
|
|
236
|
-
</Badge>
|
|
237
|
-
</Stack>
|
|
238
|
-
</Stack>
|
|
239
|
-
</Card>
|
|
240
|
-
|
|
241
223
|
{/* Section: Mode */}
|
|
242
224
|
<Card title="Mode" description="Switch between light and dark presentation.">
|
|
243
225
|
<Tabs
|
|
@@ -252,6 +234,26 @@ function ThemeComposerInner({
|
|
|
252
234
|
{/* Section: Preview */}
|
|
253
235
|
<Card title="Preview" description="A quick look at how your theme renders common controls.">
|
|
254
236
|
<Stack gap="m">
|
|
237
|
+
<Stack gap="xs">
|
|
238
|
+
<Text variant="label">Name</Text>
|
|
239
|
+
<Text>{value.name}</Text>
|
|
240
|
+
</Stack>
|
|
241
|
+
<Stack gap="xs">
|
|
242
|
+
<Text variant="label">Category</Text>
|
|
243
|
+
<Text>{formatAppCategoryLabel(value.appCategory)}</Text>
|
|
244
|
+
</Stack>
|
|
245
|
+
<Stack gap="xs">
|
|
246
|
+
<Text variant="label">Primary color</Text>
|
|
247
|
+
<Text tone="muted" variant="bodySmall">
|
|
248
|
+
{value.primaryColor}
|
|
249
|
+
</Text>
|
|
250
|
+
</Stack>
|
|
251
|
+
<Stack gap="xs">
|
|
252
|
+
<Text variant="label">Harmony</Text>
|
|
253
|
+
<Text tone="muted" variant="bodySmall">
|
|
254
|
+
{value.harmony}
|
|
255
|
+
</Text>
|
|
256
|
+
</Stack>
|
|
255
257
|
<Heading level={4}>Heading</Heading>
|
|
256
258
|
<Text>Body text — this shows default text color and weight.</Text>
|
|
257
259
|
<Text tone="muted" variant="bodySmall">
|
|
@@ -294,7 +296,7 @@ function ThemeComposerInner({
|
|
|
294
296
|
<Button
|
|
295
297
|
tone="primary"
|
|
296
298
|
emphasis="solid"
|
|
297
|
-
onPress={
|
|
299
|
+
onPress={handleSubmit}
|
|
298
300
|
testID={testID ? `${testID}-submit` : undefined}
|
|
299
301
|
>
|
|
300
302
|
Apply theme
|