@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -17,14 +17,11 @@ type ChipProps = LayoutProps & {
17
17
  actionId?: string;
18
18
  actionParams?: Record<string, string>;
19
19
  active?: boolean;
20
- variant?: 'default' | 'on_header';
21
20
  };
22
21
 
23
22
  export const Chip: BdcComponent = (props) => layoutBlock('chip', props as ChipProps);
24
23
 
25
- type ChipRowProps = LayoutProps & {
26
- variant?: 'default' | 'on_header';
27
- };
24
+ type ChipRowProps = LayoutProps;
28
25
 
29
26
  export const ChipRow: BdcComponent = (props) => layoutBlock('chip_row', props as ChipRowProps);
30
27
 
@@ -15,6 +15,7 @@ type TextFieldProps = LayoutProps & {
15
15
  placeholder?: string;
16
16
  dataRef?: string;
17
17
  submitActionId?: string;
18
+ changeActionId?: string;
18
19
  };
19
20
 
20
21
  export const TextField: BdcComponent = (props) => layoutBlock('text_field', props as TextFieldProps);
@@ -25,6 +26,9 @@ type SelectRowProps = LayoutProps & {
25
26
  optionsRef?: string;
26
27
  changeActionId?: string;
27
28
  disabled?: boolean;
29
+ /** Copy traduzida no BFF (Rosetta); o renderer não inventa rótulo. */
30
+ placeholder?: string;
31
+ confirmLabel?: string;
28
32
  };
29
33
 
30
34
  export const SelectRow: BdcComponent = (props) => layoutBlock('select_row', props as SelectRowProps);
@@ -26,6 +26,10 @@ type EmptyStateProps = LayoutProps & {
26
26
  titleRef?: string;
27
27
  hint?: string;
28
28
  hintRef?: string;
29
+ /** CTA da tela vazia — sem ação o bloco é só aviso, e o usuário não tem para onde ir. */
30
+ actionId?: string;
31
+ actionLabel?: string;
32
+ actionLabelRef?: string;
29
33
  };
30
34
 
31
35
  export const EmptyState: BdcComponent = (props) => layoutBlock('empty_state', props as EmptyStateProps);
@@ -1,7 +1,7 @@
1
1
  import React, { memo } from 'react';
2
2
  import { StyleSheet } from 'react-native';
3
3
  import type { SduiScreen, SduiBlock } from '@proulr/sdui-contract';
4
- import { colors } from './theme/proulr-theme';
4
+ import { SCREEN_CONTENT_MAX_WIDTH, colors } from './theme/proulr-theme';
5
5
  import { renderBlockTree } from './sdui-component-registry';
6
6
  import { SduiLayoutDebugFrame } from './sdui-layout-debug';
7
7
 
