@ankhorage/zora 2.10.0 → 2.12.0

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 (180) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/dist/components/button/Button.d.ts.map +1 -1
  4. package/dist/components/button/Button.js +4 -2
  5. package/dist/components/button/Button.js.map +1 -1
  6. package/dist/components/button/meta.d.ts +3 -6
  7. package/dist/components/button/meta.d.ts.map +1 -1
  8. package/dist/components/button/meta.js +9 -15
  9. package/dist/components/button/meta.js.map +1 -1
  10. package/dist/components/button/resolveButtonThemeRecipe.d.ts +9 -0
  11. package/dist/components/button/resolveButtonThemeRecipe.d.ts.map +1 -0
  12. package/dist/components/button/resolveButtonThemeRecipe.js +30 -0
  13. package/dist/components/button/resolveButtonThemeRecipe.js.map +1 -0
  14. package/dist/components/button/themeRecipeMeta.d.ts +4 -10
  15. package/dist/components/button/themeRecipeMeta.d.ts.map +1 -1
  16. package/dist/components/button/themeRecipeMeta.js +5 -10
  17. package/dist/components/button/themeRecipeMeta.js.map +1 -1
  18. package/dist/components/card/Card.d.ts.map +1 -1
  19. package/dist/components/card/Card.js +8 -4
  20. package/dist/components/card/Card.js.map +1 -1
  21. package/dist/components/card/meta.d.ts +23 -4
  22. package/dist/components/card/meta.d.ts.map +1 -1
  23. package/dist/components/card/meta.js +14 -15
  24. package/dist/components/card/meta.js.map +1 -1
  25. package/dist/components/card/resolveCardThemeRecipe.d.ts +15 -0
  26. package/dist/components/card/resolveCardThemeRecipe.d.ts.map +1 -0
  27. package/dist/components/card/resolveCardThemeRecipe.js +22 -0
  28. package/dist/components/card/resolveCardThemeRecipe.js.map +1 -0
  29. package/dist/components/card/themeRecipeMeta.d.ts +1 -1
  30. package/dist/components/card/themeRecipeMeta.js +1 -1
  31. package/dist/components/card/themeRecipeMeta.js.map +1 -1
  32. package/dist/components/card/types.d.ts +2 -0
  33. package/dist/components/card/types.d.ts.map +1 -1
  34. package/dist/components/card/types.js.map +1 -1
  35. package/dist/components/heading/Heading.d.ts.map +1 -1
  36. package/dist/components/heading/Heading.js +14 -15
  37. package/dist/components/heading/Heading.js.map +1 -1
  38. package/dist/components/heading/meta.d.ts +6 -1
  39. package/dist/components/heading/meta.d.ts.map +1 -1
  40. package/dist/components/heading/meta.js +13 -22
  41. package/dist/components/heading/meta.js.map +1 -1
  42. package/dist/components/heading/resolveHeadingThemeRecipe.d.ts +11 -0
  43. package/dist/components/heading/resolveHeadingThemeRecipe.d.ts.map +1 -0
  44. package/dist/components/heading/resolveHeadingThemeRecipe.js +46 -0
  45. package/dist/components/heading/resolveHeadingThemeRecipe.js.map +1 -0
  46. package/dist/components/heading/themeRecipeMeta.d.ts +39 -0
  47. package/dist/components/heading/themeRecipeMeta.d.ts.map +1 -0
  48. package/dist/components/heading/themeRecipeMeta.js +32 -0
  49. package/dist/components/heading/themeRecipeMeta.js.map +1 -0
  50. package/dist/components/text/Text.d.ts +0 -1
  51. package/dist/components/text/Text.d.ts.map +1 -1
  52. package/dist/components/text/Text.js +14 -16
  53. package/dist/components/text/Text.js.map +1 -1
  54. package/dist/components/text/meta.d.ts +6 -5
  55. package/dist/components/text/meta.d.ts.map +1 -1
  56. package/dist/components/text/meta.js +13 -25
  57. package/dist/components/text/meta.js.map +1 -1
  58. package/dist/components/text/resolveTextThemeRecipe.d.ts +11 -0
  59. package/dist/components/text/resolveTextThemeRecipe.d.ts.map +1 -0
  60. package/dist/components/text/resolveTextThemeRecipe.js +46 -0
  61. package/dist/components/text/resolveTextThemeRecipe.js.map +1 -0
  62. package/dist/components/text/themeRecipeMeta.d.ts +15 -7
  63. package/dist/components/text/themeRecipeMeta.d.ts.map +1 -1
  64. package/dist/components/text/themeRecipeMeta.js +12 -11
  65. package/dist/components/text/themeRecipeMeta.js.map +1 -1
  66. package/dist/foundation/meta.d.ts +4 -4
  67. package/dist/layout/screen/meta.d.ts +1 -1
  68. package/dist/layout/screen-section/meta.d.ts +1 -1
  69. package/dist/metadata/allowedChildren.d.ts +3 -3
  70. package/dist/metadata/allowedChildren.d.ts.map +1 -1
  71. package/dist/metadata/allowedChildren.js +1 -0
  72. package/dist/metadata/allowedChildren.js.map +1 -1
  73. package/dist/metadata/componentMeta.d.ts.map +1 -1
  74. package/dist/metadata/componentMeta.js +2 -0
  75. package/dist/metadata/componentMeta.js.map +1 -1
  76. package/dist/metadata/themeRecipeMeta.d.ts.map +1 -1
  77. package/dist/metadata/themeRecipeMeta.js +2 -0
  78. package/dist/metadata/themeRecipeMeta.js.map +1 -1
  79. package/dist/metadata/themeRecipeTypes.d.ts +3 -3
  80. package/dist/metadata/themeRecipeTypes.d.ts.map +1 -1
  81. package/dist/metadata/themeRecipeTypes.js.map +1 -1
  82. package/dist/metadata/types.d.ts +1 -0
  83. package/dist/metadata/types.d.ts.map +1 -1
  84. package/dist/metadata/types.js.map +1 -1
  85. package/dist/patterns/message-bubble/meta.d.ts +1 -1
  86. package/dist/patterns/notice/meta.d.ts +1 -1
  87. package/dist/patterns/panel/Panel.d.ts +0 -2
  88. package/dist/patterns/panel/Panel.d.ts.map +1 -1
  89. package/dist/patterns/panel/Panel.js +6 -4
  90. package/dist/patterns/panel/Panel.js.map +1 -1
  91. package/dist/patterns/panel/meta.d.ts +23 -4
  92. package/dist/patterns/panel/meta.d.ts.map +1 -1
  93. package/dist/patterns/panel/meta.js +14 -15
  94. package/dist/patterns/panel/meta.js.map +1 -1
  95. package/dist/patterns/panel/themeRecipeMeta.d.ts +1 -1
  96. package/dist/patterns/panel/themeRecipeMeta.js +1 -1
  97. package/dist/patterns/panel/themeRecipeMeta.js.map +1 -1
  98. package/dist/patterns/panel/types.d.ts +2 -0
  99. package/dist/patterns/panel/types.d.ts.map +1 -1
  100. package/dist/patterns/panel/types.js.map +1 -1
  101. package/dist/patterns/post-card/meta.d.ts +1 -1
  102. package/dist/patterns/scanner/meta.d.ts +2 -2
  103. package/dist/registry.d.ts.map +1 -1
  104. package/dist/registry.js +2 -0
  105. package/dist/registry.js.map +1 -1
  106. package/dist/theme/ThemeModeToggle.d.ts +1 -1
  107. package/dist/theme/ThemeModeToggle.d.ts.map +1 -1
  108. package/dist/theme/ThemeModeToggle.js +2 -2
  109. package/dist/theme/ThemeModeToggle.js.map +1 -1
  110. package/dist/theme/ThemeModeToggle.meta.d.ts +28 -0
  111. package/dist/theme/ThemeModeToggle.meta.d.ts.map +1 -0
  112. package/dist/theme/ThemeModeToggle.meta.js +28 -0
  113. package/dist/theme/ThemeModeToggle.meta.js.map +1 -0
  114. package/dist/theme/ThemeModeToggleProps.d.ts +1 -1
  115. package/dist/theme/ThemeModeToggleProps.d.ts.map +1 -1
  116. package/dist/theme/ThemeModeToggleProps.js.map +1 -1
  117. package/dist/theme/ZoraProvider.d.ts +4 -12
  118. package/dist/theme/ZoraProvider.d.ts.map +1 -1
  119. package/dist/theme/ZoraProvider.js +5 -14
  120. package/dist/theme/ZoraProvider.js.map +1 -1
  121. package/dist/theme/ZoraThemeRuntimeContext.d.ts +1 -2
  122. package/dist/theme/ZoraThemeRuntimeContext.d.ts.map +1 -1
  123. package/dist/theme/ZoraThemeRuntimeContext.js +0 -1
  124. package/dist/theme/ZoraThemeRuntimeContext.js.map +1 -1
  125. package/dist/theme/ZoraThemeScope.d.ts.map +1 -1
  126. package/dist/theme/ZoraThemeScope.js +4 -10
  127. package/dist/theme/ZoraThemeScope.js.map +1 -1
  128. package/dist/theme/resolveZoraThemeRecipe.d.ts +16 -0
  129. package/dist/theme/resolveZoraThemeRecipe.d.ts.map +1 -0
  130. package/dist/theme/resolveZoraThemeRecipe.js +56 -0
  131. package/dist/theme/resolveZoraThemeRecipe.js.map +1 -0
  132. package/dist/theme/useZoraThemeRecipe.d.ts +2 -0
  133. package/dist/theme/useZoraThemeRecipe.d.ts.map +1 -0
  134. package/dist/theme/useZoraThemeRecipe.js +7 -0
  135. package/dist/theme/useZoraThemeRecipe.js.map +1 -0
  136. package/package.json +4 -4
  137. package/src/components/button/Button.tsx +4 -2
  138. package/src/components/button/meta.ts +10 -15
  139. package/src/components/button/resolveButtonThemeRecipe.test.ts +25 -0
  140. package/src/components/button/resolveButtonThemeRecipe.ts +41 -0
  141. package/src/components/button/themeRecipeMeta.ts +5 -10
  142. package/src/components/card/Card.tsx +13 -7
  143. package/src/components/card/meta.ts +15 -15
  144. package/src/components/card/resolveCardThemeRecipe.test.ts +28 -0
  145. package/src/components/card/resolveCardThemeRecipe.ts +33 -0
  146. package/src/components/card/themeRecipeMeta.ts +1 -1
  147. package/src/components/card/types.ts +2 -0
  148. package/src/components/contentFallback.test.ts +4 -4
  149. package/src/components/heading/Heading.tsx +18 -28
  150. package/src/components/heading/meta.ts +14 -22
  151. package/src/components/heading/resolveHeadingThemeRecipe.ts +55 -0
  152. package/src/components/heading/themeRecipeMeta.ts +33 -0
  153. package/src/components/text/Text.tsx +16 -25
  154. package/src/components/text/meta.ts +14 -25
  155. package/src/components/text/resolveTextThemeRecipe.ts +48 -0
  156. package/src/components/text/themeRecipeMeta.ts +12 -11
  157. package/src/metadata/allowedChildren.ts +1 -0
  158. package/src/metadata/authoringMeta.test.ts +18 -44
  159. package/src/metadata/componentMeta.test.ts +8 -0
  160. package/src/metadata/componentMeta.ts +2 -0
  161. package/src/metadata/themeRecipeMeta.test.ts +41 -39
  162. package/src/metadata/themeRecipeMeta.ts +2 -0
  163. package/src/metadata/themeRecipeTypes.ts +3 -3
  164. package/src/metadata/types.ts +3 -9
  165. package/src/patterns/panel/Panel.tsx +14 -4
  166. package/src/patterns/panel/meta.ts +15 -15
  167. package/src/patterns/panel/themeRecipeMeta.ts +1 -1
  168. package/src/patterns/panel/types.ts +2 -0
  169. package/src/registry.test.ts +5 -3
  170. package/src/registry.ts +2 -0
  171. package/src/theme/ThemeModeToggle.meta.ts +29 -0
  172. package/src/theme/ThemeModeToggle.tsx +7 -1
  173. package/src/theme/ThemeModeToggleProps.ts +4 -1
  174. package/src/theme/ZoraProvider.tsx +10 -13
  175. package/src/theme/ZoraThemeRuntimeContext.tsx +1 -3
  176. package/src/theme/ZoraThemeScope.tsx +5 -25
  177. package/src/theme/resolveZoraThemeRecipe.test.ts +72 -0
  178. package/src/theme/resolveZoraThemeRecipe.ts +91 -0
  179. package/src/theme/themeConfigScope.test.ts +25 -0
  180. package/src/theme/useZoraThemeRecipe.ts +7 -0
