@proulr/sdui-components 0.1.85 → 0.1.87

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/bdc/components/discover.tsx +1 -4
  3. package/src/bdc/components/forms.tsx +4 -0
  4. package/src/bdc/components/inbox.tsx +4 -0
  5. package/src/renderer/SduiScreenRenderer.tsx +14 -2
  6. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +50 -34
  7. package/src/renderer/blocks/SduiInboxBlocks.tsx +26 -11
  8. package/src/renderer/blocks/SduiLayoutBlocks.tsx +39 -46
  9. package/src/renderer/blocks/SduiNativeAdBlock.tsx +7 -8
  10. package/src/renderer/blocks/SduiSocialBlocks.tsx +4 -27
  11. package/src/renderer/lib/discover-profile-formatters.test.ts +7 -0
  12. package/src/renderer/lib/discover-profile-formatters.ts +6 -3
  13. package/src/renderer/sdui-hybrid-renderer-context.tsx +6 -1
  14. package/src/renderer/theme/proulr-theme.test.ts +107 -0
  15. package/src/renderer/theme/proulr-theme.ts +51 -9
  16. package/src/renderer/ui/AppButton.tsx +54 -28
  17. package/src/renderer/ui/AppChromeIconButton.tsx +19 -7
  18. package/src/renderer/ui/AuthStepShell.tsx +1 -2
  19. package/src/renderer/ui/Chip.tsx +41 -34
  20. package/src/renderer/ui/ChipRow.tsx +7 -13
  21. package/src/renderer/ui/HintText.tsx +2 -4
  22. package/src/renderer/ui/InfoBanner.tsx +20 -3
  23. package/src/renderer/ui/Label.tsx +2 -3
  24. package/src/renderer/ui/ListItem.tsx +28 -4
  25. package/src/renderer/ui/RangeSlider.tsx +1 -2
  26. package/src/renderer/ui/SectionHeader.tsx +3 -4
  27. package/src/renderer/ui/SelectRow.tsx +113 -22
  28. package/src/renderer/ui/StatRow.tsx +3 -3
  29. package/src/renderer/ui/TextField.tsx +15 -11
  30. package/src/renderer/ui/ToggleRow.tsx +38 -11
  31. package/src/renderer/ui/compose-audience-pill.tsx +2 -3
  32. package/src/renderer/ui/compose-media-carousel.tsx +20 -7
  33. package/src/renderer/ui/compose-media-shortcut.tsx +2 -3
  34. package/src/renderer/ui/compose-pill-input.tsx +28 -9
  35. package/src/renderer/ui/compose-recent-media-grid.tsx +5 -8
  36. package/src/renderer/ui/compose-reply-audience-sheet.tsx +19 -8
  37. package/src/renderer/ui/compose-screen-header.tsx +15 -10
  38. package/src/renderer/ui/compose-text-area.tsx +14 -8
  39. package/src/renderer/ui/compose-toolbar-icon.tsx +9 -5
  40. package/src/renderer/ui/compose-toolbar.tsx +0 -7
  41. package/src/renderer/ui/compose-visibility-row.tsx +2 -3
  42. package/src/renderer/ui/floating-create-menu.tsx +2 -3
  43. package/src/renderer/ui/focus-ring.ts +26 -0
  44. package/src/renderer/ui/index.ts +2 -1
  45. package/src/renderer/ui/story-compose-header.tsx +14 -9
  46. package/src/renderer/ui/story-create-shortcut-row.tsx +1 -1
  47. package/src/renderer/ui/story-editor-chrome.tsx +30 -16
  48. package/src/renderer/ui/story-surface-picker.tsx +1 -2
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { colors, m3Colors } from './proulr-theme';
4
+
5
+ vi.mock('react-native', () => ({
6
+ Platform: {
7
+ OS: 'web',
8
+ select: (spec: Record<string, unknown>) => spec.default ?? spec.web ?? spec.ios ?? {},
9
+ },
10
+ }));
11
+
12
+ type Rgb = { r: number; g: number; b: number; a: number };
13
+
14
+ function parseColor(input: string): Rgb {
15
+ const value = input.trim();
16
+ const rgba = /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/.exec(value);
17
+ if (rgba) {
18
+ return { r: +rgba[1], g: +rgba[2], b: +rgba[3], a: +rgba[4] };
19
+ }
20
+ const hex = value.replace('#', '');
21
+ const full =
22
+ hex.length === 3
23
+ ? hex.split('').map((c) => c + c).join('')
24
+ : hex.slice(0, 6);
25
+ return {
26
+ r: parseInt(full.slice(0, 2), 16),
27
+ g: parseInt(full.slice(2, 4), 16),
28
+ b: parseInt(full.slice(4, 6), 16),
29
+ a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
30
+ };
31
+ }
32
+
33
+ /** Tema é OLED puro: qualquer rgba translúcido compõe sobre #000000. */
34
+ function compositeOnBlack(input: string): Rgb {
35
+ const { r, g, b, a } = parseColor(input);
36
+ if (a >= 1) return { r, g, b, a: 1 };
37
+ return { r: r * a, g: g * a, b: b * a, a: 1 };
38
+ }
39
+
40
+ function relativeLuminance(color: Rgb): number {
41
+ const channel = (value: number) => {
42
+ const s = value / 255;
43
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
44
+ };
45
+ return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
46
+ }
47
+
48
+ function contrastRatio(foreground: string, background: string): number {
49
+ const fg = relativeLuminance(compositeOnBlack(foreground));
50
+ const bg = relativeLuminance(compositeOnBlack(background));
51
+ const [high, low] = fg >= bg ? [fg, bg] : [bg, fg];
52
+ return (high + 0.05) / (low + 0.05);
53
+ }
54
+
55
+ const TEXT_PAIRS: [string, string, string][] = [
56
+ ['onPrimary / primaryFill', m3Colors.onPrimary, m3Colors.primaryFill],
57
+ ['onError / errorFill', m3Colors.onError, m3Colors.errorFill],
58
+ ['onSuccess / success', m3Colors.onSuccess, m3Colors.success],
59
+ ['onSurface / surface', m3Colors.onSurface, m3Colors.surface],
60
+ ['onSurface / surfaceContainer', m3Colors.onSurface, m3Colors.surfaceContainer],
61
+ ['onSurface / surfaceContainerHigh', m3Colors.onSurface, m3Colors.surfaceContainerHigh],
62
+ ['onSurface / surfaceContainerHighest', m3Colors.onSurface, m3Colors.surfaceContainerHighest],
63
+ ['onSurfaceVariant / surfaceContainerHigh', m3Colors.onSurfaceVariant, m3Colors.surfaceContainerHigh],
64
+ ['onPrimaryContainer / primaryContainer', m3Colors.onPrimaryContainer, m3Colors.primaryContainer],
65
+ ['onSecondaryContainer / secondaryContainer', m3Colors.onSecondaryContainer, m3Colors.secondaryContainer],
66
+ ['onTertiaryContainer / tertiaryContainer', m3Colors.onTertiaryContainer, m3Colors.tertiaryContainer],
67
+ ['onErrorContainer / errorContainer', m3Colors.onErrorContainer, m3Colors.errorContainer],
68
+ ['textPrimary / bg', colors.textPrimary, colors.bg],
69
+ ['textSecondary / bg', colors.textSecondary, colors.bg],
70
+ ['textMuted / bg', colors.textMuted, colors.bg],
71
+ ['textMuted / surface2', colors.textMuted, colors.surface2],
72
+ ];
73
+
74
+ /** WCAG 1.4.11: bordas, foco e indicadores que carregam significado. */
75
+ const GRAPHIC_PAIRS: [string, string, string][] = [
76
+ ['outlineInteractive / bg', m3Colors.outlineInteractive, colors.bg],
77
+ ['focus / bg', m3Colors.focus, colors.bg],
78
+ ['primary (foreground) / bg', m3Colors.primary, colors.bg],
79
+ ['secondary (foreground) / bg', m3Colors.secondary, colors.bg],
80
+ ['error (foreground) / bg', m3Colors.error, colors.bg],
81
+ ['online / bg', m3Colors.online, colors.bg],
82
+ ['presenceRecent / bg', m3Colors.presenceRecent, colors.bg],
83
+ ['onPrimary / primary (glyph) ', m3Colors.onPrimary, m3Colors.primary],
84
+ ];
85
+
86
+ describe('proulr-theme contraste WCAG', () => {
87
+ it.each(TEXT_PAIRS)('%s ≥ 4.5:1', (_label, foreground, background) => {
88
+ expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
89
+ });
90
+
91
+ it.each(GRAPHIC_PAIRS)('%s ≥ 3:1', (_label, foreground, background) => {
92
+ expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(3);
93
+ });
94
+
95
+ it('expõe tokens de estado usados pelos primitivos', () => {
96
+ expect(m3Colors.disabledContainer).toBeTruthy();
97
+ expect(m3Colors.onDisabled).toBeTruthy();
98
+ expect(m3Colors.divider).toBeTruthy();
99
+ expect(m3Colors.focus).toBeTruthy();
100
+ expect(m3Colors.primaryFill).toBeTruthy();
101
+ expect(m3Colors.errorFill).toBeTruthy();
102
+ });
103
+
104
+ it('rótulo desabilitado continua legível sobre o fundo desabilitado', () => {
105
+ expect(contrastRatio(m3Colors.onDisabled, m3Colors.disabledContainer)).toBeGreaterThanOrEqual(3);
106
+ });
107
+ });
@@ -7,6 +7,9 @@ export const m3Colors = {
7
7
  onPrimary: '#FFFFFF',
8
8
  primaryContainer: 'rgba(217,70,239,0.22)',
9
9
  onPrimaryContainer: '#F0ABFC',
10
+ /** Fundo de CTA/bolha com rótulo claro. Branco sobre #D946EF = 3.46:1 (reprova
11
+ * AA em texto); #A622D6 mantém o matiz da marca e chega a 5.5:1. */
12
+ primaryFill: '#A622D6',
10
13
  secondary: '#00E5FF',
11
14
  onSecondary: '#000000',
12
15
  secondaryContainer: 'rgba(0,229,255,0.18)',
@@ -23,8 +26,19 @@ export const m3Colors = {
23
26
  surfaceContainerHighest: '#222222',
24
27
  outline: '#1A1A1A',
25
28
  outlineVariant: '#2A2A2A',
29
+ /** Fronteira de componente interativo (botão outline, campo, chip, slider) —
30
+ * #1A1A1A dá 1.21:1 e some no OLED; 3:1 é o piso de WCAG 1.4.11. */
31
+ outlineInteractive: '#5C5C5C',
32
+ /** Divisória de lista/seção: decorativa, então fora do piso de 1.4.11. */
33
+ divider: '#242424',
34
+ /** Anel de foco (teclado / web). Ciano de marca = 13.6:1 sobre preto. */
35
+ focus: '#00E5FF',
36
+ disabledContainer: '#1F1F1F',
37
+ onDisabled: '#7A7A7A',
26
38
  error: '#FF4444',
27
39
  onError: '#FFFFFF',
40
+ /** Fundo sólido de ação destrutiva: branco sobre #FF4444 = 3.41:1; #C81E1E = 5.7:1. */
41
+ errorFill: '#C81E1E',
28
42
  errorContainer: 'rgba(255,68,68,0.22)',
29
43
  onErrorContainer: '#FF8A80',
30
44
  inverseSurface: '#FFFFFF',
@@ -32,8 +46,14 @@ export const m3Colors = {
32
46
  scrim: 'rgba(0,0,0,0.78)',
33
47
  storyInteractionScrim: 'rgba(0,0,0,0.48)',
34
48
  online: '#34FF88',
49
+ /** Verde de presença reaproveitado como feedback positivo (salvo / concluído). */
50
+ success: '#34FF88',
51
+ onSuccess: '#00382E',
35
52
  /** Saiu recentemente (≤24h) — laranja, distinto do online verde e do ciano de marca. */
36
53
  presenceRecent: '#FF9F0A',
54
+ /** States de interação sobre fundo sólido — em vez de opacity no botão inteiro. */
55
+ stateHovered: 'rgba(255,255,255,0.08)',
56
+ statePressed: 'rgba(255,255,255,0.14)',
37
57
  } as const;
38
58
 
39
59
  /** Accent/soft por reação Flash (conteúdo) — fonte única, sem hex solto nos apps. */
@@ -67,15 +87,17 @@ export const m3Shape = {
67
87
  cornerFull: 999,
68
88
  } as const;
69
89
 
90
+ /** Tracking por estilo: Montserrat é geométrica — fecha nos tamanhos de display,
91
+ * abre nas labels pequenas para continuar legível em tela de baixa DPI. */
70
92
  export const m3Type = {
71
- display: { fontFamily: 'Montserrat_800ExtraBold', fontSize: 57, lineHeight: 64 },
72
- headline: { fontFamily: 'Montserrat_700Bold', fontSize: 28, lineHeight: 36 },
73
- title: { fontFamily: 'Montserrat_600SemiBold', fontSize: 22, lineHeight: 28 },
74
- titleSmall: { fontFamily: 'Montserrat_600SemiBold', fontSize: 16, lineHeight: 24 },
75
- body: { fontFamily: 'Montserrat_400Regular', fontSize: 16, lineHeight: 24 },
76
- bodySmall: { fontFamily: 'Montserrat_400Regular', fontSize: 14, lineHeight: 20 },
77
- label: { fontFamily: 'Montserrat_500Medium', fontSize: 14, lineHeight: 20 },
78
- labelSmall: { fontFamily: 'Montserrat_500Medium', fontSize: 12, lineHeight: 16 },
93
+ display: { fontFamily: 'Montserrat_800ExtraBold', fontSize: 57, lineHeight: 64, letterSpacing: -1 },
94
+ headline: { fontFamily: 'Montserrat_700Bold', fontSize: 28, lineHeight: 36, letterSpacing: -0.5 },
95
+ title: { fontFamily: 'Montserrat_600SemiBold', fontSize: 22, lineHeight: 28, letterSpacing: -0.3 },
96
+ titleSmall: { fontFamily: 'Montserrat_600SemiBold', fontSize: 16, lineHeight: 24, letterSpacing: 0 },
97
+ body: { fontFamily: 'Montserrat_400Regular', fontSize: 16, lineHeight: 24, letterSpacing: 0.15 },
98
+ bodySmall: { fontFamily: 'Montserrat_400Regular', fontSize: 14, lineHeight: 20, letterSpacing: 0.2 },
99
+ label: { fontFamily: 'Montserrat_500Medium', fontSize: 14, lineHeight: 20, letterSpacing: 0.4 },
100
+ labelSmall: { fontFamily: 'Montserrat_500Medium', fontSize: 12, lineHeight: 16, letterSpacing: 0.5 },
79
101
  } as const;
80
102
 
81
103
  export const colors = {
@@ -84,9 +106,14 @@ export const colors = {
84
106
  surface2: '#141414',
85
107
  border: '#1A1A1A',
86
108
  borderStrong: '#2A2A2A',
109
+ /** Borda de componente interativo — ≥3:1 sobre preto (WCAG 1.4.11). */
110
+ borderInteractive: '#5C5C5C',
111
+ divider: '#242424',
87
112
  textPrimary: '#FFFFFF',
88
113
  textSecondary: '#A3A3A3',
89
- textMuted: '#666666',
114
+ /** #666666 dava 3.66:1 e reprova AA em texto corrido; #828282 = 5.4:1 no preto,
115
+ * 4.8:1 sobre surface2, onde hints costumam sentar. */
116
+ textMuted: '#828282',
90
117
  white: '#FFFFFF',
91
118
  textOnAccent: '#FFFFFF',
92
119
  accent: '#D946EF',
@@ -102,8 +129,15 @@ export const colors = {
102
129
  presenceRecent: '#FF9F0A',
103
130
  presenceRecentSoft: 'rgba(255,159,10,0.22)',
104
131
  danger: '#FF4444',
132
+ /** Fundo sólido destrutivo — branco sobre #FF4444 = 3.41:1; aqui = 5.7:1. */
133
+ dangerFill: '#C81E1E',
105
134
  dangerSoft: 'rgba(255,68,68,0.22)',
106
135
  dangerSoft18: 'rgba(239,68,68,0.18)',
136
+ success: '#34FF88',
137
+ onSuccess: '#00382E',
138
+ focus: '#00E5FF',
139
+ disabled: '#1F1F1F',
140
+ onDisabled: '#7A7A7A',
107
141
  overlay: 'rgba(0,0,0,0.78)',
108
142
  surfaceGlass: 'rgba(255,255,255,0.06)',
109
143
  /** Pílula / chrome sem BlurView — preto OLED. */
@@ -120,6 +154,7 @@ export const colors = {
120
154
  scrollbarThumb: 'rgba(255, 255, 255, 0.28)',
121
155
  scrollbarThumbHover: 'rgba(255, 255, 255, 0.45)',
122
156
  primary: '#D946EF',
157
+ primaryFill: '#A622D6',
123
158
  primarySoft18: 'rgba(217,70,239,0.18)',
124
159
  primarySoft35: 'rgba(217,70,239,0.35)',
125
160
  onSurface: '#FFFFFF',
@@ -171,6 +206,13 @@ export const colors = {
171
206
  surfaceElevated95: 'rgba(28,28,30,0.95)',
172
207
  } as const;
173
208
 
209
+ /**
210
+ * Coluna de leitura do corpo em viewport largo. Acima disso a linha de texto
211
+ * passa de ~100 caracteres e o grid perde ritmo — é teto de leitura, não de
212
+ * inset (quem se autogereia, ex. rail do Vibes, tem a própria coluna).
213
+ */
214
+ export const SCREEN_CONTENT_MAX_WIDTH = 880;
215
+
174
216
  export const spacing = {
175
217
  xs: 4,
176
218
  sm: 8,
@@ -1,6 +1,15 @@
1
1
  import { Pressable, StyleSheet, Text } from 'react-native';
2
- import { fonts, m3Colors, m3Elevation, m3Shape, spacing } from '../theme/proulr-theme';
2
+ import { m3Colors, m3Elevation, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
3
3
  import { AppLoadingSpinner } from './AppLoadingSpinner';
4
+ import { FOCUS_RING_STYLE, useFocusRing } from './focus-ring';
5
+
6
+ type AppButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger';
7
+
8
+ const FILL_BY_VARIANT: Partial<Record<AppButtonVariant, string>> = {
9
+ primary: m3Colors.primaryFill,
10
+ secondary: m3Colors.surfaceContainerHigh,
11
+ danger: m3Colors.errorFill,
12
+ };
4
13
 
5
14
  export function AppButton({
6
15
  label,
@@ -9,54 +18,66 @@ export function AppButton({
9
18
  disabled,
10
19
  loading,
11
20
  compact,
21
+ fullWidth,
12
22
  testID,
23
+ accessibilityLabel,
13
24
  }: {
14
25
  label: string;
15
26
  onPress: () => void;
16
- variant?: 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger';
27
+ variant?: AppButtonVariant;
17
28
  disabled?: boolean;
18
29
  loading?: boolean;
19
30
  compact?: boolean;
31
+ fullWidth?: boolean;
20
32
  testID?: string;
33
+ accessibilityLabel?: string;
21
34
  }) {
22
- const isPrimary = variant === 'primary';
23
- const isSecondary = variant === 'secondary';
24
- const isOutline = variant === 'outline';
25
- const isDanger = variant === 'danger';
26
- const labelColor =
27
- isPrimary || isDanger || isSecondary
28
- ? m3Colors.onPrimary
29
- : isOutline || variant === 'ghost'
30
- ? m3Colors.primary
31
- : m3Colors.onPrimary;
35
+ const focus = useFocusRing();
36
+ const inactive = Boolean(disabled) || Boolean(loading);
37
+ const isTextonlyVariant = variant === 'outline' || variant === 'ghost';
38
+
39
+ const backgroundColor = inactive
40
+ ? isTextonlyVariant
41
+ ? 'transparent'
42
+ : m3Colors.disabledContainer
43
+ : (FILL_BY_VARIANT[variant] ?? 'transparent');
44
+
45
+ // Rótulo por estado, nunca por opacity composta no botão inteiro.
46
+ const labelColor = inactive
47
+ ? m3Colors.onDisabled
48
+ : isTextonlyVariant
49
+ ? m3Colors.primary
50
+ : m3Colors.onPrimary;
32
51
 
33
52
  return (
34
53
  <Pressable
35
54
  testID={testID}
36
55
  accessibilityRole="button"
56
+ accessibilityLabel={accessibilityLabel ?? label}
57
+ accessibilityState={{ disabled: inactive, busy: Boolean(loading) }}
37
58
  onPress={onPress}
38
- disabled={disabled || loading}
59
+ onFocus={focus.onFocus}
60
+ onBlur={focus.onBlur}
61
+ disabled={inactive}
39
62
  style={({ pressed }) => [
40
63
  styles.btn,
41
64
  compact && styles.btnCompact,
42
- isPrimary && { backgroundColor: m3Colors.primary },
43
- isSecondary && { backgroundColor: m3Colors.surfaceContainerHigh },
44
- isDanger && { backgroundColor: m3Colors.error },
45
- isOutline && {
46
- backgroundColor: 'transparent',
47
- borderWidth: 1,
48
- borderColor: m3Colors.outline,
65
+ fullWidth && styles.btnFullWidth,
66
+ { backgroundColor },
67
+ variant === 'outline' && {
68
+ borderColor: inactive ? m3Colors.outlineVariant : m3Colors.outlineInteractive,
49
69
  },
50
- variant === 'ghost' && { backgroundColor: 'transparent' },
51
- (disabled || loading) && { opacity: 0.38 },
52
- pressed && { opacity: 0.85 },
53
- (isPrimary || isSecondary) && m3Elevation.level1,
70
+ focus.focused && !inactive && FOCUS_RING_STYLE,
71
+ pressed && !inactive && styles.pressed,
72
+ !inactive && (variant === 'primary' || variant === 'secondary') && m3Elevation.level1,
54
73
  ]}
55
74
  >
56
75
  {loading ? (
57
76
  <AppLoadingSpinner color={labelColor} />
58
77
  ) : (
59
- <Text style={[styles.btnText, { color: labelColor }]}>{label}</Text>
78
+ <Text style={[styles.btnText, { color: labelColor }]} numberOfLines={1}>
79
+ {label}
80
+ </Text>
60
81
  )}
61
82
  </Pressable>
62
83
  );
@@ -64,12 +85,17 @@ export function AppButton({
64
85
 
65
86
  const styles = StyleSheet.create({
66
87
  btn: {
67
- height: 48,
88
+ minHeight: 48,
89
+ minWidth: 64,
68
90
  borderRadius: m3Shape.cornerFull,
69
91
  alignItems: 'center',
70
92
  justifyContent: 'center',
71
93
  paddingHorizontal: spacing.xl,
94
+ borderWidth: 2,
95
+ borderColor: 'transparent',
72
96
  },
73
- btnCompact: { height: 40, paddingHorizontal: spacing.sm },
74
- btnText: { fontSize: 14, fontFamily: fonts.medium, letterSpacing: 0.1 },
97
+ btnCompact: { minHeight: 44, paddingHorizontal: spacing.lg },
98
+ btnFullWidth: { alignSelf: 'stretch' },
99
+ pressed: { opacity: 0.85 },
100
+ btnText: { ...m3Type.label },
75
101
  });
@@ -11,6 +11,9 @@ type AppChromeIconButtonProps = {
11
11
  size?: number;
12
12
  /** Header chrome pill — circular bordered target. */
13
13
  variant?: 'header' | 'plain';
14
+ /** Optional background for dense surfaces (banner close, sheet dismiss). */
15
+ backgroundColor?: string;
16
+ testID?: string;
14
17
  };
15
18
 
16
19
  export function AppChromeIconButton({
@@ -21,17 +24,22 @@ export function AppChromeIconButton({
21
24
  color = m3Colors.primary,
22
25
  size = APP_ICON_SIZE.chromeHeader,
23
26
  variant = 'header',
27
+ backgroundColor,
28
+ testID,
24
29
  }: AppChromeIconButtonProps) {
25
30
  return (
26
31
  <Pressable
27
32
  onPress={onPress}
28
33
  disabled={disabled || !onPress}
29
- hitSlop={8}
30
34
  accessibilityRole="button"
31
35
  accessibilityLabel={label}
32
- style={[
36
+ accessibilityState={{ disabled: Boolean(disabled) }}
37
+ testID={testID}
38
+ style={({ pressed }) => [
33
39
  variant === 'header' ? styles.headerBtn : styles.plainBtn,
40
+ pressed && styles.pressed,
34
41
  disabled && styles.disabled,
42
+ backgroundColor ? { backgroundColor } : null,
35
43
  ]}
36
44
  >
37
45
  <AppIcon name={icon} color={color} size={size} />
@@ -41,21 +49,25 @@ export function AppChromeIconButton({
41
49
 
42
50
  const styles = StyleSheet.create({
43
51
  headerBtn: {
44
- width: 45,
45
- height: 45,
46
- borderRadius: 22.5,
52
+ width: 44,
53
+ height: 44,
54
+ borderRadius: 22,
47
55
  borderWidth: 2,
48
56
  borderColor: 'rgba(255,255,255,0.22)',
49
57
  margin: spacing.xs,
50
58
  alignItems: 'center',
51
59
  justifyContent: 'center',
52
60
  },
61
+ /** 44×44: `plain` também é usado como botão de fechar em banner e sheet. */
53
62
  plainBtn: {
54
- width: 40,
55
- height: 40,
63
+ minWidth: 44,
64
+ minHeight: 44,
56
65
  alignItems: 'center',
57
66
  justifyContent: 'center',
58
67
  },
68
+ pressed: {
69
+ opacity: 0.75,
70
+ },
59
71
  disabled: {
60
72
  opacity: 0.4,
61
73
  },
@@ -92,9 +92,8 @@ const styles = StyleSheet.create({
92
92
  paddingVertical: spacing.xs,
93
93
  },
94
94
  backLabel: {
95
+ ...m3Type.label,
95
96
  color: colors.textMuted,
96
- fontSize: 14,
97
- fontFamily: fonts.medium,
98
97
  },
99
98
  pressed: {
100
99
  opacity: 0.6,
@@ -1,55 +1,65 @@
1
1
  import { Pressable, StyleSheet, Text } from 'react-native';
2
- import { colors, fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
2
+ import { colors, fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
3
3
  import { AppIcon, isAppIconName } from './app-icon';
4
+ import { FOCUS_RING_STYLE, useFocusRing } from './focus-ring';
4
5
 
6
+ /**
7
+ * (44 − 36) / 2 — o alvo de toque mínimo vem do slop, não da altura visual: a
8
+ * pílula continua compacta na linha do chrome e continua alcançando 44 pt.
9
+ */
10
+ const CHIP_HIT_SLOP = { top: 4, bottom: 4, left: 2, right: 2 };
11
+
12
+ /**
13
+ * Pílula do sistema (padrão nascido no chip de filtros do Caçar) — uma só
14
+ * superfície para filtro, seletor e tag em qualquer breakpoint. Antes havia um
15
+ * `variant: 'default'` retangular para "conteúdo" e outro para "header", o que
16
+ * fazia a mesma linha de filtros trocar de aparência entre tela pequena e grande.
17
+ */
5
18
  export function Chip({
6
19
  label,
7
20
  active,
8
21
  onPress,
9
- variant = 'default',
10
22
  disabled,
11
23
  icon,
24
+ testID,
12
25
  }: {
13
26
  label: string;
14
27
  active?: boolean;
15
28
  onPress?: () => void;
16
- variant?: 'default' | 'onHeader';
17
29
  disabled?: boolean;
18
30
  icon?: string;
31
+ testID?: string;
19
32
  }) {
20
- const isHeader = variant === 'onHeader';
21
-
22
33
  const containerStyle = active
23
- ? isHeader
24
- ? { backgroundColor: m3Colors.onPrimary, borderColor: m3Colors.onPrimary }
25
- : { backgroundColor: m3Colors.secondaryContainer, borderColor: m3Colors.outline }
26
- : isHeader
27
- ? { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder }
28
- : { backgroundColor: m3Colors.surfaceContainerHigh, borderColor: m3Colors.outline };
29
-
30
- const textColor = active
31
- ? isHeader
32
- ? colors.headerStart
33
- : m3Colors.onSecondaryContainer
34
- : isHeader
35
- ? colors.textOnAccent
36
- : m3Colors.onSurfaceVariant;
34
+ ? { backgroundColor: m3Colors.inverseSurface, borderColor: m3Colors.inverseSurface }
35
+ : { backgroundColor: colors.surfaceGlass, borderColor: colors.chromePillBorder };
37
36
 
37
+ const textColor = disabled ? m3Colors.onDisabled : active ? colors.headerStart : colors.textOnAccent;
38
38
  const iconName = icon && isAppIconName(icon) ? icon : null;
39
+ const focus = useFocusRing();
39
40
 
40
41
  return (
41
42
  <Pressable
43
+ testID={testID}
44
+ accessibilityRole="button"
45
+ accessibilityLabel={label}
46
+ accessibilityState={{ selected: Boolean(active), disabled: Boolean(disabled) }}
42
47
  onPress={onPress}
48
+ onFocus={focus.onFocus}
49
+ onBlur={focus.onBlur}
43
50
  disabled={disabled}
44
- style={[styles.chip, isHeader && styles.chipOnHeader, containerStyle]}
51
+ hitSlop={CHIP_HIT_SLOP}
52
+ style={({ pressed }) => [
53
+ styles.chip,
54
+ containerStyle,
55
+ pressed && !disabled && styles.chipPressed,
56
+ focus.focused && !disabled && FOCUS_RING_STYLE,
57
+ ]}
45
58
  >
46
- {iconName ? <AppIcon name={iconName} size={isHeader ? 14 : 15} color={textColor} /> : null}
59
+ {iconName ? <AppIcon name={iconName} size={14} color={textColor} /> : null}
47
60
  <Text
48
- style={{
49
- color: textColor,
50
- fontSize: isHeader ? 12 : 13,
51
- fontFamily: fonts.semibold,
52
- }}
61
+ style={{ ...m3Type.labelSmall, color: textColor, fontFamily: fonts.semibold }}
62
+ numberOfLines={1}
53
63
  >
54
64
  {label}
55
65
  </Text>
@@ -61,15 +71,12 @@ const styles = StyleSheet.create({
61
71
  chip: {
62
72
  flexDirection: 'row',
63
73
  alignItems: 'center',
74
+ justifyContent: 'center',
64
75
  gap: spacing.xs,
65
- paddingHorizontal: spacing.sm,
66
- paddingVertical: spacing.sm,
67
- borderRadius: m3Shape.cornerSmall,
68
- borderWidth: 1,
69
- },
70
- chipOnHeader: {
71
- borderRadius: m3Shape.cornerFull,
76
+ minHeight: 36,
72
77
  paddingHorizontal: spacing.sm + 2,
73
- paddingVertical: spacing.xs,
78
+ borderRadius: m3Shape.cornerFull,
79
+ borderWidth: 1,
74
80
  },
81
+ chipPressed: { opacity: 0.85 },
75
82
  });
@@ -1,19 +1,18 @@
1
1
  import React from 'react';
2
2
  import { ScrollView, StyleSheet, View } from 'react-native';
3
- import { CHROME_HORIZONTAL_INSET } from '../theme/chrome-layout';
4
3
  import { spacing } from '../theme/proulr-theme';
5
4
 
6
5
  type ChipRowProps = {
7
6
  children: React.ReactNode;
8
- variant?: 'default' | 'onHeader';
9
7
  };
10
8
 
11
- /** Horizontal filter chip scroller — single chrome style for Discover, inbox, etc. */
12
- export function ChipRow({ children, variant = 'default' }: ChipRowProps) {
13
- const isHeader = variant === 'onHeader';
14
-
9
+ /**
10
+ * Linha horizontal de pílulas — a mesma fileira do Caçar, do hub do chat e de
11
+ * qualquer seletor de opções. O encaixe horizontal é do invólucro que chama.
12
+ */
13
+ export function ChipRow({ children }: ChipRowProps) {
15
14
  if (!children) {
16
- return <View style={[styles.content, isHeader && styles.contentOnHeader]} />;
15
+ return <View style={styles.content} />;
17
16
  }
18
17
 
19
18
  return (
@@ -21,7 +20,7 @@ export function ChipRow({ children, variant = 'default' }: ChipRowProps) {
21
20
  horizontal
22
21
  showsHorizontalScrollIndicator={false}
23
22
  style={styles.scroll}
24
- contentContainerStyle={[styles.content, isHeader && styles.contentOnHeader]}
23
+ contentContainerStyle={styles.content}
25
24
  keyboardShouldPersistTaps="handled"
26
25
  nestedScrollEnabled
27
26
  >
@@ -41,10 +40,5 @@ const styles = StyleSheet.create({
41
40
  alignItems: 'center',
42
41
  gap: spacing.xs,
43
42
  paddingHorizontal: spacing.sm,
44
- paddingVertical: 0,
45
- },
46
- contentOnHeader: {
47
- paddingLeft: CHROME_HORIZONTAL_INSET,
48
- paddingRight: CHROME_HORIZONTAL_INSET,
49
43
  },
50
44
  });
@@ -1,5 +1,5 @@
1
1
  import { StyleSheet, Text, type TextProps } from 'react-native';
2
- import { fonts, m3Colors, spacing } from '../theme/proulr-theme';
2
+ import { m3Colors, m3Type, spacing } from '../theme/proulr-theme';
3
3
 
4
4
  type HintTextProps = TextProps & {
5
5
  spaced?: boolean;
@@ -11,10 +11,8 @@ export function HintText({ spaced, style, ...props }: HintTextProps) {
11
11
 
12
12
  const styles = StyleSheet.create({
13
13
  hint: {
14
+ ...m3Type.bodySmall,
14
15
  color: m3Colors.onSurfaceVariant,
15
- fontSize: 12,
16
- lineHeight: 17,
17
- fontFamily: fonts.regular,
18
16
  },
19
17
  spaced: {
20
18
  marginTop: spacing.sm,
@@ -1,16 +1,19 @@
1
1
  import { Pressable, StyleSheet, Text, View } from 'react-native';
2
2
  import { AppIcon, type AppIconName, APP_ICON_SIZE } from './app-icon';
3
- import { colors, fonts, radius, spacing } from '../theme/proulr-theme';
3
+ import { colors, m3Colors, m3Type, radius, spacing } from '../theme/proulr-theme';
4
4
 
5
5
  export function InfoBanner({
6
6
  message,
7
7
  icon,
8
8
  appIcon,
9
+ dismissLabel,
9
10
  onDismiss,
10
11
  }: {
11
12
  message: string;
12
13
  icon?: string;
13
14
  appIcon?: AppIconName;
15
+ /** Obrigatória com `onDismiss`: o ícone isolado não tem nome acessível. */
16
+ dismissLabel?: string;
14
17
  onDismiss?: () => void;
15
18
  }) {
16
19
  const resolvedAppIcon: AppIconName | undefined = appIcon ?? (icon ? undefined : 'info');
@@ -26,7 +29,13 @@ export function InfoBanner({
26
29
  </View>
27
30
  <Text style={styles.infoText}>{message}</Text>
28
31
  {onDismiss ? (
29
- <Pressable onPress={onDismiss} hitSlop={8}>
32
+ <Pressable
33
+ onPress={onDismiss}
34
+ hitSlop={8}
35
+ accessibilityRole="button"
36
+ accessibilityLabel={dismissLabel}
37
+ style={({ pressed }) => [styles.dismissBtn, pressed && styles.dismissBtnPressed]}
38
+ >
30
39
  <AppIcon name="close" size={14} color={colors.textMuted} />
31
40
  </Pressable>
32
41
  ) : null}
@@ -54,5 +63,13 @@ const styles = StyleSheet.create({
54
63
  justifyContent: 'center',
55
64
  },
56
65
  infoIcon: { fontSize: 16, color: colors.accentText },
57
- infoText: { flex: 1, color: colors.textSecondary, fontSize: 13, lineHeight: 18, fontFamily: fonts.medium },
66
+ infoText: { flex: 1, color: colors.textSecondary, ...m3Type.bodySmall },
67
+ dismissBtn: {
68
+ width: 28,
69
+ height: 28,
70
+ borderRadius: 14,
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ },
74
+ dismissBtnPressed: { backgroundColor: m3Colors.statePressed },
58
75
  });