# FightRate RN — Refinamento UI/UX Instrumento Forte (Direção B) > **Status:** spec aprovada para execução (workflow Spec → Plan → TDD, aprovação implícita do usuário em 2026-06-15) > **Direção visual:** 01 — Corner Instrument (oficial), refinada para Instrumento Forte > **Escopo:** React Native CLI bare, sem troca de stack, sem novas dependências externas além de fontes (JetBrains Mono localmente bundled) > **Não-escopo:** reescrita de TrainingChart (já tem viewport/zoom/pan/pin — apenas refino cosmético de furos laterais), mudanças em BLE/service/SQLite, novas telas, mudanças no design system canônico (`FIGHTRATE_DESIGN_SYSTEM.md` é atualizado, não substituído) --- ## 1. Contexto e objetivo O app FightRate já segue a direção oficial Corner Instrument: fundo preto lona, paleta 60/30/10, número BPM dominante, ausência deliberada de cards genéricos. O usuário gosta do estado atual e pediu refinamento de posicionamento e polimento "digno de Awwwards" em todas as telas, sem trocar a essência. Esta spec implementa a **Direção B — Instrumento Forte** (escolhida em 2026-06-15 com aprovação do usuário, sem pausa para revisão intermediária). A Direção B adiciona metáfora física mais presente ao Corner Instrument: LED pulsante, números tabulares com JetBrains Mono, furos laterais no gráfico, anel de aviso pulsante no botão de parar treino, dial tiles no Sensor, e microanimações orquestradas. A Direção A (Contido) foi considerada e descartada por ser menos impactante; a Direção C (Cinemática) por inflar bundle e ter risco de regressão em gestos. B é o meio-termo com maior retorno de polimento por unidade de risco. ## 2. Princípios de design (ordem de prioridade) 1. **Instrumento sobre dashboard.** Cada elemento deve parecer um dial, trilho, marcador ou botão físico. Nada de cards uniformes. 2. **Número vem primeiro, com verdade tabular.** Todos os valores numéricos usam JetBrains Mono com `fontVariant: ['tabular-nums']` para alinhamento perfeito de dígitos. 3. **Vivo sem distrair.** Animações comunicam estado (captura ativa, pin selecionado, pulse de BPM). Nunca decorativas, nunca durante treino ativo. 4. **Hierarquia assimétrica.** Lados esquerdo e direito não são simétricos. Datas, labels, valores e ícones têm pesos diferentes, alinhamentos diferentes. 5. **Hardware reading culture.** Status, BLE, bateria, RSSI aparecem como LEDs e marcadores discretos, não como caixas com sombra. 6. **Bordas e trilhos, não fundos arredondados.** Componentes de destaque herdam `borderTopWidth`/`borderBottomWidth: 1, borderColor: colors.line`. Cantos retos (radius.xs no máximo). Exceção única: botões primários (radius.xl/pill). ## 3. Tokens — mudanças ### 3.1 Tipografia — adicionar JetBrains Mono `src/theme/typography.ts` ganha pesos para display numérico. Não removemos os tokens existentes; adicionamos variantes. ```ts // Novos tokens (mantém os antigos) displayNumeric: { fontFamily: 'JetBrainsMono-ExtraBold', fontVariant: ['tabular-nums'], fontSize: 110, lineHeight: 100, letterSpacing: -7, fontWeight: '900' as const, }, displaySmallNumeric: { fontFamily: 'JetBrainsMono-ExtraBold', fontVariant: ['tabular-nums'], fontSize: 56, lineHeight: 54, letterSpacing: -3, fontWeight: '900' as const, }, metricNumeric: { fontFamily: 'JetBrainsMono-Bold', fontVariant: ['tabular-nums'], fontSize: 28, lineHeight: 30, letterSpacing: -1.2, fontWeight: '800' as const, }, detailNumeric: { fontFamily: 'JetBrainsMono-Bold', fontVariant: ['tabular-nums'], fontSize: 18, lineHeight: 22, letterSpacing: -0.8, fontWeight: '800' as const, }, ``` `display`, `displaySmall`, `body`, `label`, `button` permanecem (Inter fallback do sistema). Apenas os novos tokens numéricos exigem JetBrains Mono. **Setup da fonte (RN bare, sem Expo):** 1. `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` já baixado. 2. `react-native.config.js` em `D:\h\`: ```js module.exports = { project: { android: { packageName: 'com.healthtracking.fightrate', sourceDir: './android' } }, assets: ['./assets/fonts'], }; ``` 3. `npx react-native-asset` (já é dep transitiva via `@react-native-community/cli`). Gera o link em `android/app/src/main/assets/fonts/` e atualiza `app/build.gradle` com `apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"` (não usamos, então esse passo é pulado). 4. Verificar build: APK deve ter os 4 TTFs em `assets/fonts/`. ### 3.2 Motion — adicionar `src/theme/motion.ts` ```ts export const motion = { // Easing presets easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)', easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)', easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)', easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)', // Duration presets (ms) duration: { instant: 80, fast: 160, normal: 240, slow: 380, pulse: 800, pulseSlow: 1600, }, // Stagger offsets stagger: { row: 50, item: 80, }, } as const; ``` **Uso:** Animação com Reanimated usa `Easing.bezier(0.25, 1, 0.5, 1)` para `easeOutQuart` e `withTiming(value, { duration: 240, easing: Easing.bezier(0.25, 1, 0.5, 1) })`. CSS-equivalente para StyleSheet animations. ### 3.3 Cores — sem mudanças Paleta atual do `FIGHTRATE_DESIGN_SYSTEM.md` é mantida. Adicionamos 1 token semântico (não expande o número de cores): ```ts // em colors.ts (nova chave) ringWarn: 'rgba(226, 59, 46, 0.55)', // anel de aviso durante captura ``` ### 3.4 Espaçamento — sem mudanças Escala 4/8/12/16/24/32/48 do `spacing.ts` permanece. ## 4. Componentes novos ### 4.1 `src/components/LiveDot.tsx` Ponto de 8px que pulsa (escala 1 → 1.4 → 1, opacity 0.6 → 1 → 0.6) em loop infinito de 1.6s, cor `colors.success` quando `live` é `true`, cor `colors.textDim` quando `live` é `false`. Implementado com Reanimated (`useSharedValue` + `withRepeat`). Props: `{ readonly live: boolean; readonly label?: string }`. **TDD:** testa que: - Renderiza um `` 8×8 com `backgroundColor = colors.success` quando `live = true`. - Renderiza 8×8 com `backgroundColor = colors.textDim` quando `live = false`. - `accessibilityLabel` recebe "AO VIVO" quando `live` e "INATIVO" caso contrário. (Animação não é testada em Jest, mas o snapshot de render é.) ### 4.2 `src/components/PressableSurface.tsx` Wrapper sobre `Pressable` que adiciona feedback físico refinado: `scale 1 → 0.96 → 1` em 160ms, `opacity 1 → 0.82 → 1` simultâneo, com `easeOutQuart`. Não tem sombra — apenas a transformação. Props: `{ readonly onPress?, readonly children, readonly disabled?, readonly style?, readonly accessibilityLabel? }`. **TDD:** testa que: - Renderiza children dentro de `Pressable`. - Aplica `style` recebido via prop. - `accessibilityRole` é `button`. ### 4.3 `src/components/EmptyState.tsx` Empty state instrutivo com geometria própria. Componentes: - Eyebrow (typography.label, colors.action) - Title (typography.display, 1 linha, ex.: "Sem uso contínuo ainda") - Copy (typography.body, 1-2 linhas) - Geometria SVG 64×64 opcional (quadrado + círculo concêntrico + risco diagonal) — gerada inline via `react-native-svg` Props: `{ readonly eyebrow: string; readonly title: string; readonly copy: string; readonly tone?: 'amber' | 'red' | 'green' }`. **TDD:** testa render dos textos e tom. ### 4.4 `src/components/DialTile.tsx` Substitui os 4 cards 48.5% do `SensorScreen.signalGrid` por um trilho horizontal de marcadores em formato de dial. Cada dial tem: - 56px height - Valor numérico grande à esquerda (typography.detailNumeric) - Label técnico (typography.label, colors.textDim) à direita - Marcador vertical 2px (cor semântica: amber/green/red) à esquerda - Trilho de fundo horizontal sutil Props: `{ readonly label: string; readonly value: string; readonly tone?: 'amber' | 'green' | 'red' }`. **TDD:** testa que: - Renderiza label e value. - Aplica cor correta de marker conforme `tone`. - `accessibilityLabel` combina label e value. ### 4.5 `src/components/TrackRow.tsx` Substitui `sessionStrip` do HistoryScreen. Layout: - Track number `T01`, `T02`... (typography.label, colors.textMuted) à esquerda, 24px width - Faixa lateral 2px de cor semântica (action se isOpen, line se não) - Day block (existente) mantido, 56px width - Body (existente) com título + meta + métricas - Chevron (existente) à direita - Padding interno reduzido (spacing.sm ao invés de md) Props: `{ readonly trackNumber: number; readonly day: string; readonly hour: string; readonly title: string; readonly meta: string; readonly metrics: ReadonlyArray<{ label: string; tone?: 'normal' | 'hot' }>; readonly isOpen: boolean; readonly onPress: () => void; readonly accessibilityLabel: string }`. **TDD:** testa que: - Renderiza `T{trackNumber padded 2}`. - Faixa lateral muda cor quando `isOpen`. - `onPress` é chamado. ### 4.6 `src/components/InsightRow.tsx` Substitui `InstrumentMetricRow` do InsightsScreen. Layout: - Index `01`..`08` (typography.label, colors.textDim) à esquerda, 28px width - Stack vertical: label/status, summary, detail - Valor grande à direita (typography.metricNumeric, tom semântico) - Animação de entrada: fade + translateY 8px, stagger 50ms por row (delay = index * 50) Props: `{ readonly index: number; readonly label: string; readonly statusLabel: string; readonly summary: string; readonly detail: string; readonly value: string; readonly unit?: string; readonly tone: 'good' | 'warning' | 'danger' | 'neutral'; readonly totalRows: number }`. **TDD:** testa que: - Index formatado com 2 dígitos zero-pad. - `accessibilityLabel` combina label, status, value. (Animação de stagger: o delay é calculado por `index * motion.stagger.row` e o componente wrapa em `Animated.View` com `useAnimatedStyle`.) ### 4.7 `src/hooks/usePulseAnimation.ts` Hook reutilizável para pulse de elementos "vivos": ```ts export function usePulseAnimation(active: boolean, durationMs = 1600) { const scale = useSharedValue(1); const opacity = useSharedValue(active ? 1 : 0.5); useEffect(() => { if (active) { scale.value = withRepeat( withTiming(1.4, { duration: durationMs / 2, easing: Easing.out(Easing.quad) }), -1, true, ); opacity.value = withRepeat( withTiming(0.55, { duration: durationMs / 2, easing: Easing.inOut(Easing.quad) }), -1, true, ); } else { cancelAnimation(scale); cancelAnimation(opacity); scale.value = withTiming(1, { duration: 160 }); opacity.value = withTiming(0.5, { duration: 160 }); } }, [active, durationMs, scale, opacity]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], opacity: opacity.value, })); return animatedStyle; } ``` **TDD:** testa o hook isoladamente. Em Jest com mock de reanimated (`jest.setup.js` já tem), validamos que o hook retorna `{ transform: [{ scale: 1 }], opacity: 0.5 }` no estado inicial. ## 5. Componentes modificados ### 5.1 `ActionButton` (modificado) - Adiciona `variant: 'stop' | 'primary' | 'secondary' | 'stopAlert'`. `stopAlert` é o novo estado de "Parar Janela" durante captura: fundo `colors.action`, texto `colors.text`, anel externo pulsante de `colors.ringWarn` (2px stroke, opacity 0.55, scale 1 → 1.06 → 1 em 1.6s). - Variante `stop` (cor creme) é mantida para usos não relacionados a parar treino. - `pressed`: muda de `scale 0.98 + opacity 0.88` para `scale 0.96 + opacity 0.82` (mais físico). - Label em JetBrains Mono Bold para variantes numéricas (ex.: "01:42:18"), em `typography.button` (Inter) para textos PT-BR. ### 5.2 `TrainingChart` (modificado) - Adiciona **furos laterais** ao longo do eixo X: 6 pequenos retângulos `1.5px × 4px`, cor `colors.line`, espaçados 24px, posicionados na borda inferior do viewBox, abaixo do último label de tick. Inspiração: rolo de ECG. Não interfere com gestos. - Readout (`styles.readout`) ganha LiveDot ao lado do label (somente quando `viewport.liveTail` é `true`). - Pin (quando ativo) ganha pulse de 0.5s na entrada: scale 1.4 → 1 com easeOutBack. - `ActionButton` de zoom in/out mantém forma; o botão `↺` ganha LiveDot interno se `liveTail`. ### 5.3 `SectionPanel` (modificado) - Adiciona `divider` prop (boolean). Quando `true`, renderiza apenas borda top + bottom 1px, sem padding lateral, e children se ajusta com `paddingHorizontal: spacing.md`. Usado para listas densas. - Variante `subtle` que remove `backgroundColor: colors.surfaceRaised` (mantém borda apenas). ### 5.4 `MetricTile` (mantido, com uso diminuído) - Continua existindo (alguns usos em History/Insights). Não recebe mudanças visuais, mas ganha `numeric: boolean` prop que aplica `fontFamily: 'JetBrainsMono-Bold'` quando `true`. ### 5.5 `StatusTag` (modificado) - Tones renomeados: `'amber' | 'green' | 'red'` mantidos, mas o `red` agora usa `colors.action` como borda e `rgba(226, 59, 46, 0.12)` como fundo (mais alinhado com paleta). - Adiciona `size: 'sm' | 'md'`. `sm` = 8px fontSize, padding xxs/xxs. `md` (default) = 10px (atual). ## 6. Mudanças por tela ### 6.1 `CaptureScreen` (refatorado) Componente `CaptureScreen.tsx` reestrutura sem mexer em lógica de estado. **Top rail (eyebrow + sensor name + timer):** - Eyebrow "MAGENE H003 · CINTA CARDÍACA" ganha prefixo `•` (bullet) alinhado à esquerda, 2px à esquerda do texto, cor `colors.success` se receiving, `colors.textDim` se não. - Timer ganha LiveDot ao lado de "AO VIVO" / "INATIVO". **BPM display:** - Valor principal passa de `bpmValue` (98px) para `bpmNumeric` (`displayNumeric`, JetBrains Mono ExtraBold, 110px). - BPM pulsa (scale 1 → 1.02 → 1) a cada novo sample se `monitor.phase === 'receiving'`, com `usePulseAnimation(active, 800)`. Pulse discreto — não assusta. - Ao lado do BPM number, "BPM" vira label técnico pequeno (10px) abaixo da unidade, alinhado à esquerda. **Capture rail (MÉDIA / PICO / AMOSTRAS):** - Cada valor usa `metricNumeric` (JetBrains Mono Bold 28px). - Labels técnicas mantidas. - Separadores verticais 1px em `colors.line` entre os 3. **TrainingChart:** - Versão refatorada com furos laterais + readout com LiveDot (vide 5.2). **System strip (BLE / Bateria / Fundo):** - Cada item vira `DialTile` (vide 4.4). 4 colunas em grid 2×2 ou 4×1 dependendo do viewport (testar `width < 380`). **Action grid:** - `Ativar monitor contínuo`: ActionButton primary padrão. - `Iniciar/Fechar janela de treino`: ActionButton `stopAlert` quando `isCapturing` (anel pulsante), `primary` caso contrário. - Stack vertical com `gap: spacing.sm`. **Truth line (texto explicativo no rodapé):** - Mantida, mas com ícone `Activity` em 18px (mantido) e copy levemente ajustada para PT-BR mais natural: - Antes: "Enquanto a cinta estiver reconhecida, todo BPM real é salvo. O botão de treino só marca a janela para análise." - Depois: "Cinta reconhecida = cada BPM real é salvo. Toque em Iniciar apenas para destacar uma janela para análise." ### 6.2 `HistoryScreen` (refatorado) **Mode switch (Janelas / Uso total / Faixa):** - Mantida, mas com `PressableSurface` (feedback refinado) ao invés de `Pressable` puro. **WindowHistory / ContinuousHistory (listas de sessões/blocos):** - Cada item vira `TrackRow` (vide 4.5). - Stagger reveal na entrada: cada row aparece com fade + translateY 8px → 0, delay `index * motion.stagger.item` (80ms). - Total header (archiveHeader) mantém `archiveNumber` mas usa `displayNumeric`. **WindowDetail / UsageDetail (abertos):** - TrainingChart refatorado (vide 5.2). - Detail rail (métricas) usa `metricNumeric`. - Zone stack: label à esquerda (typography.label), barra de progresso à direita (9px height, fill `colors.action`), percentual/duration à direita. Sem mudança funcional, mantém estética. **RangeHistory (Faixa customizada):** - Mantido. Inputs ganham `fontVariant: ['tabular-nums']`. **Empty states:** - `EmptyHistory` substituído por `EmptyState` (vide 4.3). Eyebrow/título/copy mantidos. Tom default `'amber'`. ### 6.3 `SensorScreen` (refatorado) **Sensor console (topo):** - `eyebrow` SENSOR REAL ganha prefixo `•` (verde se receiving, dim caso contrário). - PhaseBadge vira pill 32px height com `borderRadius: radius.xl`, borda 1px `colors.line`, fundo `colors.surface`, ícone `Bluetooth` à esquerda, label typography.label. - "Magene H003" passa para `detailNumeric` (JetBrains Mono 22px). **Signal grid:** - Substituído por 4 `DialTile` em stack vertical com `gap: spacing.xs`. Cada um 56px height, marcador vertical 2px à esquerda, valor à esquerda, label à direita. **BleInventoryBlock (Serviços e Características):** - Mantido. Adiciona separador 1px entre rows (já tem). Refinamento: `diagnosticTitle` em `typography.label` (já), `diagnosticText` em JetBrains Mono Regular 12px para alinhar UUIDs. **Discovery block (dispositivos encontrados):** - Mantido. Adiciona PulseBorder em `connectPill` quando é o dispositivo conectado (animação discreta). **Action row (Buscar sensor / Monitor contínuo ativo):** - `ActionButton` "Buscar sensor" vira primary padrão. "Bateria" vira secondary (mantido). - Pill "Monitor contínuo ativo" vira `LiveDot` + label. ### 6.4 `InsightsScreen` (refatorado) **Empty state inicial ("Sem dado real, sem conclusão"):** - Substituído por `EmptyState` com tom `'red'`. Copy ajustada para mais assertiva: - "Ative o monitor contínuo. O FightRate não inventa pico, zona, carga, VFC ou recuperação." **Instrument header (Matriz científica):** - `headerRail` (5px esquerda vermelho) refinado para 4px com borderRadius 1px (mais instrumento). - `headerTitle` "Matriz científica" em `displaySmall` (Inter 36px) mantido. - `headerScore` mantém mas usa `scoreValue` em `displayNumeric` (JetBrains Mono 36px) — números tabulares. **Evidence strip (amostras / janela / range):** - Cada `EvidenceCell` usa `detailNumeric`. **MetricMatrix (cálculos):** - Substituído por lista de `InsightRow` (vide 4.6) com stagger reveal. - Removido `metricIndexRail` separado (o index agora é parte do `InsightRow`). **Zone panel (zonas operacionais):** - Mantido. `zoneTrack` ganha `borderRadius: 2` (sutil), `zoneFill` muda de `colors.action` para gradiente horizontal com `colors.action` → `colors.warning` via `colors.stopSurface` interpolado (CSS-like via `color-mix`, mas RN não tem — usar `colors.action` para fill sólido, e a barra 9px com gradiente via `react-native-svg` se aceitarmos mais peso, ou manter fill sólido por ora). - Decisão: **manter fill sólido em `colors.action`** (sem Skia/gradiente custom). Documentado como fora de escopo. **Protocol panel (Protocolo de honestidade):** - Mantido. `protocolTitle` em 18px já é detalhe bom. **InsightStack (leituras operacionais):** - Cada `InsightNote` ganha index `01`, `02`... à esquerda (28px width, typography.label, colors.action). - Stagger reveal aplicado. **LastSessionStrip:** - Mantido. Refinamento: `lastSessionText` em `detailNumeric` JetBrains Mono 18px (números tabulares). ### 6.5 `FightRateApp` (refatorado) **Brand row (logo + title):** - Logo 54px mantido. - Kicker "FightRate" mantido (typography.label, colors.action). - Title ganha LiveDot (à esquerda do kicker) quando qualquer monitor está ativo. Bullet 8px pulsando, cor `colors.success` se `isMonitoring || isCapturing`, `colors.textDim` caso contrário. **Tab bar inferior (4 tabs):** - Pill vermelho para tab ativa mantido. - Cada tab icon (Lucide) ganha LiveDot 4px no canto superior direito se a tab estiver em estado "vivo" (ex.: tab "Captura" tem LiveDot quando `isCapturing`; tab "Sensor" tem LiveDot quando `isMonitoring`). - Press feedback refinado via `PressableSurface` (scale 0.96). - Nav rail: borda `colors.line` 1px, fundo `colors.surfaceRaised` mantido, mas ganha sombra interna sutil (inset `rgba(0,0,0,0.4)` 1px top). **Tab transition:** - Cross-fade 200ms entre tabs. Implementado via `Animated.View` com `useAnimatedStyle` em vez de swap direto de `activeTab` mudar o JSX. O `FightRateApp` envolve o conteúdo em `Animated.View` e troca a opacidade com `withTiming(target, { duration: 200 })`. Isso é leve e evita stack de navegação real (mantém o padrão atual de tab sem router). **Nav lateral e status:** - Nenhuma mudança funcional. ## 7. Animações — especificação detalhada | Nome | Tipo | Trigger | Duração | Easing | Propriedades | |---|---|---|---|---|---| | `bpmPulse` | loop condicional | `monitor.phase === 'receiving'` | 800ms | `easeInOutQuad` | scale 1 → 1.02 → 1 | | `liveDotPulse` | loop | `live = true` | 1600ms | `easeInOutQuad` | scale 1 → 1.4 → 1, opacity 1 → 0.55 → 1 | | `stopAlertRing` | loop condicional | `isCapturing` | 1600ms | `easeInOutQuad` | scale 1 → 1.06 → 1, opacity 0.55 → 0.2 → 0.55 | | `tabFade` | one-shot | troca de tab | 200ms | `easeOutQuart` | opacity 0 → 1 | | `rowEnter` | one-shot | mount de row em lista | 240ms | `easeOutQuart` | opacity 0 → 1, translateY 8 → 0 | | `pinEnter` | one-shot | pin set | 500ms | `easeOutBack` | scale 1.4 → 1, opacity 0 → 1 | | `pressDown` | one-shot | press start | 80ms | `easeOutQuart` | scale 1 → 0.96, opacity 1 → 0.82 | | `pressUp` | one-shot | press end | 160ms | `easeOutQuart` | scale 0.96 → 1, opacity 0.82 → 1 | `reduceMotion` é respeitado: quando `AccessibilityInfo.isReduceMotionEnabled()` é `true`, todas as animações viram instantâneas (duração 0ms, valor final direto). Implementado em `src/hooks/useMotionPreference.ts`. ## 8. Estrutura de arquivos — alterações ### Criados - `assets/fonts/JetBrainsMono-Regular.ttf` - `assets/fonts/JetBrainsMono-Medium.ttf` - `assets/fonts/JetBrainsMono-Bold.ttf` - `assets/fonts/JetBrainsMono-ExtraBold.ttf` - `src/components/LiveDot.tsx` - `src/components/PressableSurface.tsx` - `src/components/EmptyState.tsx` - `src/components/DialTile.tsx` - `src/components/TrackRow.tsx` - `src/components/InsightRow.tsx` - `src/hooks/usePulseAnimation.ts` - `src/hooks/useMotionPreference.ts` - `src/theme/motion.ts` - `src/__tests__/LiveDot.test.tsx` - `src/__tests__/PressableSurface.test.tsx` - `src/__tests__/EmptyState.test.tsx` - `src/__tests__/DialTile.test.tsx` - `src/__tests__/TrackRow.test.tsx` - `src/__tests__/InsightRow.test.tsx` - `src/__tests__/usePulseAnimation.test.tsx` - `src/__tests__/motion.test.ts` - `src/__tests__/useMotionPreference.test.ts` ### Modificados - `react-native.config.js` (adiciona `assets: ['./assets/fonts']`) - `react-native-asset` linka fontes em `android/app/src/main/assets/fonts/` automaticamente após `npx react-native-asset`. Não exige mudança manual no `build.gradle`. - `src/theme/index.ts` (exporta `motion`) - `src/theme/typography.ts` (adiciona `displayNumeric`, `displaySmallNumeric`, `metricNumeric`, `detailNumeric`) - `src/theme/colors.ts` (adiciona `ringWarn`) - `src/components/ActionButton.tsx` (variante `stopAlert` + jetBrains mono labels) - `src/components/StatusTag.tsx` (`size` prop + tom red refinado) - `src/components/SectionPanel.tsx` (`divider` + `subtle`) - `src/components/MetricTile.tsx` (`numeric` prop) - `src/components/TrainingChart.tsx` (furos laterais + LiveDot no readout + pulse no pin) - `src/screens/CaptureScreen.tsx` (refatorado com novos componentes) - `src/screens/HistoryScreen.tsx` (TrackRow + EmptyState + stagger) - `src/screens/SensorScreen.tsx` (DialTile + LiveDot) - `src/screens/InsightsScreen.tsx` (InsightRow + EmptyState) - `src/app/FightRateApp.tsx` (tab fade + LiveDot na brand row + tab bar live indicator) - `jest.setup.js` (mock de `react-native-reanimated` para hooks animados — verificar se já tem) ### Removidos - Nenhum arquivo é removido. Componentes legados (`ActionButton` sem `stopAlert`, `EmptyHistory` inline no `HistoryScreen`) são substituídos em uso mas mantidos no código durante transição. Remoção fica para refactor posterior se desejado. ## 9. Critérios de aceitação ### Build - [ ] `npm run typecheck` passa com 0 erros. - [ ] `npm run lint` passa com 0 erros. - [ ] `npm test -- --runInBand` passa com 0 falhas (todos os novos testes inclusos, 55 antigos + 9 novos ≈ 64). - [ ] `npm audit --audit-level=moderate` passa. - [ ] `cd android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a` produz `app-arm64-v8a-release.apk`. - [ ] APK contém `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` (validar com `unzip -l `). ### Visual - [ ] BPM display em CaptureScreen usa JetBrains Mono ExtraBold 110px com `fontVariant: tabular-nums` (dígitos alinham perfeitamente em uma sequência de valores). - [ ] LiveDot verde 8px visível ao lado do "AO VIVO" em CaptureScreen quando `isMonitoring || isCapturing`. - [ ] Botão "Fechar janela de treino" tem anel vermelho pulsante ao redor (escala 1 → 1.06 → 1) quando `isCapturing`. - [ ] TrainingChart tem 6 retângulos `1.5×4px` na borda inferior do gráfico, espaçados uniformemente. - [ ] SensorScreen mostra 4 DialTile em stack vertical, cada um com marcador vertical 2px à esquerda. - [ ] HistoryScreen mostra `T01`, `T02`... antes de cada session day block. - [ ] InsightsScreen mostra index `01`..`08` no `InsightRow`, com fade+translateY de entrada. - [ ] Tab bar inferior mostra LiveDot 4px em "Captura" quando `isCapturing`, e em "Sensor" quando `isMonitoring`. - [ ] Transição entre tabs é cross-fade 200ms (sem flicker). ### Acessibilidade - [ ] `accessibilityLabel` de cada `LiveDot` é "AO VIVO" ou "INATIVO" conforme estado. - [ ] `AccessibilityInfo.isReduceMotionEnabled()` retorna `true`: todas as animações viram instantâneas (testado manualmente). - [ ] Touch targets: nenhum botão tem `minHeight < 44`. - [ ] Contraste de texto: nenhum par texto/fundo tem ratio < 4.5:1. ### Regressões - [ ] TrainingChart gestos (pinch, pan, tap-pin) continuam funcionando após refinos de furos/LiveDot/pin pulse. - [ ] BLE monitor (`useHeartRateMonitor`) não foi tocado — comportamento idêntico. - [ ] SQLite store não foi tocado. - [ ] Aplicação ainda instala por cima da versão anterior com `applicationId: com.healthtracking.fightrate`. ## 10. Fora de escopo - Reescrita do TrainingChart (já tem viewport + zoom/pan/pin sólidos). - Mudanças em `useHeartRateMonitor`, `FightRateCaptureNative`, `HeartRateForegroundService`, `FightRateCaptureStore`. - Mudanças em paleta (cores, raios, espaçamentos existentes permanecem). - Dark mode toggle (sempre dark; app é instrumento de captura em ambiente escuro). - Internacionalização (PT-BR permanece). - Novos ícones além dos Lucide já importados. - Acessibilidade avançada (VoiceOver/TalkBack em todos os elementos além do mínimo WCAG). - Skia para gradientes ou sombras complexas. - Lottie para animações decorativas. - Novos testes E2E (Detox/Maestro) — só unit + integração. - Performance profiling detalhado (apenas smoke test de 60fps durante gestos). ## 11. Riscos e mitigações | Risco | Probabilidade | Impacto | Mitigação | |---|---|---|---| | `react-native-asset` não linkar fontes no Android | Média | Alto — texto vira Inter em vez de JetBrains Mono | Verificar APK após build; fallback: configurar `android/app/build.gradle` com `fonts` resource manually; documentar | | `usePulseAnimation` quebrar gestos do TrainingChart | Baixa | Alto — regressão funcional | Manter TrainingChart gestures intactos; pulse só em wrappers separados | | Stagger reveal com 50+ rows laggar | Média | Médio — UX fica travada | Limitar stagger a 8 rows visíveis (index < 8 usa delay, resto = 0) | | Tabs fade conflitando com re-render do JSX | Média | Baixo — flicker | Usar `Animated.View` com `key` estável por tab, `useAnimatedStyle` para opacity, swap interno instantâneo | | Bundle APK crescer > 5 MB | Baixa | Médio — usuário pediu release enxuto | JetBrains Mono são ~270KB cada, total 1.1MB. Verificar delta no APK final | | `useMotionPreference` hook quebrar em RN testing env | Baixa | Baixo — testes falham | Mock `AccessibilityInfo` em `jest.setup.js` | ## 12. Métricas de qualidade (pós-ship) - Tela Captura: tempo para identificar estado (idle/monitoring/capturing) < 1s (medido por observação do usuário). - HistoryScreen: scroll suave com 100+ sessions (validar com throttle de 4x no dev tools). - InsightsScreen: 8 rows renderizam sem jank visível. - Build time: `assembleRelease` < 5min. - APK size delta: < 2 MB vs versão anterior (32 MB → ≤ 34 MB). ## 13. Próximos passos (handoff para writing-plans) A spec está pronta para ser quebrada em plano de implementação TDD-ready. A skill `superpowers:writing-plans` deve produzir: - 1 plan em `docs/superpowers/plans/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md`. - Tasks agrupadas em 6 fatias (Foundations → Atomics → Captura → Histórico+Sensor → Insights+Nav → Verify+Ship). - Cada task com `Files:`, `Step 1: Write failing test`, `Step 2: Run test to verify it fails`, `Step 3: Write minimal implementation`, `Step 4: Run test to verify it passes`, `Step 5: Commit`. - Critérios de aceitação desta spec usados como `Given/When/Then` no nível de fatia. Execução: prefer `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` se o agente quiser inline.