@ankhorage/surface 9.1.116 → 9.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 (68) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/features/badge/adapters/inbound/Badge.d.ts.map +1 -1
  4. package/dist/features/badge/adapters/inbound/Badge.js +1 -1
  5. package/dist/features/badge/adapters/inbound/Badge.js.map +1 -1
  6. package/dist/features/button/adapters/inbound/Button.js +1 -2
  7. package/dist/features/button/adapters/inbound/Button.js.map +1 -1
  8. package/dist/features/icon/adapters/inbound/Icon.d.ts.map +1 -1
  9. package/dist/features/icon/adapters/inbound/Icon.js +9 -1
  10. package/dist/features/icon/adapters/inbound/Icon.js.map +1 -1
  11. package/dist/features/theme/adapters/inbound/ThemeProvider.d.ts.map +1 -1
  12. package/dist/features/theme/adapters/inbound/ThemeProvider.js +1 -0
  13. package/dist/features/theme/adapters/inbound/ThemeProvider.js.map +1 -1
  14. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.d.ts.map +1 -1
  15. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.js +1 -0
  16. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.js.map +1 -1
  17. package/dist/features/theme/adapters/inbound/ThemeScope.d.ts +2 -2
  18. package/dist/features/theme/adapters/inbound/ThemeScope.d.ts.map +1 -1
  19. package/dist/features/theme/adapters/inbound/ThemeScope.js +5 -4
  20. package/dist/features/theme/adapters/inbound/ThemeScope.js.map +1 -1
  21. package/dist/features/theme/application/use-cases/createTheme.d.ts +2 -2
  22. package/dist/features/theme/application/use-cases/createTheme.d.ts.map +1 -1
  23. package/dist/features/theme/application/use-cases/createTheme.js +6 -3
  24. package/dist/features/theme/application/use-cases/createTheme.js.map +1 -1
  25. package/dist/features/theme/domain/projectInvertedTheme.d.ts +4 -0
  26. package/dist/features/theme/domain/projectInvertedTheme.d.ts.map +1 -0
  27. package/dist/features/theme/domain/projectInvertedTheme.js +178 -0
  28. package/dist/features/theme/domain/projectInvertedTheme.js.map +1 -0
  29. package/dist/features/theme/domain/resolveInverseSurface.d.ts +10 -0
  30. package/dist/features/theme/domain/resolveInverseSurface.d.ts.map +1 -0
  31. package/dist/features/theme/domain/resolveInverseSurface.js +31 -0
  32. package/dist/features/theme/domain/resolveInverseSurface.js.map +1 -0
  33. package/dist/features/typography/adapters/inbound/Text.d.ts +1 -1
  34. package/dist/features/typography/adapters/inbound/Text.d.ts.map +1 -1
  35. package/dist/features/typography/adapters/inbound/Text.js +2 -1
  36. package/dist/features/typography/adapters/inbound/Text.js.map +1 -1
  37. package/dist/internal/resolvers/resolveInteractiveColors.d.ts.map +1 -1
  38. package/dist/internal/resolvers/resolveInteractiveColors.js +10 -2
  39. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  40. package/dist/internal/resolvers/resolveTextColor.d.ts.map +1 -1
  41. package/dist/internal/resolvers/resolveTextColor.js +2 -1
  42. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  43. package/dist/types/theme.d.ts +3 -0
  44. package/dist/types/theme.d.ts.map +1 -1
  45. package/dist/types/theme.js.map +1 -1
  46. package/dist/types/typography.d.ts +1 -0
  47. package/dist/types/typography.d.ts.map +1 -1
  48. package/dist/types/typography.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/features/badge/adapters/inbound/Badge.tsx +1 -6
  51. package/src/features/button/adapters/inbound/Button.tsx +1 -3
  52. package/src/features/icon/adapters/inbound/Icon.tsx +12 -1
  53. package/src/features/icon/adapters/inbound/PortableIcon.test.tsx +32 -0
  54. package/src/features/theme/adapters/inbound/ThemeProvider.tsx +1 -0
  55. package/src/features/theme/adapters/inbound/ThemeRuntimeContext.tsx +1 -0
  56. package/src/features/theme/adapters/inbound/ThemeScope.test.tsx +38 -7
  57. package/src/features/theme/adapters/inbound/ThemeScope.tsx +7 -6
  58. package/src/features/theme/application/use-cases/createTheme.ts +6 -2
  59. package/src/features/theme/domain/projectInvertedTheme.ts +300 -0
  60. package/src/features/theme/domain/resolveInverseSurface.ts +55 -0
  61. package/src/features/typography/adapters/inbound/Text.tsx +2 -0
  62. package/src/internal/resolvers/resolveInteractiveColors.test.ts +1 -1
  63. package/src/internal/resolvers/resolveInteractiveColors.ts +12 -2
  64. package/src/internal/resolvers/resolveTextColor.test.ts +5 -0
  65. package/src/internal/resolvers/resolveTextColor.ts +2 -1
  66. package/src/invertedThemeAcceptance.test.ts +204 -0
  67. package/src/types/theme.ts +3 -0
  68. package/src/types/typography.ts +1 -0
