@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +27 -22
  3. package/dist/components/heading/resolveHeadingRecipe.d.ts +2 -2
  4. package/dist/components/heading/resolveHeadingRecipe.d.ts.map +1 -1
  5. package/dist/components/heading/resolveHeadingRecipe.js.map +1 -1
  6. package/dist/components/text/resolveTextRecipe.d.ts +2 -2
  7. package/dist/components/text/resolveTextRecipe.d.ts.map +1 -1
  8. package/dist/components/text/resolveTextRecipe.js.map +1 -1
  9. package/dist/patterns/theme-composer/ThemeComposer.d.ts.map +1 -1
  10. package/dist/patterns/theme-composer/ThemeComposer.js +101 -86
  11. package/dist/patterns/theme-composer/ThemeComposer.js.map +1 -1
  12. package/dist/patterns/theme-composer/index.d.ts +1 -1
  13. package/dist/patterns/theme-composer/index.d.ts.map +1 -1
  14. package/dist/patterns/theme-composer/index.js.map +1 -1
  15. package/dist/patterns/theme-composer/types.d.ts +3 -13
  16. package/dist/patterns/theme-composer/types.d.ts.map +1 -1
  17. package/dist/patterns/theme-composer/types.js.map +1 -1
  18. package/dist/theme/createZoraThemeConfig.d.ts +1 -1
  19. package/dist/theme/createZoraThemeConfig.d.ts.map +1 -1
  20. package/dist/theme/createZoraThemeConfig.js +5 -6
  21. package/dist/theme/createZoraThemeConfig.js.map +1 -1
  22. package/dist/theme/index.d.ts +1 -1
  23. package/dist/theme/index.d.ts.map +1 -1
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/types.d.ts +16 -11
  26. package/dist/theme/types.d.ts.map +1 -1
  27. package/dist/theme/types.js +1 -20
  28. package/dist/theme/types.js.map +1 -1
  29. package/dist/theme/useZoraTheme.d.ts +1 -1
  30. package/dist/theme/zoraDefaultTheme.js +1 -1
  31. package/dist/theme/zoraDefaultTheme.js.map +1 -1
  32. package/package.json +4 -4
  33. package/src/components/heading/resolveHeadingRecipe.test.ts +30 -5
  34. package/src/components/heading/resolveHeadingRecipe.ts +6 -6
  35. package/src/components/text/resolveTextRecipe.test.ts +30 -5
  36. package/src/components/text/resolveTextRecipe.ts +6 -6
  37. package/src/patterns/theme-composer/ThemeComposer.test.ts +128 -114
  38. package/src/patterns/theme-composer/ThemeComposer.tsx +130 -128
  39. package/src/patterns/theme-composer/index.ts +1 -6
  40. package/src/patterns/theme-composer/types.ts +4 -15
  41. package/src/showcaseCoverage.test.ts +14 -0
  42. package/src/theme/createZoraThemeConfig.test.ts +51 -26
  43. package/src/theme/createZoraThemeConfig.ts +7 -7
  44. package/src/theme/index.ts +1 -3
  45. package/src/theme/types.ts +22 -34
  46. package/src/theme/zoraDefaultTheme.ts +1 -1
  47. package/dist/internal/color/colorToneRecipes.d.ts +0 -23
  48. package/dist/internal/color/colorToneRecipes.d.ts.map +0 -1
  49. package/dist/internal/color/colorToneRecipes.js +0 -139
  50. package/dist/internal/color/colorToneRecipes.js.map +0 -1
  51. package/dist/internal/color/harmony.d.ts +0 -12
  52. package/dist/internal/color/harmony.d.ts.map +0 -1
  53. package/dist/internal/color/harmony.js +0 -69
  54. package/dist/internal/color/harmony.js.map +0 -1
  55. package/dist/internal/color/hue.d.ts +0 -3
  56. package/dist/internal/color/hue.d.ts.map +0 -1
  57. package/dist/internal/color/hue.js +0 -7
  58. package/dist/internal/color/hue.js.map +0 -1
  59. package/dist/internal/color/index.d.ts +0 -10
  60. package/dist/internal/color/index.d.ts.map +0 -1
  61. package/dist/internal/color/index.js +0 -10
  62. package/dist/internal/color/index.js.map +0 -1
  63. package/dist/internal/color/oklch.d.ts +0 -6
  64. package/dist/internal/color/oklch.d.ts.map +0 -1
  65. package/dist/internal/color/oklch.js +0 -50
  66. package/dist/internal/color/oklch.js.map +0 -1
  67. package/dist/internal/color/primary.d.ts +0 -3
  68. package/dist/internal/color/primary.d.ts.map +0 -1
  69. package/dist/internal/color/primary.js +0 -44
  70. package/dist/internal/color/primary.js.map +0 -1
  71. package/dist/internal/color/roleHues.d.ts +0 -15
  72. package/dist/internal/color/roleHues.d.ts.map +0 -1
  73. package/dist/internal/color/roleHues.js +0 -103
  74. package/dist/internal/color/roleHues.js.map +0 -1
  75. package/dist/internal/color/roleScales.d.ts +0 -20
  76. package/dist/internal/color/roleScales.d.ts.map +0 -1
  77. package/dist/internal/color/roleScales.js +0 -79
  78. package/dist/internal/color/roleScales.js.map +0 -1
  79. package/dist/internal/color/scales.d.ts +0 -19
  80. package/dist/internal/color/scales.d.ts.map +0 -1
  81. package/dist/internal/color/scales.js +0 -135
  82. package/dist/internal/color/scales.js.map +0 -1
  83. package/dist/internal/color/semanticTokens.d.ts +0 -28
  84. package/dist/internal/color/semanticTokens.d.ts.map +0 -1
  85. package/dist/internal/color/semanticTokens.js +0 -84
  86. package/dist/internal/color/semanticTokens.js.map +0 -1
  87. package/dist/internal/color/types.d.ts +0 -10
  88. package/dist/internal/color/types.d.ts.map +0 -1
  89. package/dist/internal/color/types.js +0 -4
  90. package/dist/internal/color/types.js.map +0 -1
  91. package/dist/patterns/theme-composer/recommendations.d.ts +0 -14
  92. package/dist/patterns/theme-composer/recommendations.d.ts.map +0 -1
  93. package/dist/patterns/theme-composer/recommendations.js +0 -58
  94. package/dist/patterns/theme-composer/recommendations.js.map +0 -1
  95. package/src/internal/color/colorToneRecipes.test.ts +0 -89
  96. package/src/internal/color/colorToneRecipes.ts +0 -167
  97. package/src/internal/color/harmony.test.ts +0 -145
  98. package/src/internal/color/harmony.ts +0 -96
  99. package/src/internal/color/hue.test.ts +0 -28
  100. package/src/internal/color/hue.ts +0 -7
  101. package/src/internal/color/index.ts +0 -44
  102. package/src/internal/color/oklch.ts +0 -65
  103. package/src/internal/color/primary.test.ts +0 -105
  104. package/src/internal/color/primary.ts +0 -64
  105. package/src/internal/color/roleHues.test.ts +0 -197
  106. package/src/internal/color/roleHues.ts +0 -142
  107. package/src/internal/color/roleScales.test.ts +0 -220
  108. package/src/internal/color/roleScales.ts +0 -127
  109. package/src/internal/color/scales.test.ts +0 -151
  110. package/src/internal/color/scales.ts +0 -194
  111. package/src/internal/color/semanticTokens.test.ts +0 -170
  112. package/src/internal/color/semanticTokens.ts +0 -114
  113. package/src/internal/color/types.ts +0 -15
  114. 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
