- 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/
29 KiB
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)
- Instrumento sobre dashboard. Cada elemento deve parecer um dial, trilho, marcador ou botão físico. Nada de cards uniformes.
- 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. - Vivo sem distrair. Animações comunicam estado (captura ativa, pin selecionado, pulse de BPM). Nunca decorativas, nunca durante treino ativo.
- Hierarquia assimétrica. Lados esquerdo e direito não são simétricos. Datas, labels, valores e ícones têm pesos diferentes, alinhamentos diferentes.
- Hardware reading culture. Status, BLE, bateria, RSSI aparecem como LEDs e marcadores discretos, não como caixas com sombra.
- 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):
assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttfjá baixado.react-native.config.jsemD:\h\:module.exports = { project: { android: { packageName: 'com.healthtracking.fightrate', sourceDir: './android' } }, assets: ['./assets/fonts'], };npx react-native-asset(já é dep transitiva via@react-native-community/cli). Gera o link emandroid/app/src/main/assets/fonts/e atualizaapp/build.gradlecomapply from: "../../node_modules/react-native-vector-icons/fonts.gradle"(não usamos, então esse passo é pulado).- 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 combackgroundColor = colors.successquandolive = true. - Renderiza 8×8 com
backgroundColor = colors.textDimquandolive = false. accessibilityLabelrecebe "AO VIVO" quandolivee "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
stylerecebido 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. accessibilityLabelcombina 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.
accessibilityLabelcombina 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: fundocolors.action, textocolors.text, anel externo pulsante decolors.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 descale 0.98 + opacity 0.88parascale 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, corcolors.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 quandoviewport.liveTailétrue). - Pin (quando ativo) ganha pulse de 0.5s na entrada: scale 1.4 → 1 com easeOutBack.
ActionButtonde zoom in/out mantém forma; o botão↺ganha LiveDot interno seliveTail.
5.3 SectionPanel (modificado)
- Adiciona
dividerprop (boolean). Quandotrue, renderiza apenas borda top + bottom 1px, sem padding lateral, e children se ajusta compaddingHorizontal: spacing.md. Usado para listas densas. - Variante
subtleque removebackgroundColor: 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: booleanprop que aplicafontFamily: 'JetBrainsMono-Bold'quandotrue.
5.5 StatusTag (modificado)
- Tones renomeados:
'amber' | 'green' | 'red'mantidos, mas oredagora usacolors.actioncomo borda ergba(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, corcolors.successse receiving,colors.textDimse não. - Timer ganha LiveDot ao lado de "AO VIVO" / "INATIVO".
BPM display:
- Valor principal passa de
bpmValue(98px) parabpmNumeric(displayNumeric, JetBrains Mono ExtraBold, 110px). - BPM pulsa (scale 1 → 1.02 → 1) a cada novo sample se
monitor.phase === 'receiving', comusePulseAnimation(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.lineentre 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 (testarwidth < 380).
Action grid:
Ativar monitor contínuo: ActionButton primary padrão.Iniciar/Fechar janela de treino: ActionButtonstopAlertquandoisCapturing(anel pulsante),primarycaso contrário.- Stack vertical com
gap: spacing.sm.
Truth line (texto explicativo no rodapé):
- Mantida, mas com ícone
Activityem 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 dePressablepuro.
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
archiveNumbermas usadisplayNumeric.
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:
EmptyHistorysubstituído porEmptyState(vide 4.3). Eyebrow/título/copy mantidos. Tom default'amber'.
6.3 SensorScreen (refatorado)
Sensor console (topo):
eyebrowSENSOR REAL ganha prefixo•(verde se receiving, dim caso contrário).- PhaseBadge vira pill 32px height com
borderRadius: radius.xl, borda 1pxcolors.line, fundocolors.surface, íconeBluetoothà esquerda, label typography.label. - "Magene H003" passa para
detailNumeric(JetBrains Mono 22px).
Signal grid:
- Substituído por 4
DialTileem stack vertical comgap: 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:
diagnosticTitleemtypography.label(já),diagnosticTextem JetBrains Mono Regular 12px para alinhar UUIDs.
Discovery block (dispositivos encontrados):
- Mantido. Adiciona PulseBorder em
connectPillquando é 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
EmptyStatecom 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" emdisplaySmall(Inter 36px) mantido.headerScoremantém mas usascoreValueemdisplayNumeric(JetBrains Mono 36px) — números tabulares.
Evidence strip (amostras / janela / range):
- Cada
EvidenceCellusadetailNumeric.
MetricMatrix (cálculos):
- Substituído por lista de
InsightRow(vide 4.6) com stagger reveal. - Removido
metricIndexRailseparado (o index agora é parte doInsightRow).
Zone panel (zonas operacionais):
- Mantido.
zoneTrackganhaborderRadius: 2(sutil),zoneFillmuda decolors.actionpara gradiente horizontal comcolors.action→colors.warningviacolors.stopSurfaceinterpolado (CSS-like viacolor-mix, mas RN não tem — usarcolors.actionpara fill sólido, e a barra 9px com gradiente viareact-native-svgse 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.
protocolTitleem 18px já é detalhe bom.
InsightStack (leituras operacionais):
- Cada
InsightNoteganha index01,02... à esquerda (28px width, typography.label, colors.action). - Stagger reveal aplicado.
LastSessionStrip:
- Mantido. Refinamento:
lastSessionTextemdetailNumericJetBrains 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.successseisMonitoring || isCapturing,colors.textDimcaso 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 quandoisMonitoring). - Press feedback refinado via
PressableSurface(scale 0.96). - Nav rail: borda
colors.line1px, fundocolors.surfaceRaisedmantido, mas ganha sombra interna sutil (insetrgba(0,0,0,0.4)1px top).
Tab transition:
- Cross-fade 200ms entre tabs. Implementado via
Animated.ViewcomuseAnimatedStyleem vez de swap direto deactiveTabmudar o JSX. OFightRateAppenvolve o conteúdo emAnimated.Viewe troca a opacidade comwithTiming(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.ttfassets/fonts/JetBrainsMono-Medium.ttfassets/fonts/JetBrainsMono-Bold.ttfassets/fonts/JetBrainsMono-ExtraBold.ttfsrc/components/LiveDot.tsxsrc/components/PressableSurface.tsxsrc/components/EmptyState.tsxsrc/components/DialTile.tsxsrc/components/TrackRow.tsxsrc/components/InsightRow.tsxsrc/hooks/usePulseAnimation.tssrc/hooks/useMotionPreference.tssrc/theme/motion.tssrc/__tests__/LiveDot.test.tsxsrc/__tests__/PressableSurface.test.tsxsrc/__tests__/EmptyState.test.tsxsrc/__tests__/DialTile.test.tsxsrc/__tests__/TrackRow.test.tsxsrc/__tests__/InsightRow.test.tsxsrc/__tests__/usePulseAnimation.test.tsxsrc/__tests__/motion.test.tssrc/__tests__/useMotionPreference.test.ts
Modificados
react-native.config.js(adicionaassets: ['./assets/fonts'])react-native-assetlinka fontes emandroid/app/src/main/assets/fonts/automaticamente apósnpx react-native-asset. Não exige mudança manual nobuild.gradle.src/theme/index.ts(exportamotion)src/theme/typography.ts(adicionadisplayNumeric,displaySmallNumeric,metricNumeric,detailNumeric)src/theme/colors.ts(adicionaringWarn)src/components/ActionButton.tsx(variantestopAlert+ jetBrains mono labels)src/components/StatusTag.tsx(sizeprop + tom red refinado)src/components/SectionPanel.tsx(divider+subtle)src/components/MetricTile.tsx(numericprop)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 dereact-native-reanimatedpara hooks animados — verificar se já tem)
Removidos
- Nenhum arquivo é removido. Componentes legados (
ActionButtonsemstopAlert,EmptyHistoryinline noHistoryScreen) 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 typecheckpassa com 0 erros.npm run lintpassa com 0 erros.npm test -- --runInBandpassa com 0 falhas (todos os novos testes inclusos, 55 antigos + 9 novos ≈ 64).npm audit --audit-level=moderatepassa.cd android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8aproduzapp-arm64-v8a-release.apk.- APK contém
assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf(validar comunzip -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×4pxna 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..08noInsightRow, com fade+translateY de entrada. - Tab bar inferior mostra LiveDot 4px em "Captura" quando
isCapturing, e em "Sensor" quandoisMonitoring. - Transição entre tabs é cross-fade 200ms (sem flicker).
Acessibilidade
accessibilityLabelde cadaLiveDoté "AO VIVO" ou "INATIVO" conforme estado.AccessibilityInfo.isReduceMotionEnabled()retornatrue: 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/Thenno nível de fatia.
Execução: prefer superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans se o agente quiser inline.