Files
HealthTracking/docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md
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

549 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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":
```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 <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.