@ankhorage/surface 9.1.116 → 9.3.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 (100) hide show
  1. package/CHANGELOG.md +12 -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/tabs/adapters/inbound/Tab.d.ts.map +1 -1
  12. package/dist/features/tabs/adapters/inbound/Tab.js +14 -2
  13. package/dist/features/tabs/adapters/inbound/Tab.js.map +1 -1
  14. package/dist/features/tabs/adapters/inbound/TabPanel.d.ts +1 -1
  15. package/dist/features/tabs/adapters/inbound/TabPanel.d.ts.map +1 -1
  16. package/dist/features/tabs/adapters/inbound/TabPanel.js +8 -3
  17. package/dist/features/tabs/adapters/inbound/TabPanel.js.map +1 -1
  18. package/dist/features/tabs/adapters/inbound/Tabs.d.ts +1 -1
  19. package/dist/features/tabs/adapters/inbound/Tabs.d.ts.map +1 -1
  20. package/dist/features/tabs/adapters/inbound/Tabs.js +2 -2
  21. package/dist/features/tabs/adapters/inbound/Tabs.js.map +1 -1
  22. package/dist/features/tabs/composition/useTabsController.d.ts +1 -1
  23. package/dist/features/tabs/composition/useTabsController.d.ts.map +1 -1
  24. package/dist/features/tabs/composition/useTabsController.js +3 -2
  25. package/dist/features/tabs/composition/useTabsController.js.map +1 -1
  26. package/dist/features/tabs/public.d.ts +1 -1
  27. package/dist/features/tabs/public.d.ts.map +1 -1
  28. package/dist/features/tabs/public.js.map +1 -1
  29. package/dist/features/theme/adapters/inbound/ThemeProvider.d.ts.map +1 -1
  30. package/dist/features/theme/adapters/inbound/ThemeProvider.js +1 -0
  31. package/dist/features/theme/adapters/inbound/ThemeProvider.js.map +1 -1
  32. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.d.ts.map +1 -1
  33. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.js +1 -0
  34. package/dist/features/theme/adapters/inbound/ThemeRuntimeContext.js.map +1 -1
  35. package/dist/features/theme/adapters/inbound/ThemeScope.d.ts +2 -2
  36. package/dist/features/theme/adapters/inbound/ThemeScope.d.ts.map +1 -1
  37. package/dist/features/theme/adapters/inbound/ThemeScope.js +5 -4
  38. package/dist/features/theme/adapters/inbound/ThemeScope.js.map +1 -1
  39. package/dist/features/theme/application/use-cases/createTheme.d.ts +2 -2
  40. package/dist/features/theme/application/use-cases/createTheme.d.ts.map +1 -1
  41. package/dist/features/theme/application/use-cases/createTheme.js +6 -3
  42. package/dist/features/theme/application/use-cases/createTheme.js.map +1 -1
  43. package/dist/features/theme/domain/projectInvertedTheme.d.ts +4 -0
  44. package/dist/features/theme/domain/projectInvertedTheme.d.ts.map +1 -0
  45. package/dist/features/theme/domain/projectInvertedTheme.js +178 -0
  46. package/dist/features/theme/domain/projectInvertedTheme.js.map +1 -0
  47. package/dist/features/theme/domain/resolveInverseSurface.d.ts +10 -0
  48. package/dist/features/theme/domain/resolveInverseSurface.d.ts.map +1 -0
  49. package/dist/features/theme/domain/resolveInverseSurface.js +31 -0
  50. package/dist/features/theme/domain/resolveInverseSurface.js.map +1 -0
  51. package/dist/features/typography/adapters/inbound/Text.d.ts +1 -1
  52. package/dist/features/typography/adapters/inbound/Text.d.ts.map +1 -1
  53. package/dist/features/typography/adapters/inbound/Text.js +2 -1
  54. package/dist/features/typography/adapters/inbound/Text.js.map +1 -1
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/internal/resolvers/resolveInteractiveColors.d.ts.map +1 -1
  59. package/dist/internal/resolvers/resolveInteractiveColors.js +10 -2
  60. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  61. package/dist/internal/resolvers/resolveTextColor.d.ts.map +1 -1
  62. package/dist/internal/resolvers/resolveTextColor.js +2 -1
  63. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  64. package/dist/types/tabs.d.ts +3 -0
  65. package/dist/types/tabs.d.ts.map +1 -1
  66. package/dist/types/tabs.js.map +1 -1
  67. package/dist/types/theme.d.ts +3 -0
  68. package/dist/types/theme.d.ts.map +1 -1
  69. package/dist/types/theme.js.map +1 -1
  70. package/dist/types/typography.d.ts +1 -0
  71. package/dist/types/typography.d.ts.map +1 -1
  72. package/dist/types/typography.js.map +1 -1
  73. package/package.json +2 -2
  74. package/src/features/badge/adapters/inbound/Badge.tsx +1 -6
  75. package/src/features/button/adapters/inbound/Button.tsx +1 -3
  76. package/src/features/icon/adapters/inbound/Icon.tsx +12 -1
  77. package/src/features/icon/adapters/inbound/PortableIcon.test.tsx +32 -0
  78. package/src/features/tabs/adapters/inbound/Tab.tsx +23 -1
  79. package/src/features/tabs/adapters/inbound/TabPanel.tsx +8 -2
  80. package/src/features/tabs/adapters/inbound/Tabs.presentation.test.tsx +70 -1
  81. package/src/features/tabs/adapters/inbound/Tabs.tsx +2 -1
  82. package/src/features/tabs/composition/useTabsController.ts +7 -2
  83. package/src/features/tabs/public.ts +7 -1
  84. package/src/features/theme/adapters/inbound/ThemeProvider.tsx +1 -0
  85. package/src/features/theme/adapters/inbound/ThemeRuntimeContext.tsx +1 -0
  86. package/src/features/theme/adapters/inbound/ThemeScope.test.tsx +38 -7
  87. package/src/features/theme/adapters/inbound/ThemeScope.tsx +7 -6
  88. package/src/features/theme/application/use-cases/createTheme.ts +6 -2
  89. package/src/features/theme/domain/projectInvertedTheme.ts +300 -0
  90. package/src/features/theme/domain/resolveInverseSurface.ts +55 -0
  91. package/src/features/typography/adapters/inbound/Text.tsx +2 -0
  92. package/src/index.ts +7 -1
  93. package/src/internal/resolvers/resolveInteractiveColors.test.ts +1 -1
  94. package/src/internal/resolvers/resolveInteractiveColors.ts +12 -2
  95. package/src/internal/resolvers/resolveTextColor.test.ts +5 -0
  96. package/src/internal/resolvers/resolveTextColor.ts +2 -1
  97. package/src/invertedThemeAcceptance.test.ts +204 -0
  98. package/src/types/tabs.ts +4 -0
  99. package/src/types/theme.ts +3 -0
  100. 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}
