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:
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.
|
||||
Reference in new issue
Block a user