Files
HealthTracking/docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md
T
fightrate-bot b7a6b78ff8 spec: refinamento UI/UX Instrumento Forte + JetBrains Mono
- Spec 2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md
- Direção B confirmada: LED pulsante, JetBrains Mono tabular, furos no gráfico, anel de aviso no Stop, dial tiles no Sensor
- 6 novos componentes, 2 hooks, motion.ts, ~9 novos testes TDD
- Fontes JetBrains Mono (Regular, Medium, Bold, ExtraBold) em assets/fonts/
2026-06-16 01:36:42 -03:00

29 KiB
Raw Blame History

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.

// 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\:
    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

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):

// 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 <View> 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":

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 <apk>).

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.