package/src/index.ts CHANGED
@@ -78,7 +78,13 @@ export type { PressableProps } from './features/pressable/public';
78
78
  export { Pressable } from './features/pressable/public';
79
79
  export type { SurfaceProps, SurfaceVariant } from './features/surface/public';
80
80
  export { Surface } from './features/surface/public';
81
- export type { TabListProps, TabPanelProps, TabProps, TabsProps } from './features/tabs/public';
81
+ export type {
82
+ TabListProps,
83
+ TabPanelProps,
84
+ TabProps,
85
+ TabsProps,
86
+ TabsVariant,
87
+ } from './features/tabs/public';
82
88
  export { Tab, TabList, TabPanel, Tabs } from './features/tabs/public';
83
89
  export type {
84
90
  ActionSemantics,
@@ -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
+ });
package/src/types/tabs.ts CHANGED
@@ -3,12 +3,15 @@ import type React from 'react';
3
3
  import type { InteractionPolicy } from './interactionPolicy';
4
4
  import type { ViewStyleProps } from './layout';
5
5
 
6
+ export type TabsVariant = 'line' | 'attached';
7
+
6
8
  export interface TabsProps extends ViewStyleProps {
7
9
  children?: React.ReactNode;
8
10
  value?: string;
9
11
  defaultValue?: string;
10
12
  onValueChange?: ((value: string) => void) | undefined;
11
13
  testID?: string;
14
+ variant?: TabsVariant;
12
15
  }
13
16
 
14
17
  export interface TabListProps {
@@ -48,6 +51,7 @@ export interface TabsContextValue {
48
51
  setFocusedValue: (value: string | undefined) => void;
49
52
  tabs: readonly TabRegistration[];
50
53
  unregisterTab: (value: string) => void;
54
+ variant: TabsVariant;
51
55
  }
52
56
 
53
57
  export type TabNavigationKey =