- createThemeFromThemeComposerRecommendation,
7
- findThemeComposerRecommendation,
8
- formatThemeComposerLabel,
9
- hueDegreesToZoraHexColor,
10
- } from './recommendations';
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('#0f766e');
18
+ expect(props.value.primaryColor).toBe(zoraDefaultTheme.primaryColor);
21
19
  expect(props.value.harmony).toBe('analogous');
22
- expect(props.value.colorTone).toBe('jewel');
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 recommendation inputs', () => {
49
- const recommendation: ThemeComposerRecommendation = {
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
- appCategory: 'finance_money',
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
- describe('ZORA_COLOR_HARMONIES coverage for ThemeComposer', () => {
69
- test('all harmony values are present for the Select options', () => {
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(ZORA_COLOR_HARMONIES).toEqual(expected);
72
+ expect(COLOR_HARMONIES).toEqual(expected);
79
73
  });
80
74
  });
81
75
 
82
- describe('ZORA_COLOR_TONES coverage for ThemeComposer', () => {
83
- test('all color tones are present for the Select options', () => {
84
- const expected = [
85
- 'neutral',
86
- 'pastel',
87
- 'earth',
88
- 'mineral',
89
- 'muted',
90
- 'jewel',
91
- 'fluorescent',
92
- 'obsidian',
93
- 'vaporwave',
94
- 'monochromeAccent',
95
- ] as const;
96
- expect(ZORA_COLOR_TONES).toEqual(expected);
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 colorTone', () => {
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, colorTone: 'obsidian' });
120
- expect(received[0]?.colorTone).toBe('obsidian');
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
- const updated = createThemeFromThemeComposerRecommendation({
136
+ test('onChange receives updated theme with new valid name', () => {
137
+ const received: (typeof zoraDefaultTheme)[] = [];
138
+ const props: ThemeComposerProps = {
168
139
  value: zoraDefaultTheme,
169
- recommendation,
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
- expect(zoraDefaultTheme.colorTone).toBe('jewel');
173
- expect(updated.colorTone).toBe('mineral');
174
- expect(updated.harmony).toBe('analogous');
175
- expect(updated.primaryColor).not.toBe(zoraDefaultTheme.primaryColor);
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('keeps the current primary color when recommendation has no hue', () => {
179
- const [, recommendation] = recommendations;
180
- if (recommendation === undefined) {
181
- throw new Error('Expected fixture recommendation.');
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
- const updated = createThemeFromThemeComposerRecommendation({
185
- value: zoraDefaultTheme,
186
- recommendation,
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
- expect(updated.primaryColor).toBe(zoraDefaultTheme.primaryColor);
190
- expect(updated.colorTone).toBe('vaporwave');
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('formats serialized labels for display', () => {
194
- expect(formatThemeComposerLabel('finance_money')).toBe('Finance money');
195
- expect(formatThemeComposerLabel('splitComplementary')).toBe('Split Complementary');
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('converts hue degrees to lowercase hex', () => {
199
- expect(hueDegreesToZoraHexColor(195)).toMatch(/^#[0-9a-f]{6}$/);
200
- expect(hueDegreesToZoraHexColor(555)).toBe(hueDegreesToZoraHexColor(195));
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 HEX_RE = /^#[0-9A-Fa-f]{6}$/;
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
- return HEX_RE.test(value);
24
+ try {
25
+ parseHexColorOrThrow(value);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
32
30
  }
33
31
 
34
- const HARMONY_OPTIONS = ZORA_COLOR_HARMONIES.map((h) => ({ value: h, label: h }));
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 TONE_OPTIONS = ZORA_COLOR_TONES.map((t) => ({ value: t, label: t }));
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
- appCategory,
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
- // Keep local hex input in sync when value.primaryColor changes externally
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 as ZoraTheme['primaryColor'] });
92
+ onChange({ ...value, primaryColor: normalized });
100
93
  } else {
101
- setHexError('Enter a valid 6-digit hex color (e.g. #0f766e).');
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 recommendation = findThemeComposerRecommendation({
107
- appCategory,
108
- appMood,
109
- recommendations,
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
- {recommendation ? (
115
- <Card
116
- title="Recommended starting point"
117
- description="Use this as an optional starting point. It is only applied when you choose it."
118
- actions={
119
- <Button
120
- size="s"
121
- emphasis="soft"
122
- tone="primary"
123
- onPress={() =>
124
- onChange(
125
- createThemeFromThemeComposerRecommendation({
126
- value,
127
- recommendation,
128
- }),
129
- )
130
- }
131
- testID={testID ? `${testID}-apply-recommendation` : undefined}
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
- Apply recommendation
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
- </Card>
161
- ) : null}
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={() => onSubmit(value)}
299
+ onPress={handleSubmit}
298
300
  testID={testID ? `${testID}-submit` : undefined}
299
301
  >
300
302
  Apply theme
@@ -1,7 +1,2 @@
1
1
  export { ThemeComposer } from './ThemeComposer';
2
- export type {
3
- ThemeComposerAppCategory,
4
- ThemeComposerAppMood,
5
- ThemeComposerProps,
6
- ThemeComposerRecommendation,
7
- } from './types';
2
+ export type { ThemeComposerProps } from './types';