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/
This commit is contained in:
fightrate-bot committed 2026-06-16 01:36:42 -03:00
1 parent 41b49ba442
commit b7a6b78ff8
5 files changed
+548

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 `<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.