@ankhorage/zora 2.11.0 → 2.13.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 (157) 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 +22 -3
  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/image/meta.d.ts +24 -3
  51. package/dist/components/image/meta.d.ts.map +1 -1
  52. package/dist/components/image/meta.js +18 -3
  53. package/dist/components/image/meta.js.map +1 -1
  54. package/dist/components/text/Text.d.ts +0 -1
  55. package/dist/components/text/Text.d.ts.map +1 -1
  56. package/dist/components/text/Text.js +14 -16
  57. package/dist/components/text/Text.js.map +1 -1
  58. package/dist/components/text/meta.d.ts +6 -5
  59. package/dist/components/text/meta.d.ts.map +1 -1
  60. package/dist/components/text/meta.js +13 -25
  61. package/dist/components/text/meta.js.map +1 -1
  62. package/dist/components/text/resolveTextThemeRecipe.d.ts +11 -0
  63. package/dist/components/text/resolveTextThemeRecipe.d.ts.map +1 -0
  64. package/dist/components/text/resolveTextThemeRecipe.js +46 -0
  65. package/dist/components/text/resolveTextThemeRecipe.js.map +1 -0
  66. package/dist/components/text/themeRecipeMeta.d.ts +15 -7
  67. package/dist/components/text/themeRecipeMeta.d.ts.map +1 -1
  68. package/dist/components/text/themeRecipeMeta.js +12 -11
  69. package/dist/components/text/themeRecipeMeta.js.map +1 -1
  70. package/dist/metadata/bindableComponentMeta.d.ts +22 -7
  71. package/dist/metadata/bindableComponentMeta.d.ts.map +1 -1
  72. package/dist/metadata/bindableComponentMeta.js +3 -13
  73. package/dist/metadata/bindableComponentMeta.js.map +1 -1
  74. package/dist/metadata/themeRecipeMeta.d.ts.map +1 -1
  75. package/dist/metadata/themeRecipeMeta.js +2 -0
  76. package/dist/metadata/themeRecipeMeta.js.map +1 -1
  77. package/dist/metadata/themeRecipeTypes.d.ts +3 -3
  78. package/dist/metadata/themeRecipeTypes.d.ts.map +1 -1
  79. package/dist/metadata/themeRecipeTypes.js.map +1 -1
  80. package/dist/metadata/types.d.ts +4 -2
  81. package/dist/metadata/types.d.ts.map +1 -1
  82. package/dist/metadata/types.js.map +1 -1
  83. package/dist/patterns/panel/Panel.d.ts +0 -2
  84. package/dist/patterns/panel/Panel.d.ts.map +1 -1
  85. package/dist/patterns/panel/Panel.js +6 -4
  86. package/dist/patterns/panel/Panel.js.map +1 -1
  87. package/dist/patterns/panel/meta.d.ts +22 -3
  88. package/dist/patterns/panel/meta.d.ts.map +1 -1
  89. package/dist/patterns/panel/meta.js +14 -15
  90. package/dist/patterns/panel/meta.js.map +1 -1
  91. package/dist/patterns/panel/themeRecipeMeta.d.ts +1 -1
  92. package/dist/patterns/panel/themeRecipeMeta.js +1 -1
  93. package/dist/patterns/panel/themeRecipeMeta.js.map +1 -1
  94. package/dist/patterns/panel/types.d.ts +2 -0
  95. package/dist/patterns/panel/types.d.ts.map +1 -1
  96. package/dist/patterns/panel/types.js.map +1 -1
  97. package/dist/theme/ZoraProvider.d.ts +4 -12
  98. package/dist/theme/ZoraProvider.d.ts.map +1 -1
  99. package/dist/theme/ZoraProvider.js +5 -14
  100. package/dist/theme/ZoraProvider.js.map +1 -1
  101. package/dist/theme/ZoraThemeRuntimeContext.d.ts +1 -2
  102. package/dist/theme/ZoraThemeRuntimeContext.d.ts.map +1 -1
  103. package/dist/theme/ZoraThemeRuntimeContext.js +0 -1
  104. package/dist/theme/ZoraThemeRuntimeContext.js.map +1 -1
  105. package/dist/theme/ZoraThemeScope.d.ts.map +1 -1
  106. package/dist/theme/ZoraThemeScope.js +4 -10
  107. package/dist/theme/ZoraThemeScope.js.map +1 -1
  108. package/dist/theme/resolveZoraThemeRecipe.d.ts +16 -0
  109. package/dist/theme/resolveZoraThemeRecipe.d.ts.map +1 -0
  110. package/dist/theme/resolveZoraThemeRecipe.js +56 -0
  111. package/dist/theme/resolveZoraThemeRecipe.js.map +1 -0
  112. package/dist/theme/useZoraTheme.d.ts +1 -1
  113. package/dist/theme/useZoraThemeRecipe.d.ts +2 -0
  114. package/dist/theme/useZoraThemeRecipe.d.ts.map +1 -0
  115. package/dist/theme/useZoraThemeRecipe.js +7 -0
  116. package/dist/theme/useZoraThemeRecipe.js.map +1 -0
  117. package/package.json +4 -4
  118. package/src/components/button/Button.tsx +4 -2
  119. package/src/components/button/meta.ts +10 -15
  120. package/src/components/button/resolveButtonThemeRecipe.test.ts +25 -0
  121. package/src/components/button/resolveButtonThemeRecipe.ts +41 -0
  122. package/src/components/button/themeRecipeMeta.ts +5 -10
  123. package/src/components/card/Card.tsx +13 -7
  124. package/src/components/card/meta.ts +15 -15
  125. package/src/components/card/resolveCardThemeRecipe.test.ts +28 -0
  126. package/src/components/card/resolveCardThemeRecipe.ts +33 -0
  127. package/src/components/card/themeRecipeMeta.ts +1 -1
  128. package/src/components/card/types.ts +2 -0
  129. package/src/components/contentFallback.test.ts +4 -4
  130. package/src/components/heading/Heading.tsx +18 -28
  131. package/src/components/heading/meta.ts +14 -22
  132. package/src/components/heading/resolveHeadingThemeRecipe.ts +55 -0
  133. package/src/components/heading/themeRecipeMeta.ts +33 -0
  134. package/src/components/image/meta.ts +18 -3
  135. package/src/components/text/Text.tsx +16 -25
  136. package/src/components/text/meta.ts +14 -25
  137. package/src/components/text/resolveTextThemeRecipe.ts +48 -0
  138. package/src/components/text/themeRecipeMeta.ts +12 -11
  139. package/src/metadata/authoringMeta.test.ts +18 -44
  140. package/src/metadata/bindableComponentMeta.test.ts +13 -0
  141. package/src/metadata/bindableComponentMeta.ts +4 -13
  142. package/src/metadata/componentMeta.test.ts +14 -0
  143. package/src/metadata/themeRecipeMeta.test.ts +41 -39
  144. package/src/metadata/themeRecipeMeta.ts +2 -0
  145. package/src/metadata/themeRecipeTypes.ts +3 -3
  146. package/src/metadata/types.ts +11 -24
  147. package/src/patterns/panel/Panel.tsx +14 -4
  148. package/src/patterns/panel/meta.ts +15 -15
  149. package/src/patterns/panel/themeRecipeMeta.ts +1 -1
  150. package/src/patterns/panel/types.ts +2 -0
  151. package/src/theme/ZoraProvider.tsx +10 -13
  152. package/src/theme/ZoraThemeRuntimeContext.tsx +1 -3
  153. package/src/theme/ZoraThemeScope.tsx +5 -25
  154. package/src/theme/resolveZoraThemeRecipe.test.ts +72 -0
  155. package/src/theme/resolveZoraThemeRecipe.ts +91 -0
  156. package/src/theme/themeConfigScope.test.ts +25 -0
  157. package/src/theme/useZoraThemeRecipe.ts +7 -0
