@ankhorage/surface 2.1.0 → 2.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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -642
- package/dist/theme/colorEngine.d.ts +2 -2
- package/dist/theme/colorEngine.d.ts.map +1 -1
- package/dist/theme/colorEngine.js.map +1 -1
- package/dist/theme/createTheme.d.ts +2 -2
- package/dist/theme/createTheme.d.ts.map +1 -1
- package/dist/theme/createTheme.js +33 -92
- package/dist/theme/createTheme.js.map +1 -1
- package/dist/theme/defaultTokens.d.ts +3 -0
- package/dist/theme/defaultTokens.d.ts.map +1 -0
- package/dist/theme/defaultTokens.js +56 -0
- package/dist/theme/defaultTokens.js.map +1 -0
- package/dist/theme/resolveGlobalTokens.d.ts +6 -0
- package/dist/theme/resolveGlobalTokens.d.ts.map +1 -0
- package/dist/theme/resolveGlobalTokens.js +46 -0
- package/dist/theme/resolveGlobalTokens.js.map +1 -0
- package/dist/theme/resolveHeadingTokenOverrides.d.ts +6 -0
- package/dist/theme/resolveHeadingTokenOverrides.d.ts.map +1 -0
- package/dist/theme/resolveHeadingTokenOverrides.js +38 -0
- package/dist/theme/resolveHeadingTokenOverrides.js.map +1 -0
- package/dist/theme/resolveNumericTokenOverrides.d.ts +2 -0
- package/dist/theme/resolveNumericTokenOverrides.d.ts.map +1 -0
- package/dist/theme/resolveNumericTokenOverrides.js +15 -0
- package/dist/theme/resolveNumericTokenOverrides.js.map +1 -0
- package/dist/theme/resolveWeightTokenOverrides.d.ts +3 -0
- package/dist/theme/resolveWeightTokenOverrides.d.ts.map +1 -0
- package/dist/theme/resolveWeightTokenOverrides.js +43 -0
- package/dist/theme/resolveWeightTokenOverrides.js.map +1 -0
- package/dist/theme/types.d.ts +1 -0
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js.map +1 -1
- package/package.json +4 -4
- package/src/theme/colorEngine.ts +3 -2
- package/src/theme/createTheme.ts +35 -95
- package/src/theme/createThemeOverrides.test.ts +51 -0
- package/src/theme/createThemeValidation.test.ts +50 -0
- package/src/theme/defaultTokens.ts +58 -0
- package/src/theme/resolveGlobalTokens.ts +55 -0
- package/src/theme/resolveHeadingTokenOverrides.ts +61 -0
- package/src/theme/resolveNumericTokenOverrides.ts +19 -0
- package/src/theme/resolveWeightTokenOverrides.ts +48 -0
- package/src/theme/types.ts +1 -0
package/dist/theme/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/theme/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { GeneratedThemeSwatches } from '@ankhorage/color-theory';\nimport type { ThemeConfig as ContractsThemeConfig } from '@ankhorage/contracts';\n\nexport type {\n ColorHarmony,\n ColorSwatch,\n ColorSwatchStep,\n GeneratedColorRole,\n GeneratedThemeModeColors,\n GeneratedThemeSwatches,\n HexColor,\n SemanticColorReferenceMap,\n SemanticColorToken,\n} from '@ankhorage/color-theory';\nexport type { ThemeConfig, ThemeModeConfig } from '@ankhorage/contracts';\n\nexport interface NeutralSemantics {\n bg: string;\n bgSubtle: string;\n surface: string;\n surfaceHover: string;\n surfaceActive: string;\n border: string;\n borderStrong: string;\n divider: string;\n text: string;\n textMuted: string;\n textSubtle: string;\n}\n\nexport interface RoleSemantics {\n base: string;\n hover: string;\n strong: string;\n softBg: string;\n softHover: string;\n softActive: string;\n outline: string;\n onSolidText: string;\n}\n\nexport interface SurfaceSemantics {\n default: string;\n subtle: string;\n raised: string;\n inverse: string;\n}\n\nexport interface ContentSemantics {\n default: string;\n muted: string;\n subtle: string;\n inverse: string;\n}\n\nexport interface BorderSemantics {\n default: string;\n strong: string;\n focus: string;\n}\n\nexport interface ActionSemantics {\n primary: RoleSemantics;\n neutral: RoleSemantics;\n danger: RoleSemantics;\n}\n\nexport interface ThemeSemantics {\n neutral: NeutralSemantics;\n brand: RoleSemantics;\n secondary: RoleSemantics;\n accent: RoleSemantics;\n highlight: RoleSemantics;\n danger: RoleSemantics;\n success: RoleSemantics;\n warning: RoleSemantics;\n error: RoleSemantics;\n info: RoleSemantics;\n surface: SurfaceSemantics;\n content: ContentSemantics;\n border: BorderSemantics;\n action: ActionSemantics;\n}\n\nexport type FontWeight =\n | '100'\n | '200'\n | '300'\n | '400'\n | '500'\n | '600'\n | '700'\n | '800'\n | '900'\n | 'bold'\n | 'normal';\n\nexport interface ThemeTokens {\n colors: {\n primary: string;\n secondary: string;\n accent: string;\n highlight: string;\n tertiary: string;\n quaternary: string;\n background: string;\n surface: string;\n text: string;\n textSecondary: string;\n border: string;\n error: string;\n success: string;\n warning: string;\n info: string;\n [key: string]: string;\n };\n swatches: GeneratedThemeSwatches;\n semantics: ThemeSemantics;\n spacing: {\n none: 0;\n xs: number;\n s: number;\n m: number;\n l: number;\n xl: number;\n xxl: number;\n [key: string]: number;\n };\n radii: {\n none: 0;\n s: number;\n m: number;\n l: number;\n full: number;\n };\n typography: {\n headings: Record<\n 1 | 2 | 3 | 4 | 5 | 6,\n {\n size: number;\n lineHeight: number;\n weight: 'regular' | 'medium' | 'semiBold' | 'bold';\n }\n >;\n sizes: {\n xs: number;\n s: number;\n m: number;\n l: number;\n xl: number;\n xxl: number;\n '3xl': number;\n h1: number;\n h2: number;\n h3: number;\n h4: number;\n h5: number;\n h6: number;\n [key: string]: number;\n };\n weights: {\n thin: FontWeight;\n extraLight: FontWeight;\n light: FontWeight;\n regular: FontWeight;\n medium: FontWeight;\n semiBold: FontWeight;\n bold: FontWeight;\n extraBold: FontWeight;\n black: FontWeight;\n };\n fonts: {\n /** Map of weight -> fontFamily name for normal style */\n normal: Record<FontWeight, string | undefined>;\n /** Map of weight -> fontFamily name for italic style */\n italic: Record<FontWeight, string | undefined>;\n };\n };\n shadows: {\n soft: number;\n medium: number;\n hard: number;\n [key: string]: number;\n };\n}\n\nexport interface SurfaceTheme extends ThemeTokens {\n config: ContractsThemeConfig;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/theme/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { GeneratedThemeSwatches } from '@ankhorage/color-theory';\nimport type { ThemeConfig as ContractsThemeConfig } from '@ankhorage/contracts';\n\nexport type {\n ColorHarmony,\n ColorSwatch,\n ColorSwatchStep,\n GeneratedColorRole,\n GeneratedThemeModeColors,\n GeneratedThemeSwatches,\n HexColor,\n SemanticColorReferenceMap,\n SemanticColorToken,\n} from '@ankhorage/color-theory';\nexport type { ThemeConfig, ThemeModeConfig } from '@ankhorage/contracts';\n\nexport interface NeutralSemantics {\n bg: string;\n bgSubtle: string;\n surface: string;\n surfaceHover: string;\n surfaceActive: string;\n border: string;\n borderStrong: string;\n divider: string;\n text: string;\n textMuted: string;\n textSubtle: string;\n}\n\nexport interface RoleSemantics {\n base: string;\n hover: string;\n strong: string;\n softBg: string;\n softHover: string;\n softActive: string;\n outline: string;\n onSolidText: string;\n}\n\nexport interface SurfaceSemantics {\n default: string;\n subtle: string;\n raised: string;\n inverse: string;\n}\n\nexport interface ContentSemantics {\n default: string;\n muted: string;\n subtle: string;\n inverse: string;\n}\n\nexport interface BorderSemantics {\n default: string;\n strong: string;\n focus: string;\n}\n\nexport interface ActionSemantics {\n primary: RoleSemantics;\n neutral: RoleSemantics;\n danger: RoleSemantics;\n}\n\nexport interface ThemeSemantics {\n neutral: NeutralSemantics;\n brand: RoleSemantics;\n secondary: RoleSemantics;\n accent: RoleSemantics;\n highlight: RoleSemantics;\n danger: RoleSemantics;\n success: RoleSemantics;\n warning: RoleSemantics;\n error: RoleSemantics;\n info: RoleSemantics;\n surface: SurfaceSemantics;\n content: ContentSemantics;\n border: BorderSemantics;\n action: ActionSemantics;\n}\n\nexport type FontWeight =\n | '100'\n | '200'\n | '300'\n | '400'\n | '500'\n | '600'\n | '700'\n | '800'\n | '900'\n | 'bold'\n | 'normal';\n\nexport interface ThemeTokens {\n colors: {\n primary: string;\n secondary: string;\n accent: string;\n highlight: string;\n tertiary: string;\n quaternary: string;\n background: string;\n surface: string;\n text: string;\n textSecondary: string;\n border: string;\n error: string;\n success: string;\n warning: string;\n info: string;\n [key: string]: string;\n };\n swatches: GeneratedThemeSwatches;\n semantics: ThemeSemantics;\n spacing: {\n none: 0;\n xs: number;\n s: number;\n m: number;\n l: number;\n xl: number;\n xxl: number;\n [key: string]: number;\n };\n radii: {\n none: 0;\n s: number;\n m: number;\n l: number;\n full: number;\n [key: string]: number;\n };\n typography: {\n headings: Record<\n 1 | 2 | 3 | 4 | 5 | 6,\n {\n size: number;\n lineHeight: number;\n weight: 'regular' | 'medium' | 'semiBold' | 'bold';\n }\n >;\n sizes: {\n xs: number;\n s: number;\n m: number;\n l: number;\n xl: number;\n xxl: number;\n '3xl': number;\n h1: number;\n h2: number;\n h3: number;\n h4: number;\n h5: number;\n h6: number;\n [key: string]: number;\n };\n weights: {\n thin: FontWeight;\n extraLight: FontWeight;\n light: FontWeight;\n regular: FontWeight;\n medium: FontWeight;\n semiBold: FontWeight;\n bold: FontWeight;\n extraBold: FontWeight;\n black: FontWeight;\n };\n fonts: {\n /** Map of weight -> fontFamily name for normal style */\n normal: Record<FontWeight, string | undefined>;\n /** Map of weight -> fontFamily name for italic style */\n italic: Record<FontWeight, string | undefined>;\n };\n };\n shadows: {\n soft: number;\n medium: number;\n hard: number;\n [key: string]: number;\n };\n}\n\nexport interface SurfaceTheme extends ThemeTokens {\n config: ContractsThemeConfig;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/surface",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "Stable UI foundation for React Native and React Native Web.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/surface#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@ankhorage/color-theory": "^0.0.
|
|
46
|
-
"@ankhorage/contracts": "^2.
|
|
45
|
+
"@ankhorage/color-theory": "^0.0.8",
|
|
46
|
+
"@ankhorage/contracts": "^7.2.0"
|
|
47
47
|
},
|
|
48
48
|
"files": [
|
|
49
49
|
"dist",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@ankhorage/devtools": "^1.0.6",
|
|
85
|
-
"@ankhorage/paradox": "^0.1.
|
|
85
|
+
"@ankhorage/paradox": "^0.1.21",
|
|
86
86
|
"@changesets/cli": "^2.31.0",
|
|
87
87
|
"@expo/vector-icons": "^15.1.1",
|
|
88
88
|
"@types/bun": "^1.3.13",
|
package/src/theme/colorEngine.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
RoleSemantics,
|
|
23
23
|
SurfaceSemantics,
|
|
24
24
|
ThemeSemantics,
|
|
25
|
+
ThemeTokens,
|
|
25
26
|
} from './types';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -109,7 +110,7 @@ export function generatePalette(
|
|
|
109
110
|
config: ThemeConfig,
|
|
110
111
|
mode: 'light' | 'dark' = 'light',
|
|
111
112
|
): {
|
|
112
|
-
colors:
|
|
113
|
+
colors: ThemeTokens['colors'];
|
|
113
114
|
swatches: GeneratedThemeSwatches;
|
|
114
115
|
semantics: ThemeSemantics;
|
|
115
116
|
} {
|
|
@@ -168,7 +169,7 @@ export function generatePalette(
|
|
|
168
169
|
danger,
|
|
169
170
|
};
|
|
170
171
|
|
|
171
|
-
const colors:
|
|
172
|
+
const colors: ThemeTokens['colors'] = {
|
|
172
173
|
primary: brand.base,
|
|
173
174
|
secondary: secondarySwatch[500],
|
|
174
175
|
accent: tertiarySwatch[500],
|
package/src/theme/createTheme.ts
CHANGED
|
@@ -1,93 +1,27 @@
|
|
|
1
1
|
import { generatePalette } from './colorEngine';
|
|
2
|
-
import
|
|
2
|
+
import { resolveGlobalTokens } from './resolveGlobalTokens';
|
|
3
|
+
import type { FontWeight, SurfaceTheme, ThemeConfig } from './types';
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
spacing: {
|
|
6
|
-
none: 0,
|
|
7
|
-
xs: 4,
|
|
8
|
-
s: 8,
|
|
9
|
-
m: 16,
|
|
10
|
-
l: 24,
|
|
11
|
-
xl: 32,
|
|
12
|
-
xxl: 48,
|
|
13
|
-
},
|
|
14
|
-
radii: {
|
|
15
|
-
none: 0,
|
|
16
|
-
s: 4,
|
|
17
|
-
m: 8,
|
|
18
|
-
l: 16,
|
|
19
|
-
full: 9999,
|
|
20
|
-
},
|
|
21
|
-
typography: {
|
|
22
|
-
headings: {
|
|
23
|
-
1: { size: 32, lineHeight: 40, weight: 'bold' },
|
|
24
|
-
2: { size: 24, lineHeight: 32, weight: 'bold' },
|
|
25
|
-
3: { size: 20, lineHeight: 28, weight: 'bold' },
|
|
26
|
-
4: { size: 18, lineHeight: 24, weight: 'semiBold' },
|
|
27
|
-
5: { size: 16, lineHeight: 22, weight: 'semiBold' },
|
|
28
|
-
6: { size: 14, lineHeight: 20, weight: 'semiBold' },
|
|
29
|
-
},
|
|
30
|
-
sizes: {
|
|
31
|
-
xs: 12,
|
|
32
|
-
s: 14,
|
|
33
|
-
m: 16,
|
|
34
|
-
l: 18,
|
|
35
|
-
xl: 20,
|
|
36
|
-
xxl: 24,
|
|
37
|
-
'3xl': 30,
|
|
38
|
-
h1: 32,
|
|
39
|
-
h2: 24,
|
|
40
|
-
h3: 20,
|
|
41
|
-
h4: 18,
|
|
42
|
-
h5: 16,
|
|
43
|
-
h6: 14,
|
|
44
|
-
},
|
|
45
|
-
weights: {
|
|
46
|
-
thin: '100',
|
|
47
|
-
extraLight: '200',
|
|
48
|
-
light: '300',
|
|
49
|
-
regular: '400',
|
|
50
|
-
medium: '500',
|
|
51
|
-
semiBold: '600',
|
|
52
|
-
bold: '700',
|
|
53
|
-
extraBold: '800',
|
|
54
|
-
black: '900',
|
|
55
|
-
},
|
|
56
|
-
fonts: {
|
|
57
|
-
normal: {} as Record<FontWeight, string | undefined>,
|
|
58
|
-
italic: {} as Record<FontWeight, string | undefined>,
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
shadows: {
|
|
62
|
-
soft: 2,
|
|
63
|
-
medium: 4,
|
|
64
|
-
hard: 8,
|
|
65
|
-
},
|
|
66
|
-
};
|
|
5
|
+
export { DEFAULT_TOKENS } from './defaultTokens';
|
|
67
6
|
|
|
68
7
|
export const DEFAULT_CONFIG: ThemeConfig = {
|
|
69
8
|
id: 'default',
|
|
70
9
|
name: 'Default',
|
|
71
|
-
light: {
|
|
72
|
-
|
|
73
|
-
harmony: 'monochromatic',
|
|
74
|
-
},
|
|
75
|
-
dark: {
|
|
76
|
-
primaryColor: '#3B82F6',
|
|
77
|
-
harmony: 'monochromatic',
|
|
78
|
-
},
|
|
10
|
+
light: { primaryColor: '#3B82F6', harmony: 'monochromatic' },
|
|
11
|
+
dark: { primaryColor: '#3B82F6', harmony: 'monochromatic' },
|
|
79
12
|
};
|
|
80
13
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
14
|
+
const ACTIVE_FONT_WEIGHTS: readonly FontWeight[] = [
|
|
15
|
+
'100',
|
|
16
|
+
'200',
|
|
17
|
+
'300',
|
|
18
|
+
'400',
|
|
19
|
+
'500',
|
|
20
|
+
'600',
|
|
21
|
+
'700',
|
|
22
|
+
'800',
|
|
23
|
+
'900',
|
|
24
|
+
];
|
|
91
25
|
|
|
92
26
|
export function createTheme(
|
|
93
27
|
config: ThemeConfig = DEFAULT_CONFIG,
|
|
@@ -95,25 +29,31 @@ export function createTheme(
|
|
|
95
29
|
activeFontId?: string | null,
|
|
96
30
|
): SurfaceTheme {
|
|
97
31
|
const { colors, swatches, semantics } = generatePalette(config, mode);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
colors: colors as SurfaceTheme['colors'],
|
|
32
|
+
const theme: SurfaceTheme = {
|
|
33
|
+
...resolveGlobalTokens(config.tokens),
|
|
34
|
+
colors,
|
|
102
35
|
swatches,
|
|
103
36
|
semantics,
|
|
104
37
|
config,
|
|
105
38
|
};
|
|
106
39
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
40
|
+
applyActiveFont(theme, activeFontId);
|
|
41
|
+
return theme;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applyActiveFont(theme: SurfaceTheme, activeFontId: string | null | undefined): void {
|
|
45
|
+
if (!activeFontId) return;
|
|
110
46
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
47
|
+
const familyName = toPascalCase(activeFontId);
|
|
48
|
+
for (const weight of ACTIVE_FONT_WEIGHTS) {
|
|
49
|
+
theme.typography.fonts.normal[weight] = `${familyName}_${weight}Regular`;
|
|
50
|
+
theme.typography.fonts.italic[weight] = `${familyName}_${weight}Italic`;
|
|
116
51
|
}
|
|
52
|
+
}
|
|
117
53
|
|
|
118
|
-
|
|
54
|
+
function toPascalCase(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.split(/[- ]+/)
|
|
57
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
58
|
+
.join('');
|
|
119
59
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ThemeConfig } from '@ankhorage/contracts';
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { createTheme, DEFAULT_CONFIG, DEFAULT_TOKENS } from './createTheme';
|
|
5
|
+
|
|
6
|
+
describe('createTheme authored global tokens', () => {
|
|
7
|
+
it('preserves defaults when no authored overrides exist', () => {
|
|
8
|
+
const theme = createTheme(DEFAULT_CONFIG);
|
|
9
|
+
|
|
10
|
+
expect(theme.spacing).toEqual(DEFAULT_TOKENS.spacing);
|
|
11
|
+
expect(theme.radii).toEqual(DEFAULT_TOKENS.radii);
|
|
12
|
+
expect(theme.typography.sizes).toEqual(DEFAULT_TOKENS.typography.sizes);
|
|
13
|
+
expect(theme.typography.weights).toEqual(DEFAULT_TOKENS.typography.weights);
|
|
14
|
+
expect(theme.shadows).toEqual(DEFAULT_TOKENS.shadows);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('merges authored global tokens without copying omitted defaults into config', () => {
|
|
18
|
+
const config: ThemeConfig = {
|
|
19
|
+
...DEFAULT_CONFIG,
|
|
20
|
+
tokens: {
|
|
21
|
+
spacing: { m: 18, hero: 64 },
|
|
22
|
+
radii: { l: 20, pill: 999 },
|
|
23
|
+
typography: {
|
|
24
|
+
sizes: { m: 17, display: 44 },
|
|
25
|
+
weights: { medium: '600' },
|
|
26
|
+
headings: { '1': { size: 36, lineHeight: 44, weight: 'semiBold' } },
|
|
27
|
+
},
|
|
28
|
+
shadows: { soft: 3, floating: 12 },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const theme = createTheme(config);
|
|
33
|
+
|
|
34
|
+
expect(theme.spacing).toMatchObject({ xs: 4, m: 18, hero: 64 });
|
|
35
|
+
expect(theme.radii).toMatchObject({ m: 8, l: 20, pill: 999 });
|
|
36
|
+
expect(theme.typography.sizes).toMatchObject({ s: 14, m: 17, display: 44 });
|
|
37
|
+
expect(theme.typography.weights).toMatchObject({ regular: '400', medium: '600' });
|
|
38
|
+
expect(theme.typography.headings[1]).toEqual({ size: 36, lineHeight: 44, weight: 'semiBold' });
|
|
39
|
+
expect(theme.shadows).toMatchObject({ medium: 4, soft: 3, floating: 12 });
|
|
40
|
+
expect(theme.config.tokens).toEqual(config.tokens);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps module-derived font families isolated per resolved theme', () => {
|
|
44
|
+
const withFont = createTheme(DEFAULT_CONFIG, 'light', 'source-sans');
|
|
45
|
+
const withoutFont = createTheme(DEFAULT_CONFIG);
|
|
46
|
+
|
|
47
|
+
expect(withFont.typography.fonts.normal['400']).toBe('SourceSans_400Regular');
|
|
48
|
+
expect(withoutFont.typography.fonts.normal['400']).toBeUndefined();
|
|
49
|
+
expect(DEFAULT_TOKENS.typography.fonts.normal['400']).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ThemeConfig, ThemeGlobalTokenOverrides } from '@ankhorage/contracts';
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { createTheme, DEFAULT_CONFIG } from './createTheme';
|
|
5
|
+
|
|
6
|
+
describe('createTheme token validation', () => {
|
|
7
|
+
it('rejects negative or non-finite numeric tokens', () => {
|
|
8
|
+
expect(() => createTheme(withTokens({ spacing: { m: -1 } }))).toThrow(RangeError);
|
|
9
|
+
expect(() => createTheme(withTokens({ radii: { l: Number.NaN } }))).toThrow(RangeError);
|
|
10
|
+
expect(() => createTheme(withTokens({ shadows: { soft: Number.POSITIVE_INFINITY } }))).toThrow(
|
|
11
|
+
RangeError,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps reserved zero tokens canonical', () => {
|
|
16
|
+
expect(() => createTheme(withTokens({ spacing: { none: 1 } }))).toThrow('spacing.none');
|
|
17
|
+
expect(() => createTheme(withTokens({ radii: { none: 1 } }))).toThrow('radii.none');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('requires positive typography dimensions', () => {
|
|
21
|
+
expect(() => createTheme(withTokens({ typography: { sizes: { m: 0 } } }))).toThrow(
|
|
22
|
+
'typography.sizes.m',
|
|
23
|
+
);
|
|
24
|
+
expect(() =>
|
|
25
|
+
createTheme(withTokens({ typography: { headings: { '1': { lineHeight: 0 } } } })),
|
|
26
|
+
).toThrow('typography.headings.1.lineHeight');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects unknown heading and weight slots', () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
createTheme(withTokens({ typography: { headings: { hero: { size: 40 } } } })),
|
|
32
|
+
).toThrow('Unknown typography heading token');
|
|
33
|
+
expect(() => createTheme(withTokens({ typography: { weights: { hero: '700' } } }))).toThrow(
|
|
34
|
+
'Unknown typography weight token',
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects unsupported heading and font-weight values', () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
createTheme(withTokens({ typography: { headings: { '1': { weight: '900' } } } })),
|
|
41
|
+
).toThrow('Invalid heading weight');
|
|
42
|
+
expect(() => createTheme(withTokens({ typography: { weights: { medium: '950' } } }))).toThrow(
|
|
43
|
+
'Invalid font weight',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function withTokens(tokens: ThemeGlobalTokenOverrides): ThemeConfig {
|
|
49
|
+
return { ...DEFAULT_CONFIG, tokens };
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { FontWeight, ThemeTokens } from './types';
|
|
2
|
+
|
|
3
|
+
const EMPTY_FONT_MAP: Record<FontWeight, string | undefined> = {
|
|
4
|
+
'100': undefined,
|
|
5
|
+
'200': undefined,
|
|
6
|
+
'300': undefined,
|
|
7
|
+
'400': undefined,
|
|
8
|
+
'500': undefined,
|
|
9
|
+
'600': undefined,
|
|
10
|
+
'700': undefined,
|
|
11
|
+
'800': undefined,
|
|
12
|
+
'900': undefined,
|
|
13
|
+
bold: undefined,
|
|
14
|
+
normal: undefined,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_TOKENS: Omit<ThemeTokens, 'colors' | 'swatches' | 'semantics'> = {
|
|
18
|
+
spacing: { none: 0, xs: 4, s: 8, m: 16, l: 24, xl: 32, xxl: 48 },
|
|
19
|
+
radii: { none: 0, s: 4, m: 8, l: 16, full: 9999 },
|
|
20
|
+
typography: {
|
|
21
|
+
headings: {
|
|
22
|
+
1: { size: 32, lineHeight: 40, weight: 'bold' },
|
|
23
|
+
2: { size: 24, lineHeight: 32, weight: 'bold' },
|
|
24
|
+
3: { size: 20, lineHeight: 28, weight: 'bold' },
|
|
25
|
+
4: { size: 18, lineHeight: 24, weight: 'semiBold' },
|
|
26
|
+
5: { size: 16, lineHeight: 22, weight: 'semiBold' },
|
|
27
|
+
6: { size: 14, lineHeight: 20, weight: 'semiBold' },
|
|
28
|
+
},
|
|
29
|
+
sizes: {
|
|
30
|
+
xs: 12,
|
|
31
|
+
s: 14,
|
|
32
|
+
m: 16,
|
|
33
|
+
l: 18,
|
|
34
|
+
xl: 20,
|
|
35
|
+
xxl: 24,
|
|
36
|
+
'3xl': 30,
|
|
37
|
+
h1: 32,
|
|
38
|
+
h2: 24,
|
|
39
|
+
h3: 20,
|
|
40
|
+
h4: 18,
|
|
41
|
+
h5: 16,
|
|
42
|
+
h6: 14,
|
|
43
|
+
},
|
|
44
|
+
weights: {
|
|
45
|
+
thin: '100',
|
|
46
|
+
extraLight: '200',
|
|
47
|
+
light: '300',
|
|
48
|
+
regular: '400',
|
|
49
|
+
medium: '500',
|
|
50
|
+
semiBold: '600',
|
|
51
|
+
bold: '700',
|
|
52
|
+
extraBold: '800',
|
|
53
|
+
black: '900',
|
|
54
|
+
},
|
|
55
|
+
fonts: { normal: { ...EMPTY_FONT_MAP }, italic: { ...EMPTY_FONT_MAP } },
|
|
56
|
+
},
|
|
57
|
+
shadows: { soft: 2, medium: 4, hard: 8 },
|
|
58
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ThemeGlobalTokenOverrides } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_TOKENS } from './defaultTokens';
|
|
4
|
+
import { resolveHeadingTokenOverrides } from './resolveHeadingTokenOverrides';
|
|
5
|
+
import { resolveNumericTokenOverrides } from './resolveNumericTokenOverrides';
|
|
6
|
+
import { resolveWeightTokenOverrides } from './resolveWeightTokenOverrides';
|
|
7
|
+
import type { ThemeTokens } from './types';
|
|
8
|
+
|
|
9
|
+
type GlobalThemeTokens = Omit<ThemeTokens, 'colors' | 'swatches' | 'semantics'>;
|
|
10
|
+
|
|
11
|
+
export function resolveGlobalTokens(overrides?: ThemeGlobalTokenOverrides): GlobalThemeTokens {
|
|
12
|
+
validateZeroTokens(overrides);
|
|
13
|
+
const typography = overrides?.typography;
|
|
14
|
+
return {
|
|
15
|
+
spacing: {
|
|
16
|
+
...DEFAULT_TOKENS.spacing,
|
|
17
|
+
...resolveNumericTokenOverrides('spacing', overrides?.spacing),
|
|
18
|
+
},
|
|
19
|
+
radii: {
|
|
20
|
+
...DEFAULT_TOKENS.radii,
|
|
21
|
+
...resolveNumericTokenOverrides('radii', overrides?.radii),
|
|
22
|
+
},
|
|
23
|
+
typography: {
|
|
24
|
+
headings: resolveHeadingTokenOverrides(
|
|
25
|
+
DEFAULT_TOKENS.typography.headings,
|
|
26
|
+
typography?.headings,
|
|
27
|
+
),
|
|
28
|
+
sizes: {
|
|
29
|
+
...DEFAULT_TOKENS.typography.sizes,
|
|
30
|
+
...resolveNumericTokenOverrides('typography.sizes', typography?.sizes, true),
|
|
31
|
+
},
|
|
32
|
+
weights: {
|
|
33
|
+
...DEFAULT_TOKENS.typography.weights,
|
|
34
|
+
...resolveWeightTokenOverrides(typography?.weights),
|
|
35
|
+
},
|
|
36
|
+
fonts: {
|
|
37
|
+
normal: { ...DEFAULT_TOKENS.typography.fonts.normal },
|
|
38
|
+
italic: { ...DEFAULT_TOKENS.typography.fonts.italic },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
shadows: {
|
|
42
|
+
...DEFAULT_TOKENS.shadows,
|
|
43
|
+
...resolveNumericTokenOverrides('shadows', overrides?.shadows),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateZeroTokens(overrides: ThemeGlobalTokenOverrides | undefined): void {
|
|
49
|
+
if (overrides?.spacing?.none !== undefined && overrides.spacing.none !== 0) {
|
|
50
|
+
throw new RangeError('Theme token spacing.none must remain 0.');
|
|
51
|
+
}
|
|
52
|
+
if (overrides?.radii?.none !== undefined && overrides.radii.none !== 0) {
|
|
53
|
+
throw new RangeError('Theme token radii.none must remain 0.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ThemeTypographyHeadingOverrides } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
import type { ThemeTokens } from './types';
|
|
4
|
+
|
|
5
|
+
type HeadingTokens = ThemeTokens['typography']['headings'];
|
|
6
|
+
type HeadingToken = HeadingTokens[1];
|
|
7
|
+
type HeadingWeight = HeadingToken['weight'];
|
|
8
|
+
|
|
9
|
+
const HEADING_KEYS = new Set<string>(['1', '2', '3', '4', '5', '6']);
|
|
10
|
+
const HEADING_WEIGHTS = new Set<string>(['regular', 'medium', 'semiBold', 'bold']);
|
|
11
|
+
|
|
12
|
+
export function resolveHeadingTokenOverrides(
|
|
13
|
+
defaults: HeadingTokens,
|
|
14
|
+
overrides: Readonly<Record<string, ThemeTypographyHeadingOverrides>> | undefined,
|
|
15
|
+
): HeadingTokens {
|
|
16
|
+
validateHeadingKeys(overrides);
|
|
17
|
+
return {
|
|
18
|
+
1: resolveHeading('1', defaults[1], overrides?.['1']),
|
|
19
|
+
2: resolveHeading('2', defaults[2], overrides?.['2']),
|
|
20
|
+
3: resolveHeading('3', defaults[3], overrides?.['3']),
|
|
21
|
+
4: resolveHeading('4', defaults[4], overrides?.['4']),
|
|
22
|
+
5: resolveHeading('5', defaults[5], overrides?.['5']),
|
|
23
|
+
6: resolveHeading('6', defaults[6], overrides?.['6']),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateHeadingKeys(
|
|
28
|
+
overrides: Readonly<Record<string, ThemeTypographyHeadingOverrides>> | undefined,
|
|
29
|
+
): void {
|
|
30
|
+
for (const key of Object.keys(overrides ?? {})) {
|
|
31
|
+
if (!HEADING_KEYS.has(key)) throw new RangeError(`Unknown typography heading token: ${key}.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveHeading(
|
|
36
|
+
key: string,
|
|
37
|
+
fallback: HeadingToken,
|
|
38
|
+
override: ThemeTypographyHeadingOverrides | undefined,
|
|
39
|
+
): HeadingToken {
|
|
40
|
+
const size = override?.size ?? fallback.size;
|
|
41
|
+
const lineHeight = override?.lineHeight ?? fallback.lineHeight;
|
|
42
|
+
const weight = override?.weight ?? fallback.weight;
|
|
43
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
44
|
+
throw new RangeError(
|
|
45
|
+
`Theme token typography.headings.${key}.size must be a finite positive number.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
|
|
49
|
+
throw new RangeError(
|
|
50
|
+
`Theme token typography.headings.${key}.lineHeight must be a finite positive number.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (!isHeadingWeight(weight)) {
|
|
54
|
+
throw new RangeError(`Invalid heading weight for typography.headings.${key}: ${weight}.`);
|
|
55
|
+
}
|
|
56
|
+
return { size, lineHeight, weight };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isHeadingWeight(value: string): value is HeadingWeight {
|
|
60
|
+
return HEADING_WEIGHTS.has(value);
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function resolveNumericTokenOverrides(
|
|
2
|
+
family: string,
|
|
3
|
+
overrides: Readonly<Record<string, number>> | undefined,
|
|
4
|
+
requirePositive = false,
|
|
5
|
+
): Readonly<Record<string, number>> {
|
|
6
|
+
if (overrides === undefined) return {};
|
|
7
|
+
|
|
8
|
+
for (const [token, value] of Object.entries(overrides)) {
|
|
9
|
+
const invalid = !Number.isFinite(value) || (requirePositive ? value <= 0 : value < 0);
|
|
10
|
+
if (invalid) {
|
|
11
|
+
const expectation = requirePositive
|
|
12
|
+
? 'a finite positive number'
|
|
13
|
+
: 'a finite non-negative number';
|
|
14
|
+
throw new RangeError(`Theme token ${family}.${token} must be ${expectation}.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { ...overrides };
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FontWeight } from './types';
|
|
2
|
+
|
|
3
|
+
const WEIGHT_TOKEN_KEYS = new Set<string>([
|
|
4
|
+
'thin',
|
|
5
|
+
'extraLight',
|
|
6
|
+
'light',
|
|
7
|
+
'regular',
|
|
8
|
+
'medium',
|
|
9
|
+
'semiBold',
|
|
10
|
+
'bold',
|
|
11
|
+
'extraBold',
|
|
12
|
+
'black',
|
|
13
|
+
]);
|
|
14
|
+
const FONT_WEIGHT_VALUES = new Set<string>([
|
|
15
|
+
'100',
|
|
16
|
+
'200',
|
|
17
|
+
'300',
|
|
18
|
+
'400',
|
|
19
|
+
'500',
|
|
20
|
+
'600',
|
|
21
|
+
'700',
|
|
22
|
+
'800',
|
|
23
|
+
'900',
|
|
24
|
+
'bold',
|
|
25
|
+
'normal',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function resolveWeightTokenOverrides(
|
|
29
|
+
overrides: Readonly<Record<string, string>> | undefined,
|
|
30
|
+
): Readonly<Record<string, FontWeight>> {
|
|
31
|
+
if (overrides === undefined) return {};
|
|
32
|
+
|
|
33
|
+
const resolved: Record<string, FontWeight> = {};
|
|
34
|
+
for (const [token, value] of Object.entries(overrides)) {
|
|
35
|
+
if (!WEIGHT_TOKEN_KEYS.has(token)) {
|
|
36
|
+
throw new RangeError(`Unknown typography weight token: ${token}.`);
|
|
37
|
+
}
|
|
38
|
+
if (!isFontWeight(value)) {
|
|
39
|
+
throw new RangeError(`Invalid font weight for typography.weights.${token}: ${value}.`);
|
|
40
|
+
}
|
|
41
|
+
resolved[token] = value;
|
|
42
|
+
}
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isFontWeight(value: string): value is FontWeight {
|
|
47
|
+
return FONT_WEIGHT_VALUES.has(value);
|
|
48
|
+
}
|