@ankhorage/zora 1.0.0 → 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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 938bcfe: ThemeComposer now edits the full ZoraTheme source model.
8
+ - ThemeComposer adds name editing with empty-name validation.
9
+ - ThemeComposer adds app category editing via a Select using APP_CATEGORIES from @ankhorage/contracts.
10
+ - ThemeComposer supports optional `appCategories` prop for narrowing the category options list.
11
+ - ThemeComposer validates primary color input with parseHexColorOrThrow from @ankhorage/color-theory while keeping public `primaryColor` as `string`.
12
+ - ThemeComposer preview shows name, appCategory, primaryColor, and harmony metadata.
13
+ - README and examples app updated to reflect the new API.
14
+
3
15
  ## 1.0.0
4
16
 
5
17
  ### Major Changes
package/README.md CHANGED
@@ -1368,7 +1368,13 @@ Pass your current theme as `value` and handle updates through `onChange`. Wrap b
1368
1368
  in a `ZoraProvider` so the preview area reflects every change immediately.
1369
1369
 
1370
1370
  ```tsx
1371
- const [theme, setTheme] = React.useState<ZoraTheme>(zoraDefaultTheme);
1371
+ const [theme, setTheme] = React.useState<ZoraTheme>({
1372
+ id: 'my-app',
1373
+ name: 'My App',
1374
+ appCategory: 'developer_tools',
1375
+ primaryColor: '#0f766e',
1376
+ harmony: 'analogous',
1377
+ });
1372
1378
  const [mode, setMode] = React.useState<ZoraThemeMode>('light');
1373
1379
 
1374
1380
  return (
@@ -1389,14 +1395,15 @@ return (
1389
1395
 
1390
1396
  ZORA props:
1391
1397
 
1392
- | Prop | Type | Default | Notes |
1393
- | -------------- | ------------------------------- | ------- | ---------------------------------------------------- |
1394
- | `value` | `ZoraTheme` | - | Required controlled theme seed. |
1395
- | `onChange` | `(theme: ZoraTheme) => void` | - | Required. Fires on every valid change. |
1396
- | `mode` | `ZoraThemeMode` | - | Current light/dark mode shown in the mode toggle. |
1397
- | `onModeChange` | `(mode: ZoraThemeMode) => void` | - | Called when the user switches the mode toggle. |
1398
- | `onSubmit` | `(theme: ZoraTheme) => void` | - | Optional. Renders an "Apply theme" button if set. |
1399
- | `testID` | `string` | - | Forwarded to the root element and child test points. |
1398
+ | Prop | Type | Default | Notes |
1399
+ | --------------- | ------------------------------- | ---------------- | ---------------------------------------------------- |
1400
+ | `value` | `ZoraTheme` | - | Required controlled theme seed. |
1401
+ | `onChange` | `(theme: ZoraTheme) => void` | - | Required. Fires on every valid change. |
1402
+ | `mode` | `ZoraThemeMode` | - | Current light/dark mode shown in the mode toggle. |
1403
+ | `onModeChange` | `(mode: ZoraThemeMode) => void` | - | Called when the user switches the mode toggle. |
1404
+ | `onSubmit` | `(theme: ZoraTheme) => void` | - | Optional. Renders an "Apply theme" button if set. |
1405
+ | `appCategories` | `readonly AppCategory[]` | `APP_CATEGORIES` | Optional override for the app category options list. |
1406
+ | `testID` | `string` | - | Forwarded to the root element and child test points. |
1400
1407
 
1401
1408
  </details>
1402
1409
 
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeComposer.d.ts","sourceRoot":"","sources":["../../../src/patterns/theme-composer/ThemeComposer.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AA0KlD,eAAO,MAAM,aAAa,0DAAyC,CAAC"}
1
+ {"version":3,"file":"ThemeComposer.d.ts","sourceRoot":"","sources":["../../../src/patterns/theme-composer/ThemeComposer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAmSlD,eAAO,MAAM,aAAa,0DAAyC,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';
2
+ import { APP_CATEGORIES } from '@ankhorage/contracts';
2
3
  import { Box, Stack } from '@ankhorage/surface';
3
4
  import React from 'react';
4
5
  import { Badge } from '../../components/badge';
@@ -11,6 +12,8 @@ import { Tabs } from '../../components/tabs';
11
12
  import { Text } from '../../components/text';
12
13
  import { useZoraTheme } from '../../theme/useZoraTheme';
13
14
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
15
+ const HEX_ERROR_MESSAGE = 'Enter a valid 6-digit hex color (e.g. #0f766e).';
16
+ const NAME_ERROR_MESSAGE = 'Theme name cannot be empty.';
14
17
  function isValidHex(value) {
15
18
  try {
16
19
  parseHexColorOrThrow(value);
@@ -20,20 +23,42 @@ function isValidHex(value) {
20
23
  return false;
21
24
  }
22
25
  }
26
+ function formatAppCategoryLabel(category) {
27
+ return category
28
+ .split('_')
29
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
30
+ .join(' ');
31
+ }
23
32
  const HARMONY_OPTIONS = COLOR_HARMONIES.map((h) => ({ value: h, label: h }));
24
33
  const MODE_TABS = [
25
34
  { value: 'light', label: 'Light' },
26
35
  { value: 'dark', label: 'Dark' },
27
36
  ];
28
- function ThemeComposerInner({ themeId: _themeId, value, onChange, mode, onModeChange, onSubmit, testID, }) {
37
+ function ThemeComposerInner({ themeId: _themeId, value, onChange, mode, onModeChange, onSubmit, appCategories, testID, }) {
29
38
  const { theme } = useZoraTheme();
30
39
  const [hexInput, setHexInput] = React.useState(value.primaryColor);
31
40
  const [hexError, setHexError] = React.useState(undefined);
32
- // Keep local hex input in sync when value.primaryColor changes externally
41
+ const [nameInput, setNameInput] = React.useState(value.name);
42
+ const [nameError, setNameError] = React.useState(undefined);
43
+ // Keep local inputs in sync when value changes externally
33
44
  React.useEffect(() => {
34
45
  setHexInput(value.primaryColor);
35
46
  setHexError(undefined);
36
47
  }, [value.primaryColor]);
48
+ React.useEffect(() => {
49
+ setNameInput(value.name);
50
+ setNameError(undefined);
51
+ }, [value.name]);
52
+ function handleNameChange(text) {
53
+ setNameInput(text);
54
+ if (text.trim().length === 0) {
55
+ setNameError(NAME_ERROR_MESSAGE);
56
+ }
57
+ else {
58
+ setNameError(undefined);
59
+ onChange({ ...value, name: text });
60
+ }
61
+ }
37
62
  function handleHexChange(text) {
38
63
  // Ensure leading hash
39
64
  const normalized = text.startsWith('#') ? text : `#${text}`;
