@astryxdesign/native 0.0.0-bootstrap.1 → 0.7.4-canary.720bb10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -1
- package/apple/Package.swift +18 -0
- package/apple/Sources/AstryxNative/Generated/AstryxBase.swift +12 -0
- package/apple/Sources/AstryxNative/Generated/Selector.swift +159 -0
- package/apple/Tests/AstryxNativeTests/SelectorPropsTests.swift +17 -0
- package/dist/components/Selector.d.ts +10 -0
- package/dist/components/Selector.d.ts.map +1 -0
- package/dist/components/Selector.js +38 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +3 -0
- package/dist/contract/fromComponentDoc.d.ts +32 -0
- package/dist/contract/fromComponentDoc.d.ts.map +1 -0
- package/dist/contract/fromComponentDoc.js +81 -0
- package/dist/contract/generateSwift.d.ts +4 -0
- package/dist/contract/generateSwift.d.ts.map +1 -0
- package/dist/contract/generateSwift.js +133 -0
- package/dist/contract/generateTypeScript.d.ts +4 -0
- package/dist/contract/generateTypeScript.d.ts.map +1 -0
- package/dist/contract/generateTypeScript.js +87 -0
- package/dist/contract/index.d.ts +20 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +33 -0
- package/dist/contract/names.d.ts +20 -0
- package/dist/contract/names.d.ts.map +1 -0
- package/dist/contract/names.js +90 -0
- package/dist/contract/parseDocType.d.ts +22 -0
- package/dist/contract/parseDocType.d.ts.map +1 -0
- package/dist/contract/parseDocType.js +183 -0
- package/dist/contract/rules.d.ts +10 -0
- package/dist/contract/rules.d.ts.map +1 -0
- package/dist/contract/rules.js +87 -0
- package/dist/contract/types.d.ts +57 -0
- package/dist/contract/types.d.ts.map +1 -0
- package/dist/contract/types.js +2 -0
- package/dist/icons/defaults.d.ts +25 -0
- package/dist/icons/defaults.d.ts.map +1 -0
- package/dist/icons/defaults.js +111 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/platforms.d.ts +13 -0
- package/dist/platforms.d.ts.map +1 -0
- package/dist/platforms.js +14 -0
- package/dist/theme/defineNativeTheme.d.ts +75 -0
- package/dist/theme/defineNativeTheme.d.ts.map +1 -0
- package/dist/theme/defineNativeTheme.js +126 -0
- package/dist/types/android.d.ts +8 -0
- package/dist/types/core.d.ts +35 -0
- package/dist/types/ios.d.ts +8 -0
- package/dist/types/macos.d.ts +8 -0
- package/dist/types/shared.d.ts +6 -0
- package/dist/types/windows.d.ts +6 -0
- package/package.json +72 -5
- package/src/components/Selector.ts +50 -0
- package/src/components/index.ts +14 -0
- package/src/contract/contracts.test.ts +150 -0
- package/src/contract/fromComponentDoc.ts +131 -0
- package/src/contract/generateSwift.ts +170 -0
- package/src/contract/generateTypeScript.ts +115 -0
- package/src/contract/index.ts +53 -0
- package/src/contract/names.ts +103 -0
- package/src/contract/parseDocType.test.ts +52 -0
- package/src/contract/parseDocType.ts +211 -0
- package/src/contract/rules.ts +132 -0
- package/src/contract/types.ts +52 -0
- package/src/icons/defaults.test.ts +47 -0
- package/src/icons/defaults.ts +136 -0
- package/src/index.ts +35 -0
- package/src/platforms.ts +20 -0
- package/src/theme/defineNativeTheme.test.ts +88 -0
- package/src/theme/defineNativeTheme.ts +224 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file contract/types.ts
|
|
5
|
+
* @input None (pure type definitions)
|
|
6
|
+
* @output The contract model: portable prop types, prop specs, and component contracts
|
|
7
|
+
* @position Foundation of the contract generator; parseDocType produces these,
|
|
8
|
+
* generators consume them
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {NativePlatform} from '../platforms.js';
|
|
12
|
+
|
|
13
|
+
/** A prop type every supported platform language can express (AST-082 FR4). */
|
|
14
|
+
export type PropType =
|
|
15
|
+
| {kind: 'string'}
|
|
16
|
+
| {kind: 'number'}
|
|
17
|
+
| {kind: 'boolean'}
|
|
18
|
+
| {kind: 'enum'; values: readonly string[]}
|
|
19
|
+
| {kind: 'array'; of: PropType}
|
|
20
|
+
| {kind: 'record'; fields: Readonly<Record<string, PropSpec>>}
|
|
21
|
+
| {kind: 'fn'; args: readonly PropType[]}
|
|
22
|
+
| {kind: 'named'; name: string};
|
|
23
|
+
|
|
24
|
+
export interface PropSpec {
|
|
25
|
+
readonly type: PropType;
|
|
26
|
+
readonly optional?: boolean;
|
|
27
|
+
readonly doc?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PropMap = Readonly<Record<string, PropSpec>>;
|
|
31
|
+
|
|
32
|
+
/** The part of a component its base props attach to (AST-082 FR7). */
|
|
33
|
+
export type BaseTarget = 'root' | 'trigger' | 'control' | 'container';
|
|
34
|
+
|
|
35
|
+
/** A web prop left out of the core contract, with the reason. */
|
|
36
|
+
export interface WebOnlyProp {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ComponentContract {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly baseTarget: BaseTarget;
|
|
44
|
+
/** Named record types the contract references, such as an option shape. */
|
|
45
|
+
readonly types: Readonly<Record<string, PropMap>>;
|
|
46
|
+
/** Shared on every platform that offers the component. */
|
|
47
|
+
readonly core: PropMap;
|
|
48
|
+
/** Added per native platform; never removes or renames a core prop. */
|
|
49
|
+
readonly platforms: Readonly<Partial<Record<NativePlatform, PropMap>>>;
|
|
50
|
+
/** Core-doc props that stay in the web contract. */
|
|
51
|
+
readonly webOnly: readonly WebOnlyProp[];
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {describe, expect, it} from 'vitest';
|
|
6
|
+
import {readCoreIconNames} from '../../scripts/core-docs.mjs';
|
|
7
|
+
import {
|
|
8
|
+
BUILTIN_ICON_NAMES,
|
|
9
|
+
NATIVE_ICON_DEFAULTS,
|
|
10
|
+
SF_SYMBOL_NAMES,
|
|
11
|
+
} from './defaults.js';
|
|
12
|
+
|
|
13
|
+
// The system's SF Symbols catalog; present on macOS only.
|
|
14
|
+
const SF_CATALOG =
|
|
15
|
+
'/System/Library/CoreServices/CoreGlyphs.bundle/Contents/Resources/name_availability.plist';
|
|
16
|
+
|
|
17
|
+
describe('native icon defaults', () => {
|
|
18
|
+
it("lists exactly core's built-in icon names", () => {
|
|
19
|
+
expect([...BUILTIN_ICON_NAMES].sort()).toEqual(readCoreIconNames().sort());
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('gives every built-in name a default on each supported platform', () => {
|
|
23
|
+
for (const platform of ['ios', 'macos', 'android'] as const) {
|
|
24
|
+
expect(Object.keys(NATIVE_ICON_DEFAULTS[platform]).sort()).toEqual(
|
|
25
|
+
[...BUILTIN_ICON_NAMES].sort(),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.skipIf(!fs.existsSync(SF_CATALOG))(
|
|
31
|
+
'names only SF Symbols the system has',
|
|
32
|
+
() => {
|
|
33
|
+
const json = execFileSync(
|
|
34
|
+
'plutil',
|
|
35
|
+
['-convert', 'json', '-o', '-', SF_CATALOG],
|
|
36
|
+
{
|
|
37
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
38
|
+
},
|
|
39
|
+
).toString();
|
|
40
|
+
const symbols = JSON.parse(json).symbols as Record<string, string>;
|
|
41
|
+
const missing = Object.values(SF_SYMBOL_NAMES).filter(
|
|
42
|
+
name => symbols[name] == null,
|
|
43
|
+
);
|
|
44
|
+
expect(missing).toEqual([]);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file icons/defaults.ts
|
|
5
|
+
* @input None
|
|
6
|
+
* @output Shared icon names and each native platform's default system symbol
|
|
7
|
+
* @position Native icon defaults (AST-082 FR17, DEC-10). An icon the app or
|
|
8
|
+
* theme defines replaces these
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {PlatformIcon} from '../theme/defineNativeTheme.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Astryx's built-in icon names. Until core's portable entry carries them,
|
|
15
|
+
* a test keeps this list equal to core's `IconName` union.
|
|
16
|
+
*/
|
|
17
|
+
export const BUILTIN_ICON_NAMES = [
|
|
18
|
+
'close',
|
|
19
|
+
'chevronDown',
|
|
20
|
+
'chevronLeft',
|
|
21
|
+
'chevronRight',
|
|
22
|
+
'chevronsLeft',
|
|
23
|
+
'chevronsRight',
|
|
24
|
+
'check',
|
|
25
|
+
'success',
|
|
26
|
+
'error',
|
|
27
|
+
'warning',
|
|
28
|
+
'info',
|
|
29
|
+
'calendar',
|
|
30
|
+
'clock',
|
|
31
|
+
'externalLink',
|
|
32
|
+
'menu',
|
|
33
|
+
'moreHorizontal',
|
|
34
|
+
'search',
|
|
35
|
+
'upload',
|
|
36
|
+
'arrowUp',
|
|
37
|
+
'arrowDown',
|
|
38
|
+
'arrowsUpDown',
|
|
39
|
+
'funnel',
|
|
40
|
+
'eyeSlash',
|
|
41
|
+
'viewColumns',
|
|
42
|
+
'copy',
|
|
43
|
+
'checkDouble',
|
|
44
|
+
'wrench',
|
|
45
|
+
'stop',
|
|
46
|
+
'microphone',
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
export type BuiltinIconName = (typeof BUILTIN_ICON_NAMES)[number];
|
|
50
|
+
|
|
51
|
+
/** SF Symbols, shared by iOS and macOS. */
|
|
52
|
+
const SF_SYMBOLS: Record<BuiltinIconName, string> = {
|
|
53
|
+
close: 'xmark',
|
|
54
|
+
chevronDown: 'chevron.down',
|
|
55
|
+
chevronLeft: 'chevron.left',
|
|
56
|
+
chevronRight: 'chevron.right',
|
|
57
|
+
chevronsLeft: 'chevron.left.2',
|
|
58
|
+
chevronsRight: 'chevron.right.2',
|
|
59
|
+
check: 'checkmark',
|
|
60
|
+
success: 'checkmark.circle.fill',
|
|
61
|
+
error: 'exclamationmark.circle.fill',
|
|
62
|
+
warning: 'exclamationmark.triangle.fill',
|
|
63
|
+
info: 'info.circle.fill',
|
|
64
|
+
calendar: 'calendar',
|
|
65
|
+
clock: 'clock',
|
|
66
|
+
externalLink: 'arrow.up.right.square',
|
|
67
|
+
menu: 'line.3.horizontal',
|
|
68
|
+
moreHorizontal: 'ellipsis',
|
|
69
|
+
search: 'magnifyingglass',
|
|
70
|
+
upload: 'square.and.arrow.up',
|
|
71
|
+
arrowUp: 'arrow.up',
|
|
72
|
+
arrowDown: 'arrow.down',
|
|
73
|
+
arrowsUpDown: 'arrow.up.arrow.down',
|
|
74
|
+
funnel: 'line.3.horizontal.decrease',
|
|
75
|
+
eyeSlash: 'eye.slash',
|
|
76
|
+
viewColumns: 'rectangle.split.3x1',
|
|
77
|
+
copy: 'doc.on.doc',
|
|
78
|
+
checkDouble: 'checkmark.message',
|
|
79
|
+
wrench: 'wrench.adjustable',
|
|
80
|
+
stop: 'stop.fill',
|
|
81
|
+
microphone: 'mic',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Material Symbols names for Android. */
|
|
85
|
+
const MATERIAL_SYMBOLS: Record<BuiltinIconName, string> = {
|
|
86
|
+
close: 'close',
|
|
87
|
+
chevronDown: 'expand_more',
|
|
88
|
+
chevronLeft: 'chevron_left',
|
|
89
|
+
chevronRight: 'chevron_right',
|
|
90
|
+
chevronsLeft: 'keyboard_double_arrow_left',
|
|
91
|
+
chevronsRight: 'keyboard_double_arrow_right',
|
|
92
|
+
check: 'check',
|
|
93
|
+
success: 'check_circle',
|
|
94
|
+
error: 'error',
|
|
95
|
+
warning: 'warning',
|
|
96
|
+
info: 'info',
|
|
97
|
+
calendar: 'calendar_today',
|
|
98
|
+
clock: 'schedule',
|
|
99
|
+
externalLink: 'open_in_new',
|
|
100
|
+
menu: 'menu',
|
|
101
|
+
moreHorizontal: 'more_horiz',
|
|
102
|
+
search: 'search',
|
|
103
|
+
upload: 'upload',
|
|
104
|
+
arrowUp: 'arrow_upward',
|
|
105
|
+
arrowDown: 'arrow_downward',
|
|
106
|
+
arrowsUpDown: 'swap_vert',
|
|
107
|
+
funnel: 'filter_alt',
|
|
108
|
+
eyeSlash: 'visibility_off',
|
|
109
|
+
viewColumns: 'view_column',
|
|
110
|
+
copy: 'content_copy',
|
|
111
|
+
checkDouble: 'done_all',
|
|
112
|
+
wrench: 'build',
|
|
113
|
+
stop: 'stop',
|
|
114
|
+
microphone: 'mic',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const mapIcons = (
|
|
118
|
+
names: Record<BuiltinIconName, string>,
|
|
119
|
+
make: (name: string) => PlatformIcon,
|
|
120
|
+
): Record<BuiltinIconName, PlatformIcon> =>
|
|
121
|
+
Object.fromEntries(
|
|
122
|
+
BUILTIN_ICON_NAMES.map(name => [name, make(names[name])]),
|
|
123
|
+
) as Record<BuiltinIconName, PlatformIcon>;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Default icons per native platform. Windows has no map yet, so it is not a
|
|
127
|
+
* supported platform for icons (FR17).
|
|
128
|
+
*/
|
|
129
|
+
export const NATIVE_ICON_DEFAULTS = {
|
|
130
|
+
ios: mapIcons(SF_SYMBOLS, systemName => ({systemName})),
|
|
131
|
+
macos: mapIcons(SF_SYMBOLS, systemName => ({systemName})),
|
|
132
|
+
android: mapIcons(MATERIAL_SYMBOLS, materialSymbol => ({materialSymbol})),
|
|
133
|
+
} as const;
|
|
134
|
+
|
|
135
|
+
export const SF_SYMBOL_NAMES: Readonly<Record<BuiltinIconName, string>> =
|
|
136
|
+
SF_SYMBOLS;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file index.ts
|
|
5
|
+
* @input None
|
|
6
|
+
* @output The run-time API of @astryxdesign/native: platforms, native theme
|
|
7
|
+
* configuration, and native icon defaults
|
|
8
|
+
* @position Package root. Experimental and canary-only (AST-082 FR15); component
|
|
9
|
+
* types live at `@astryxdesign/native/components`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
NATIVE_PLATFORMS,
|
|
14
|
+
SHARED_CONDITION,
|
|
15
|
+
isNativePlatform,
|
|
16
|
+
} from './platforms.js';
|
|
17
|
+
export type {NativePlatform} from './platforms.js';
|
|
18
|
+
export {
|
|
19
|
+
compareVersions,
|
|
20
|
+
defineNativeTheme,
|
|
21
|
+
resolveNativeTheme,
|
|
22
|
+
} from './theme/defineNativeTheme.js';
|
|
23
|
+
export type {
|
|
24
|
+
DefineNativeThemeOptions,
|
|
25
|
+
DurableMaterial,
|
|
26
|
+
NativeTheme,
|
|
27
|
+
NativeThemeConfig,
|
|
28
|
+
PlatformEntry,
|
|
29
|
+
PlatformIcon,
|
|
30
|
+
PlatformVocabulary,
|
|
31
|
+
PortableThemeLike,
|
|
32
|
+
ResolvedNativeTheme,
|
|
33
|
+
} from './theme/defineNativeTheme.js';
|
|
34
|
+
export {BUILTIN_ICON_NAMES, NATIVE_ICON_DEFAULTS} from './icons/defaults.js';
|
|
35
|
+
export type {BuiltinIconName} from './icons/defaults.js';
|
package/src/platforms.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file platforms.ts
|
|
5
|
+
* @input None
|
|
6
|
+
* @output The native platform names shared by contracts, types, themes, and icons
|
|
7
|
+
* @position Foundation; every other module keys platform data by these names
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Native platforms, in the order AST-082 lists them. */
|
|
11
|
+
export const NATIVE_PLATFORMS = ['ios', 'android', 'macos', 'windows'] as const;
|
|
12
|
+
|
|
13
|
+
export type NativePlatform = (typeof NATIVE_PLATFORMS)[number];
|
|
14
|
+
|
|
15
|
+
/** Type-resolution condition that exposes only core contracts (AST-082 FR6). */
|
|
16
|
+
export const SHARED_CONDITION = 'shared';
|
|
17
|
+
|
|
18
|
+
export function isNativePlatform(value: string): value is NativePlatform {
|
|
19
|
+
return (NATIVE_PLATFORMS as readonly string[]).includes(value);
|
|
20
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
compareVersions,
|
|
6
|
+
defineNativeTheme,
|
|
7
|
+
resolveNativeTheme,
|
|
8
|
+
} from './defineNativeTheme.js';
|
|
9
|
+
|
|
10
|
+
const theme = {name: 'brand', tokens: {'--color-accent': '#0064e0'}};
|
|
11
|
+
|
|
12
|
+
describe('defineNativeTheme', () => {
|
|
13
|
+
it('scopes a version-specific material and resolves it per OS version', () => {
|
|
14
|
+
const native = defineNativeTheme(
|
|
15
|
+
theme,
|
|
16
|
+
{
|
|
17
|
+
material: 'translucent',
|
|
18
|
+
platforms: {
|
|
19
|
+
ios: [{minVersion: '26', material: 'liquidGlass'}],
|
|
20
|
+
android: [{material: 'opaque'}],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{onReport: () => {}},
|
|
24
|
+
);
|
|
25
|
+
expect(
|
|
26
|
+
resolveNativeTheme(native, {platform: 'ios', osVersion: '26.1'}).material,
|
|
27
|
+
).toBe('liquidGlass');
|
|
28
|
+
expect(
|
|
29
|
+
resolveNativeTheme(native, {platform: 'ios', osVersion: '18.6'}).material,
|
|
30
|
+
).toBe('translucent');
|
|
31
|
+
expect(
|
|
32
|
+
resolveNativeTheme(native, {platform: 'android', osVersion: '16'})
|
|
33
|
+
.material,
|
|
34
|
+
).toBe('opaque');
|
|
35
|
+
expect(
|
|
36
|
+
resolveNativeTheme(native, {platform: 'windows', osVersion: '11'})
|
|
37
|
+
.material,
|
|
38
|
+
).toBe('translucent');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects a platform feature outside the versions that have it', () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
defineNativeTheme(
|
|
44
|
+
theme,
|
|
45
|
+
{platforms: {ios: [{material: 'liquidGlass'}]}},
|
|
46
|
+
{onReport: () => {}},
|
|
47
|
+
),
|
|
48
|
+
).toThrow(/liquidGlass.*minVersion: '26'/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reports web-only theme parts and keeps only the portable part', () => {
|
|
52
|
+
const report = vi.fn();
|
|
53
|
+
const webTheme = {
|
|
54
|
+
...theme,
|
|
55
|
+
components: {Button: {root: {}}},
|
|
56
|
+
icons: {},
|
|
57
|
+
__onDark: {x: 1},
|
|
58
|
+
};
|
|
59
|
+
const native = defineNativeTheme(webTheme, {}, {onReport: report});
|
|
60
|
+
expect(report.mock.calls.map(call => String(call[0]))).toEqual([
|
|
61
|
+
expect.stringContaining('"components" is web-only'),
|
|
62
|
+
expect.stringContaining('"onDark" is web-only'),
|
|
63
|
+
]);
|
|
64
|
+
expect(native.theme).toEqual(theme);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('extends another native theme: durable members merge, platform lists replace', () => {
|
|
68
|
+
const base = defineNativeTheme(theme, {
|
|
69
|
+
material: 'opaque',
|
|
70
|
+
platforms: {android: [{material: 'translucent'}]},
|
|
71
|
+
});
|
|
72
|
+
const child = defineNativeTheme(
|
|
73
|
+
theme,
|
|
74
|
+
{platforms: {ios: [{material: 'opaque'}]}},
|
|
75
|
+
{extends: base},
|
|
76
|
+
);
|
|
77
|
+
expect(child.config.material).toBe('opaque');
|
|
78
|
+
expect(child.config.platforms?.android).toEqual([
|
|
79
|
+
{material: 'translucent'},
|
|
80
|
+
]);
|
|
81
|
+
expect(child.config.platforms?.ios).toEqual([{material: 'opaque'}]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('compares dotted versions numerically', () => {
|
|
85
|
+
expect(compareVersions('26', '26.0.1')).toBeLessThan(0);
|
|
86
|
+
expect(compareVersions('10.15', '10.9')).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file theme/defineNativeTheme.ts
|
|
5
|
+
* @input A defined theme (its portable part) and native theme configuration
|
|
6
|
+
* @output A native theme, and its resolution for one platform and OS version
|
|
7
|
+
* @position Native theme configuration (AST-082 FR10, FR18). Builds on the
|
|
8
|
+
* defined theme without changing it and never sets tokens
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {NATIVE_PLATFORMS, type NativePlatform} from '../platforms.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The portable part of a defined theme (AST-082 FR18). A core `DefinedTheme`
|
|
15
|
+
* satisfies it structurally, so native never imports core to accept one.
|
|
16
|
+
*/
|
|
17
|
+
export interface PortableThemeLike {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly tokens: Readonly<Record<string, string>>;
|
|
20
|
+
readonly localTokens?: Readonly<Record<string, string>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Durable outcomes a person sees; no platform feature names (DEC-5). */
|
|
24
|
+
export type DurableMaterial = 'opaque' | 'translucent';
|
|
25
|
+
|
|
26
|
+
export interface PlatformVocabulary {
|
|
27
|
+
ios: {material: DurableMaterial | 'liquidGlass'};
|
|
28
|
+
macos: {material: DurableMaterial | 'liquidGlass'};
|
|
29
|
+
android: {material: DurableMaterial};
|
|
30
|
+
windows: {material: DurableMaterial | 'mica' | 'acrylic'};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Platform icon reference, as data (AST-082 FR17). */
|
|
34
|
+
export type PlatformIcon =
|
|
35
|
+
{readonly systemName: string} | {readonly materialSymbol: string};
|
|
36
|
+
|
|
37
|
+
export type PlatformEntry<P extends NativePlatform> = Partial<
|
|
38
|
+
PlatformVocabulary[P]
|
|
39
|
+
> & {
|
|
40
|
+
readonly minVersion?: string;
|
|
41
|
+
readonly maxVersion?: string;
|
|
42
|
+
/** Overrides for this platform's theme targets, keyed by target. */
|
|
43
|
+
readonly components?: Readonly<
|
|
44
|
+
Record<string, Readonly<Record<string, string | number>>>
|
|
45
|
+
>;
|
|
46
|
+
readonly icons?: Readonly<Record<string, PlatformIcon>>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface NativeThemeConfig {
|
|
50
|
+
readonly material?: DurableMaterial;
|
|
51
|
+
readonly platforms?: {
|
|
52
|
+
readonly [P in NativePlatform]?: ReadonlyArray<PlatformEntry<P>>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NativeTheme {
|
|
57
|
+
readonly theme: PortableThemeLike;
|
|
58
|
+
readonly config: NativeThemeConfig;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DefineNativeThemeOptions {
|
|
62
|
+
/** Native theme to extend: durable members merge, platform entry lists replace. */
|
|
63
|
+
readonly extends?: NativeTheme;
|
|
64
|
+
/** Receives each report; defaults to console.warn outside production. */
|
|
65
|
+
readonly onReport?: (message: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Platform terms that exist only from a given OS version. */
|
|
69
|
+
const FEATURE_FLOORS: Readonly<
|
|
70
|
+
Record<string, Readonly<Record<string, string>>>
|
|
71
|
+
> = {
|
|
72
|
+
ios: {liquidGlass: '26'},
|
|
73
|
+
macos: {liquidGlass: '26'},
|
|
74
|
+
windows: {mica: '11'},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Web-only parts of a defined theme that have no native meaning (FR18). */
|
|
78
|
+
const WEB_PARTS = [
|
|
79
|
+
'components',
|
|
80
|
+
'icons',
|
|
81
|
+
'indicators',
|
|
82
|
+
'__onDark',
|
|
83
|
+
'__onLight',
|
|
84
|
+
'__adaptations',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export function compareVersions(a: string, b: string): number {
|
|
88
|
+
const pa = a.split('.').map(Number);
|
|
89
|
+
const pb = b.split('.').map(Number);
|
|
90
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
91
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
92
|
+
if (diff !== 0) {
|
|
93
|
+
return diff;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasContent(value: unknown): boolean {
|
|
100
|
+
if (value == null) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
return value.length > 0;
|
|
105
|
+
}
|
|
106
|
+
if (typeof value === 'object') {
|
|
107
|
+
return Object.keys(value).length > 0;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validate(config: NativeThemeConfig): string[] {
|
|
113
|
+
const errors: string[] = [];
|
|
114
|
+
for (const platform of NATIVE_PLATFORMS) {
|
|
115
|
+
for (const entry of config.platforms?.[platform] ?? []) {
|
|
116
|
+
const material = (entry as {material?: string}).material;
|
|
117
|
+
const floor = material ? FEATURE_FLOORS[platform]?.[material] : undefined;
|
|
118
|
+
if (
|
|
119
|
+
floor &&
|
|
120
|
+
(entry.minVersion == null ||
|
|
121
|
+
compareVersions(entry.minVersion, floor) < 0)
|
|
122
|
+
) {
|
|
123
|
+
errors.push(
|
|
124
|
+
`${platform} material "${material}" exists only from ${platform} ${floor}; ` +
|
|
125
|
+
`scope its entry with minVersion: '${floor}' or later`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
entry.minVersion &&
|
|
130
|
+
entry.maxVersion &&
|
|
131
|
+
compareVersions(entry.minVersion, entry.maxVersion) > 0
|
|
132
|
+
) {
|
|
133
|
+
errors.push(`${platform} entry has minVersion above maxVersion`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return errors;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function defineNativeTheme(
|
|
141
|
+
theme: PortableThemeLike,
|
|
142
|
+
config: NativeThemeConfig,
|
|
143
|
+
options: DefineNativeThemeOptions = {},
|
|
144
|
+
): NativeTheme {
|
|
145
|
+
const merged: NativeThemeConfig = options.extends
|
|
146
|
+
? {
|
|
147
|
+
...options.extends.config,
|
|
148
|
+
...config,
|
|
149
|
+
platforms: {...options.extends.config.platforms, ...config.platforms},
|
|
150
|
+
}
|
|
151
|
+
: config;
|
|
152
|
+
|
|
153
|
+
const errors = validate(merged);
|
|
154
|
+
if (errors.length > 0) {
|
|
155
|
+
throw new Error(`defineNativeTheme(${theme.name}): ${errors.join('; ')}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const report =
|
|
159
|
+
options.onReport ??
|
|
160
|
+
((message: string) => {
|
|
161
|
+
const env =
|
|
162
|
+
typeof process === 'undefined' ? undefined : process.env?.NODE_ENV;
|
|
163
|
+
if (env !== 'production') {
|
|
164
|
+
console.warn(message);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
const received = theme as unknown as Record<string, unknown>;
|
|
168
|
+
for (const part of WEB_PARTS) {
|
|
169
|
+
if (hasContent(received[part])) {
|
|
170
|
+
report(
|
|
171
|
+
`defineNativeTheme(${theme.name}): "${part.replace(/^__/, '')}" is web-only and has ` +
|
|
172
|
+
'no native meaning; use native platform entries instead',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const portable: PortableThemeLike = {
|
|
178
|
+
name: theme.name,
|
|
179
|
+
tokens: theme.tokens,
|
|
180
|
+
...(theme.localTokens ? {localTokens: theme.localTokens} : {}),
|
|
181
|
+
};
|
|
182
|
+
return {theme: portable, config: merged};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface ResolvedNativeTheme {
|
|
186
|
+
readonly material?: string;
|
|
187
|
+
readonly components: Readonly<
|
|
188
|
+
Record<string, Readonly<Record<string, string | number>>>
|
|
189
|
+
>;
|
|
190
|
+
readonly icons: Readonly<Record<string, PlatformIcon>>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Applies durable intent, then each matching platform entry in order. */
|
|
194
|
+
export function resolveNativeTheme(
|
|
195
|
+
nativeTheme: NativeTheme,
|
|
196
|
+
target: {readonly platform: NativePlatform; readonly osVersion: string},
|
|
197
|
+
): ResolvedNativeTheme {
|
|
198
|
+
let resolved: ResolvedNativeTheme = {
|
|
199
|
+
...(nativeTheme.config.material
|
|
200
|
+
? {material: nativeTheme.config.material}
|
|
201
|
+
: {}),
|
|
202
|
+
components: {},
|
|
203
|
+
icons: {},
|
|
204
|
+
};
|
|
205
|
+
const entries = (nativeTheme.config.platforms?.[target.platform] ??
|
|
206
|
+
[]) as ReadonlyArray<PlatformEntry<NativePlatform>>;
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (
|
|
209
|
+
entry.minVersion &&
|
|
210
|
+
compareVersions(target.osVersion, entry.minVersion) < 0
|
|
211
|
+
) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
entry.maxVersion &&
|
|
216
|
+
compareVersions(target.osVersion, entry.maxVersion) > 0
|
|
217
|
+
) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const {minVersion: _min, maxVersion: _max, ...members} = entry;
|
|
221
|
+
resolved = {...resolved, ...members};
|
|
222
|
+
}
|
|
223
|
+
return resolved;
|
|
224
|
+
}
|