diff --git a/assets/fonts/JetBrainsMono-Bold.ttf b/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..cd1bee0 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Bold.ttf differ diff --git a/assets/fonts/JetBrainsMono-ExtraBold.ttf b/assets/fonts/JetBrainsMono-ExtraBold.ttf new file mode 100644 index 0000000..437259b Binary files /dev/null and b/assets/fonts/JetBrainsMono-ExtraBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..dc2e5d0 Binary files /dev/null and b/assets/fonts/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md b/docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md new file mode 100644 index 0000000..154cab3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md @@ -0,0 +1,548 @@ +# 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.