@@ -43,11 +68,57 @@ function ThemeComposerInner({ themeId: _themeId, value, onChange, mode, onModeCh
43
68
  onChange({ ...value, primaryColor: normalized });
44
69
  }
45
70
  else {
46
- setHexError('Enter a valid 6-digit hex color (e.g. #0f766e).');
71
+ setHexError(HEX_ERROR_MESSAGE);
47
72
  }
48
73
  }
74
+ function handleSubmit() {
75
+ const hasValidName = nameInput.trim().length > 0;
76
+ const hasValidHex = isValidHex(hexInput);
77
+ if (!hasValidName) {
78
+ setNameError(NAME_ERROR_MESSAGE);
79
+ }
80
+ if (!hasValidHex) {
81
+ setHexError(HEX_ERROR_MESSAGE);
82
+ }
83
+ if (!hasValidName || !hasValidHex) {
84
+ return;
85
+ }
86
+ onSubmit?.({
87
+ ...value,
88
+ name: nameInput,
89
+ primaryColor: hexInput,
90
+ });
91
+ }
49
92
  const activeMode = mode ?? 'light';
93
+ const categoryOptions = (appCategories ?? APP_CATEGORIES).map((c) => ({
94
+ value: c,
95
+ label: formatAppCategoryLabel(c),
96
+ }));
50
97
  return (<Stack gap="l" testID={testID}>
98
+ {/* Section: Theme identity */}
99
+ <Card title="Theme identity" description="Name your theme. The ID is assigned automatically and shown for reference.">
100
+ <Stack gap="m">
101
+ <Stack gap="xs">
102
+ <Text variant="label">Name</Text>
103
+ <Input value={nameInput} onChangeText={handleNameChange} placeholder="My theme" autoCorrect={false} invalid={nameError !== undefined} testID={testID ? `${testID}-name-input` : undefined}/>
104
+ {nameError ? (<Text tone="danger" variant="bodySmall">
105
+ {nameError}
106
+ </Text>) : null}
107
+ </Stack>
108
+ <Stack gap="xs">
109
+ <Text variant="label">ID</Text>
110
+ <Text tone="muted" variant="bodySmall" testID={testID ? `${testID}-id-display` : undefined}>
111
+ {value.id}
112
+ </Text>
113
+ </Stack>
114
+ </Stack>
115
+ </Card>
116
+
117
+ {/* Section: App category */}
118
+ <Card title="App category" description="Choose the category that best describes this app.">
119
+ <Select value={value.appCategory} options={categoryOptions} onValueChange={(c) => onChange({ ...value, appCategory: c })} testID={testID ? `${testID}-category-select` : undefined}/>
120
+ </Card>
121
+
51
122
  {/* Section: Primary Color */}
52
123
  <Card title="Primary color" description="Set the seed color for your theme palette.">
53
124
  <Stack gap="m">
@@ -81,6 +152,26 @@ function ThemeComposerInner({ themeId: _themeId, value, onChange, mode, onModeCh
81
152
  {/* Section: Preview */}
82
153
  <Card title="Preview" description="A quick look at how your theme renders common controls.">
83
154
  <Stack gap="m">
155
+ <Stack gap="xs">
156
+ <Text variant="label">Name</Text>
157
+ <Text>{value.name}</Text>
158
+ </Stack>
159
+ <Stack gap="xs">
160
+ <Text variant="label">Category</Text>
161
+ <Text>{formatAppCategoryLabel(value.appCategory)}</Text>
162
+ </Stack>
163
+ <Stack gap="xs">
164
+ <Text variant="label">Primary color</Text>
165
+ <Text tone="muted" variant="bodySmall">
166
+ {value.primaryColor}
167
+ </Text>
168
+ </Stack>
169
+ <Stack gap="xs">
170
+ <Text variant="label">Harmony</Text>
171
+ <Text tone="muted" variant="bodySmall">
172
+ {value.harmony}
173
+ </Text>
174
+ </Stack>
84
175
  <Heading level={4}>Heading</Heading>
85
176
  <Text>Body text — this shows default text color and weight.</Text>
86
177
  <Text tone="muted" variant="bodySmall">
@@ -114,7 +205,7 @@ function ThemeComposerInner({ themeId: _themeId, value, onChange, mode, onModeCh
114
205
  </Card>
115
206
 
116
207
  {/* Submit */}
117
- {onSubmit ? (<Button tone="primary" emphasis="solid" onPress={() => onSubmit(value)} testID={testID ? `${testID}-submit` : undefined}>
208
+ {onSubmit ? (<Button tone="primary" emphasis="solid" onPress={handleSubmit} testID={testID ? `${testID}-submit` : undefined}>
118
209
  Apply theme
119
210
  </Button>) : null}
120
211
  </Stack>);
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeComposer.js","sourceRoot":"","sources":["../../../src/patterns/theme-composer/ThemeComposer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAChF,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACjD,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACjD,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAE7C,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAGpE,SAAS,UAAU,CAAC,KAAa;IAC/B,IAAI,CAAC;QACH,oBAAoB,CAAC,KAAK,CAAC,CAAC;QAC5B,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,eAAe,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAE7E,MAAM,SAAS,GAAG;IAChB,EAAE,KAAK,EAAE,OAAwB,EAAE,KAAK,EAAE,OAAO,EAAE;IACnD,EAAE,KAAK,EAAE,MAAuB,EAAE,KAAK,EAAE,MAAM,EAAE;CAClD,CAAC;AAEF,SAAS,kBAAkB,CAAC,EAC1B,OAAO,EAAE,QAAQ,EACjB,KAAK,EACL,QAAQ,EACR,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,MAAM,GACa;IACnB,MAAM,EAAE,KAAK,EAAE,GAAG,YAAY,EAAE,CAAC;IAEjC,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAS,KAAK,CAAC,YAAY,CAAC,CAAC;IAC3E,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAqB,SAAS,CAAC,CAAC;IAE9E,0EAA0E;IAC1E,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACnB,WAAW,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QAChC,WAAW,CAAC,SAAS,CAAC,CAAC;IACzB,CAAC,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC;IAEzB,SAAS,eAAe,CAAC,IAAY;QACnC,sBAAsB;QACtB,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;QAC5D,WAAW,CAAC,UAAU,CAAC,CAAC;QAExB,IAAI,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;YAC3B,WAAW,CAAC,SAAS,CAAC,CAAC;YACvB,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,CAAC,CAAC;QACnD,CAAC;aAAM,CAAC;YACN,WAAW,CAAC,iDAAiD,CAAC,CAAC;QACjE,CAAC;IACH,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,IAAI,OAAO,CAAC;IAEnC,OAAO,CACL,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAC5B;MAAA,CAAC,4BAA4B,CAC7B;MAAA,CAAC,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,WAAW,CAAC,4CAA4C,CAClF;QAAA,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CACZ;UAAA,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAC3C;YAAA,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CACX;cAAA,CAAC,KAAK,CACJ,KAAK,CAAC,CAAC,QAAQ,CAAC,CAChB,YAAY,CAAC,CAAC,eAAe,CAAC,CAC9B,WAAW,CAAC,SAAS,CACrB,cAAc,CAAC,MAAM,CACrB,WAAW,CAAC,CAAC,KAAK,CAAC,CACnB,SAAS,CAAC,CAAC,CAAC,CAAC,CACb,OAAO,CAAC,CAAC,QAAQ,KAAK,SAAS,CAAC,CAChC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,YAAY,CAAC,CAAC,CAAC,SAAS,CAAC,EAEvD;YAAA,EAAE,GAAG,CACL;YAAA,CAAC,wBAAwB,CACzB;YAAA,CAAC,GAAG,CACF,KAAK,CAAC,CAAC,EAAE,CAAC,CACV,MAAM,CAAC,CAAC,EAAE,CAAC,CACX,MAAM,CAAC,GAAG,CACV,KAAK,CAAC,CAAC;YACL,eAAe,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM;YACtE,WAAW,EAAE,CAAC;YACd,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM;SACjC,CAAC,EAEN;UAAA,EAAE,KAAK,CACP;UAAA,CAAC,QAAQ,CAAC,CAAC,CAAC,CACV,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,WAAW,CACrC;cAAA,CAAC,QAAQ,CACX;YAAA,EAAE,IAAI,CAAC,CACR,CAAC,CAAC,CAAC,IAAI,CACV;QAAA,EAAE,KAAK,CACT;MAAA,EAAE,IAAI,CAEN;;MAAA,CAAC,sBAAsB,CACvB;MAAA,CAAC,IAAI,CACH,KAAK,CAAC,SAAS,CACf,WAAW,CAAC,+DAA+D,CAE3E;QAAA,CAAC,MAAM,CACL,KAAK,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CACrB,OAAO,CAAC,CAAC,eAAe,CAAC,CACzB,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CACzD,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,iBAAiB,CAAC,CAAC,CAAC,SAAS,CAAC,EAE5D;MAAA,EAAE,IAAI,CAEN;;MAAA,CAAC,mBAAmB,CACpB;MAAA,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,6CAA6C,CAC1E;QAAA,CAAC,IAAI,CACH,KAAK,CAAC,CAAC,UAAU,CAAC,CAClB,KAAK,CAAC,CAAC,SAAS,CAAC,CACjB,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC,CACxC,OAAO,CAAC,WAAW,CACnB,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,YAAY,CAAC,CAAC,CAAC,SAAS,CAAC,EAEvD;MAAA,EAAE,IAAI,CAEN;;MAAA,CAAC,sBAAsB,CACvB;MAAA,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,WAAW,CAAC,yDAAyD,CACzF;QAAA,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CACZ;UAAA,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CACnC;UAAA,CAAC,IAAI,CAAC,qDAAqD,EAAE,IAAI,CACjE;UAAA,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,CACpC;;UACF,EAAE,IAAI,CACN;UAAA,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAC3C;YAAA,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAC9C;;YACF,EAAE,MAAM,CACR;YAAA,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAC7C;;YACF,EAAE,MAAM,CACR;YAAA,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAC7C;;YACF,EAAE,MAAM,CACV;UAAA,EAAE,KAAK,CACP;UAAA,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAC3C;YAAA,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,KAAK,CACpC;YAAA,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CACnC;;YACF,EAAE,KAAK,CACP;YAAA,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CACnC;;YACF,EAAE,KAAK,CACP;YAAA,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAClC;;YACF,EAAE,KAAK,CACT;UAAA,EAAE,KAAK,CACP;UAAA,CAAC,IAAI,CACH,IAAI,CAAC,QAAQ,CACb,KAAK,CAAC,aAAa,CACnB,WAAW,CAAC,iCAAiC,CAC7C,OAAO,EAEX;QAAA,EAAE,KAAK,CACT;MAAA,EAAE,IAAI,CAEN;;MAAA,CAAC,YAAY,CACb;MAAA,CAAC,QAAQ,CAAC,CAAC,CAAC,CACV,CAAC,MAAM,CACL,IAAI,CAAC,SAAS,CACd,QAAQ,CAAC,OAAO,CAChB,OAAO,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAC/B,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAEhD;;QACF,EAAE,MAAM,CAAC,CACV,CAAC,CAAC,CAAC,IAAI,CACV;IAAA,EAAE,KAAK,CAAC,CACT,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,MAAM,aAAa,GAAG,kBAAkB,CAAC,kBAAkB,CAAC,CAAC","sourcesContent":["import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';\nimport { Box, Stack } from '@ankhorage/surface';\nimport React from 'react';\n\nimport { Badge } from '../../components/badge';\nimport { Button } from '../../components/button';\nimport { Card } from '../../components/card';\nimport { Heading } from '../../components/heading';\nimport { Input } from '../../components/input';\nimport { Select } from '../../components/select';\nimport { Tabs } from '../../components/tabs';\nimport { Text } from '../../components/text';\nimport type { ZoraThemeMode } from '../../theme/types';\nimport { useZoraTheme } from '../../theme/useZoraTheme';\nimport { withZoraThemeScope } from '../../theme/withZoraThemeScope';\nimport type { ThemeComposerProps } from './types';\n\nfunction isValidHex(value: string): boolean {\n try {\n parseHexColorOrThrow(value);\n return true;\n } catch {\n return false;\n }\n}\n\nconst HARMONY_OPTIONS = COLOR_HARMONIES.map((h) => ({ value: h, label: h }));\n\nconst MODE_TABS = [\n { value: 'light' as ZoraThemeMode, label: 'Light' },\n { value: 'dark' as ZoraThemeMode, label: 'Dark' },\n];\n\nfunction ThemeComposerInner({\n themeId: _themeId,\n value,\n onChange,\n mode,\n onModeChange,\n onSubmit,\n testID,\n}: ThemeComposerProps) {\n const { theme } = useZoraTheme();\n\n const [hexInput, setHexInput] = React.useState<string>(value.primaryColor);\n const [hexError, setHexError] = React.useState<string | undefined>(undefined);\n\n // Keep local hex input in sync when value.primaryColor changes externally\n React.useEffect(() => {\n setHexInput(value.primaryColor);\n setHexError(undefined);\n }, [value.primaryColor]);\n\n function handleHexChange(text: string) {\n // Ensure leading hash\n const normalized = text.startsWith('#') ? text : `#${text}`;\n setHexInput(normalized);\n\n if (isValidHex(normalized)) {\n setHexError(undefined);\n onChange({ ...value, primaryColor: normalized });\n } else {\n setHexError('Enter a valid 6-digit hex color (e.g. #0f766e).');\n }\n }\n\n const activeMode = mode ?? 'light';\n\n return (\n <Stack gap=\"l\" testID={testID}>\n {/* Section: Primary Color */}\n <Card title=\"Primary color\" description=\"Set the seed color for your theme palette.\">\n <Stack gap=\"m\">\n <Stack direction=\"row\" gap=\"m\" align=\"center\">\n <Box flex={1}>\n <Input\n value={hexInput}\n onChangeText={handleHexChange}\n placeholder=\"#0f766e\"\n autoCapitalize=\"none\"\n autoCorrect={false}\n maxLength={7}\n invalid={hexError !== undefined}\n testID={testID ? `${testID}-hex-input` : undefined}\n />\n </Box>\n {/* Color preview chip */}\n <Box\n width={36}\n height={36}\n radius=\"m\"\n style={{\n backgroundColor: isValidHex(hexInput) ? hexInput : theme.colors.border,\n borderWidth: 1,\n borderColor: theme.colors.border,\n }}\n />\n </Stack>\n {hexError ? (\n <Text tone=\"danger\" variant=\"bodySmall\">\n {hexError}\n </Text>\n ) : null}\n </Stack>\n </Card>\n\n {/* Section: Harmony */}\n <Card\n title=\"Harmony\"\n description=\"Choose how accent hues are generated from your primary color.\"\n >\n <Select\n value={value.harmony}\n options={HARMONY_OPTIONS}\n onValueChange={(h) => onChange({ ...value, harmony: h })}\n testID={testID ? `${testID}-harmony-select` : undefined}\n />\n </Card>\n\n {/* Section: Mode */}\n <Card title=\"Mode\" description=\"Switch between light and dark presentation.\">\n <Tabs\n value={activeMode}\n items={MODE_TABS}\n onValueChange={(m) => onModeChange?.(m)}\n variant=\"segmented\"\n testID={testID ? `${testID}-mode-tabs` : undefined}\n />\n </Card>\n\n {/* Section: Preview */}\n <Card title=\"Preview\" description=\"A quick look at how your theme renders common controls.\">\n <Stack gap=\"m\">\n <Heading level={4}>Heading</Heading>\n <Text>Body text — this shows default text color and weight.</Text>\n <Text tone=\"muted\" variant=\"bodySmall\">\n Muted caption text.\n </Text>\n <Stack direction=\"row\" gap=\"s\" align=\"center\">\n <Button tone=\"primary\" emphasis=\"solid\" size=\"m\">\n Primary\n </Button>\n <Button tone=\"neutral\" emphasis=\"soft\" size=\"m\">\n Neutral\n </Button>\n <Button tone=\"danger\" emphasis=\"ghost\" size=\"m\">\n Danger\n </Button>\n </Stack>\n <Stack direction=\"row\" gap=\"s\" align=\"center\">\n <Badge tone=\"primary\">Primary</Badge>\n <Badge tone=\"success\" emphasis=\"soft\">\n Success\n </Badge>\n <Badge tone=\"warning\" emphasis=\"soft\">\n Warning\n </Badge>\n <Badge tone=\"danger\" emphasis=\"soft\">\n Danger\n </Badge>\n </Stack>\n <Card\n tone=\"subtle\"\n title=\"Nested card\"\n description=\"Subtle tone inside the preview.\"\n compact\n />\n </Stack>\n </Card>\n\n {/* Submit */}\n {onSubmit ? (\n <Button\n tone=\"primary\"\n emphasis=\"solid\"\n onPress={() => onSubmit(value)}\n testID={testID ? `${testID}-submit` : undefined}\n >\n Apply theme\n </Button>\n ) : null}\n </Stack>\n );\n}\n\nexport const ThemeComposer = withZoraThemeScope(ThemeComposerInner);\n"]}
1
+ {"version":3,"file":"ThemeComposer.js","sourceRoot":"","sources":["../../../src/patterns/theme-composer/ThemeComposer.tsx"],"names":[],"mappings":"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","sourcesContent":["import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';\nimport type { AppCategory } from '@ankhorage/contracts';\nimport { APP_CATEGORIES } from '@ankhorage/contracts';\nimport { Box, Stack } from '@ankhorage/surface';\nimport React from 'react';\n\nimport { Badge } from '../../components/badge';\nimport { Button } from '../../components/button';\nimport { Card } from '../../components/card';\nimport { Heading } from '../../components/heading';\nimport { Input } from '../../components/input';\nimport { Select } from '../../components/select';\nimport { Tabs } from '../../components/tabs';\nimport { Text } from '../../components/text';\nimport type { ZoraThemeMode } from '../../theme/types';\nimport { useZoraTheme } from '../../theme/useZoraTheme';\nimport { withZoraThemeScope } from '../../theme/withZoraThemeScope';\nimport type { ThemeComposerProps } from './types';\n\nconst HEX_ERROR_MESSAGE = 'Enter a valid 6-digit hex color (e.g. #0f766e).';\nconst NAME_ERROR_MESSAGE = 'Theme name cannot be empty.';\n\nfunction isValidHex(value: string): boolean {\n try {\n parseHexColorOrThrow(value);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction formatAppCategoryLabel(category: AppCategory): string {\n return category\n .split('_')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ');\n}\n\nconst HARMONY_OPTIONS = COLOR_HARMONIES.map((h) => ({ value: h, label: h }));\n\nconst MODE_TABS = [\n { value: 'light' as ZoraThemeMode, label: 'Light' },\n { value: 'dark' as ZoraThemeMode, label: 'Dark' },\n];\n\nfunction ThemeComposerInner({\n themeId: _themeId,\n value,\n onChange,\n mode,\n onModeChange,\n onSubmit,\n appCategories,\n testID,\n}: ThemeComposerProps) {\n const { theme } = useZoraTheme();\n\n const [hexInput, setHexInput] = React.useState<string>(value.primaryColor);\n const [hexError, setHexError] = React.useState<string | undefined>(undefined);\n\n const [nameInput, setNameInput] = React.useState<string>(value.name);\n const [nameError, setNameError] = React.useState<string | undefined>(undefined);\n\n // Keep local inputs in sync when value changes externally\n React.useEffect(() => {\n setHexInput(value.primaryColor);\n setHexError(undefined);\n }, [value.primaryColor]);\n\n React.useEffect(() => {\n setNameInput(value.name);\n setNameError(undefined);\n }, [value.name]);\n\n function handleNameChange(text: string) {\n setNameInput(text);\n if (text.trim().length === 0) {\n setNameError(NAME_ERROR_MESSAGE);\n } else {\n setNameError(undefined);\n onChange({ ...value, name: text });\n }\n }\n\n function handleHexChange(text: string) {\n // Ensure leading hash\n const normalized = text.startsWith('#') ? text : `#${text}`;\n setHexInput(normalized);\n\n if (isValidHex(normalized)) {\n setHexError(undefined);\n onChange({ ...value, primaryColor: normalized });\n } else {\n setHexError(HEX_ERROR_MESSAGE);\n }\n }\n\n function handleSubmit() {\n const hasValidName = nameInput.trim().length > 0;\n const hasValidHex = isValidHex(hexInput);\n\n if (!hasValidName) {\n setNameError(NAME_ERROR_MESSAGE);\n }\n\n if (!hasValidHex) {\n setHexError(HEX_ERROR_MESSAGE);\n }\n\n if (!hasValidName || !hasValidHex) {\n return;\n }\n\n onSubmit?.({\n ...value,\n name: nameInput,\n primaryColor: hexInput,\n });\n }\n\n const activeMode = mode ?? 'light';\n const categoryOptions = (appCategories ?? APP_CATEGORIES).map((c) => ({\n value: c,\n label: formatAppCategoryLabel(c),\n }));\n\n return (\n <Stack gap=\"l\" testID={testID}>\n {/* Section: Theme identity */}\n <Card\n title=\"Theme identity\"\n description=\"Name your theme. The ID is assigned automatically and shown for reference.\"\n >\n <Stack gap=\"m\">\n <Stack gap=\"xs\">\n <Text variant=\"label\">Name</Text>\n <Input\n value={nameInput}\n onChangeText={handleNameChange}\n placeholder=\"My theme\"\n autoCorrect={false}\n invalid={nameError !== undefined}\n testID={testID ? `${testID}-name-input` : undefined}\n />\n {nameError ? (\n <Text tone=\"danger\" variant=\"bodySmall\">\n {nameError}\n </Text>\n ) : null}\n </Stack>\n <Stack gap=\"xs\">\n <Text variant=\"label\">ID</Text>\n <Text\n tone=\"muted\"\n variant=\"bodySmall\"\n testID={testID ? `${testID}-id-display` : undefined}\n >\n {value.id}\n </Text>\n </Stack>\n </Stack>\n </Card>\n\n {/* Section: App category */}\n <Card title=\"App category\" description=\"Choose the category that best describes this app.\">\n <Select\n value={value.appCategory}\n options={categoryOptions}\n onValueChange={(c) => onChange({ ...value, appCategory: c })}\n testID={testID ? `${testID}-category-select` : undefined}\n />\n </Card>\n\n {/* Section: Primary Color */}\n <Card title=\"Primary color\" description=\"Set the seed color for your theme palette.\">\n <Stack gap=\"m\">\n <Stack direction=\"row\" gap=\"m\" align=\"center\">\n <Box flex={1}>\n <Input\n value={hexInput}\n onChangeText={handleHexChange}\n placeholder=\"#0f766e\"\n autoCapitalize=\"none\"\n autoCorrect={false}\n maxLength={7}\n invalid={hexError !== undefined}\n testID={testID ? `${testID}-hex-input` : undefined}\n />\n </Box>\n {/* Color preview chip */}\n <Box\n width={36}\n height={36}\n radius=\"m\"\n style={{\n backgroundColor: isValidHex(hexInput) ? hexInput : theme.colors.border,\n borderWidth: 1,\n borderColor: theme.colors.border,\n }}\n />\n </Stack>\n {hexError ? (\n <Text tone=\"danger\" variant=\"bodySmall\">\n {hexError}\n </Text>\n ) : null}\n </Stack>\n </Card>\n\n {/* Section: Harmony */}\n <Card\n title=\"Harmony\"\n description=\"Choose how accent hues are generated from your primary color.\"\n >\n <Select\n value={value.harmony}\n options={HARMONY_OPTIONS}\n onValueChange={(h) => onChange({ ...value, harmony: h })}\n testID={testID ? `${testID}-harmony-select` : undefined}\n />\n </Card>\n\n {/* Section: Mode */}\n <Card title=\"Mode\" description=\"Switch between light and dark presentation.\">\n <Tabs\n value={activeMode}\n items={MODE_TABS}\n onValueChange={(m) => onModeChange?.(m)}\n variant=\"segmented\"\n testID={testID ? `${testID}-mode-tabs` : undefined}\n />\n </Card>\n\n {/* Section: Preview */}\n <Card title=\"Preview\" description=\"A quick look at how your theme renders common controls.\">\n <Stack gap=\"m\">\n <Stack gap=\"xs\">\n <Text variant=\"label\">Name</Text>\n <Text>{value.name}</Text>\n </Stack>\n <Stack gap=\"xs\">\n <Text variant=\"label\">Category</Text>\n <Text>{formatAppCategoryLabel(value.appCategory)}</Text>\n </Stack>\n <Stack gap=\"xs\">\n <Text variant=\"label\">Primary color</Text>\n <Text tone=\"muted\" variant=\"bodySmall\">\n {value.primaryColor}\n </Text>\n </Stack>\n <Stack gap=\"xs\">\n <Text variant=\"label\">Harmony</Text>\n <Text tone=\"muted\" variant=\"bodySmall\">\n {value.harmony}\n </Text>\n </Stack>\n <Heading level={4}>Heading</Heading>\n <Text>Body text — this shows default text color and weight.</Text>\n <Text tone=\"muted\" variant=\"bodySmall\">\n Muted caption text.\n </Text>\n <Stack direction=\"row\" gap=\"s\" align=\"center\">\n <Button tone=\"primary\" emphasis=\"solid\" size=\"m\">\n Primary\n </Button>\n <Button tone=\"neutral\" emphasis=\"soft\" size=\"m\">\n Neutral\n </Button>\n <Button tone=\"danger\" emphasis=\"ghost\" size=\"m\">\n Danger\n </Button>\n </Stack>\n <Stack direction=\"row\" gap=\"s\" align=\"center\">\n <Badge tone=\"primary\">Primary</Badge>\n <Badge tone=\"success\" emphasis=\"soft\">\n Success\n </Badge>\n <Badge tone=\"warning\" emphasis=\"soft\">\n Warning\n </Badge>\n <Badge tone=\"danger\" emphasis=\"soft\">\n Danger\n </Badge>\n </Stack>\n <Card\n tone=\"subtle\"\n title=\"Nested card\"\n description=\"Subtle tone inside the preview.\"\n compact\n />\n </Stack>\n </Card>\n\n {/* Submit */}\n {onSubmit ? (\n <Button\n tone=\"primary\"\n emphasis=\"solid\"\n onPress={handleSubmit}\n testID={testID ? `${testID}-submit` : undefined}\n >\n Apply theme\n </Button>\n ) : null}\n </Stack>\n );\n}\n\nexport const ThemeComposer = withZoraThemeScope(ThemeComposerInner);\n"]}
@@ -1,3 +1,4 @@
1
+ import type { AppCategory } from '@ankhorage/contracts';
1
2
  import type { ZoraTheme, ZoraThemeMode } from '../../theme/types';
2
3
  import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
3
4
  export interface ThemeComposerProps extends ZoraBaseProps {
@@ -6,5 +7,6 @@ export interface ThemeComposerProps extends ZoraBaseProps {
6
7
  mode?: ZoraThemeMode;
7
8
  onModeChange?: (mode: ZoraThemeMode) => void;
8
9
  onSubmit?: (theme: ZoraTheme) => void;
10
+ appCategories?: readonly AppCategory[];
9
11
  }
10
12
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/patterns/theme-composer/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACvD,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAC;IAC7C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACvC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/patterns/theme-composer/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACvD,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAC;IAC7C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACtC,aAAa,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;CACxC"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/patterns/theme-composer/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ZoraTheme, ZoraThemeMode } from '../../theme/types';\nimport type { ZoraBaseProps } from '../../theme/ZoraBaseProps';\n\nexport interface ThemeComposerProps extends ZoraBaseProps {\n value: ZoraTheme;\n onChange: (theme: ZoraTheme) => void;\n mode?: ZoraThemeMode;\n onModeChange?: (mode: ZoraThemeMode) => void;\n onSubmit?: (theme: ZoraTheme) => void;\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/patterns/theme-composer/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { AppCategory } from '@ankhorage/contracts';\n\nimport type { ZoraTheme, ZoraThemeMode } from '../../theme/types';\nimport type { ZoraBaseProps } from '../../theme/ZoraBaseProps';\n\nexport interface ThemeComposerProps extends ZoraBaseProps {\n value: ZoraTheme;\n onChange: (theme: ZoraTheme) => void;\n mode?: ZoraThemeMode;\n onModeChange?: (mode: ZoraThemeMode) => void;\n onSubmit?: (theme: ZoraTheme) => void;\n appCategories?: readonly AppCategory[];\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
6
6
  "homepage": "https://github.com/ankhorage/zora#readme",
7
7
  "bugs": {
@@ -1,10 +1,14 @@
1
- import { COLOR_HARMONIES } from '@ankhorage/color-theory';
1
+ import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';
2
+ import { APP_CATEGORIES } from '@ankhorage/contracts';
2
3
  import { describe, expect, test } from 'bun:test';
3
4
 
4
5
  import { zoraDefaultTheme } from '../../theme/zoraDefaultTheme';
5
6
  import type { ThemeComposerProps } from './types';
6
7
 
7
- // Validate the exported types compile correctly by asserting shape
8
+ // ---------------------------------------------------------------------------
9
+ // ThemeComposerProps shape tests
10
+ // ---------------------------------------------------------------------------
11
+
8
12
  describe('ThemeComposerProps', () => {
9
13
  test('accepts a valid ZoraTheme as value', () => {
10
14
  const props: ThemeComposerProps = {
@@ -39,8 +43,22 @@ describe('ThemeComposerProps', () => {
39
43
  props.onSubmit?.(zoraDefaultTheme);
40
44
  expect(submitted).toBe(true);
41
45
  });
46
+
47
+ test('accepts optional appCategories as option list override', () => {
48
+ const narrow = ['developer_tools', 'utilities_tools'] as const;
49
+ const props: ThemeComposerProps = {
50
+ value: zoraDefaultTheme,
51
+ onChange: () => undefined,
52
+ appCategories: narrow,
53
+ };
54
+ expect(props.appCategories).toEqual(narrow);
55
+ });
42
56
  });
43
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // COLOR_HARMONIES coverage
60
+ // ---------------------------------------------------------------------------
61
+
44
62
  describe('COLOR_HARMONIES coverage for ThemeComposer', () => {
45
63
  test('all canonical harmony values are present for the Select options', () => {
46
64
  const expected = [
@@ -55,6 +73,42 @@ describe('COLOR_HARMONIES coverage for ThemeComposer', () => {
55
73
  });
56
74
  });
57
75
 
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');
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // onChange propagation contracts
110
+ // ---------------------------------------------------------------------------
111
+
58
112
  describe('onChange propagation contract', () => {
59
113
  test('onChange receives updated theme with new harmony', () => {
60
114
  const received: (typeof zoraDefaultTheme)[] = [];
@@ -67,4 +121,96 @@ describe('onChange propagation contract', () => {
67
121
  expect(received).toHaveLength(1);
68
122
  expect(received[0]?.harmony).toBe('triadic');
69
123
  });
124
+
125
+ test('onChange receives updated theme with new appCategory', () => {
126
+ const received: (typeof zoraDefaultTheme)[] = [];
127
+ const props: ThemeComposerProps = {
128
+ value: zoraDefaultTheme,
129
+ onChange: (t) => received.push(t),
130
+ };
131
+ props.onChange({ ...zoraDefaultTheme, appCategory: 'lifestyle' });
132
+ expect(received).toHaveLength(1);
133
+ expect(received[0]?.appCategory).toBe('lifestyle');
134
+ });
135
+
136
+ test('onChange receives updated theme with new valid name', () => {
137
+ const received: (typeof zoraDefaultTheme)[] = [];
138
+ const props: ThemeComposerProps = {
139
+ value: zoraDefaultTheme,
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
+ });
146
+
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);
158
+ });
159
+
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
172
+ }
173
+ expect(received).toHaveLength(0);
174
+ });
175
+
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
+ });
201
+
202
+ test('ThemeComposerProps value carries no appMood field', () => {
203
+ const theme = zoraDefaultTheme;
204
+ expect('appMood' in theme).toBe(false);
205
+ });
206
+
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);
210
+ });
211
+
212
+ test('value.appCategory is required — zoraDefaultTheme.appCategory is set', () => {
213
+ expect(typeof zoraDefaultTheme.appCategory).toBe('string');
214
+ expect(zoraDefaultTheme.appCategory.length).toBeGreaterThan(0);
215
+ });
70
216
  });
@@ -1,4 +1,6 @@
1
1
  import { COLOR_HARMONIES, parseHexColorOrThrow } from '@ankhorage/color-theory';
2
+ import type { AppCategory } from '@ankhorage/contracts';
3
+ import { APP_CATEGORIES } from '@ankhorage/contracts';
2
4
  import { Box, Stack } from '@ankhorage/surface';
3
5
  import React from 'react';
4
6
 
@@ -15,6 +17,9 @@ import { useZoraTheme } from '../../theme/useZoraTheme';
15
17
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
16
18
  import type { ThemeComposerProps } from './types';
17
19
 
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.';
22
+
18
23
  function isValidHex(value: string): boolean {
19
24
  try {
20
25
  parseHexColorOrThrow(value);
@@ -24,6 +29,13 @@ function isValidHex(value: string): boolean {
24
29
  }
25
30
  }
26
31
 
32
+ function formatAppCategoryLabel(category: AppCategory): string {
33
+ return category
34
+ .split('_')
35
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
36
+ .join(' ');
37
+ }
38
+
27
39
  const HARMONY_OPTIONS = COLOR_HARMONIES.map((h) => ({ value: h, label: h }));
28
40
 
29
41
  const MODE_TABS = [
@@ -38,6 +50,7 @@ function ThemeComposerInner({
38
50
  mode,
39
51
  onModeChange,
40
52
  onSubmit,
53
+ appCategories,
41
54
  testID,
42
55
  }: ThemeComposerProps) {
43
56
  const { theme } = useZoraTheme();
@@ -45,12 +58,30 @@ function ThemeComposerInner({
45
58
  const [hexInput, setHexInput] = React.useState<string>(value.primaryColor);
46
59
  const [hexError, setHexError] = React.useState<string | undefined>(undefined);
47
60
 
48
- // 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
49
65
  React.useEffect(() => {
50
66
  setHexInput(value.primaryColor);
51
67
  setHexError(undefined);
52
68
  }, [value.primaryColor]);
53
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
+
54
85
  function handleHexChange(text: string) {
55
86
  // Ensure leading hash
56
87
  const normalized = text.startsWith('#') ? text : `#${text}`;
@@ -60,14 +91,86 @@ function ThemeComposerInner({
60
91
  setHexError(undefined);
61
92
  onChange({ ...value, primaryColor: normalized });
62
93
  } else {
63
- setHexError('Enter a valid 6-digit hex color (e.g. #0f766e).');
94
+ setHexError(HEX_ERROR_MESSAGE);
64
95
  }
65
96
  }
66
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);
108
+ }
109
+
110
+ if (!hasValidName || !hasValidHex) {
111
+ return;
112
+ }
113
+
114
+ onSubmit?.({
115
+ ...value,
116
+ name: nameInput,
117
+ primaryColor: hexInput,
118
+ });
119
+ }
120
+
67
121
  const activeMode = mode ?? 'light';
122
+ const categoryOptions = (appCategories ?? APP_CATEGORIES).map((c) => ({
123
+ value: c,
124
+ label: formatAppCategoryLabel(c),
125
+ }));
68
126
 
69
127
  return (
70
128
  <Stack gap="l" testID={testID}>
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}
157
+ >
158
+ {value.id}
159
+ </Text>
160
+ </Stack>
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>
173
+
71
174
  {/* Section: Primary Color */}
72
175
  <Card title="Primary color" description="Set the seed color for your theme palette.">
73
176
  <Stack gap="m">
@@ -131,6 +234,26 @@ function ThemeComposerInner({
131
234
  {/* Section: Preview */}
132
235
  <Card title="Preview" description="A quick look at how your theme renders common controls.">
133
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>
134
257
  <Heading level={4}>Heading</Heading>
135
258
  <Text>Body text — this shows default text color and weight.</Text>
136
259
  <Text tone="muted" variant="bodySmall">
@@ -173,7 +296,7 @@ function ThemeComposerInner({
173
296
  <Button
174
297
  tone="primary"
175
298
  emphasis="solid"
176
- onPress={() => onSubmit(value)}
299
+ onPress={handleSubmit}
177
300
  testID={testID ? `${testID}-submit` : undefined}
178
301
  >
179
302
  Apply theme
@@ -1,3 +1,5 @@
1
+ import type { AppCategory } from '@ankhorage/contracts';
2
+
1
3
  import type { ZoraTheme, ZoraThemeMode } from '../../theme/types';
2
4
  import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
3
5
 
@@ -7,4 +9,5 @@ export interface ThemeComposerProps extends ZoraBaseProps {
7
9
  mode?: ZoraThemeMode;
8
10
  onModeChange?: (mode: ZoraThemeMode) => void;
9
11
  onSubmit?: (theme: ZoraTheme) => void;
12
+ appCategories?: readonly AppCategory[];
10
13
  }