@@ -0,0 +1,300 @@
1
+ import type {
2
+ ColorContrastContext,
3
+ ColorSelectionTarget,
4
+ ColorSwatch,
5
+ ColorSwatchTiePolicy,
6
+ HexColor,
7
+ } from '@ankhorage/color-theory';
8
+ import {
9
+ createDefaultSemanticStatusSwatches,
10
+ parseHexColorOrThrow,
11
+ selectColorSwatchStep,
12
+ } from '@ankhorage/color-theory';
13
+
14
+ import type {
15
+ BorderSemantics,
16
+ ContentSemantics,
17
+ NeutralSemantics,
18
+ RoleSemantics,
19
+ SurfaceSemantics,
20
+ SurfaceTheme,
21
+ ThemeSemantics,
22
+ } from '../../../types/theme';
23
+ import { SURFACE_COLOR_POLICY } from '../constants';
24
+ import { createSurfaceColorDiagnosticCollector } from './createSurfaceColorDiagnosticCollector';
25
+ import { resolveInverseSurface } from './resolveInverseSurface';
26
+
27
+ type Collector = ReturnType<typeof createSurfaceColorDiagnosticCollector>;
28
+
29
+ interface InverseSelection {
30
+ collector: Collector;
31
+ contexts: readonly ColorContrastContext[];
32
+ tiePolicy: ColorSwatchTiePolicy;
33
+ textTarget: ColorSelectionTarget;
34
+ mutedTarget: ColorSelectionTarget;
35
+ subtleTarget: ColorSelectionTarget;
36
+ borderTarget: ColorSelectionTarget;
37
+ neutral: ColorSwatch;
38
+ }
39
+
40
+ /*** Project normal canonical theme semantics onto the opposite surface polarity. */
41
+ export function projectInvertedTheme(theme: SurfaceTheme): SurfaceTheme {
42
+ const collector = createSurfaceColorDiagnosticCollector();
43
+ const lightSurface = theme.colorDiagnostics.mode === 'dark';
44
+ const { neutral } = theme.swatches;
45
+ const { surface, surfaceSeparation } = resolveInverseSurface(theme, lightSurface, collector);
46
+ const selection = createInverseSelection(collector, surface, neutral, lightSurface);
47
+ const content = resolveInverseContent(theme, selection);
48
+ const border = resolveInverseBorder(theme, selection);
49
+ const roles = resolveInverseRoles(theme, selection);
50
+ const { neutralAction, ...semanticRoles } = roles;
51
+ const semantics: ThemeSemantics = {
52
+ ...theme.semantics,
53
+ neutral: resolveInverseNeutral(theme, surface, content, border, neutral, lightSurface),
54
+ surface,
55
+ content,
56
+ border,
57
+ ...semanticRoles,
58
+ error: roles.danger,
59
+ selection: {
60
+ background: roles.brand.softBg,
61
+ content: roles.brand.onSoftText,
62
+ border: border.focus,
63
+ },
64
+ action: { primary: roles.brand, neutral: neutralAction, danger: roles.danger },
65
+ };
66
+
67
+ return {
68
+ ...theme,
69
+ inverted: true,
70
+ semantics,
71
+ colors: {
72
+ ...theme.colors,
73
+ background: surface.sunken,
74
+ surface: surface.default,
75
+ text: content.default,
76
+ textSecondary: content.muted,
77
+ border: border.default,
78
+ },
79
+ colorDiagnostics: {
80
+ ...theme.colorDiagnostics,
81
+ selections: [...theme.colorDiagnostics.selections, ...collector.selections],
82
+ contrasts: [...theme.colorDiagnostics.contrasts, ...collector.contrasts],
83
+ surfaceSeparation,
84
+ },
85
+ };
86
+ }
87
+
88
+ /*** Select swatch steps against both inverse surface levels. */
89
+ function createInverseSelection(
90
+ collector: Collector,
91
+ surface: SurfaceSemantics,
92
+ neutral: ColorSwatch,
93
+ lightSurface: boolean,
94
+ ): InverseSelection {
95
+ return {
96
+ collector,
97
+ neutral,
98
+ contexts: [
99
+ {
100
+ id: 'inverse-surface',
101
+ against: parseHexColorOrThrow(surface.default),
102
+ minimumContrast: SURFACE_COLOR_POLICY.textContrast,
103
+ },
104
+ {
105
+ id: 'inverse-subtle',
106
+ against: parseHexColorOrThrow(surface.subtle),
107
+ minimumContrast: SURFACE_COLOR_POLICY.textContrast,
108
+ },
109
+ {
110
+ id: 'inverse-sunken',
111
+ against: parseHexColorOrThrow(surface.sunken),
112
+ minimumContrast: SURFACE_COLOR_POLICY.textContrast,
113
+ },
114
+ ],
115
+ tiePolicy: lightSurface ? 'higher-step' : 'lower-step',
116
+ textTarget: { lightness: lightSurface ? 0.2 : 0.9, chroma: 0.02 },
117
+ mutedTarget: { lightness: lightSurface ? 0.35 : 0.75, chroma: 0.02 },
118
+ subtleTarget: { lightness: lightSurface ? 0.4 : 0.7, chroma: 0.02 },
119
+ borderTarget: { lightness: lightSurface ? 0.45 : 0.65, chroma: 0.02 },
120
+ };
121
+ }
122
+
123
+ /*** Resolve all surface-relative content colors against inverse backgrounds. */
124
+ function resolveInverseContent(theme: SurfaceTheme, selection: InverseSelection): ContentSemantics {
125
+ const { neutral, primary } = theme.swatches;
126
+ const secondary = theme.swatches.secondary ?? primary;
127
+ return {
128
+ default: selectText('content.default', neutral, selection.textTarget, selection),
129
+ muted: selectText('content.muted', neutral, selection.mutedTarget, selection),
130
+ subtle: selectText('content.subtle', neutral, selection.subtleTarget, selection),
131
+ disabled: selectText(
132
+ 'content.disabled',
133
+ neutral,
134
+ selection.subtleTarget,
135
+ selection,
136
+ SURFACE_COLOR_POLICY.disabledContrast,
137
+ ),
138
+ icon: selectText('content.icon', neutral, selection.textTarget, selection),
139
+ link: selectText('content.link', primary, selection.textTarget, selection),
140
+ visited: selectText('content.visited', secondary, selection.textTarget, selection),
141
+ inverse: theme.semantics.content.default,
142
+ };
143
+ }
144
+
145
+ /*** Resolve separators and focus chrome against inverse backgrounds. */
146
+ function resolveInverseBorder(theme: SurfaceTheme, selection: InverseSelection): BorderSemantics {
147
+ const { neutral } = theme.swatches;
148
+ const outline = selection.borderTarget;
149
+ return {
150
+ default: selectOutline('border.default', neutral, outline, selection),
151
+ subtle: selectOutline('border.subtle', neutral, outline, selection),
152
+ strong: selectOutline('border.strong', neutral, outline, selection),
153
+ divider: selectOutline('border.divider', neutral, outline, selection),
154
+ focus: selectOutline('border.focus', theme.swatches.primary, selection.textTarget, selection),
155
+ };
156
+ }
157
+
158
+ /*** Project only surface-relative role fields and preserve self-owned color pairs. */
159
+ function resolveInverseRoles(theme: SurfaceTheme, selection: InverseSelection) {
160
+ const status = createDefaultSemanticStatusSwatches().swatches;
161
+ const { semantics } = theme;
162
+ return {
163
+ brand: projectRole('brand', semantics.brand, theme.swatches.primary, selection),
164
+ secondary: projectRole(
165
+ 'secondary',
166
+ semantics.secondary,
167
+ theme.swatches.secondary ?? theme.swatches.primary,
168
+ selection,
169
+ ),
170
+ accent: projectRole(
171
+ 'accent',
172
+ semantics.accent,
173
+ theme.swatches.tertiary ?? theme.swatches.primary,
174
+ selection,
175
+ ),
176
+ highlight: projectRole(
177
+ 'highlight',
178
+ semantics.highlight,
179
+ theme.swatches.quaternary ?? theme.swatches.primary,
180
+ selection,
181
+ ),
182
+ danger: projectRole('danger', semantics.danger, status.danger, selection),
183
+ success: projectRole('success', semantics.success, status.success, selection),
184
+ warning: projectRole('warning', semantics.warning, status.warning, selection),
185
+ info: projectRole('info', semantics.info, status.info, selection),
186
+ neutralAction: projectRole(
187
+ 'neutral',
188
+ semantics.action.neutral,
189
+ theme.swatches.neutral,
190
+ selection,
191
+ ),
192
+ };
193
+ }
194
+
195
+ /*** Keep opaque and soft role pairs intact while selecting inverse-safe transparent colors. */
196
+ function projectRole(
197
+ id: string,
198
+ role: RoleSemantics,
199
+ swatch: ColorSwatch,
200
+ selection: InverseSelection,
201
+ ): RoleSemantics {
202
+ return {
203
+ ...role,
204
+ onSurfaceText: selectText(`${id}.onSurfaceText`, swatch, selection.textTarget, selection),
205
+ outline: selectOutline(`${id}.outline`, swatch, selection.borderTarget, selection),
206
+ };
207
+ }
208
+
209
+ /*** Keep direct neutral semantic reads aligned with active surface polarity. */
210
+ function resolveInverseNeutral(
211
+ theme: SurfaceTheme,
212
+ surface: SurfaceSemantics,
213
+ content: ContentSemantics,
214
+ border: BorderSemantics,
215
+ swatch: ColorSwatch,
216
+ lightSurface: boolean,
217
+ ): NeutralSemantics {
218
+ return {
219
+ ...theme.semantics.neutral,
220
+ bg: surface.sunken,
221
+ bgSubtle: surface.subtle,
222
+ surface: surface.default,
223
+ surfaceHover: lightSurface ? swatch[200] : swatch[700],
224
+ surfaceActive: lightSurface ? swatch[300] : swatch[600],
225
+ border: border.default,
226
+ borderStrong: border.strong,
227
+ divider: border.divider,
228
+ text: content.default,
229
+ textMuted: content.muted,
230
+ textSubtle: content.subtle,
231
+ disabledBg: surface.disabled,
232
+ disabledText: content.disabled,
233
+ };
234
+ }
235
+
236
+ /*** Select and measure inverse-surface text through Color Theory. */
237
+ function selectText(
238
+ id: string,
239
+ swatch: ColorSwatch,
240
+ target: ColorSelectionTarget,
241
+ selection: InverseSelection,
242
+ minimumContrast: number = SURFACE_COLOR_POLICY.textContrast,
243
+ ): HexColor {
244
+ const contexts = selection.contexts.map((context) => ({ ...context, minimumContrast }));
245
+ const color = selectInverseColor(id, swatch, target, contexts, selection);
246
+ for (const context of contexts) {
247
+ selection.collector.measureForeground(
248
+ `inverse.${id}/${context.id}`,
249
+ color,
250
+ context.against,
251
+ minimumContrast,
252
+ );
253
+ }
254
+ return color;
255
+ }
256
+
257
+ /*** Select inverse-surface outlines with the UI contrast policy. */
258
+ function selectOutline(
259
+ id: string,
260
+ swatch: ColorSwatch,
261
+ target: ColorSelectionTarget,
262
+ selection: InverseSelection,
263
+ ): HexColor {
264
+ const contexts = selection.contexts.map((context) => ({
265
+ ...context,
266
+ minimumContrast: SURFACE_COLOR_POLICY.uiContrast,
267
+ }));
268
+ const color = selectInverseColor(id, swatch, target, contexts, selection);
269
+ for (const context of contexts) {
270
+ selection.collector.measureForeground(
271
+ `inverse.${id}/${context.id}`,
272
+ color,
273
+ context.against,
274
+ SURFACE_COLOR_POLICY.uiContrast,
275
+ );
276
+ }
277
+ return color;
278
+ }
279
+
280
+ /*** Keep inverse contrast safe when a chromatic swatch cannot meet the policy. */
281
+ function selectInverseColor(
282
+ id: string,
283
+ swatch: ColorSwatch,
284
+ target: ColorSelectionTarget,
285
+ contexts: readonly ColorContrastContext[],
286
+ selection: InverseSelection,
287
+ ): HexColor {
288
+ const result = selectColorSwatchStep(swatch, target, contexts, selection.tiePolicy);
289
+ selection.collector.selections.push({ id: `inverse.${id}`, result });
290
+ return (
291
+ result.selected?.hex ??
292
+ selection.collector.selectColor(
293
+ `inverse.${id}.neutralFallback`,
294
+ selection.neutral,
295
+ target,
296
+ contexts,
297
+ selection.tiePolicy,
298
+ )
299
+ );
300
+ }
@@ -0,0 +1,55 @@
1
+ import { parseHexColorOrThrow } from '@ankhorage/color-theory';
2
+
3
+ import type {
4
+ SurfaceContrastDiagnostic,
5
+ SurfaceSemantics,
6
+ SurfaceTheme,
7
+ } from '../../../types/theme';
8
+ import { SURFACE_COLOR_POLICY } from '../constants';
9
+ import type { createSurfaceColorDiagnosticCollector } from './createSurfaceColorDiagnosticCollector';
10
+
11
+ type Collector = ReturnType<typeof createSurfaceColorDiagnosticCollector>;
12
+
13
+ /*** Resolve inverted surface levels and measure their effective separation. */
14
+ export function resolveInverseSurface(
15
+ theme: SurfaceTheme,
16
+ lightSurface: boolean,
17
+ collector: Collector,
18
+ ): { surface: SurfaceSemantics; surfaceSeparation: SurfaceContrastDiagnostic[] } {
19
+ const { neutral } = theme.swatches;
20
+ const base = theme.semantics.surface.inverse;
21
+ const adjacent = lightSurface ? neutral[100] : neutral[800];
22
+ const sunken = lightSurface ? neutral[200] : neutral[950];
23
+ const surface: SurfaceSemantics = {
24
+ ...theme.semantics.surface,
25
+ default: base,
26
+ subtle: adjacent,
27
+ raised: adjacent,
28
+ sunken,
29
+ overlay: adjacent,
30
+ disabled: adjacent,
31
+ inverse: theme.semantics.surface.default,
32
+ };
33
+ return { surface, surfaceSeparation: measureInverseSurfaceSeparation(surface, collector) };
34
+ }
35
+
36
+ /*** Measure the returned surface levels against their shared sunken background. */
37
+ function measureInverseSurfaceSeparation(
38
+ surface: SurfaceSemantics,
39
+ collector: Collector,
40
+ ): SurfaceContrastDiagnostic[] {
41
+ const background = parseHexColorOrThrow(surface.sunken);
42
+ const surfaces = [
43
+ ['surface.default', surface.default],
44
+ ['surface.raised', surface.raised],
45
+ ['surface.disabled', surface.disabled],
46
+ ] as const;
47
+ return surfaces.map(([id, foreground]) =>
48
+ collector.measureSurface(
49
+ id,
50
+ parseHexColorOrThrow(foreground),
51
+ background,
52
+ SURFACE_COLOR_POLICY.surfaceSeparation,
53
+ ),
54
+ );
55
+ }
@@ -17,6 +17,7 @@ export function Text({
17
17
  italic = false,
18
18
  numberOfLines,
19
19
  testID,
20
+ style,
20
21
  }: TextProps) {
21
22
  const { theme } = useTheme();
22
23
  const content = children ?? i18nKey ?? null;
@@ -28,6 +29,7 @@ export function Text({
28
29
  style={[
29
30
  resolveTextStyles(theme, { align, italic, variant, weight }),
30
31
  { color: resolveTextColor(theme, emphasis, color) },
32
+ style,
31
33
  ]}
32
34
  >
33
35
  {content}
@@ -32,7 +32,7 @@ describe('resolveInteractiveColors', () => {
32
32
  expect(solid.contentColor).toBe(theme.semantics.action.primary.onSolidText);
33
33
  expect(outline.borderColor).toBe(theme.semantics.action.danger.outline);
34
34
  expect(outline.backgroundColor).toBe(theme.semantics.action.danger.softHover);
35
- expect(outline.contentColor).toBe(theme.semantics.action.danger.onSurfaceText);
35
+ expect(outline.contentColor).toBe(theme.semantics.action.danger.onSoftHoverText);
36
36
  });
37
37
 
38
38
  it('derives input colors from interaction and validation state', () => {
@@ -43,7 +43,7 @@ export function resolveButtonColors(
43
43
  ? semanticTone.softHover
44
44
  : 'transparent',
45
45
  borderColor: semanticTone.outline,
46
- contentColor: semanticTone.onSurfaceText,
46
+ contentColor: resolveTransparentContentColor(semanticTone, state),
47
47
  };
48
48
  case 'ghost':
49
49
  return {
@@ -53,7 +53,7 @@ export function resolveButtonColors(
53
53
  ? semanticTone.softHover
54
54
  : 'transparent',
55
55
  borderColor: 'transparent',
56
- contentColor: semanticTone.onSurfaceText,
56
+ contentColor: resolveTransparentContentColor(semanticTone, state),
57
57
  };
58
58
  case 'soft':
59
59
  return {
@@ -87,6 +87,16 @@ export function resolveButtonColors(
87
87
  }
88
88
  }
89
89
 
90
+ /*** Pair hovered and pressed transparent controls with their own soft fill. */
91
+ function resolveTransparentContentColor(
92
+ role: ReturnType<typeof resolveSurfaceColor>,
93
+ state: InteractionState,
94
+ ): string {
95
+ if (state.pressed) return role.onSoftActiveText;
96
+ if (state.hovered) return role.onSoftHoverText;
97
+ return role.onSurfaceText;
98
+ }
99
+
90
100
  export function resolveInputColors(
91
101
  theme: SurfaceTheme,
92
102
  fieldState: FieldState,
@@ -4,6 +4,11 @@ import { createTheme } from '../../features/theme/application/use-cases/createTh
4
4
  import { resolveTextColor } from './resolveTextColor';
5
5
 
6
6
  describe('resolveTextColor', () => {
7
+ it('uses inverse-surface role foregrounds for semantic text inside inverted scopes', () => {
8
+ const theme = createTheme(undefined, 'light', undefined, true);
9
+ expect(resolveTextColor(theme, 'default', 'primary')).toBe(theme.semantics.brand.onSurfaceText);
10
+ expect(resolveTextColor(theme, 'inverse', 'primary')).toBe(theme.semantics.brand.onSolidText);
11
+ });
7
12
  it('maps semantic text emphases through content aliases', () => {
8
13
  const theme = createTheme();
9
14
 
@@ -12,7 +12,8 @@ export function resolveTextColor(
12
12
  return resolveSurfaceColor(theme, color).onSolidText;
13
13
  }
14
14
 
15
- return resolveSurfaceColor(theme, color).base;
15
+ const role = resolveSurfaceColor(theme, color);
16
+ return theme.inverted ? role.onSurfaceText : role.base;
16
17
  }
17
18
 
18
19
  switch (emphasis) {
@@ -0,0 +1,204 @@
1
+ import { getContrastRatio, parseHexColorOrThrow } from '@ankhorage/color-theory';
2
+ import { expect, it } from 'bun:test';
3
+
4
+ import { createTheme } from './features/theme/application/use-cases/createTheme';
5
+ import { SURFACE_COLOR_POLICY } from './features/theme/constants';
6
+ import {
7
+ resolveButtonColors,
8
+ resolveInputColors,
9
+ } from './internal/resolvers/resolveInteractiveColors';
10
+
11
+ const idle = { disabled: false, focused: false, hovered: false, pressed: false };
12
+ const modes: ('light' | 'dark')[] = ['light', 'dark'];
13
+
14
+ it.each(modes)('%s inversion keeps mode and self-owned role pairs', (mode) => {
15
+ const normal = createTheme(undefined, mode);
16
+ const inverted = createTheme(undefined, mode, undefined, true);
17
+ expect(inverted.colorDiagnostics.mode).toBe(mode);
18
+ expect(inverted.semantics.surface.default).toBe(normal.semantics.surface.inverse);
19
+ expect(inverted.semantics.content.default).not.toBe(normal.semantics.content.default);
20
+ expect(inverted.semantics.brand.base).toBe(normal.semantics.brand.base);
21
+ expect(inverted.semantics.brand.onSolidText).toBe(normal.semantics.brand.onSolidText);
22
+ expect(inverted.semantics.brand.softBg).toBe(normal.semantics.brand.softBg);
23
+ expect(inverted.semantics.brand.onSoftText).toBe(normal.semantics.brand.onSoftText);
24
+ expect(inverted.colors.background).toBe(inverted.semantics.surface.sunken);
25
+ expect(inverted.semantics.neutral.bg).toBe(inverted.semantics.surface.sunken);
26
+ });
27
+
28
+ it.each(modes)('%s inversion selects readable content and border semantics', (mode) => {
29
+ const inverted = createTheme(undefined, mode, undefined, true);
30
+ const surface = parseHexColorOrThrow(inverted.semantics.surface.default);
31
+ const { content, border } = inverted.semantics;
32
+ for (const foreground of [content.default, content.muted, content.subtle, content.icon]) {
33
+ expect(getContrastRatio(parseHexColorOrThrow(foreground), surface)).toBeGreaterThanOrEqual(
34
+ SURFACE_COLOR_POLICY.textContrast,
35
+ );
36
+ }
37
+ expect(getContrastRatio(parseHexColorOrThrow(border.divider), surface)).toBeGreaterThanOrEqual(
38
+ SURFACE_COLOR_POLICY.uiContrast,
39
+ );
40
+ expect(
41
+ inverted.colorDiagnostics.contrasts
42
+ .filter((entry) => entry.id.startsWith('inverse.'))
43
+ .every((entry) => entry.passes),
44
+ ).toBe(true);
45
+ });
46
+
47
+ it.each(modes)(
48
+ '%s inversion diagnostics describe projected surfaces and disabled content',
49
+ (mode) => {
50
+ const theme = createTheme(undefined, mode, undefined, true);
51
+ const disabled = theme.colorDiagnostics.contrasts.filter((entry) =>
52
+ entry.id.startsWith('inverse.content.disabled/'),
53
+ );
54
+ expect(disabled).toHaveLength(3);
55
+ expect(
56
+ disabled.every((entry) => entry.minimumContrast === SURFACE_COLOR_POLICY.disabledContrast),
57
+ ).toBe(true);
58
+ expect(disabled.every((entry) => entry.passes)).toBe(true);
59
+ expect(
60
+ theme.colorDiagnostics.surfaceSeparation.map((entry) => String(entry.foreground)),
61
+ ).toEqual([
62
+ theme.semantics.surface.default,
63
+ theme.semantics.surface.raised,
64
+ theme.semantics.surface.disabled,
65
+ ]);
66
+ expect(
67
+ theme.colorDiagnostics.surfaceSeparation.every(
68
+ (entry) => entry.background === theme.semantics.surface.sunken,
69
+ ),
70
+ ).toBe(true);
71
+ expect(theme.semantics.surface.default).not.toBe(theme.semantics.surface.sunken);
72
+ expect(theme.colorDiagnostics.surfaceSeparation.every((entry) => entry.passes)).toBe(true);
73
+ },
74
+ );
75
+
76
+ it.each(modes)('%s inversion keeps selection semantics paired', (mode) => {
77
+ const theme = createTheme(undefined, mode, undefined, true);
78
+ const { brand, border, selection } = theme.semantics;
79
+ expect(selection.background).toBe(brand.softBg);
80
+ expect(selection.content).toBe(brand.onSoftText);
81
+ expect(selection.border).toBe(border.focus);
82
+ expect(
83
+ getContrastRatio(
84
+ parseHexColorOrThrow(selection.content),
85
+ parseHexColorOrThrow(selection.background),
86
+ ),
87
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.textContrast);
88
+ });
89
+
90
+ it.each(modes)('%s disabled controls use the disabled contrast policy', (mode) => {
91
+ const theme = createTheme(undefined, mode, undefined, true);
92
+ const button = resolveButtonColors(theme, {
93
+ color: 'primary',
94
+ variant: 'ghost',
95
+ state: { ...idle, disabled: true },
96
+ });
97
+ const input = resolveInputColors(theme, {
98
+ name: 'disabled',
99
+ disabled: true,
100
+ focused: false,
101
+ invalid: false,
102
+ readOnly: false,
103
+ });
104
+ for (const control of [button, input]) {
105
+ expect(
106
+ getContrastRatio(
107
+ parseHexColorOrThrow(control.contentColor),
108
+ parseHexColorOrThrow(control.backgroundColor),
109
+ ),
110
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.disabledContrast);
111
+ }
112
+ });
113
+
114
+ it.each(modes)('%s inversion keeps transparent controls and input readable', (mode) => {
115
+ const normal = createTheme(undefined, mode);
116
+ const inverted = createTheme(undefined, mode, undefined, true);
117
+ const surface = parseHexColorOrThrow(inverted.semantics.surface.default);
118
+ for (const color of ['primary', 'neutral'] as const) {
119
+ for (const variant of ['ghost', 'outline'] as const) {
120
+ const colors = resolveButtonColors(inverted, { color, variant, state: idle });
121
+ expect(
122
+ getContrastRatio(parseHexColorOrThrow(colors.contentColor), surface),
123
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.textContrast);
124
+ if (variant === 'outline') {
125
+ expect(
126
+ getContrastRatio(parseHexColorOrThrow(colors.borderColor), surface),
127
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.uiContrast);
128
+ }
129
+ }
130
+ }
131
+ const solid = resolveButtonColors(inverted, { color: 'primary', variant: 'solid', state: idle });
132
+ const normalSolid = resolveButtonColors(normal, {
133
+ color: 'primary',
134
+ variant: 'solid',
135
+ state: idle,
136
+ });
137
+ expect(solid).toEqual(normalSolid);
138
+ const input = resolveInputColors(inverted, {
139
+ name: 'default',
140
+ disabled: false,
141
+ focused: false,
142
+ invalid: false,
143
+ readOnly: false,
144
+ });
145
+ expect(
146
+ getContrastRatio(
147
+ parseHexColorOrThrow(input.contentColor),
148
+ parseHexColorOrThrow(input.backgroundColor),
149
+ ),
150
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.textContrast);
151
+ });
152
+
153
+ it.each([false, true])(
154
+ 'transparent actions pair hover and press fills (inverted=%p)',
155
+ (inverted) => {
156
+ for (const mode of modes) {
157
+ const theme = createTheme(undefined, mode, undefined, inverted);
158
+ for (const color of ['primary', 'neutral'] as const) {
159
+ for (const variant of ['ghost', 'outline'] as const) {
160
+ for (const interaction of ['hovered', 'pressed'] as const) {
161
+ const colors = resolveButtonColors(theme, {
162
+ color,
163
+ variant,
164
+ state: { ...idle, [interaction]: true },
165
+ });
166
+ const role =
167
+ color === 'primary' ? theme.semantics.action.primary : theme.semantics.action.neutral;
168
+ expect(colors.contentColor).toBe(
169
+ interaction === 'hovered' ? role.onSoftHoverText : role.onSoftActiveText,
170
+ );
171
+ expect(
172
+ getContrastRatio(
173
+ parseHexColorOrThrow(colors.contentColor),
174
+ parseHexColorOrThrow(colors.backgroundColor),
175
+ ),
176
+ ).toBeGreaterThanOrEqual(SURFACE_COLOR_POLICY.textContrast);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ },
182
+ );
183
+
184
+ it.each([
185
+ ['light', '#000000'],
186
+ ['dark', '#FFFFFF'],
187
+ ] as const)('%s inversion retains contrast for extreme brand seeds', (mode, primaryColor) => {
188
+ const config = {
189
+ id: 'extreme',
190
+ name: 'Extreme',
191
+ light: { primaryColor, harmony: 'monochromatic' as const },
192
+ dark: { primaryColor, harmony: 'monochromatic' as const },
193
+ };
194
+ const theme = createTheme(config, mode, undefined, true);
195
+ expect(
196
+ theme.colorDiagnostics.contrasts
197
+ .filter((entry) => entry.id.startsWith('inverse.'))
198
+ .every((entry) => entry.passes),
199
+ ).toBe(true);
200
+ expect(
201
+ theme.colorDiagnostics.selections.some((entry) => entry.id.endsWith('neutralFallback')),
202
+ ).toBe(true);
203
+ expect(theme.colorDiagnostics.surfaceSeparation.every((entry) => entry.passes)).toBe(true);
204
+ });
@@ -229,6 +229,7 @@ export interface ThemeTokens {
229
229
 
230
230
  export interface SurfaceTheme extends ThemeTokens {
231
231
  config: ContractsThemeConfig;
232
+ inverted?: boolean;
232
233
  }
233
234
 
234
235
  export type ThemeMode = 'light' | 'dark';
@@ -236,6 +237,7 @@ export type ThemeMode = 'light' | 'dark';
236
237
  export interface ThemeRuntime {
237
238
  theme: SurfaceTheme;
238
239
  mode: ThemeMode;
240
+ inverted?: boolean;
239
241
  setThemeConfig: (config: Partial<ContractsThemeConfig>) => void;
240
242
  setMode: (mode: ThemeMode) => void;
241
243
  }
@@ -250,4 +252,5 @@ export interface ThemeScopeProps {
250
252
  children: ReactNode;
251
253
  themeConfig?: Partial<ContractsThemeConfig>;
252
254
  mode?: ThemeMode;
255
+ inverted?: boolean;
253
256
  }
@@ -32,4 +32,5 @@ export interface TextProps {
32
32
  italic?: boolean;
33
33
  numberOfLines?: number;
34
34
  testID?: string;
35
+ style?: TextStyle;
35
36
  }