@ankhorage/contracts 9.0.0 → 10.1.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.
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { describe, expect, it } from 'bun:test';
5
+
6
+ import { isAppNavigatorManifest } from './appManifest/screens';
7
+ import {
8
+ type AppNavigatorManifest,
9
+ NAVIGATOR_PRESETS,
10
+ type TabsNavigatorConfig,
11
+ } from './navigator';
12
+
13
+ function createAdaptiveTabs(): AppNavigatorManifest {
14
+ return {
15
+ type: 'tabs',
16
+ preset: 'tabs-stack',
17
+ implementation: 'adaptive',
18
+ web: {
19
+ presentation: 'responsive',
20
+ responsive: {
21
+ compact: 'bottom',
22
+ medium: 'rail',
23
+ expanded: 'sidebar',
24
+ },
25
+ },
26
+ flows: { onboarding: true },
27
+ routes: [{ name: 'home', path: '/', screenId: 'home' }],
28
+ };
29
+ }
30
+
31
+ describe('app navigator manifest topology', () => {
32
+ it('keeps canonical topology presets finite and authorable', () => {
33
+ expect(NAVIGATOR_PRESETS).toContain('root-stack-tabs-stack');
34
+ expect(NAVIGATOR_PRESETS).toContain('root-stack-drawer-tabs-stack');
35
+ });
36
+
37
+ it('accepts adaptive native/Web tabs with responsive custom presentation', () => {
38
+ expect(isAppNavigatorManifest(createAdaptiveTabs())).toBe(true);
39
+ });
40
+
41
+ it('accepts SVG media icon references without a provider', () => {
42
+ const navigator = createAdaptiveTabs();
43
+ navigator.routes[0] = {
44
+ ...navigator.routes[0],
45
+ icon: { source: { mediaId: 'navigation-home' } },
46
+ };
47
+
48
+ expect(isAppNavigatorManifest(navigator)).toBe(true);
49
+ });
50
+
51
+ it('rejects icon definitions that mix named and media sources', () => {
52
+ const navigator = createAdaptiveTabs();
53
+ navigator.routes[0] = {
54
+ ...navigator.routes[0],
55
+ icon: {
56
+ name: 'home-outline',
57
+ source: { mediaId: 'navigation-home' },
58
+ } as never,
59
+ };
60
+
61
+ expect(isAppNavigatorManifest(navigator)).toBe(false);
62
+ });
63
+
64
+ it('treats omitted tabs implementation as the canonical adaptive default', () => {
65
+ const tabs: TabsNavigatorConfig = { type: 'tabs' };
66
+ const navigator: AppNavigatorManifest = { ...tabs, routes: [] };
67
+
68
+ expect(isAppNavigatorManifest(navigator)).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe('app navigator manifest custom presentation', () => {
73
+ it('accepts fixed and registered custom Web presentations', () => {
74
+ expect(
75
+ isAppNavigatorManifest({
76
+ type: 'tabs',
77
+ implementation: 'custom',
78
+ presentation: 'sidebar',
79
+ routes: [],
80
+ }),
81
+ ).toBe(true);
82
+
83
+ expect(
84
+ isAppNavigatorManifest({
85
+ type: 'tabs',
86
+ implementation: 'custom',
87
+ presentation: 'custom',
88
+ customPresentationId: 'workspace-tabs',
89
+ routes: [],
90
+ }),
91
+ ).toBe(true);
92
+ });
93
+
94
+ it('rejects incomplete responsive and custom presentation configuration', () => {
95
+ expect(
96
+ isAppNavigatorManifest({
97
+ type: 'tabs',
98
+ implementation: 'custom',
99
+ presentation: 'responsive',
100
+ routes: [],
101
+ }),
102
+ ).toBe(false);
103
+
104
+ expect(
105
+ isAppNavigatorManifest({
106
+ type: 'tabs',
107
+ implementation: 'custom',
108
+ presentation: 'custom',
109
+ routes: [],
110
+ }),
111
+ ).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('app navigator manifest composition', () => {
116
+ it('keeps nested topology separate from app-level flow metadata', () => {
117
+ expect(
118
+ isAppNavigatorManifest({
119
+ type: 'stack',
120
+ flows: { authentication: true },
121
+ routes: [
122
+ {
123
+ name: 'app',
124
+ navigator: {
125
+ type: 'tabs',
126
+ routes: [{ name: 'home', screenId: 'home' }],
127
+ },
128
+ },
129
+ ],
130
+ }),
131
+ ).toBe(true);
132
+ });
133
+
134
+ it('publishes the focused navigator contract subpath', async () => {
135
+ const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as {
136
+ exports?: Record<string, { default?: string; types?: string }>;
137
+ };
138
+
139
+ expect(packageJson.exports?.['./navigator']).toEqual({
140
+ types: './dist/navigator.d.ts',
141
+ default: './dist/navigator.js',
142
+ });
143
+ });
144
+ });
@@ -0,0 +1,136 @@
1
+ import type { IconSpec } from './types';
2
+
3
+ export const NAVIGATOR_TYPES = ['stack', 'tabs', 'drawer'] as const;
4
+ export type NavigatorType = (typeof NAVIGATOR_TYPES)[number];
5
+
6
+ export const NAVIGATOR_PRESETS = [
7
+ 'stack',
8
+ 'tabs',
9
+ 'tabs-stack',
10
+ 'drawer',
11
+ 'drawer-stack',
12
+ 'drawer-tabs',
13
+ 'drawer-tabs-stack',
14
+ 'root-stack-tabs',
15
+ 'root-stack-tabs-stack',
16
+ 'root-stack-drawer',
17
+ 'root-stack-drawer-stack',
18
+ 'root-stack-drawer-tabs',
19
+ 'root-stack-drawer-tabs-stack',
20
+ ] as const;
21
+ export type NavigatorPreset = (typeof NAVIGATOR_PRESETS)[number];
22
+
23
+ export const FIXED_CUSTOM_TABS_PRESENTATIONS = ['bottom', 'top', 'rail', 'sidebar'] as const;
24
+ export type FixedCustomTabsPresentation = (typeof FIXED_CUSTOM_TABS_PRESENTATIONS)[number];
25
+
26
+ export const CUSTOM_TABS_PRESENTATIONS = [
27
+ ...FIXED_CUSTOM_TABS_PRESENTATIONS,
28
+ 'responsive',
29
+ 'custom',
30
+ ] as const;
31
+ export type CustomTabsPresentation = (typeof CUSTOM_TABS_PRESENTATIONS)[number];
32
+
33
+ export const JAVASCRIPT_TABS_PRESENTATIONS = ['bottom', 'top'] as const;
34
+ export type JavaScriptTabsPresentation = (typeof JAVASCRIPT_TABS_PRESENTATIONS)[number];
35
+
36
+ export interface ResponsiveTabsPresentation {
37
+ compact: FixedCustomTabsPresentation;
38
+ medium?: FixedCustomTabsPresentation;
39
+ expanded: FixedCustomTabsPresentation;
40
+ }
41
+
42
+ export interface CustomTabsConfig {
43
+ implementation: 'custom';
44
+ presentation: CustomTabsPresentation;
45
+ responsive?: ResponsiveTabsPresentation;
46
+ /** Serializable registered presentation id used when `presentation` is `custom`. */
47
+ customPresentationId?: string;
48
+ }
49
+
50
+ export interface NativeTabsConfig {
51
+ implementation: 'native';
52
+ }
53
+
54
+ export interface JavaScriptTabsConfig {
55
+ implementation: 'javascript';
56
+ presentation?: JavaScriptTabsPresentation;
57
+ }
58
+
59
+ export interface AdaptiveTabsConfig {
60
+ /** Omission selects the canonical adaptive default. */
61
+ implementation?: 'adaptive';
62
+ /** Android/iOS branch. Expo Router may expose this implementation as unstable. */
63
+ native?: NativeTabsConfig;
64
+ /** Web branch rendered through headless custom tabs. */
65
+ web?: Omit<CustomTabsConfig, 'implementation'>;
66
+ }
67
+
68
+ export type TabsImplementationConfig =
69
+ AdaptiveTabsConfig | CustomTabsConfig | JavaScriptTabsConfig | NativeTabsConfig;
70
+
71
+ interface NavigatorNodeBase {
72
+ initialRouteName?: string;
73
+ routes: RouteDefinition[];
74
+ /** Typed upstream options can be layered by the owning Navigator package. */
75
+ options?: Record<string, unknown>;
76
+ }
77
+
78
+ export interface StackNavigatorNode extends NavigatorNodeBase {
79
+ type: 'stack';
80
+ }
81
+
82
+ export interface DrawerNavigatorNode extends NavigatorNodeBase {
83
+ type: 'drawer';
84
+ }
85
+
86
+ export type TabsNavigatorConfig = {
87
+ type: 'tabs';
88
+ } & TabsImplementationConfig;
89
+
90
+ export type TabsNavigatorNode = NavigatorNodeBase & TabsNavigatorConfig;
91
+
92
+ export type NavigatorNode = DrawerNavigatorNode | StackNavigatorNode | TabsNavigatorNode;
93
+
94
+ export interface RouteDefinition {
95
+ name: string;
96
+ path?: string;
97
+ label?: string;
98
+ icon?: IconSpec;
99
+ /** Hide this route from primary Tabs/Drawer presentation without making it unnavigable. */
100
+ showInPrimaryNavigation?: boolean;
101
+ guards?: string[];
102
+ screenId?: string;
103
+ navigator?: NavigatorNode;
104
+ }
105
+
106
+ export interface NavigatorFlows {
107
+ onboarding?: boolean;
108
+ authentication?: boolean;
109
+ }
110
+
111
+ export interface NavigatorDefaults {
112
+ tabs?: TabsImplementationConfig;
113
+ }
114
+
115
+ export interface NavigatorPlatformConfig {
116
+ tabs?: TabsImplementationConfig;
117
+ }
118
+
119
+ export interface NavigatorPlatforms {
120
+ android?: NavigatorPlatformConfig;
121
+ ios?: NavigatorPlatformConfig;
122
+ web?: NavigatorPlatformConfig;
123
+ }
124
+
125
+ /**
126
+ * Serializable desired state for `AppManifest.navigator`.
127
+ *
128
+ * The manifest slice is the root navigator tree itself; optional authoring metadata does not add
129
+ * a redundant `root` wrapper. The standalone Navigator capability consumes this slice directly.
130
+ */
131
+ export type AppNavigatorManifest = NavigatorNode & {
132
+ preset?: NavigatorPreset;
133
+ flows?: NavigatorFlows;
134
+ defaults?: NavigatorDefaults;
135
+ platforms?: NavigatorPlatforms;
136
+ };
package/src/types.ts CHANGED
@@ -8,7 +8,8 @@ import type {
8
8
  } from './bindings';
9
9
  import type { ApiDefinitionList, DataSourceRegistry } from './data';
10
10
  import type { AppDeployManifest } from './deploy';
11
- import type { MediaManifest } from './media';
11
+ import type { MediaAssetReference, MediaManifest } from './media';
12
+ import type { AppNavigatorManifest } from './navigator';
12
13
  import type { RepositoryManifest } from './repository';
13
14
  import type { ScreenRequirements } from './requirements';
14
15
  import type { ThemeGlobalTokenOverrides, ThemeRecipeOverrides } from './theme';
@@ -135,9 +136,6 @@ export type ComponentEventDtoKind =
135
136
  export type KnownComponentEventDto =
136
137
  ButtonPressEventDto | CollectionItemPressEventDto | FormSubmitEventDto;
137
138
 
138
- export const NAVIGATOR_TYPES = ['stack', 'tabs', 'drawer'] as const;
139
- export type NavigatorType = (typeof NAVIGATOR_TYPES)[number];
140
-
141
139
  export const APP_CATEGORIES = [
142
140
  'books_reading',
143
141
  'business_productivity',
@@ -224,13 +222,25 @@ export type AuthProfileCreateStrategy = (typeof AUTH_PROFILE_CREATE_STRATEGIES)[
224
222
  export const AUTH_PROFILE_UPDATE_STRATEGIES = ['api', 'app'] as const;
225
223
  export type AuthProfileUpdateStrategy = (typeof AUTH_PROFILE_UPDATE_STRATEGIES)[number];
226
224
 
227
- export interface IconSpec {
228
- name: string;
229
- provider?: string;
225
+ interface IconPresentationSpec {
230
226
  size?: number | string;
231
227
  color?: string;
232
228
  }
233
229
 
230
+ export interface NamedIconSpec extends IconPresentationSpec {
231
+ name: string;
232
+ provider?: string;
233
+ source?: never;
234
+ }
235
+
236
+ export interface SvgIconSpec extends IconPresentationSpec {
237
+ source: MediaAssetReference;
238
+ name?: never;
239
+ provider?: never;
240
+ }
241
+
242
+ export type IconSpec = NamedIconSpec | SvgIconSpec;
243
+
234
244
  export interface UiNodeRepeatSpec {
235
245
  source: BindingValueSource;
236
246
  itemAlias?: string;
@@ -258,31 +268,6 @@ export interface ScreenSpec {
258
268
  requires?: ScreenRequirements;
259
269
  }
260
270
 
261
- export interface NavigatorSpec {
262
- type: NavigatorType;
263
- initialRouteName?: string;
264
- routes: RouteDefinition[];
265
- options?: Record<string, unknown>;
266
- }
267
-
268
- export interface RouteDefinition {
269
- name: string;
270
- path?: string;
271
- label?: string;
272
- icon?: IconSpec;
273
- /**
274
- * Whether this route appears in Tabs and Drawer primary navigation.
275
- *
276
- * Omitted routes are visible by default. Setting this to `false` hides the
277
- * route from primary navigation without making it unnavigable. Stack
278
- * navigators preserve the value but do not present primary navigation.
279
- */
280
- showInPrimaryNavigation?: boolean;
281
- guards?: string[];
282
- screenId?: string;
283
- navigator?: NavigatorSpec;
284
- }
285
-
286
271
  export type SplashScreenResizeMode = 'contain' | 'cover' | 'native';
287
272
 
288
273
  export interface SplashScreenAssetSpec {
@@ -396,7 +381,7 @@ export interface AppManifest {
396
381
  /** App distribution desired state. Infrastructure deployment remains under `infra.deployment`. */
397
382
  deploy?: AppDeployManifest;
398
383
  infra: InfraManifest;
399
- navigator: NavigatorSpec;
384
+ navigator: AppNavigatorManifest;
400
385
  screens: Record<string, ScreenSpec>;
401
386
  dataSources?: DataSourceRegistry;
402
387
  dataBindings?: ComponentDataBindingRegistry;