@@ -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 =
@@ -1,21 +1,13 @@
1
- import type { ComponentEventDtoKind, ComponentRequirements } from '@ankhorage/contracts';
1
+ import type {
2
+ ComponentEventDtoKind,
3
+ ComponentRequirements,
4
+ MediaAssetKind,
5
+ UiComponentPropType,
6
+ } from '@ankhorage/contracts';
2
7
 
3
8
  export type ZoraComponentCategory = 'foundation' | 'component' | 'pattern' | 'layout';
4
9
 
5
- export type ZoraComponentPropType =
6
- | 'string'
7
- | 'number'
8
- | 'boolean'
9
- | 'enum'
10
- | 'color'
11
- | 'spacing'
12
- | 'radius'
13
- | 'shadow'
14
- | 'typographySize'
15
- | 'typographyWeight'
16
- | 'action'
17
- | 'imageAsset'
18
- | 'array';
10
+ export type ZoraComponentPropType = UiComponentPropType;
19
11
 
20
12
  export type ZoraComponentPropValue =
21
13
  | string
@@ -30,6 +22,7 @@ export type ZoraComponentPropAuthoring =
30
22
  | {
31
23
  readonly authority: 'theme';
32
24
  readonly scope: 'global' | 'component' | 'pattern';
25
+ readonly allowInstanceOverride?: boolean;
33
26
  };