@@ -49,7 +49,19 @@ export const SduiScreenRenderer = memo(function SduiScreenRenderer({ screen, onA
49
49
  });
50
50
 
51
51
  const styles = StyleSheet.create({
52
- screen: { flex: 1, minHeight: 0, backgroundColor: colors.bg },
52
+ /**
53
+ * Teto de leitura também aqui: o shell aplica o mesmo token, mas o renderer
54
+ * pode ser montado direto (master-detail, wrappers futuros) — sem este clamp
55
+ * essas superfícies esticariam de ponta a ponta em desktop.
56
+ */
57
+ screen: {
58
+ flex: 1,
59
+ minHeight: 0,
60
+ width: '100%',
61
+ maxWidth: SCREEN_CONTENT_MAX_WIDTH,
62
+ alignSelf: 'center',
63
+ backgroundColor: colors.bg,
64
+ },
53
65
  block: { flex: 1, minHeight: 0 },
54
66
  overlayBlock: {
55
67
  ...StyleSheet.absoluteFill,
@@ -14,7 +14,7 @@ import { TextField as UiTextField } from '../ui/TextField';
14
14
  import { Chip } from '../ui/Chip';
15
15
  import { ChipRow } from '../ui/ChipRow';
16
16
  import { AppIcon } from '../ui/app-icon';
17
- import { colors, fonts, gradients, m3Colors, m3Elevation, m3Shape, spacing } from '../theme/proulr-theme';
17
+ import { colors, fonts, gradients, m3Colors, m3Elevation, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
18
18
  import { resolveDataRef, resolveDataRefString } from '../lib/resolve-data-ref';
19
19
  import { useSduiHybridRenderer } from '../sdui-hybrid-renderer-context';
20
20
  import {
@@ -102,6 +102,7 @@ export function SduiDiscoverProfileCardBlock({ block, data, onAction }: SduiBloc
102
102
  }
103
103
 
104
104
  const age = profileAgeFromBirthDate(person.birth_date);
105
+ const distanceLabel = formatDistanceLabel(person.distance_m);
105
106
  const photoUri = person.photo_url ?? person.primary_photo_path ?? null;
106
107
  const presence = getPresenceStatus(person.last_seen);
107
108
  const online = presence === 'online';
@@ -151,7 +152,9 @@ export function SduiDiscoverProfileCardBlock({ block, data, onAction }: SduiBloc
151
152
  </Text>
152
153
  <View style={styles.distRow}>
153
154
  <PresenceDot status={presence} />
154
- <Text style={styles.distText}>{formatDistanceLabel(person.distance_m)}</Text>
155
+ {distanceLabel ? (
156
+ <Text style={styles.distText}>{distanceLabel}</Text>
157
+ ) : null}
155
158
  </View>
156
159
  </View>
157
160
  </Pressable>
@@ -280,8 +283,6 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
280
283
  const actionId = readStringProp(block, 'action_id');
281
284
  const actionParams = readStringRecordProp(block, 'action_params');
282
285
  const active = readBooleanProp(block, 'active');
283
- const variant = readStringProp(block, 'variant') ?? 'default';
284
- const isHeader = variant === 'on_header';
285
286
 
286
287
  return (
287
288
  <Chip
@@ -289,17 +290,13 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
289
290
  icon={glyph}
290
291
  active={active}
291
292
  disabled={!actionId}
292
- variant={isHeader ? 'onHeader' : 'default'}
293
293
  onPress={() => actionId && onAction(actionId, actionParams)}
294
294
  />
295
295
  );
296
296
  }
297
297
 
298
- export function SduiChipRowBlock({ block, children }: SduiBlockRenderProps) {
299
- const variant = readStringProp(block, 'variant') ?? 'default';
300
- const isHeader = variant === 'on_header';
301
-
302
- return <ChipRow variant={isHeader ? 'onHeader' : 'default'}>{children}</ChipRow>;
298
+ export function SduiChipRowBlock({ children }: SduiBlockRenderProps) {
299
+ return <ChipRow>{children}</ChipRow>;
303
300
  }
304
301
 
305
302
  export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderProps) {
@@ -307,7 +304,9 @@ export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderPro
307
304
  const placeholder = readStringProp(block, 'placeholder') ?? '';
308
305
  const dataRef = readStringProp(block, 'data_ref');
309
306
  const submitActionId = readStringProp(block, 'submit_action_id');
307
+ const changeActionId = readStringProp(block, 'change_action_id');
310
308
  const value = dataRef ? resolveDataRefString(data, dataRef) : '';
309
+ const [draft, setDraft] = useState(value);
311
310
 
312
311
  if (hybrid.renderTextField && dataRef) {
313
312
  return (
@@ -323,14 +322,19 @@ export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderPro
323
322
  );
324
323
  }
325
324
 
325
+ // Com change_action_id o wire é a fonte do valor; sem ele o input fica
326
+ // não-controlado e o texto digitado segue no payload do submit.
327
+ const binding = changeActionId && dataRef ? { value } : { defaultValue: value };
328
+
326
329
  return (
327
330
  <UiTextField
331
+ {...binding}
328
332
  placeholder={placeholder}
329
- value={value}
330
- onChangeText={() => {
331
- // Hybrid: app-side state for text input until api_call wiring lands.
333
+ onChangeText={(next) => {
334
+ setDraft(next);
335
+ if (changeActionId) onAction(changeActionId, { value: next });
332
336
  }}
333
- onSubmitEditing={() => submitActionId && onAction(submitActionId)}
337
+ onSubmitEditing={() => submitActionId && onAction(submitActionId, { value: draft })}
334
338
  returnKeyType={submitActionId ? 'search' : 'done'}
335
339
  autoCapitalize="words"
336
340
  autoCorrect={false}
@@ -440,34 +444,42 @@ const styles = StyleSheet.create({
440
444
  top: spacing.sm,
441
445
  left: spacing.sm,
442
446
  backgroundColor: m3Colors.primary,
443
- paddingHorizontal: 8,
444
- paddingVertical: 3,
447
+ paddingHorizontal: spacing.sm,
448
+ paddingVertical: spacing.xs,
445
449
  borderRadius: m3Shape.cornerSmall,
446
450
  },
447
- newBadgeText: { color: m3Colors.onPrimary, fontSize: 11, fontWeight: '700' },
451
+ newBadgeText: {
452
+ ...m3Type.labelSmall,
453
+ color: m3Colors.onPrimary,
454
+ fontFamily: fonts.bold,
455
+ },
448
456
  travelBadge: {
449
457
  position: 'absolute',
450
458
  top: spacing.sm,
451
459
  right: spacing.sm,
452
460
  flexDirection: 'row',
453
461
  alignItems: 'center',
454
- gap: 4,
462
+ gap: spacing.xs,
455
463
  backgroundColor: m3Colors.secondaryContainer,
456
- paddingHorizontal: 8,
457
- paddingVertical: 3,
464
+ paddingHorizontal: spacing.sm,
465
+ paddingVertical: spacing.xs,
458
466
  borderRadius: m3Shape.cornerSmall,
459
467
  },
460
468
  travelBadgeOffset: {
461
469
  top: spacing.sm + 24,
462
470
  },
463
- travelBadgeText: { color: m3Colors.onSecondaryContainer, fontSize: 11, fontWeight: '700' },
471
+ travelBadgeText: {
472
+ ...m3Type.labelSmall,
473
+ color: m3Colors.onSecondaryContainer,
474
+ fontFamily: fonts.bold,
475
+ },
464
476
  rightNowBadge: {
465
477
  position: 'absolute',
466
478
  top: spacing.sm,
467
479
  left: spacing.sm,
468
480
  width: 26,
469
481
  height: 26,
470
- borderRadius: 13,
482
+ borderRadius: m3Shape.cornerFull,
471
483
  alignItems: 'center',
472
484
  justifyContent: 'center',
473
485
  backgroundColor: m3Colors.surfaceContainerHighest,
@@ -478,10 +490,17 @@ const styles = StyleSheet.create({
478
490
  left: spacing.sm + 52,
479
491
  },
480
492
  profileInfo: { position: 'absolute', left: spacing.sm, bottom: spacing.sm, right: spacing.sm },
481
- profileName: { color: colors.white, fontSize: 13, fontWeight: '700' },
482
- distRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 },
483
- distText: { color: colors.textSecondary, fontSize: 11, fontWeight: '500' },
484
- presenceDot: { width: 8, height: 8, borderRadius: 4 },
493
+ profileName: {
494
+ ...m3Type.label,
495
+ color: colors.white,
496
+ fontFamily: fonts.bold,
497
+ },
498
+ distRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs },
499
+ distText: {
500
+ ...m3Type.labelSmall,
501
+ color: colors.textSecondary,
502
+ },
503
+ presenceDot: { width: 8, height: 8, borderRadius: m3Shape.cornerFull },
485
504
  gridContent: {
486
505
  gap: GRID_GAP,
487
506
  paddingHorizontal: spacing.sm,
@@ -493,9 +512,8 @@ const styles = StyleSheet.create({
493
512
  marginHorizontal: spacing.sm,
494
513
  },
495
514
  loadMoreText: {
515
+ ...m3Type.label,
496
516
  color: m3Colors.primary,
497
- fontSize: 14,
498
- fontWeight: '600',
499
517
  fontFamily: fonts.semibold,
500
518
  },
501
519
  textField: {
@@ -513,24 +531,22 @@ const styles = StyleSheet.create({
513
531
  alignItems: 'center',
514
532
  justifyContent: 'center',
515
533
  padding: spacing.lg,
516
- backgroundColor: 'rgba(0,0,0,0.45)',
534
+ backgroundColor: colors.videoControlBackground,
517
535
  gap: spacing.sm,
518
536
  },
519
537
  mapTitle: {
538
+ ...m3Type.titleSmall,
520
539
  color: colors.white,
521
- fontSize: 16,
522
- fontFamily: fonts.semibold,
523
540
  textAlign: 'center',
524
541
  },
525
542
  mapCoords: {
543
+ ...m3Type.bodySmall,
526
544
  color: colors.textSecondary,
527
- fontSize: 14,
528
- fontFamily: fonts.regular,
529
545
  textAlign: 'center',
530
546
  },
531
547
  mapHint: {
548
+ ...m3Type.labelSmall,
532
549
  color: colors.textSecondary,
533
- fontSize: 12,
534
550
  fontFamily: fonts.regular,
535
551
  textAlign: 'center',
536
552
  },
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { Pressable, StyleSheet, Text, View } from 'react-native';
3
3
  import type { SduiBlock } from '@proulr/sdui-contract';
4
4
  import { Card } from '../ui/Card';
5
- import { fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
5
+ import { AppButton } from '../ui/AppButton';
6
+ import { fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
6
7
  import { resolveDataRef, resolveDataRefString } from '../lib/resolve-data-ref';
7
8
  import { useSduiHybridRenderer } from '../sdui-hybrid-renderer-context';
8
9
  import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
@@ -85,21 +86,37 @@ export function SduiSegmentedTabsBlock({ block, data, onAction }: SduiBlockRende
85
86
  );
86
87
  }
87
88
 
88
- export function SduiEmptyStateBlock({ block, data }: SduiBlockRenderProps) {
89
+ export function SduiEmptyStateBlock({ block, data, onAction }: SduiBlockRenderProps) {
89
90
  const hybrid = useSduiHybridRenderer();
90
91
  const title =
91
92
  readStringProp(block, 'title') || resolveDataRefString(data, readStringProp(block, 'title_ref'));
92
93
  const hint =
93
94
  readStringProp(block, 'hint') || resolveDataRefString(data, readStringProp(block, 'hint_ref'));
95
+ const actionLabel =
96
+ readStringProp(block, 'action_label') ||
97
+ resolveDataRefString(data, readStringProp(block, 'action_label_ref'));
98
+ const actionId = readStringProp(block, 'action_id');
94
99
 
95
100
  if (hybrid.renderEmptyState) {
96
- return <>{hybrid.renderEmptyState({ title, hint })}</>;
101
+ return (
102
+ <>
103
+ {hybrid.renderEmptyState({
104
+ title,
105
+ hint,
106
+ actionLabel,
107
+ onAction: actionId ? () => onAction(actionId) : undefined,
108
+ })}
109
+ </>
110
+ );
97
111
  }
98
112
 
99
113
  return (
100
114
  <Card style={styles.emptyCard}>
101
115
  <Text style={styles.emptyTitle}>{title}</Text>
102
116
  {hint ? <Text style={styles.emptyHint}>{hint}</Text> : null}
117
+ {actionId && actionLabel ? (
118
+ <AppButton label={actionLabel} variant="primary" onPress={() => onAction(actionId)} />
119
+ ) : null}
103
120
  </Card>
104
121
  );
105
122
  }
@@ -262,7 +279,7 @@ const styles = StyleSheet.create({
262
279
  paddingVertical: spacing.sm,
263
280
  paddingHorizontal: spacing.sm,
264
281
  borderRadius: m3Shape.cornerFull,
265
- minHeight: 40,
282
+ minHeight: 44,
266
283
  },
267
284
  segmentActive: {
268
285
  backgroundColor: m3Colors.primaryContainer,
@@ -271,9 +288,9 @@ const styles = StyleSheet.create({
271
288
  fontSize: 14,
272
289
  },
273
290
  segmentLabel: {
291
+ ...m3Type.label,
274
292
  color: m3Colors.onSurfaceVariant,
275
293
  fontFamily: fonts.semibold,
276
- fontSize: 14,
277
294
  },
278
295
  segmentLabelActive: {
279
296
  color: m3Colors.onPrimaryContainer,
@@ -285,14 +302,13 @@ const styles = StyleSheet.create({
285
302
  marginHorizontal: spacing.sm,
286
303
  },
287
304
  emptyTitle: {
305
+ ...m3Type.label,
288
306
  color: m3Colors.onSurfaceVariant,
289
- fontSize: 15,
290
- fontFamily: fonts.medium,
291
307
  textAlign: 'center',
292
308
  },
293
309
  emptyHint: {
310
+ ...m3Type.bodySmall,
294
311
  color: m3Colors.onSurfaceVariant,
295
- fontSize: 13,
296
312
  textAlign: 'center',
297
313
  },
298
314
  rowCard: {
@@ -300,13 +316,12 @@ const styles = StyleSheet.create({
300
316
  marginHorizontal: spacing.sm,
301
317
  },
302
318
  rowTitle: {
319
+ ...m3Type.titleSmall,
303
320
  color: m3Colors.onSurface,
304
- fontSize: 16,
305
- fontFamily: fonts.semibold,
306
321
  },
307
322
  rowMeta: {
323
+ ...m3Type.bodySmall,
308
324
  color: m3Colors.onSurfaceVariant,
309
- fontSize: 12,
310
325
  marginTop: spacing.xs,
311
326
  },
312
327
  });
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Pressable, StyleSheet, Switch, Text, View } from 'react-native';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
3
  import type { SduiBlock } from '@proulr/sdui-contract';
4
4
  import { Card } from '../ui/Card';
5
5
  import { AppIcon } from '../ui/app-icon';
@@ -10,6 +10,7 @@ import {
10
10
  resolveDataRefString,
11
11
  } from '../lib/resolve-data-ref';
12
12
  import { SelectRow as UiSelectRow } from '../ui/SelectRow';
13
+ import { ToggleRow as UiToggleRow } from '../ui/ToggleRow';
13
14
  import { resolveViewStyleProps } from '../theme/resolve-style-props';
14
15
  import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
15
16
 
@@ -39,7 +40,12 @@ export function SduiSectionHeaderBlock({ block, onAction }: SduiBlockRenderProps
39
40
  <View style={styles.sectionHeaderRow}>
40
41
  <Text style={styles.sectionHeader}>{title}</Text>
41
42
  {actionLabel && actionId ? (
42
- <Pressable onPress={() => onAction(actionId)} hitSlop={8}>
43
+ <Pressable
44
+ onPress={() => onAction(actionId)}
45
+ hitSlop={8}
46
+ accessibilityRole="button"
47
+ style={({ pressed }) => [styles.sectionActionWrap, pressed && styles.pressed]}
48
+ >
43
49
  <Text style={styles.sectionAction}>{actionLabel}</Text>
44
50
  </Pressable>
45
51
  ) : null}
@@ -80,7 +86,13 @@ export function SduiListItemBlock({ block, data, onAction }: SduiBlockRenderProp
80
86
  if (actionId) {
81
87
  return (
82
88
  <Pressable
83
- style={[styles.listItem, showDivider && styles.listItemDivider]}
89
+ accessibilityRole="button"
90
+ testID={readStringProp(block, 'test_id')}
91
+ style={({ pressed }) => [
92
+ styles.listItem,
93
+ showDivider && styles.listItemDivider,
94
+ pressed && styles.pressed,
95
+ ]}
84
96
  onPress={() => onAction(actionId)}
85
97
  >
86
98
  {inner}
@@ -100,22 +112,16 @@ export function SduiToggleRowBlock({ block, data, onAction }: SduiBlockRenderPro
100
112
  const value = resolveDataRefBoolean(data, dataRef);
101
113
 
102
114
  return (
103
- <View style={styles.toggleRow}>
104
- <View style={styles.toggleCopy}>
105
- <Text style={styles.toggleLabel}>{label}</Text>
106
- {hint ? <Text style={styles.toggleHint}>{hint}</Text> : null}
107
- </View>
108
- <Switch
109
- value={value}
110
- disabled={disabled || !toggleActionId}
111
- onValueChange={(next) => {
112
- if (!toggleActionId) return;
113
- onAction(toggleActionId, { value: String(next) });
114
- }}
115
- trackColor={{ false: m3Colors.surfaceContainerHighest, true: m3Colors.primary }}
116
- thumbColor={m3Colors.onPrimary}
117
- />
118
- </View>
115
+ <UiToggleRow
116
+ label={label}
117
+ hint={hint}
118
+ value={value}
119
+ disabled={disabled || !toggleActionId}
120
+ onChange={(next) => {
121
+ if (!toggleActionId) return;
122
+ onAction(toggleActionId, { value: String(next) });
123
+ }}
124
+ />
119
125
  );
120
126
  }
121
127
 
@@ -134,6 +140,8 @@ export function SduiSelectRowBlock({ block, data, onAction }: SduiBlockRenderPro
134
140
  label={label}
135
141
  value={value || null}
136
142
  options={options}
143
+ placeholder={readStringProp(block, 'placeholder') ?? ''}
144
+ confirmLabel={readStringProp(block, 'confirm_label') ?? ''}
137
145
  onChange={(next) => {
138
146
  if (disabled || !changeActionId) return;
139
147
  onAction(changeActionId, { value: next });
@@ -155,48 +163,33 @@ const styles = StyleSheet.create({
155
163
  marginTop: spacing.xl,
156
164
  },
157
165
  sectionHeader: {
166
+ ...m3Type.titleSmall,
158
167
  color: colors.textSecondary,
159
- fontSize: 14,
160
- fontFamily: fonts.semibold,
168
+ },
169
+ sectionActionWrap: {
170
+ minHeight: 44,
171
+ justifyContent: 'center',
172
+ paddingLeft: spacing.md,
161
173
  },
162
174
  sectionAction: {
175
+ ...m3Type.label,
163
176
  color: colors.accentText,
164
- fontSize: 13,
165
177
  fontFamily: fonts.semibold,
166
178
  },
179
+ pressed: { backgroundColor: m3Colors.surfaceContainerHigh },
167
180
  listItem: {
168
181
  flexDirection: 'row',
169
182
  alignItems: 'center',
170
183
  gap: spacing.md,
184
+ minHeight: 48,
171
185
  paddingVertical: spacing.md,
172
186
  },
173
187
  listItemDivider: {
174
188
  borderBottomWidth: StyleSheet.hairlineWidth,
175
- borderBottomColor: colors.border,
189
+ borderBottomColor: m3Colors.divider,
176
190
  },
177
191
  listContent: { flex: 1 },
178
- listTitle: { color: colors.textPrimary, fontSize: 15, fontFamily: fonts.semibold },
179
- listSubtitle: { color: colors.textSecondary, fontSize: 13, marginTop: 2 },
192
+ listTitle: { ...m3Type.titleSmall, color: colors.textPrimary },
193
+ listSubtitle: { ...m3Type.bodySmall, color: colors.textSecondary, marginTop: 2 },
180
194
  trailingText: { ...m3Type.bodySmall, color: colors.textSecondary },
181
- toggleRow: {
182
- flexDirection: 'row',
183
- alignItems: 'center',
184
- gap: spacing.md,
185
- paddingVertical: spacing.xs,
186
- },
187
- toggleCopy: {
188
- flex: 1,
189
- gap: spacing.xs,
190
- },
191
- toggleLabel: {
192
- color: m3Colors.onSurface,
193
- fontSize: 15,
194
- fontFamily: fonts.semibold,
195
- },
196
- toggleHint: {
197
- color: m3Colors.onSurfaceVariant,
198
- fontSize: 12,
199
- lineHeight: 17,
200
- fontFamily: fonts.regular,
201
- },
202
195
  });
@@ -6,7 +6,7 @@ import {
6
6
  DEFAULT_NATIVE_AD_PLACEMENT,
7
7
  getNativeAdCatalog,
8
8
  } from '../ads/native-ad-catalog';
9
- import { colors, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
9
+ import { colors, fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
10
10
  import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
11
11
 
12
12
  type NativeAdModule = typeof import('react-native-google-mobile-ads');
@@ -220,10 +220,9 @@ const styles = StyleSheet.create({
220
220
  borderRadius: m3Shape.cornerSmall,
221
221
  },
222
222
  adBadgeText: {
223
+ ...m3Type.labelSmall,
223
224
  color: colors.onSurfaceVariant,
224
- fontSize: 10,
225
- fontWeight: '700',
226
- letterSpacing: 0.4,
225
+ fontFamily: fonts.bold,
227
226
  textTransform: 'uppercase',
228
227
  },
229
228
  headline: {
@@ -231,17 +230,17 @@ const styles = StyleSheet.create({
231
230
  left: spacing.sm,
232
231
  right: spacing.sm,
233
232
  bottom: spacing.xl + spacing.sm,
233
+ ...m3Type.labelSmall,
234
234
  color: m3Colors.onSurface,
235
- fontSize: 12,
236
- fontWeight: '700',
235
+ fontFamily: fonts.bold,
237
236
  },
238
237
  cta: {
239
238
  position: 'absolute',
240
239
  left: spacing.sm,
241
240
  right: spacing.sm,
242
241
  bottom: spacing.sm,
242
+ ...m3Type.labelSmall,
243
243
  color: m3Colors.secondary,
244
- fontSize: 11,
245
- fontWeight: '600',
244
+ fontFamily: fonts.semibold,
246
245
  },
247
246
  });
@@ -1,5 +1,4 @@
1
1
  import React from 'react';
2
- import { StyleSheet, Text, View } from 'react-native';
3
2
  import type { SduiBlock } from '@proulr/sdui-contract';
4
3
  import { resolveDataRefString } from '../lib/resolve-data-ref';
5
4
  import { useSduiHybridRenderer } from '../sdui-hybrid-renderer-context';
@@ -14,15 +13,8 @@ export function SduiStoryRailBlock({ block }: SduiBlockRenderProps) {
14
13
  const hybrid = useSduiHybridRenderer();
15
14
  const surface = readStringProp(block, 'surface') === 'vibes' ? 'vibes' : 'discover';
16
15
 
17
- if (hybrid.renderStoryRail) {
18
- return <>{hybrid.renderStoryRail({ surface })}</>;
19
- }
20
-
21
- return (
22
- <View style={styles.placeholder}>
23
- <Text style={styles.placeholderText}>StoryRail ({surface})</Text>
24
- </View>
25
- );
16
+ if (!hybrid.renderStoryRail) return null;
17
+ return <>{hybrid.renderStoryRail({ surface })}</>;
26
18
  }
27
19
 
28
20
  export function SduiProfileCompletionBannerBlock({ block, data, onAction }: SduiBlockRenderProps) {
@@ -44,21 +36,6 @@ export function SduiProfileCompletionBannerBlock({ block, data, onAction }: Sdui
44
36
  );
45
37
  }
46
38
 
47
- if (percent <= 0) return null;
48
-
49
- return (
50
- <View style={styles.placeholder}>
51
- <Text style={styles.placeholderText}>Complete seu perfil ({percent}%)</Text>
52
- </View>
53
- );
39
+ // Sem hybrid registrado o slot fica vazio: o renderer não inventa UI nem copy.
40
+ return null;
54
41
  }
55
-
56
- const styles = StyleSheet.create({
57
- placeholder: {
58
- padding: 12,
59
- },
60
- placeholderText: {
61
- color: '#A3A3A3',
62
- fontSize: 13,
63
- },
64
- });
@@ -29,6 +29,13 @@ describe('formatDistanceLabel', () => {
29
29
  expect(formatDistanceLabel(10, 'imperial')).toMatch(/ft$/);
30
30
  expect(formatDistanceLabel(2000, 'imperial')).toMatch(/mi$/);
31
31
  });
32
+
33
+ it('returns null when the visitor hides distance', () => {
34
+ expect(formatDistanceLabel(null)).toBeNull();
35
+ expect(formatDistanceLabel(undefined)).toBeNull();
36
+ expect(formatDistanceLabel(Number.NaN)).toBeNull();
37
+ expect(formatDistanceLabel(null, 'imperial')).toBeNull();
38
+ });
32
39
  });
33
40
 
34
41
  describe('getPresenceStatus', () => {
@@ -2,7 +2,8 @@ export type DiscoverNearbyProfileWire = {
2
2
  id: string;
3
3
  display_name: string | null;
4
4
  birth_date: string | null;
5
- distance_m: number;
5
+ /** `null` = visitante ativou "ocultar distância" — o rótulo some, o resto do card fica. */
6
+ distance_m: number | null;
6
7
  primary_photo_path: string | null;
7
8
  photo_url?: string | null;
8
9
  is_online: boolean | null;
@@ -42,9 +43,11 @@ export function profileAgeFromBirthDate(birthDate: string | null | undefined): n
42
43
  }
43
44
 
44
45
  export function formatDistanceLabel(
45
- distanceM: number,
46
+ distanceM: number | null | undefined,
46
47
  unit: 'metric' | 'imperial' = 'metric',
47
- ): string {
48
+ ): string | null {
49
+ if (distanceM === null || distanceM === undefined || !Number.isFinite(distanceM)) return null;
50
+
48
51
  if (unit === 'imperial') {
49
52
  const feet = distanceM * 3.28084;
50
53
  if (feet < 528) return `${Math.round(feet)} ft`;
@@ -102,7 +102,12 @@ export type SduiHybridRenderers = {
102
102
  activeRef?: string;
103
103
  actionPrefix?: string;
104
104
  }) => ReactNode;
105
- renderEmptyState?: (props: { title: string; hint?: string }) => ReactNode;
105
+ renderEmptyState?: (props: {
106
+ title: string;
107
+ hint?: string;
108
+ actionLabel?: string;
109
+ onAction?: () => void;
110
+ }) => ReactNode;
106
111
  renderInboxPersonRow?: (props: {
107
112
  block: SduiBlock;
108
113
  data: Record<string, unknown>;