@ankhorage/contracts 7.0.0 → 7.2.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 (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/appManifest/bindings.d.ts +4 -0
  4. package/dist/appManifest/bindings.d.ts.map +1 -0
  5. package/dist/appManifest/bindings.js +96 -0
  6. package/dist/appManifest/bindings.js.map +1 -0
  7. package/dist/appManifest/dataSources.d.ts +2 -0
  8. package/dist/appManifest/dataSources.d.ts.map +1 -0
  9. package/dist/appManifest/dataSources.js +133 -0
  10. package/dist/appManifest/dataSources.js.map +1 -0
  11. package/dist/appManifest/generatedApis.d.ts +2 -0
  12. package/dist/appManifest/generatedApis.d.ts.map +1 -0
  13. package/dist/appManifest/generatedApis.js +87 -0
  14. package/dist/appManifest/generatedApis.js.map +1 -0
  15. package/dist/appManifest/infra.d.ts +2 -0
  16. package/dist/appManifest/infra.d.ts.map +1 -0
  17. package/dist/appManifest/infra.js +136 -0
  18. package/dist/appManifest/infra.js.map +1 -0
  19. package/dist/appManifest/screens.d.ts +6 -0
  20. package/dist/appManifest/screens.d.ts.map +1 -0
  21. package/dist/appManifest/screens.js +119 -0
  22. package/dist/appManifest/screens.js.map +1 -0
  23. package/dist/appManifest/shared.d.ts +8 -0
  24. package/dist/appManifest/shared.d.ts.map +1 -0
  25. package/dist/appManifest/shared.js +31 -0
  26. package/dist/appManifest/shared.js.map +1 -0
  27. package/dist/appManifest.d.ts +19 -0
  28. package/dist/appManifest.d.ts.map +1 -0
  29. package/dist/appManifest.js +64 -0
  30. package/dist/appManifest.js.map +1 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/theme.d.ts +44 -0
  36. package/dist/theme.d.ts.map +1 -0
  37. package/dist/theme.js +2 -0
  38. package/dist/theme.js.map +1 -0
  39. package/dist/types.d.ts +5 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/appManifest/bindings.ts +135 -0
  44. package/src/appManifest/dataSources.ts +175 -0
  45. package/src/appManifest/generatedApis.ts +122 -0
  46. package/src/appManifest/infra.ts +199 -0
  47. package/src/appManifest/screens.ts +171 -0
  48. package/src/appManifest/shared.ts +40 -0
  49. package/src/appManifest.test.ts +185 -0
  50. package/src/appManifest.ts +86 -0
  51. package/src/contracts.test.ts +40 -0
  52. package/src/index.ts +2 -0
  53. package/src/theme.ts +50 -0
  54. package/src/types.ts +5 -0
@@ -0,0 +1,171 @@
1
+ import { COLOR_HARMONIES } from '@ankhorage/color-theory';
2
+
3
+ import { APP_CATEGORIES, NAVIGATOR_TYPES } from '../types';
4
+ import { isBindingValueSource, isScreenDataLoaderDefinition } from './bindings';
5
+ import {
6
+ isOptionalBoolean,
7
+ isOptionalNumber,
8
+ isOptionalString,
9
+ isRecord,
10
+ isStringArray,
11
+ } from './shared';
12
+
13
+ const APP_CATEGORY_SET = new Set<string>(APP_CATEGORIES);
14
+ const COLOR_HARMONY_SET = new Set<string>(COLOR_HARMONIES);
15
+ const NAVIGATOR_TYPE_SET = new Set<string>(NAVIGATOR_TYPES);
16
+ const SPLASH_SCREEN_RESIZE_MODE_SET = new Set<string>(['contain', 'cover', 'native']);
17
+
18
+ export function isManifestMetadata(value: unknown): boolean {
19
+ return (
20
+ isRecord(value) &&
21
+ typeof value.name === 'string' &&
22
+ typeof value.slug === 'string' &&
23
+ typeof value.version === 'string' &&
24
+ typeof value.category === 'string' &&
25
+ APP_CATEGORY_SET.has(value.category) &&
26
+ typeof value.themeId === 'string' &&
27
+ isOptionalString(value.created) &&
28
+ isOptionalString(value.updated)
29
+ );
30
+ }
31
+
32
+ export function isThemeConfig(value: unknown): boolean {
33
+ return (
34
+ isRecord(value) &&
35
+ typeof value.id === 'string' &&
36
+ typeof value.name === 'string' &&
37
+ isThemeModeConfig(value.light) &&
38
+ isThemeModeConfig(value.dark)
39
+ );
40
+ }
41
+
42
+ export function isNavigatorSpec(value: unknown): boolean {
43
+ return (
44
+ isRecord(value) &&
45
+ typeof value.type === 'string' &&
46
+ NAVIGATOR_TYPE_SET.has(value.type) &&
47
+ isOptionalString(value.initialRouteName) &&
48
+ Array.isArray(value.routes) &&
49
+ value.routes.every(isRouteDefinition) &&
50
+ (value.options === undefined || isRecord(value.options))
51
+ );
52
+ }
53
+
54
+ export function isScreenRegistry(value: unknown): boolean {
55
+ return (
56
+ isRecord(value) &&
57
+ Object.entries(value).every(
58
+ ([registryKey, screen]) =>
59
+ isScreenSpec(screen) && isRecord(screen) && registryKey === screen.id,
60
+ )
61
+ );
62
+ }
63
+
64
+ export function isSplashScreenSpec(value: unknown): boolean {
65
+ return (
66
+ isSplashScreenModeSpec(value) &&
67
+ isRecord(value) &&
68
+ (value.dark === undefined || isSplashScreenModeSpec(value.dark))
69
+ );
70
+ }
71
+
72
+ function isThemeModeConfig(value: unknown): boolean {
73
+ return (
74
+ isRecord(value) &&
75
+ typeof value.primaryColor === 'string' &&
76
+ typeof value.harmony === 'string' &&
77
+ COLOR_HARMONY_SET.has(value.harmony)
78
+ );
79
+ }
80
+
81
+ function isUiNode(value: unknown): boolean {
82
+ return (
83
+ isRecord(value) &&
84
+ typeof value.id === 'string' &&
85
+ typeof value.type === 'string' &&
86
+ isOptionalString(value.alias) &&
87
+ (value.props === undefined || isRecord(value.props)) &&
88
+ (value.style === undefined || isRecord(value.style)) &&
89
+ (value.repeat === undefined || isUiNodeRepeatSpec(value.repeat)) &&
90
+ (value.children === undefined ||
91
+ (Array.isArray(value.children) && value.children.every(isUiNode)))
92
+ );
93
+ }
94
+
95
+ function isUiNodeRepeatSpec(value: unknown): boolean {
96
+ return (
97
+ isRecord(value) &&
98
+ isBindingValueSource(value.source) &&
99
+ isOptionalString(value.itemAlias) &&
100
+ isOptionalString(value.keyPath) &&
101
+ (value.empty === undefined || (Array.isArray(value.empty) && value.empty.every(isUiNode)))
102
+ );
103
+ }
104
+
105
+ function isScreenSpec(value: unknown): boolean {
106
+ return (
107
+ isRecord(value) &&
108
+ typeof value.id === 'string' &&
109
+ typeof value.name === 'string' &&
110
+ isOptionalString(value.title) &&
111
+ isOptionalString(value.description) &&
112
+ (value.dataLoaders === undefined ||
113
+ (Array.isArray(value.dataLoaders) &&
114
+ value.dataLoaders.every(isScreenDataLoaderDefinition))) &&
115
+ (value.requires === undefined || isScreenRequirements(value.requires)) &&
116
+ isUiNode(value.root)
117
+ );
118
+ }
119
+
120
+ function isRouteDefinition(value: unknown): boolean {
121
+ return (
122
+ isRecord(value) &&
123
+ typeof value.name === 'string' &&
124
+ isOptionalString(value.path) &&
125
+ isOptionalString(value.label) &&
126
+ (value.icon === undefined || isIconSpec(value.icon)) &&
127
+ isOptionalBoolean(value.showInPrimaryNavigation) &&
128
+ (value.guards === undefined || isStringArray(value.guards)) &&
129
+ isOptionalString(value.screenId) &&
130
+ (value.navigator === undefined || isNavigatorSpec(value.navigator))
131
+ );
132
+ }
133
+
134
+ function isIconSpec(value: unknown): boolean {
135
+ return (
136
+ isRecord(value) &&
137
+ typeof value.name === 'string' &&
138
+ isOptionalString(value.provider) &&
139
+ (value.size === undefined ||
140
+ typeof value.size === 'string' ||
141
+ typeof value.size === 'number') &&
142
+ isOptionalString(value.color)
143
+ );
144
+ }
145
+
146
+ function isSplashScreenModeSpec(value: unknown): boolean {
147
+ return (
148
+ isRecord(value) &&
149
+ isOptionalString(value.image) &&
150
+ isOptionalNumber(value.imageWidth) &&
151
+ (value.resizeMode === undefined ||
152
+ (typeof value.resizeMode === 'string' &&
153
+ SPLASH_SCREEN_RESIZE_MODE_SET.has(value.resizeMode))) &&
154
+ isOptionalString(value.backgroundColor)
155
+ );
156
+ }
157
+
158
+ function isScreenRequirements(value: unknown): boolean {
159
+ return (
160
+ isRecord(value) &&
161
+ (value.permissions === undefined || isRequirementArray(value.permissions, 'permission')) &&
162
+ (value.capabilities === undefined || isRequirementArray(value.capabilities, 'capability'))
163
+ );
164
+ }
165
+
166
+ function isRequirementArray(value: unknown, key: 'capability' | 'permission'): boolean {
167
+ return (
168
+ Array.isArray(value) &&
169
+ value.every((entry) => isRecord(entry) && typeof entry[key] === 'string')
170
+ );
171
+ }
@@ -0,0 +1,40 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ export function isOptionalString(value: unknown): boolean {
6
+ return value === undefined || typeof value === 'string';
7
+ }
8
+
9
+ export function isOptionalNumber(value: unknown): boolean {
10
+ return value === undefined || typeof value === 'number';
11
+ }
12
+
13
+ export function isOptionalBoolean(value: unknown): boolean {
14
+ return value === undefined || typeof value === 'boolean';
15
+ }
16
+
17
+ export function isStringArray(value: unknown): value is string[] {
18
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
19
+ }
20
+
21
+ export function isStringRecord(value: unknown): boolean {
22
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
23
+ }
24
+
25
+ export function isManifestValue(value: unknown): boolean {
26
+ if (
27
+ value === null ||
28
+ typeof value === 'string' ||
29
+ typeof value === 'number' ||
30
+ typeof value === 'boolean'
31
+ ) {
32
+ return true;
33
+ }
34
+
35
+ if (Array.isArray(value)) {
36
+ return value.every(isManifestValue);
37
+ }
38
+
39
+ return isRecord(value) && Object.values(value).every(isManifestValue);
40
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { isAppManifest, parseAppManifest } from './appManifest';
4
+
5
+ function createManifest(): Record<string, unknown> {
6
+ return {
7
+ metadata: {
8
+ name: 'Example',
9
+ slug: 'example',
10
+ version: '1.0.0',
11
+ category: 'developer_tools',
12
+ themeId: 'default',
13
+ },
14
+ themes: [
15
+ {
16
+ id: 'default',
17
+ name: 'Default',
18
+ light: { primaryColor: '#3366ff', harmony: 'analogous' },
19
+ dark: { primaryColor: '#6699ff', harmony: 'analogous' },
20
+ },
21
+ ],
22
+ activeThemeId: 'default',
23
+ activeThemeMode: 'light',
24
+ splashScreen: {
25
+ image: './assets/splash.png',
26
+ resizeMode: 'contain',
27
+ backgroundColor: '#ffffff',
28
+ dark: { backgroundColor: '#000000' },
29
+ },
30
+ infra: {
31
+ deployment: { target: 'minikube', monitoring: true },
32
+ database: { provider: 'supabase', tier: 'dev' },
33
+ storage: { provider: 'auto', buckets: ['media'] },
34
+ state: { provider: 'legend', persistence: 'local' },
35
+ networking: { domain: 'example.test', cdn: false },
36
+ modules: ['expo-localization'],
37
+ modulesConfig: { localization: { defaultLocale: 'en' } },
38
+ },
39
+ navigator: {
40
+ type: 'stack',
41
+ initialRouteName: 'home',
42
+ routes: [
43
+ {
44
+ name: 'home',
45
+ path: '/',
46
+ screenId: 'home',
47
+ showInPrimaryNavigation: true,
48
+ },
49
+ ],
50
+ },
51
+ screens: {
52
+ home: {
53
+ id: 'home',
54
+ name: 'Home',
55
+ root: {
56
+ id: 'root',
57
+ type: 'Stack',
58
+ children: [
59
+ {
60
+ id: 'title',
61
+ type: 'Text',
62
+ repeat: { source: { kind: 'state', path: 'items' }, itemAlias: 'item' },
63
+ },
64
+ ],
65
+ },
66
+ dataLoaders: [
67
+ {
68
+ kind: 'operation',
69
+ id: 'load-users',
70
+ operation: { dataSourceId: 'external', endpointId: 'main', operationId: 'list' },
71
+ },
72
+ ],
73
+ requires: {
74
+ permissions: [{ permission: 'camera' }],
75
+ capabilities: [{ capability: 'barcodeScanner' }],
76
+ },
77
+ },
78
+ },
79
+ generatedApis: {
80
+ users: {
81
+ id: 'users',
82
+ protocol: 'rest',
83
+ basePath: '/users',
84
+ database: { id: 'primary', kind: 'database' },
85
+ resources: [
86
+ {
87
+ id: 'users',
88
+ path: '/users',
89
+ collection: {
90
+ name: 'users',
91
+ fields: [{ name: 'id', type: 'uuid', required: true }],
92
+ primaryKey: 'id',
93
+ },
94
+ operations: ['list', 'read'],
95
+ },
96
+ ],
97
+ },
98
+ },
99
+ dataSources: {
100
+ external: {
101
+ id: 'external',
102
+ kind: 'api',
103
+ origin: 'external',
104
+ protocol: 'rest',
105
+ baseUrl: 'https://example.test',
106
+ endpoints: {
107
+ main: {
108
+ id: 'main',
109
+ kind: 'http',
110
+ operations: {
111
+ list: { id: 'list', protocol: 'rest', intent: 'read', path: '/users' },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ },
117
+ dataBindings: {
118
+ title: {
119
+ componentId: 'title',
120
+ props: { text: { source: { kind: 'state', path: 'title' } } },
121
+ events: {
122
+ press: [{ target: { kind: 'action', type: 'console' } }],
123
+ },
124
+ },
125
+ },
126
+ settings: {
127
+ apiBaseUrl: 'https://example.test/api',
128
+ localization: { defaultLocale: 'en', locales: ['en', 'de'] },
129
+ },
130
+ };
131
+ }
132
+
133
+ describe('AppManifest runtime parsing', () => {
134
+ it('accepts the canonical manifest including optional nested sections', () => {
135
+ const manifest = createManifest();
136
+
137
+ expect(isAppManifest(manifest)).toBe(true);
138
+ expect(parseAppManifest(manifest)).toEqual({ ok: true, manifest });
139
+ });
140
+
141
+ it('rejects missing required top-level sections', () => {
142
+ const manifest = createManifest();
143
+ delete manifest.settings;
144
+
145
+ expect(isAppManifest(manifest)).toBe(false);
146
+ });
147
+
148
+ it('rejects malformed nested canonical structures', () => {
149
+ const manifest = createManifest();
150
+ const themes = manifest.themes as Record<string, unknown>[];
151
+ const light = themes[0]?.light as Record<string, unknown>;
152
+ light.harmony = 'not-a-harmony';
153
+
154
+ expect(parseAppManifest(manifest)).toEqual({
155
+ ok: false,
156
+ message: 'Value is not a canonical AppManifest.',
157
+ });
158
+ });
159
+
160
+ it('rejects legacy infra plugin state', () => {
161
+ const manifest = createManifest();
162
+ const infra = manifest.infra as Record<string, unknown>;
163
+ infra.plugins = ['legacy'];
164
+
165
+ expect(isAppManifest(manifest)).toBe(false);
166
+ });
167
+
168
+ it('rejects screen registries whose key disagrees with screen id', () => {
169
+ const manifest = createManifest();
170
+ const screens = manifest.screens as Record<string, unknown>;
171
+ screens.other = screens.home;
172
+ delete screens.home;
173
+
174
+ expect(isAppManifest(manifest)).toBe(false);
175
+ });
176
+
177
+ it('rejects invalid data-binding source kinds', () => {
178
+ const manifest = createManifest();
179
+ const bindings = manifest.dataBindings as Record<string, Record<string, unknown>>;
180
+ const props = bindings.title?.props as Record<string, Record<string, unknown>>;
181
+ props.text = { source: { kind: 'provider-specific', path: 'title' } };
182
+
183
+ expect(isAppManifest(manifest)).toBe(false);
184
+ });
185
+ });
@@ -0,0 +1,86 @@
1
+ import { isComponentDataBindingRegistry } from './appManifest/bindings';
2
+ import { isDataSourceRegistry } from './appManifest/dataSources';
3
+ import { isGeneratedApiRegistry } from './appManifest/generatedApis';
4
+ import { isInfraManifest } from './appManifest/infra';
5
+ import {
6
+ isManifestMetadata,
7
+ isNavigatorSpec,
8
+ isScreenRegistry,
9
+ isSplashScreenSpec,
10
+ isThemeConfig,
11
+ } from './appManifest/screens';
12
+ import { isOptionalString, isRecord, isStringArray } from './appManifest/shared';
13
+ import type { AppManifest } from './types';
14
+
15
+ export type AppManifestParseResult =
16
+ | { readonly ok: true; readonly manifest: AppManifest }
17
+ | { readonly ok: false; readonly message: string };
18
+
19
+ const APP_MANIFEST_KEY_POLICY = {
20
+ metadata: 'required',
21
+ themes: 'required',
22
+ activeThemeId: 'required',
23
+ activeThemeMode: 'optional',
24
+ splashScreen: 'optional',
25
+ infra: 'required',
26
+ navigator: 'required',
27
+ screens: 'required',
28
+ generatedApis: 'optional',
29
+ dataSources: 'optional',
30
+ dataBindings: 'optional',
31
+ settings: 'required',
32
+ } as const satisfies Record<keyof AppManifest, 'optional' | 'required'>;
33
+
34
+ /**
35
+ * Parse unknown JSON-compatible input at the canonical AppManifest boundary.
36
+ *
37
+ * Contracts owns structural manifest validation. Consumers may add semantic
38
+ * diagnostics after this parser succeeds, but should not reconstruct the
39
+ * AppManifest shape in their own packages.
40
+ */
41
+ export function parseAppManifest(value: unknown): AppManifestParseResult {
42
+ return isAppManifest(value)
43
+ ? { ok: true, manifest: value }
44
+ : { ok: false, message: 'Value is not a canonical AppManifest.' };
45
+ }
46
+
47
+ /** Return whether an unknown value satisfies the canonical AppManifest shape. */
48
+ export function isAppManifest(value: unknown): value is AppManifest {
49
+ return (
50
+ isRecord(value) &&
51
+ hasRequiredManifestKeys(value) &&
52
+ isManifestMetadata(value.metadata) &&
53
+ Array.isArray(value.themes) &&
54
+ value.themes.every(isThemeConfig) &&
55
+ typeof value.activeThemeId === 'string' &&
56
+ isActiveThemeMode(value.activeThemeMode) &&
57
+ (value.splashScreen === undefined || isSplashScreenSpec(value.splashScreen)) &&
58
+ isInfraManifest(value.infra) &&
59
+ isNavigatorSpec(value.navigator) &&
60
+ isScreenRegistry(value.screens) &&
61
+ (value.generatedApis === undefined || isGeneratedApiRegistry(value.generatedApis)) &&
62
+ (value.dataSources === undefined || isDataSourceRegistry(value.dataSources)) &&
63
+ (value.dataBindings === undefined || isComponentDataBindingRegistry(value.dataBindings)) &&
64
+ isAppSettings(value.settings)
65
+ );
66
+ }
67
+
68
+ function hasRequiredManifestKeys(value: Record<string, unknown>): boolean {
69
+ return Object.entries(APP_MANIFEST_KEY_POLICY).every(
70
+ ([key, policy]) => policy === 'optional' || key in value,
71
+ );
72
+ }
73
+
74
+ function isActiveThemeMode(value: unknown): boolean {
75
+ return value === undefined || value === 'dark' || value === 'light';
76
+ }
77
+
78
+ function isAppSettings(value: unknown): boolean {
79
+ return (
80
+ isRecord(value) &&
81
+ isRecord(value.localization) &&
82
+ typeof value.localization.defaultLocale === 'string' &&
83
+ isStringArray(value.localization.locales) &&
84
+ isOptionalString(value.apiBaseUrl)
85
+ );
86
+ }
@@ -156,6 +156,46 @@ describe('contracts', () => {
156
156
  expect(theme.light.harmony).toBe('analogous');
157
157
  });
158
158
 
159
+ it('serializes authored global theme tokens and recipe override values', () => {
160
+ const theme: ThemeConfig = {
161
+ id: 'theme-default',
162
+ name: 'Default',
163
+ light: {
164
+ primaryColor: '#3366ff',
165
+ harmony: 'analogous',
166
+ },
167
+ dark: {
168
+ primaryColor: '#7799ff',
169
+ harmony: 'analogous',
170
+ },
171
+ tokens: {
172
+ spacing: { m: 18, l: 28 },
173
+ radii: { m: 10, l: 18 },
174
+ typography: {
175
+ sizes: { body: 16, lead: 20 },
176
+ weights: { body: '400', strong: '700' },
177
+ headings: {
178
+ h1: { size: 34, lineHeight: 42, weight: '700' },
179
+ },
180
+ },
181
+ shadows: { soft: 3 },
182
+ },
183
+ recipes: {
184
+ components: {
185
+ Card: { compact: true, padding: 'l', radius: 'm', tone: 'subtle' },
186
+ },
187
+ patterns: {
188
+ Panel: { compact: false, padding: 'xl' },
189
+ },
190
+ },
191
+ };
192
+
193
+ expect(JSON.parse(JSON.stringify(theme))).toEqual(theme);
194
+ expect(theme.tokens?.spacing?.m).toBe(18);
195
+ expect(theme.recipes?.components?.Card?.compact).toBe(true);
196
+ expect(theme.recipes?.patterns?.Panel?.padding).toBe('xl');
197
+ });
198
+
159
199
  it('accepts serializable splash screen branding on app manifests', () => {
160
200
  const splashScreen: SplashScreenSpec = {
161
201
  backgroundColor: '#ffffff',
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './appManifest';
1
2
  export * from './auth';
2
3
  export * from './bindings';
3
4
  export * from './cli';
@@ -9,5 +10,6 @@ export * from './secretManifest';
9
10
  export * from './secrets';
10
11
  export * from './state';
11
12
  export * from './storage';
13
+ export * from './theme';
12
14
  export * from './types';
13
15
  export * from './ui';
package/src/theme.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** Numeric token values authored at theme level. */
2
+ export type ThemeNumericTokenOverrides = Readonly<Record<string, number>>;
3
+
4
+ /** String token values authored at theme level. */
5
+ export type ThemeStringTokenOverrides = Readonly<Record<string, string>>;
6
+
7
+ /** Partial authored override for one semantic heading recipe. */
8
+ export interface ThemeTypographyHeadingOverrides {
9
+ readonly size?: number;
10
+ readonly lineHeight?: number;
11
+ readonly weight?: string;
12
+ }
13
+
14
+ /** Theme-global typography source overrides. Font installation remains module-owned. */
15
+ export interface ThemeTypographyTokenOverrides {
16
+ readonly headings?: Readonly<Record<string, ThemeTypographyHeadingOverrides>>;
17
+ readonly sizes?: ThemeNumericTokenOverrides;
18
+ readonly weights?: ThemeStringTokenOverrides;
19
+ }
20
+
21
+ /**
22
+ * Theme-global authored token overrides.
23
+ *
24
+ * Color source remains mode-specific on ThemeModeConfig. These values are shared by
25
+ * light and dark mode and are resolved by the render-theme owner rather than copied
26
+ * into every mode branch.
27
+ */
28
+ export interface ThemeGlobalTokenOverrides {
29
+ readonly spacing?: ThemeNumericTokenOverrides;
30
+ readonly radii?: ThemeNumericTokenOverrides;
31
+ readonly typography?: ThemeTypographyTokenOverrides;
32
+ readonly shadows?: ThemeNumericTokenOverrides;
33
+ }
34
+
35
+ /** Serializable value supported by the current theme-recipe metadata field kinds. */
36
+ export type ThemeRecipeOverrideValue = boolean | string;
37
+
38
+ /** Persisted values for one component or pattern recipe. */
39
+ export type ThemeRecipeFieldOverrides = Readonly<Record<string, ThemeRecipeOverrideValue>>;
40
+
41
+ /**
42
+ * Generic persisted recipe values.
43
+ *
44
+ * The owning UI package defines available recipes, field schemas, defaults and token
45
+ * relationships. Contracts stores selected values only and does not duplicate that metadata.
46
+ */
47
+ export interface ThemeRecipeOverrides {
48
+ readonly components?: Readonly<Record<string, ThemeRecipeFieldOverrides>>;
49
+ readonly patterns?: Readonly<Record<string, ThemeRecipeFieldOverrides>>;
50
+ }
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  } from './bindings';
9
9
  import type { DataSourceRegistry, GeneratedApiRegistry } from './data';
10
10
  import type { ScreenRequirements } from './requirements';
11
+ import type { ThemeGlobalTokenOverrides, ThemeRecipeOverrides } from './theme';
11
12
 
12
13
  export interface ThemeModeConfig {
13
14
  primaryColor: string;
@@ -19,6 +20,10 @@ export interface ThemeConfig {
19
20
  name: string;
20
21
  light: ThemeModeConfig;
21
22
  dark: ThemeModeConfig;
23
+ /** Theme-global authored token overrides shared by light and dark mode. */
24
+ tokens?: ThemeGlobalTokenOverrides;
25
+ /** Component/pattern recipe override values; recipe schemas remain package-owned metadata. */
26
+ recipes?: ThemeRecipeOverrides;
22
27
  }
23
28
 
24
29
  export type ActionType =