34
27
 
35
28
  export interface ZoraComponentPropArrayItemSchema {
@@ -44,27 +37,21 @@ export interface ZoraComponentPropSchema {
44
37
  enum?: readonly (string | number)[];
45
38
  default?: ZoraComponentPropValue;
46
39
  itemSchema?: readonly ZoraComponentPropArrayItemSchema[];
40
+ mediaKinds?: readonly MediaAssetKind[];
47
41
  authoring?: ZoraComponentPropAuthoring;
48
42
  }
49
43
 
50
44
  export interface ZoraComponentBlueprint {
51
45
  label: string;
52
- icon?: {
53
- name: string;
54
- provider?: string;
55
- };
46
+ icon?: { name: string; provider?: string };
56
47
  defaultProps?: Readonly<Record<string, ZoraComponentPropValue>>;
57
48
  }
58
49
 
59
50
  export interface ZoraComponentI18nMeta {
60
- fields: readonly {
61
- keyProp: string;
62
- defaultTextProp: string;
63
- }[];
51
+ fields: readonly { keyProp: string; defaultTextProp: string }[];
64
52
  }
65
53
 
66
54
  export type ZoraComponentEventPayloadKind = ComponentEventDtoKind | (string & {});
67
-
68
55
  export type ZoraComponentEventPayloadFieldType =
69
56
  | 'boolean'
70
57
  | '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
  }