@@ -2,70 +2,44 @@ import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import { ZORA_COMPONENT_META, type ZoraComponentPropAuthoring } from './index';
4
4
 
5
+ const componentMetaByName = new Map(Object.entries(ZORA_COMPONENT_META));
6
+
5
7
  describe('ZORA component prop authoring authority', () => {
6
- test('supports the canonical instance and theme scope combinations', () => {
8
+ test('supports instance and theme authority with explicit instance override capability', () => {
7
9
  const values: readonly ZoraComponentPropAuthoring[] = [
8
10
  { authority: 'instance' },
9
11
  { authority: 'theme', scope: 'global' },
10
- { authority: 'theme', scope: 'component' },
11
- { authority: 'theme', scope: 'pattern' },
12
+ { authority: 'theme', scope: 'component', allowInstanceOverride: true },
13
+ { authority: 'theme', scope: 'pattern', allowInstanceOverride: true },
12
14
  ];
13
-
14
- expect(values).toEqual([
15
- { authority: 'instance' },
16
- { authority: 'theme', scope: 'global' },
17
- { authority: 'theme', scope: 'component' },
18
- { authority: 'theme', scope: 'pattern' },
19
- ]);
15
+ expect(values).toHaveLength(4);
20
16
  });
21
17
 
22
- test('classifies representative instance content without exposing arbitrary styling', () => {
23
- expect(ZORA_COMPONENT_META.Button.props.children?.authoring).toEqual({
24
- authority: 'instance',
25
- });
18
+ test('keeps representative content and semantics instance-owned', () => {
19
+ expect(ZORA_COMPONENT_META.Button.props.children?.authoring).toEqual({ authority: 'instance' });
26
20
  expect(ZORA_COMPONENT_META.Card.props.title?.authoring).toEqual({ authority: 'instance' });
27
- expect(ZORA_COMPONENT_META.Heading.props.text?.authoring).toEqual({ authority: 'instance' });
28
21
  expect(ZORA_COMPONENT_META.Heading.props.level?.authoring).toEqual({ authority: 'instance' });
29
22
  expect(ZORA_COMPONENT_META.Text.props.text?.authoring).toEqual({ authority: 'instance' });
30
- expect(ZORA_COMPONENT_META.Panel.props.description?.authoring).toEqual({
31
- authority: 'instance',
32
- });
33
23
  });
34
24
 
35
- test('classifies component and pattern styling as theme authority', () => {
36
- expect(ZORA_COMPONENT_META.Button.props.size?.authoring).toEqual({
37
- authority: 'theme',
38
- scope: 'component',
39
- });
40
- expect(ZORA_COMPONENT_META.Card.props.tone?.authoring).toEqual({
41
- authority: 'theme',
42
- scope: 'component',
43
- });
44
- expect(ZORA_COMPONENT_META.Heading.props.size?.authoring).toEqual({
45
- authority: 'theme',
46
- scope: 'component',
47
- });
48
- expect(ZORA_COMPONENT_META.Text.props.variant?.authoring).toEqual({
49
- authority: 'theme',
50
- scope: 'component',
51
- });
52
- expect(ZORA_COMPONENT_META.Panel.props.compact?.authoring).toEqual({
53
- authority: 'theme',
54
- scope: 'pattern',
55
- });
25
+ test('marks recipe props theme-owned while allowing explicit instance overrides', () => {
26
+ for (const name of ['Button', 'Card', 'Heading', 'Text', 'Panel']) {
27
+ const meta = componentMetaByName.get(name);
28
+ expect(meta, name).toBeDefined();
29
+ for (const prop of Object.values(meta?.props ?? {})) {
30
+ if (prop.authoring?.authority !== 'theme') continue;
31
+ expect(prop.authoring.allowInstanceOverride, name).toBe(true);
32
+ }
33
+ }
56
34
  });
57
35
 
58
- test('keeps Heading blueprint content useful before instance editing', () => {
36
+ test('keeps Heading blueprint semantic content useful', () => {
59
37
  expect(ZORA_COMPONENT_META.Heading.blueprint?.defaultProps).toEqual({
60
38
  text: 'Heading',
61
39
  level: 2,
62
40
  });
63
41
  });
64
42
 
65
- test('treats absent authoring metadata as not authorable', () => {
66
- expect(ZORA_COMPONENT_META.Progress.props.value?.authoring).toBeUndefined();
67
- });
68
-
69
43
  test('does not introduce a system authority', () => {
70
44
  expect(JSON.stringify(ZORA_COMPONENT_META)).not.toContain('"authority":"system"');
71
45
  });
@@ -28,6 +28,7 @@ describe('ZORA_COMPONENT_META registry coverage', () => {
28
28
  .map((item) => item.split(' as ')[0].trim())
29
29
  .filter((name) => /^[A-Z][A-Za-z0-9]+$/.test(name))
30
30
  .filter((name) => !['ToastProvider'].includes(name));
31
+ componentExports.push('ThemeModeToggle');
31
32
 
32
33
  for (const name of componentExports) {
33
34
  expect(
@@ -118,6 +119,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
118
119
  'EmptyState',
119
120
  'Hero',
120
121
  'Button',
122
+ 'ThemeModeToggle',
121
123
  'Input',
122
124
  'Textarea',
123
125
  'Text',
@@ -170,6 +172,12 @@ describe('ZORA_COMPONENT_META invariants', () => {
170
172
  }
171
173
  });
172
174
 
175
+ test('ThemeModeToggle is a direct manifest leaf with serializable defaults', () => {
176
+ expect(ZORA_COMPONENT_META.ThemeModeToggle.directManifestNode).toBe(true);
177
+ expect(ZORA_COMPONENT_META.ThemeModeToggle.allowedChildren).toEqual([]);
178
+ expect(ZORA_COMPONENT_META.ThemeModeToggle.blueprint?.defaultProps).toEqual({ size: 'm' });
179
+ });
180
+
173
181
  test('Progress is a direct manifest leaf with serializable defaults', () => {
174
182
  expect(ZORA_COMPONENT_META.Progress.directManifestNode).toBe(true);
175
183
  expect(ZORA_COMPONENT_META.Progress.allowedChildren).toEqual([]);
@@ -92,6 +92,7 @@ import { timelineMeta } from '../patterns/timeline/meta';
92
92
  import { treeItemMeta, treeViewMeta } from '../patterns/tree-view/meta';
93
93
  import { zoraDrawerContentMeta } from '../patterns/zora-drawer-content/meta';
94
94
  import { zoraTabBarMeta } from '../patterns/zora-tab-bar/meta';
95
+ import { themeModeToggleMeta } from '../theme/ThemeModeToggle.meta';
95
96
  import type { ZoraComponentMetaRegistry } from './types';
96
97
 
97
98
  export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
@@ -150,6 +151,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
150
151
  ToastProvider: toastProviderMeta,
151
152
  Toolbar: toolbarMeta,
152
153
  ToolbarAction: toolbarActionMeta,
154
+ ThemeModeToggle: themeModeToggleMeta,
153
155
  AppShell: appShellMeta,
154
156
  Screen: screenMeta,
155
157
  ScreenSection: screenSectionMeta,
@@ -3,65 +3,67 @@ import { describe, expect, test } from 'bun:test';
3
3
  import { ZORA_COMPONENT_META, ZORA_THEME_RECIPE_META, ZORA_THEME_TOKEN_FAMILIES } from './index';
4
4
 
5
5
  const tokenFamilies = new Set<string>(ZORA_THEME_TOKEN_FAMILIES);
6
- const expectedKinds = new Map<string, 'component' | 'pattern'>([
7
- ['Button', 'component'],
8
- ['Card', 'component'],
9
- ['Text', 'component'],
10
- ['Panel', 'pattern'],
11
- ]);
6
+ const recipeMetaByName = new Map(Object.entries(ZORA_THEME_RECIPE_META));
12
7
 
13
8
  describe('ZORA_THEME_RECIPE_META', () => {
14
- test('exports representative component and pattern recipes', () => {
15
- expect(Object.keys(ZORA_THEME_RECIPE_META)).toEqual(['Button', 'Card', 'Text', 'Panel']);
16
- expect(ZORA_THEME_RECIPE_META.Button?.kind).toBe('component');
17
- expect(ZORA_THEME_RECIPE_META.Panel?.kind).toBe('pattern');
18
- });
9
+ test('exports every current component/pattern theme-authority recipe', () => {
10
+ expect(Object.keys(ZORA_THEME_RECIPE_META)).toEqual([
11
+ 'Button',
12
+ 'Card',
13
+ 'Heading',
14
+ 'Text',
15
+ 'Panel',
16
+ ]);
19
17
 
20
- test('keeps registry names unique and aligned with component metadata', () => {
21
- const names = Object.values(ZORA_THEME_RECIPE_META).map((meta) => meta.name);
22
- expect(new Set(names).size).toBe(names.length);
18
+ for (const [name, componentMeta] of Object.entries(ZORA_COMPONENT_META)) {
19
+ const themeProps = Object.entries(componentMeta.props).filter(
20
+ ([, prop]) => prop.authoring?.authority === 'theme' && prop.authoring.scope !== 'global',
21
+ );
22
+ if (themeProps.length === 0) continue;
23
23
 
24
- for (const [key, meta] of Object.entries(ZORA_THEME_RECIPE_META)) {
25
- expect(meta.name).toBe(key);
26
- expect(meta.kind).toBe(expectedKinds.get(key));
24
+ const recipe = recipeMetaByName.get(name);
25
+ expect(recipe, `${name} has theme props but no theme recipe`).toBeDefined();
26
+ expect(recipe?.kind).toBe(componentMeta.category === 'pattern' ? 'pattern' : 'component');
27
+ const fields = new Map(Object.entries(recipe?.fields ?? {}));
28
+ for (const [propName] of themeProps) {
29
+ expect(
30
+ fields.get(propName),
31
+ `${name}.${propName} is missing from its recipe`,
32
+ ).toBeDefined();
33
+ }
27
34
  }
28
-
29
- expect(ZORA_COMPONENT_META.Button.category).toBe('component');
30
- expect(ZORA_COMPONENT_META.Card.category).toBe('component');
31
- expect(ZORA_COMPONENT_META.Text.category).toBe('component');
32
- expect(ZORA_COMPONENT_META.Panel.category).toBe('pattern');
33
35
  });
34
36
 
35
- test('references only canonical token families and valid choice defaults', () => {
37
+ test('references only canonical token families and valid declared defaults', () => {
36
38
  for (const meta of Object.values(ZORA_THEME_RECIPE_META)) {
37
39
  for (const field of Object.values(meta.fields)) {
38
40
  if (field.type === 'token') {
39
41
  expect(tokenFamilies.has(field.tokenFamily), `${meta.name}.${field.label}`).toBe(true);
40
42
  }
41
- if (field.type === 'choice') {
43
+ if (field.type === 'choice' && field.default !== undefined) {
42
44
  expect(field.options).toContain(field.default);
43
45
  }
44
46
  }
45
47
  }
46
48
  });
47
49
 
48
- test('describes component-specific semantic scales instead of universal geometry', () => {
49
- expect(ZORA_THEME_RECIPE_META.Button?.fields.size).toMatchObject({
50
- type: 'choice',
51
- options: ['s', 'm', 'l'],
52
- default: 'm',
53
- });
54
- expect(ZORA_THEME_RECIPE_META.Card?.fields.padding).toMatchObject({
55
- type: 'token',
56
- tokenFamily: 'spacing',
57
- default: 'l',
58
- });
50
+ test('keeps recipe defaults aligned with existing runtime behavior', () => {
51
+ expect(ZORA_THEME_RECIPE_META.Button?.fields.size?.default).toBe('l');
52
+ expect(ZORA_THEME_RECIPE_META.Card?.fields.padding?.default).toBeUndefined();
53
+ expect(ZORA_THEME_RECIPE_META.Text?.fields.weight?.default).toBeUndefined();
54
+ expect(ZORA_THEME_RECIPE_META.Heading?.fields.size?.default).toBeUndefined();
59
55
  });
60
56
 
61
- test('stays distinct from component metadata', () => {
62
- expect(ZORA_THEME_RECIPE_META).not.toBe(ZORA_COMPONENT_META);
63
- expect(ZORA_COMPONENT_META.Button.props.size?.type).toBe('enum');
64
- expect(ZORA_THEME_RECIPE_META.Button?.fields.size?.type).toBe('choice');
57
+ test('does not persist theme-owned defaults into new instance blueprints', () => {
58
+ for (const [name, meta] of Object.entries(ZORA_COMPONENT_META)) {
59
+ const props = new Map(Object.entries(meta.props));
60
+ for (const propName of Object.keys(meta.blueprint?.defaultProps ?? {})) {
61
+ expect(
62
+ props.get(propName)?.authoring?.authority,
63
+ `${name}.${propName} blueprint must not pin a theme-owned value`,
64
+ ).not.toBe('theme');
65
+ }
66
+ }
65
67
  });
66
68
 
67
69
  test('is serializable for downstream authoring consumers', () => {
@@ -1,5 +1,6 @@
1
1
  import { buttonThemeRecipeMeta } from '../components/button/themeRecipeMeta';
2
2
  import { cardThemeRecipeMeta } from '../components/card/themeRecipeMeta';
3
+ import { headingThemeRecipeMeta } from '../components/heading/themeRecipeMeta';
3
4
  import { textThemeRecipeMeta } from '../components/text/themeRecipeMeta';
4
5
  import { panelThemeRecipeMeta } from '../patterns/panel/themeRecipeMeta';
5
6
  import type { ZoraThemeRecipeMetaRegistry } from './themeRecipeTypes';
@@ -7,6 +8,7 @@ import type { ZoraThemeRecipeMetaRegistry } from './themeRecipeTypes';
7
8
  export const ZORA_THEME_RECIPE_META: ZoraThemeRecipeMetaRegistry = {
8
9
  Button: buttonThemeRecipeMeta,
9
10
  Card: cardThemeRecipeMeta,
11
+ Heading: headingThemeRecipeMeta,
10
12
  Text: textThemeRecipeMeta,
11
13
  Panel: panelThemeRecipeMeta,
12
14
  };
@@ -18,18 +18,18 @@ interface ZoraThemeRecipeFieldBase {
18
18
  export interface ZoraThemeRecipeTokenFieldMeta extends ZoraThemeRecipeFieldBase {
19
19
  readonly type: 'token';
20
20
  readonly tokenFamily: ZoraThemeTokenFamily;
21
- readonly default: string;
21
+ readonly default?: string;
22
22
  }
23
23
 
24
24
  export interface ZoraThemeRecipeChoiceFieldMeta extends ZoraThemeRecipeFieldBase {
25
25
  readonly type: 'choice';
26
26
  readonly options: readonly string[];
27
- readonly default: string;
27
+ readonly default?: string;
28
28
  }
29
29
 
30
30
  export interface ZoraThemeRecipeBooleanFieldMeta extends ZoraThemeRecipeFieldBase {
31
31
  readonly type: 'boolean';
32
- readonly default: boolean;
32
+ readonly default?: boolean;
33
33
  }
34
34
 
35
35
  export type ZoraThemeRecipeFieldMeta =
@@ -30,6 +30,7 @@ export type ZoraComponentPropAuthoring =
30
30
  | {
31
31
  readonly authority: 'theme';
32
32
  readonly scope: 'global' | 'component' | 'pattern';
33
+ readonly allowInstanceOverride?: boolean;
33
34
  };
34
35
 
35
36
  export interface ZoraComponentPropArrayItemSchema {
@@ -49,22 +50,15 @@ export interface ZoraComponentPropSchema {
49
50
 
50
51
  export interface ZoraComponentBlueprint {
51
52
  label: string;
52
- icon?: {
53
- name: string;
54
- provider?: string;
55
- };
53
+ icon?: { name: string; provider?: string };
56
54
  defaultProps?: Readonly<Record<string, ZoraComponentPropValue>>;
57
55
  }
58
56
 
59
57
  export interface ZoraComponentI18nMeta {
60
- fields: readonly {
61
- keyProp: string;
62
- defaultTextProp: string;
63
- }[];
58
+ fields: readonly { keyProp: string; defaultTextProp: string }[];
64
59
  }
65
60
 
66
61
  export type ZoraComponentEventPayloadKind = ComponentEventDtoKind | (string & {});
67
-
68
62
  export type ZoraComponentEventPayloadFieldType =
69
63
  | 'boolean'
70
64
  | 'number'
@@ -1,16 +1,26 @@
1
1
  import React from 'react';
2
2
 
3
3
  import { Card } from '../../components/card';
4
+ import { resolveCardThemeRecipe } from '../../components/card/resolveCardThemeRecipe';
5
+ import { useZoraThemeRecipe } from '../../theme/useZoraThemeRecipe';
4
6
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
5
7
  import type { PanelProps } from './types';
6
8
 
7
- function PanelInner({ themeId: _themeId, mode: _mode, ...props }: PanelProps) {
8
- return <Card {...props} />;
9
+ function PanelInner({
10
+ themeId: _themeId,
11
+ mode: _mode,
12
+ tone,
13
+ compact,
14
+ padding,
15
+ radius,
16
+ ...props
17
+ }: PanelProps) {
18
+ const themeFields = useZoraThemeRecipe('Panel');
19
+ const recipe = resolveCardThemeRecipe({ tone, compact, padding, radius, themeFields });
20
+ return <Card {...props} {...recipe} />;
9
21
  }
10
22
 
11
23
  /***
12
24
  * Semantic wrapper around `Card` for panel-style page sections.
13
- *
14
-
15
25
  */
16
26
  export const Panel = withZoraThemeScope(PanelInner);
@@ -1,18 +1,18 @@
1
1
  import type { ZoraComponentMeta } from '../../metadata';
2
2
  import { CONTAINER_ALLOWED_CHILDREN } from '../../metadata/allowedChildren';
3
3
 
4
+ const themeAuthoring = {
5
+ authority: 'theme',
6
+ scope: 'pattern',
7
+ allowInstanceOverride: true,
8
+ } as const;
9
+
4
10
  export const panelMeta = {
5
11
  name: 'Panel',
6
12
  category: 'pattern',
7
13
  directManifestNode: true,
8
14
  allowedChildren: [...CONTAINER_ALLOWED_CHILDREN],
9
- blueprint: {
10
- label: 'Panel',
11
- defaultProps: {
12
- title: 'Panel',
13
- tone: 'default',
14
- },
15
- },
15
+ blueprint: { label: 'Panel', defaultProps: { title: 'Panel' } },
16
16
  props: {
17
17
  title: {
18
18
  type: 'string',
@@ -37,15 +37,15 @@ export const panelMeta = {
37
37
  category: 'Style',
38
38
  label: 'Tone',
39
39
  enum: ['default', 'subtle', 'outline'],
40
- default: 'default',
41
- authoring: { authority: 'theme', scope: 'pattern' },
40
+ authoring: themeAuthoring,
42
41
  },
43
- compact: {
44
- type: 'boolean',
45
- category: 'Layout',
46
- label: 'Compact',
47
- default: false,
48
- authoring: { authority: 'theme', scope: 'pattern' },
42
+ compact: { type: 'boolean', category: 'Layout', label: 'Compact', authoring: themeAuthoring },
43
+ padding: { type: 'spacing', category: 'Layout', label: 'Padding', authoring: themeAuthoring },
44
+ radius: {
45
+ type: 'radius',
46
+ category: 'Style',
47
+ label: 'Corner radius',
48
+ authoring: themeAuthoring,
49
49
  },
50
50
  },
51
51
  } as const satisfies ZoraComponentMeta;
@@ -15,7 +15,7 @@ export const panelThemeRecipeMeta = {
15
15
  type: 'token',
16
16
  tokenFamily: 'spacing',
17
17
  label: 'Padding',
18
- default: 'l',
18
+ description: 'When unset, Panel keeps Card compact-aware spacing.',
19
19
  },
20
20
  radius: {
21
21
  type: 'token',
@@ -12,4 +12,6 @@ export interface PanelProps extends ZoraBaseProps {
12
12
  children?: React.ReactNode;
13
13
  tone?: ZoraCardTone;
14
14
  compact?: boolean;
15
+ padding?: string;
16
+ radius?: string;
15
17
  }
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test';
3
3
  import type { ZoraComponentRegistry } from './index';
4
4
 
5
5
  const NON_RUNTIME_COMPONENT_EXPORTS = new Set(['SelectionProvider', 'ToastProvider']);
6
+ const RUNTIME_THEME_COMPONENT_EXPORTS = ['ThemeModeToggle'] as const;
6
7
  const REGISTRY_SNAPSHOT_PREFIX = 'ZORA_REGISTRY_SNAPSHOT:';
7
8
 
8
9
  let registryEntriesPromise: Promise<readonly string[]> | undefined;
@@ -48,7 +49,7 @@ async function listRuntimeRegistryEntries(): Promise<readonly string[]> {
48
49
 
49
50
  async function listPublicConcreteComponentExports(): Promise<readonly string[]> {
50
51
  const source = await Bun.file('src/index.ts').text();
51
- return Array.from(
52
+ const componentExports = Array.from(
52
53
  source.matchAll(
53
54
  /export\s+\{([^}]+)\}\s+from '\.\/(components\/[^']+|foundation|layout\/[^']+|patterns\/[^']+)';/g,
54
55
  ),
@@ -56,8 +57,9 @@ async function listPublicConcreteComponentExports(): Promise<readonly string[]>
56
57
  .flatMap((match) => match[1].split(',').map((item) => item.trim()))
57
58
  .map((item) => item.split(' as ')[0].trim())
58
59
  .filter((name) => /^[A-Z][A-Za-z0-9]+$/.test(name))
59
- .filter((name) => !NON_RUNTIME_COMPONENT_EXPORTS.has(name))
60
- .sort();
60
+ .filter((name) => !NON_RUNTIME_COMPONENT_EXPORTS.has(name));
61
+
62
+ return [...componentExports, ...RUNTIME_THEME_COMPONENT_EXPORTS].sort();
61
63
  }
62
64
 
63
65
  describe('ZORA_COMPONENT_REGISTRY', () => {
package/src/registry.ts CHANGED
@@ -97,6 +97,7 @@ import { Timeline } from './patterns/timeline';
97
97
  import { TreeItem, TreeView } from './patterns/tree-view';
98
98
  import { ZoraDrawerContent } from './patterns/zora-drawer-content';
99
99
  import { ZoraTabBar } from './patterns/zora-tab-bar';
100
+ import { ThemeModeToggle } from './theme/ThemeModeToggle';
100
101
 
101
102
  export type ZoraComponentRegistry = Readonly<Record<string, React.ElementType>>;
102
103
 
@@ -177,6 +178,7 @@ const _ZORA_COMPONENT_REGISTRY = {
177
178
  Toast,
178
179
  Toolbar,
179
180
  ToolbarAction,
181
+ ThemeModeToggle,
180
182
  Box,
181
183
  Center,
182
184
  Container,
@@ -0,0 +1,29 @@
1
+ import type { ZoraComponentMeta } from '../metadata';
2
+
3
+ export const themeModeToggleMeta = {
4
+ name: 'ThemeModeToggle',
5
+ category: 'component',
6
+ directManifestNode: true,
7
+ allowedChildren: [],
8
+ blueprint: {
9
+ label: 'Theme mode toggle',
10
+ defaultProps: {
11
+ size: 'm',
12
+ },
13
+ },
14
+ props: {
15
+ disabled: {
16
+ type: 'boolean',
17
+ category: 'Behavior',
18
+ label: 'Disabled',
19
+ default: false,
20
+ },
21
+ size: {
22
+ type: 'enum',
23
+ category: 'Style',
24
+ label: 'Size',
25
+ enum: ['s', 'm', 'l'],
26
+ default: 'm',
27
+ },
28
+ },
29
+ } as const satisfies ZoraComponentMeta;
@@ -3,7 +3,12 @@ import { resolveThemeModeToggleState } from './resolveThemeModeToggleState';
3
3
  import type { ThemeModeToggleProps } from './ThemeModeToggleProps';
4
4
  import { useZoraTheme } from './useZoraTheme';
5
5
 
6
- export function ThemeModeToggle({ disabled, size = 'm', testID }: ThemeModeToggleProps) {
6
+ export function ThemeModeToggle({
7
+ disabled,
8
+ interactionPolicy,
9
+ size = 'm',
10
+ testID,
11
+ }: ThemeModeToggleProps) {
7
12
  const { mode, setMode } = useZoraTheme();
8
13
  const state = resolveThemeModeToggleState(mode);
9
14
 
@@ -12,6 +17,7 @@ export function ThemeModeToggle({ disabled, size = 'm', testID }: ThemeModeToggl
12
17
  color="neutral"
13
18
  disabled={disabled}
14
19
  icon={{ name: state.iconName }}
20
+ interactionPolicy={interactionPolicy}
15
21
  label={state.label}
16
22
  onPress={() => setMode(state.nextMode)}
17
23
  size={size}
@@ -1,3 +1,6 @@
1
1
  import type { IconButtonProps } from '../components/icon-button';
2
2
 
3
- export type ThemeModeToggleProps = Pick<IconButtonProps, 'disabled' | 'size' | 'testID'>;
3
+ export type ThemeModeToggleProps = Pick<
4
+ IconButtonProps,
5
+ 'disabled' | 'interactionPolicy' | 'size' | 'testID'
6
+ >;
@@ -1,3 +1,4 @@
1
+ import type { ThemeConfig } from '@ankhorage/contracts';
1
2
  import { ThemeProvider } from '@ankhorage/surface';
2
3
  import React from 'react';
3
4
 
@@ -9,30 +10,26 @@ import { ZoraThemeRuntimeContext } from './ZoraThemeRuntimeContext';
9
10
  export interface ZoraProviderProps {
10
11
  children: React.ReactNode;
11
12
  theme?: ZoraTheme;
13
+ themeConfig?: ThemeConfig;
12
14
  initialMode?: ZoraThemeMode;
13
15
  }
14
16
 
15
- /***
16
- * Installs the ZORA theme runtime and underlying Surface theme provider.
17
- *
18
- * Wrap an app with `ZoraProvider` once near the root so components, patterns,
19
- * layouts, and theme hooks resolve the same design tokens and color mode.
20
- *
21
- * @example App provider
22
- * ```tsx
23
- * <ZoraProvider initialMode="light"><App /></ZoraProvider>
24
- * ```
25
- */
17
+ /** Installs the ZORA theme runtime and underlying Surface theme provider. */
26
18
  export function ZoraProvider({
27
19
  children,
28
20
  theme = zoraDefaultTheme,
21
+ themeConfig,
29
22
  initialMode = 'light',
30
23
  }: ZoraProviderProps) {
31
- const runtimeValue = React.useMemo(() => ({ sourceTheme: theme, themeId: theme.id }), [theme]);
24
+ const resolvedConfig = React.useMemo(
25
+ () => themeConfig ?? createZoraThemeConfig(theme),
26
+ [theme, themeConfig],
27
+ );
28
+ const runtimeValue = React.useMemo(() => ({ themeId: resolvedConfig.id }), [resolvedConfig.id]);
32
29
 
33
30
  return (
34
31
  <ZoraThemeRuntimeContext.Provider value={runtimeValue}>
35
- <ThemeProvider initialConfig={createZoraThemeConfig(theme)} initialMode={initialMode}>
32
+ <ThemeProvider initialConfig={resolvedConfig} initialMode={initialMode}>
36
33
  {children}
37
34
  </ThemeProvider>
38
35
  </ZoraThemeRuntimeContext.Provider>
@@ -1,15 +1,13 @@
1
1
  import { createContext, useContext } from 'react';
2
2
 
3
- import type { ZoraTheme, ZoraThemeId } from './types';
3
+ import type { ZoraThemeId } from './types';
4
4
  import { zoraDefaultTheme } from './zoraDefaultTheme';
5
5
 
6
6
  interface ZoraThemeRuntime {
7
- sourceTheme: ZoraTheme;
8
7
  themeId: ZoraThemeId;
9
8
  }
10
9
 
11
10
  export const ZoraThemeRuntimeContext = createContext<ZoraThemeRuntime>({
12
- sourceTheme: zoraDefaultTheme,
13
11
  themeId: zoraDefaultTheme.id,
14
12
  });
15
13
 
@@ -1,7 +1,6 @@
1
1
  import { createTheme, ThemeContext, useFontContext, useTheme } from '@ankhorage/surface';
2
2
  import React, { useMemo } from 'react';
3
3
 
4
- import { createZoraThemeConfig } from './createZoraThemeConfig';
5
4
  import { resolveZoraScopedThemeId } from './resolveZoraScopedThemeId';
6
5
  import type { ZoraThemeId, ZoraThemeMode } from './types';
7
6
  import { useZoraThemeRuntime, ZoraThemeRuntimeContext } from './ZoraThemeRuntimeContext';
@@ -16,25 +15,16 @@ function ZoraThemeScopeInner({ children, themeId, mode }: ZoraThemeScopeProps) {
16
15
  const parentSurface = useTheme();
17
16
  const parentRuntime = useZoraThemeRuntime();
18
17
  const { activeFontId } = useFontContext();
19
-
20
18
  const scopedThemeId = resolveZoraScopedThemeId({
21
19
  desiredThemeId: themeId,
22
20
  inheritedThemeId: parentRuntime.themeId,
23
21
  });
24
-
25
22
  const scopedMode = mode ?? parentSurface.mode;
26
-
27
- // Plan 2: there is no multi-theme registry yet. Keep the active theme seed inherited.
28
- const surfaceConfig = useMemo(
29
- () => createZoraThemeConfig(parentRuntime.sourceTheme),
30
- [parentRuntime.sourceTheme],
31
- );
32
-
23
+ const sourceConfig = parentSurface.theme.config;
33
24
  const scopedTheme = useMemo(
34
- () => createTheme(surfaceConfig, scopedMode, activeFontId),
35
- [surfaceConfig, scopedMode, activeFontId],
25
+ () => createTheme(sourceConfig, scopedMode, activeFontId),
26
+ [sourceConfig, scopedMode, activeFontId],
36
27
  );
37
-
38
28
  const scopedSurfaceValue = useMemo(
39
29
  () => ({
40
30
  theme: scopedTheme,
@@ -45,14 +35,7 @@ function ZoraThemeScopeInner({ children, themeId, mode }: ZoraThemeScopeProps) {
45
35
  }),
46
36
  [parentSurface.setMode, parentSurface.setThemeConfig, scopedMode, scopedTheme],
47
37
  );
48
-
49
- const scopedRuntimeValue = useMemo(
50
- () => ({
51
- sourceTheme: parentRuntime.sourceTheme,
52
- themeId: scopedThemeId,
53
- }),
54
- [parentRuntime.sourceTheme, scopedThemeId],
55
- );
38
+ const scopedRuntimeValue = useMemo(() => ({ themeId: scopedThemeId }), [scopedThemeId]);
56
39
 
57
40
  return (
58
41
  <ZoraThemeRuntimeContext.Provider value={scopedRuntimeValue}>
@@ -62,10 +45,7 @@ function ZoraThemeScopeInner({ children, themeId, mode }: ZoraThemeScopeProps) {
62
45
  }
63
46
 
64
47
  export function ZoraThemeScope({ children, themeId, mode }: ZoraThemeScopeProps) {
65
- if (mode === undefined && themeId === undefined) {
66
- return children;
67
- }
68
-
48
+ if (mode === undefined && themeId === undefined) return children;
69
49
  return (
70
50
  <ZoraThemeScopeInner mode={mode} themeId={themeId}>
71
51
  {children}