@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -642
  3. package/dist/theme/colorEngine.d.ts +2 -2
  4. package/dist/theme/colorEngine.d.ts.map +1 -1
  5. package/dist/theme/colorEngine.js.map +1 -1
  6. package/dist/theme/createTheme.d.ts +2 -2
  7. package/dist/theme/createTheme.d.ts.map +1 -1
  8. package/dist/theme/createTheme.js +33 -92
  9. package/dist/theme/createTheme.js.map +1 -1
  10. package/dist/theme/defaultTokens.d.ts +3 -0
  11. package/dist/theme/defaultTokens.d.ts.map +1 -0
  12. package/dist/theme/defaultTokens.js +56 -0
  13. package/dist/theme/defaultTokens.js.map +1 -0
  14. package/dist/theme/resolveGlobalTokens.d.ts +6 -0
  15. package/dist/theme/resolveGlobalTokens.d.ts.map +1 -0
  16. package/dist/theme/resolveGlobalTokens.js +46 -0
  17. package/dist/theme/resolveGlobalTokens.js.map +1 -0
  18. package/dist/theme/resolveHeadingTokenOverrides.d.ts +6 -0
  19. package/dist/theme/resolveHeadingTokenOverrides.d.ts.map +1 -0
  20. package/dist/theme/resolveHeadingTokenOverrides.js +38 -0
  21. package/dist/theme/resolveHeadingTokenOverrides.js.map +1 -0
  22. package/dist/theme/resolveNumericTokenOverrides.d.ts +2 -0
  23. package/dist/theme/resolveNumericTokenOverrides.d.ts.map +1 -0
  24. package/dist/theme/resolveNumericTokenOverrides.js +15 -0
  25. package/dist/theme/resolveNumericTokenOverrides.js.map +1 -0
  26. package/dist/theme/resolveWeightTokenOverrides.d.ts +3 -0
  27. package/dist/theme/resolveWeightTokenOverrides.d.ts.map +1 -0
  28. package/dist/theme/resolveWeightTokenOverrides.js +43 -0
  29. package/dist/theme/resolveWeightTokenOverrides.js.map +1 -0
  30. package/dist/theme/types.d.ts +1 -0
  31. package/dist/theme/types.d.ts.map +1 -1
  32. package/dist/theme/types.js.map +1 -1
  33. package/package.json +4 -4
  34. package/src/theme/colorEngine.ts +3 -2
  35. package/src/theme/createTheme.ts +35 -95
  36. package/src/theme/createThemeOverrides.test.ts +51 -0
  37. package/src/theme/createThemeValidation.test.ts +50 -0
  38. package/src/theme/defaultTokens.ts +58 -0
  39. package/src/theme/resolveGlobalTokens.ts +55 -0
  40. package/src/theme/resolveHeadingTokenOverrides.ts +61 -0
  41. package/src/theme/resolveNumericTokenOverrides.ts +19 -0
  42. package/src/theme/resolveWeightTokenOverrides.ts +48 -0
  43. package/src/theme/types.ts +1 -0
@@ -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.1.0",
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.7",
46
- "@ankhorage/contracts": "^2.1.0"
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.7",
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",
@@ -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: Record<string, string>;
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: Record<string, string> = {
172
+ const colors: ThemeTokens['colors'] = {
172
173
  primary: brand.base,
173
174
  secondary: secondarySwatch[500],
174
175
  accent: tertiarySwatch[500],
@@ -1,93 +1,27 @@
1
1
  import { generatePalette } from './colorEngine';
2
- import type { FontWeight, SurfaceTheme, ThemeConfig, ThemeTokens } from './types';
2
+ import { resolveGlobalTokens } from './resolveGlobalTokens';
3
+ import type { FontWeight, SurfaceTheme, ThemeConfig } from './types';
3
4
 
4
- export const DEFAULT_TOKENS: Omit<ThemeTokens, 'colors' | 'swatches' | 'semantics'> = {
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
- primaryColor: '#3B82F6',
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
- * Normalizes font family name to PascalCase for consistent key generation
83
- * matches GoogleFontsPlugin logic.
84
- */
85
- function toPascalCase(str: string) {
86
- return str
87
- .split(/[- ]+/)
88
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
89
- .join('');
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
- const theme = {
100
- ...DEFAULT_TOKENS,
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
- if (activeFontId) {
108
- const familyName = toPascalCase(activeFontId);
109
- const weights: FontWeight[] = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
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
- weights.forEach((w) => {
112
- // Convention from GoogleFonts plugin: Family_WeightRegular
113
- theme.typography.fonts.normal[w] = `${familyName}_${w}Regular`;
114
- theme.typography.fonts.italic[w] = `${familyName}_${w}Italic`;
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
- return theme;
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
+ }
@@ -132,6 +132,7 @@ export interface ThemeTokens {
132
132
  m: number;
133
133
  l: number;
134
134
  full: number;
135
+ [key: string]: number;
135
136
  };
136
137
  typography: {
137
138
  headings: Record<