@@ -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}
@@ -0,0 +1,72 @@
1
+ import type { ThemeConfig } from '@ankhorage/contracts';
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { resolveZoraThemeRecipe } from './resolveZoraThemeRecipe';
5
+
6
+ const baseConfig: ThemeConfig = {
7
+ id: 'test',
8
+ name: 'Test',
9
+ light: { primaryColor: '#3B82F6', harmony: 'monochromatic' },
10
+ dark: { primaryColor: '#3B82F6', harmony: 'monochromatic' },
11
+ tokens: { spacing: { hero: 64 } },
12
+ };
13
+
14
+ function runtimeTheme(config: ThemeConfig) {
15
+ return {
16
+ config,
17
+ colors: { primary: '#3B82F6' },
18
+ spacing: { m: 16, l: 24, hero: 64 },
19
+ radii: { m: 8, l: 16 },
20
+ shadows: { soft: 2 },
21
+ typography: {
22
+ sizes: { m: 16 },
23
+ weights: { regular: '400' },
24
+ headings: { 1: { size: 32 } },
25
+ },
26
+ };
27
+ }
28
+
29
+ function withRecipes(recipes: ThemeConfig['recipes']): ThemeConfig {
30
+ return { ...baseConfig, recipes };
31
+ }
32
+
33
+ describe('resolveZoraThemeRecipe', () => {
34
+ test('merges metadata defaults with persisted known fields', () => {
35
+ const theme = runtimeTheme(
36
+ withRecipes({ components: { Card: { tone: 'outline', padding: 'hero' } } }),
37
+ );
38
+ expect(resolveZoraThemeRecipe(theme, 'Card')).toEqual({
39
+ tone: 'outline',
40
+ padding: 'hero',
41
+ radius: 'l',
42
+ compact: false,
43
+ });
44
+ });
45
+
46
+ test('ignores stale unknown persisted fields without guessing metadata', () => {
47
+ const theme = runtimeTheme(
48
+ withRecipes({ components: { Button: { variant: 'soft', retiredField: 'legacy' } } }),
49
+ );
50
+ expect(resolveZoraThemeRecipe(theme, 'Button')).toEqual({
51
+ color: 'primary',
52
+ variant: 'soft',
53
+ size: 'l',
54
+ });
55
+ });
56
+
57
+ test('rejects invalid known choices and token references', () => {
58
+ const choiceTheme = runtimeTheme(withRecipes({ components: { Button: { variant: 'neon' } } }));
59
+ expect(() => resolveZoraThemeRecipe(choiceTheme, 'Button')).toThrow(
60
+ 'Invalid theme recipe choice',
61
+ );
62
+
63
+ const tokenTheme = runtimeTheme(withRecipes({ components: { Card: { padding: 'missing' } } }));
64
+ expect(() => resolveZoraThemeRecipe(tokenTheme, 'Card')).toThrow('Unknown spacing token');
65
+ });
66
+
67
+ test('rejects code requests for unknown recipe names', () => {
68
+ expect(() => resolveZoraThemeRecipe(runtimeTheme(baseConfig), 'Missing')).toThrow(
69
+ 'Unknown ZORA theme recipe',
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,91 @@
1
+ import type { ThemeConfig, ThemeRecipeOverrideValue } from '@ankhorage/contracts';
2
+
3
+ import { ZORA_THEME_RECIPE_META } from '../metadata/themeRecipeMeta';
4
+ import type { ZoraThemeRecipeFieldMeta, ZoraThemeTokenFamily } from '../metadata/themeRecipeTypes';
5
+
6
+ interface ZoraThemeRecipeRuntimeTheme {
7
+ readonly config: ThemeConfig;
8
+ readonly colors: object;
9
+ readonly spacing: object;
10
+ readonly radii: object;
11
+ readonly shadows: object;
12
+ readonly typography: {
13
+ readonly sizes: object;
14
+ readonly weights: object;
15
+ readonly headings: object;
16
+ };
17
+ }
18
+
19
+ const recipeMetaByName = new Map(Object.entries(ZORA_THEME_RECIPE_META));
20
+
21
+ export function resolveZoraThemeRecipe(
22
+ theme: ZoraThemeRecipeRuntimeTheme,
23
+ recipeName: string,
24
+ ): Readonly<Record<string, ThemeRecipeOverrideValue>> {
25
+ const meta = recipeMetaByName.get(recipeName);
26
+ if (!meta) throw new RangeError(`Unknown ZORA theme recipe: ${recipeName}.`);
27
+
28
+ const recipes =
29
+ meta.kind === 'component' ? theme.config.recipes?.components : theme.config.recipes?.patterns;
30
+ const overrides = new Map(Object.entries(recipes ?? {})).get(recipeName);
31
+ const overrideByField = new Map(Object.entries(overrides ?? {}));
32
+ const resolved: [string, ThemeRecipeOverrideValue][] = [];
33
+
34
+ for (const [fieldName, fieldMeta] of Object.entries(meta.fields)) {
35
+ const override = overrideByField.get(fieldName);
36
+ if (override !== undefined) {
37
+ validateFieldValue(theme, recipeName, fieldName, fieldMeta, override);
38
+ resolved.push([fieldName, override]);
39
+ } else if (fieldMeta.default !== undefined) {
40
+ resolved.push([fieldName, fieldMeta.default]);
41
+ }
42
+ }
43
+
44
+ return Object.fromEntries(resolved);
45
+ }
46
+
47
+ function validateFieldValue(
48
+ theme: ZoraThemeRecipeRuntimeTheme,
49
+ recipeName: string,
50
+ fieldName: string,
51
+ meta: ZoraThemeRecipeFieldMeta,
52
+ value: ThemeRecipeOverrideValue,
53
+ ): void {
54
+ if (meta.type === 'boolean') {
55
+ if (typeof value !== 'boolean') {
56
+ throw new TypeError(`Theme recipe ${recipeName}.${fieldName} must be boolean.`);
57
+ }
58
+ return;
59
+ }
60
+ if (typeof value !== 'string') {
61
+ throw new TypeError(`Theme recipe ${recipeName}.${fieldName} must be a string.`);
62
+ }
63
+ if (meta.type === 'choice' && !meta.options.includes(value)) {
64
+ throw new RangeError(`Invalid theme recipe choice for ${recipeName}.${fieldName}: ${value}.`);
65
+ }
66
+ if (meta.type === 'token' && !hasThemeToken(theme, meta.tokenFamily, value)) {
67
+ throw new RangeError(
68
+ `Unknown ${meta.tokenFamily} token for ${recipeName}.${fieldName}: ${value}.`,
69
+ );
70
+ }
71
+ }
72
+
73
+ function hasThemeToken(
74
+ theme: ZoraThemeRecipeRuntimeTheme,
75
+ family: ZoraThemeTokenFamily,
76
+ token: string,
77
+ ): boolean {
78
+ if (family === 'colors') return hasOwn(theme.colors, token);
79
+ if (family === 'spacing') return hasOwn(theme.spacing, token);
80
+ if (family === 'radii') return hasOwn(theme.radii, token);
81
+ if (family === 'shadows') return hasOwn(theme.shadows, token);
82
+ return (
83
+ hasOwn(theme.typography.sizes, token) ||
84
+ hasOwn(theme.typography.weights, token) ||
85
+ hasOwn(theme.typography.headings, token)
86
+ );
87
+ }
88
+
89
+ function hasOwn(value: object, key: PropertyKey): boolean {
90
+ return Object.prototype.hasOwnProperty.call(value, key);
91
+ }
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ const themeDir = import.meta.dir;
7
+
8
+ describe('canonical ThemeConfig scope propagation', () => {
9
+ test('provider accepts the canonical ThemeConfig without duplicating live config state', () => {
10
+ const provider = readFileSync(join(themeDir, 'ZoraProvider.tsx'), 'utf8');
11
+ const context = readFileSync(join(themeDir, 'ZoraThemeRuntimeContext.tsx'), 'utf8');
12
+ expect(provider).toContain('themeConfig?: ThemeConfig');
13
+ expect(provider).toContain('themeConfig ?? createZoraThemeConfig(theme)');
14
+ expect(context).not.toContain('ThemeConfig');
15
+ expect(context).not.toContain('sourceTheme');
16
+ });
17
+
18
+ test('nested scopes derive from the current Surface config authority', () => {
19
+ const scope = readFileSync(join(themeDir, 'ZoraThemeScope.tsx'), 'utf8');
20
+ expect(scope).toContain('const sourceConfig = parentSurface.theme.config;');
21
+ expect(scope).toContain('createTheme(sourceConfig');
22
+ expect(scope).not.toContain('createZoraThemeConfig');
23
+ expect(scope).not.toContain('sourceTheme');
24
+ });
25
+ });
@@ -0,0 +1,7 @@
1
+ import { resolveZoraThemeRecipe } from './resolveZoraThemeRecipe';
2
+ import { useZoraTheme } from './useZoraTheme';
3
+
4
+ export function useZoraThemeRecipe(recipeName: string) {
5
+ const { theme } = useZoraTheme();
6
+ return resolveZoraThemeRecipe(theme, recipeName);
7
+ }