Files
HealthTracking/docs/superpowers/plans/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md

116 KiB
Raw Permalink Blame History

FightRate RN — Refinamento UI/UX Instrumento Forte (Direção B) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implementar a Direção B (Instrumento Forte) do refinamento UI/UX do FightRate RN conforme spec 2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md. Adicionar JetBrains Mono, LED pulsante, números tabulares, furos no TrainingChart, anel de aviso no Stop, dial tiles no Sensor, stagger reveal, e microanimações orquestradas.

Architecture: React Native bare, sem novas deps externas. Adiciona ~6 componentes, 2 hooks, 1 módulo de motion. Tokens adicionados ao src/theme/. Componentes existentes modificados sem breaking change.

Tech Stack: React Native 0.86, TypeScript 5.8, Reanimated 4.4 + Worklets 0.9, Gesture Handler 3.0, react-native-svg 15.15, lucide-react-native, Jest 29.

Reference spec: docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md

Conventions: Every commit messages follow Conventional Commits. PT-BR visible UI; English code identifiers. Run npm run typecheck && npm run lint && npm test -- --runInBand before each commit.


File Structure

New files

  • assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf (already downloaded)
  • src/theme/motion.ts
  • src/hooks/usePulseAnimation.ts
  • src/hooks/useMotionPreference.ts
  • 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/__tests__/motion.test.ts
  • src/__tests__/useMotionPreference.test.ts
  • src/__tests__/usePulseAnimation.test.tsx
  • 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

Modified files

  • react-native.config.js
  • src/theme/index.ts
  • src/theme/typography.ts
  • src/theme/colors.ts
  • src/components/ActionButton.tsx
  • src/components/StatusTag.tsx
  • src/components/SectionPanel.tsx
  • src/components/MetricTile.tsx
  • src/components/TrainingChart.tsx
  • src/screens/CaptureScreen.tsx
  • src/screens/HistoryScreen.tsx
  • src/screens/SensorScreen.tsx
  • src/screens/InsightsScreen.tsx
  • src/app/FightRateApp.tsx
  • FIGHTRATE_DESIGN_SYSTEM.md

F1 — Foundations

Task F1.1: Wire JetBrains Mono via react-native.config.js

Files:

  • Modify: react-native.config.js (root)

  • Verify: android/app/src/main/assets/fonts/JetBrainsMono-Bold.ttf (after link)

  • Step 1: Inspect current config

Run: ls /d/h/react-native.config.js 2>/dev/null || echo "NOT FOUND"

Expected: NOT FOUND (file doesn't exist yet — was deleted during RN rebuild)

  • Step 2: Create the config file

Create react-native.config.js at repo root with this exact content:

module.exports = {
  project: {
    android: {
      packageName: 'com.healthtracking.fightrate',
      sourceDir: './android',
    },
  },
  assets: ['./assets/fonts'],
};
  • Step 3: Link fonts into Android assets

Run: cd /d/h && npx --no-install react-native-asset 2>&1 | tail -10

Expected: creates android/app/src/main/assets/fonts/JetBrainsMono-*.ttf (4 files). If react-native-asset is not installed, install it first: npm install --save-dev react-native-asset.

If react-native-asset is unavailable as CLI, copy the files directly:

mkdir -p /d/h/android/app/src/main/assets/fonts
cp /d/h/assets/fonts/JetBrainsMono-*.ttf /d/h/android/app/src/main/assets/fonts/
  • Step 4: Verify assets are linked

Run: ls /d/h/android/app/src/main/assets/fonts/

Expected: 4 ttf files.

  • Step 5: Commit
cd /d/h && git add react-native.config.js android/app/src/main/assets/fonts/ 2>/dev/null
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(assets): link JetBrains Mono via react-native.config.js"

Task F1.2: theme/motion.ts (presets de timing e easing)

Files:

  • Create: src/theme/motion.ts

  • Modify: src/theme/index.ts

  • Test: src/__tests__/motion.test.ts

  • Step 1: Write the failing test

Create src/__tests__/motion.test.ts:

import { motion } from '../theme/motion';

describe('motion', () => {
  it('exports bezier strings for easing presets', () => {
    expect(motion.easeOutQuart).toBe('cubic-bezier(0.25, 1, 0.5, 1)');
    expect(motion.easeOutExpo).toBe('cubic-bezier(0.16, 1, 0.3, 1)');
    expect(motion.easeOutBack).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)');
    expect(motion.easeInOutQuart).toBe('cubic-bezier(0.76, 0, 0.24, 1)');
  });

  it('exports duration presets in ms', () => {
    expect(motion.duration.instant).toBe(80);
    expect(motion.duration.fast).toBe(160);
    expect(motion.duration.normal).toBe(240);
    expect(motion.duration.slow).toBe(380);
    expect(motion.duration.pulse).toBe(800);
    expect(motion.duration.pulseSlow).toBe(1600);
  });

  it('exports stagger offsets', () => {
    expect(motion.stagger.row).toBe(50);
    expect(motion.stagger.item).toBe(80);
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -10

Expected: FAIL — cannot find ../theme/motion.

  • Step 3: Write minimal implementation

Create src/theme/motion.ts:

export const motion = {
  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: {
    instant: 80,
    fast: 160,
    normal: 240,
    slow: 380,
    pulse: 800,
    pulseSlow: 1600,
  },

  stagger: {
    row: 50,
    item: 80,
  },
} as const;

export type MotionDuration = keyof typeof motion.duration;
export type MotionEasing = keyof Pick<
  typeof motion,
  'easeOutQuart' | 'easeOutExpo' | 'easeOutBack' | 'easeInOutQuart'
>;
  • Step 4: Re-export from theme index

Edit src/theme/index.ts:

export { colors } from './colors';
export { radius } from './radius';
export { spacing } from './spacing';
export { typography } from './typography';
export { motion } from './motion';
  • Step 5: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -5

Expected: PASS — 3 tests passed.

  • Step 6: Commit
cd /d/h && git add src/theme/motion.ts src/theme/index.ts src/__tests__/motion.test.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): motion presets (timing, easing, stagger)"

Task F1.3: theme/typography.ts (add numeric tokens for JetBrains Mono)

Files:

  • Modify: src/theme/typography.ts

  • Verify: existing tests still pass

  • Step 1: Add numeric tokens

Edit src/theme/typography.ts to add these keys (preserving existing display, displaySmall, label, body, button):

export const typography = {
  display: {
    fontSize: 82,
    lineHeight: 74,
    fontWeight: '900' as const,
    letterSpacing: -7,
  },
  displaySmall: {
    fontSize: 48,
    lineHeight: 46,
    fontWeight: '900' as const,
    letterSpacing: -3,
  },
  displayNumeric: {
    fontFamily: 'JetBrainsMono-ExtraBold',
    fontVariant: ['tabular-nums'] as const,
    fontSize: 110,
    lineHeight: 100,
    fontWeight: '900' as const,
    letterSpacing: -7,
  },
  displaySmallNumeric: {
    fontFamily: 'JetBrainsMono-ExtraBold',
    fontVariant: ['tabular-nums'] as const,
    fontSize: 56,
    lineHeight: 54,
    fontWeight: '900' as const,
    letterSpacing: -3,
  },
  metricNumeric: {
    fontFamily: 'JetBrainsMono-Bold',
    fontVariant: ['tabular-nums'] as const,
    fontSize: 28,
    lineHeight: 30,
    fontWeight: '800' as const,
    letterSpacing: -1.2,
  },
  detailNumeric: {
    fontFamily: 'JetBrainsMono-Bold',
    fontVariant: ['tabular-nums'] as const,
    fontSize: 18,
    lineHeight: 22,
    fontWeight: '800' as const,
    letterSpacing: -0.8,
  },
  label: {
    fontSize: 10,
    lineHeight: 13,
    fontWeight: '900' as const,
    letterSpacing: 1.4,
    textTransform: 'uppercase' as const,
  },
  body: {
    fontSize: 15,
    lineHeight: 22,
    fontWeight: '500' as const,
  },
  button: {
    fontSize: 12,
    lineHeight: 16,
    fontWeight: '900' as const,
    letterSpacing: 0.8,
    textTransform: 'uppercase' as const,
  },
} as const;
  • Step 2: Run typecheck

Run: cd /d/h && npm run typecheck 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: Run existing tests to verify no regression

Run: cd /d/h && npm test -- --runInBand 2>&1 | tail -10

Expected: all 55 tests still pass.

  • Step 4: Commit
cd /d/h && git add src/theme/typography.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): numeric typography tokens (JetBrains Mono tabular)"

Task F1.4: theme/colors.ts (add ringWarn for stop alert ring)

Files:

  • Modify: src/theme/colors.ts

  • Step 1: Add the new key

Edit src/theme/colors.ts — add ringWarn after the stopText line:

export const colors = {
  canvas: '#070605',
  canvasSoft: '#0D0906',
  surface: '#12100C',
  surfaceRaised: '#1D170F',
  surfaceHot: '#251A10',
  text: '#F8EAD8',
  textMuted: '#B9A98E',
  textDim: '#746754',
  line: '#392D1E',
  action: '#E23B2E',
  warning: '#FFB43D',
  success: '#34C77B',
  stopSurface: '#FFF0DC',
  stopText: '#120B07',
  ringWarn: 'rgba(226, 59, 46, 0.55)',
  black: '#030302',
} as const;
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, all 55 tests pass.

  • Step 3: Commit
cd /d/h && git add src/theme/colors.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(theme): add ringWarn color for stop alert ring"

Task F1.5: useMotionPreference hook

Files:

  • Create: src/hooks/useMotionPreference.ts

  • Test: src/__tests__/useMotionPreference.test.ts

  • Step 1: Write the failing test

Create src/__tests__/useMotionPreference.test.ts:

import { act, renderHook } from '@testing-library/react-native';
import {
  useMotionPreference,
  type MotionPreferenceListener,
} from '../hooks/useMotionPreference';

type AccessibilityInfoMock = {
  isReduceMotionEnabled: jest.Mock<Promise<boolean>, []>;
  addEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
  removeEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
};

const mockAccessibilityInfo: AccessibilityInfoMock = {
  isReduceMotionEnabled: jest.fn(),
  addEventListener: jest.fn(),
  removeEventListener: jest.fn(),
};

jest.mock('react-native', () => ({
  AccessibilityInfo: mockAccessibilityInfo,
}));

describe('useMotionPreference', () => {
  beforeEach(() => {
    mockAccessibilityInfo.isReduceMotionEnabled.mockReset();
    mockAccessibilityInfo.addEventListener.mockReset();
    mockAccessibilityInfo.removeEventListener.mockReset();
    mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValue(false);
  });

  it('starts as false by default and resolves to the system value', async () => {
    mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValueOnce(true);

    const { result, waitForNextUpdate } = renderHook(() =>
      useMotionPreference(),
    );

    expect(result.current).toBe(false);
    await waitForNextUpdate();
    expect(result.current).toBe(true);
  });

  it('subscribes to reduceMotionChanged and updates state', () => {
    let listener: MotionPreferenceListener | undefined;
    mockAccessibilityInfo.addEventListener.mockImplementation((event, fn) => {
      if (event === 'reduceMotionChanged') {
        listener = fn;
      }
    });

    const { result } = renderHook(() => useMotionPreference());
    expect(result.current).toBe(false);

    act(() => {
      listener?.(true);
    });
    expect(result.current).toBe(true);
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -10

Expected: FAIL — cannot find ../hooks/useMotionPreference.

  • Step 3: Write minimal implementation

Create src/hooks/useMotionPreference.ts:

import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';

export type MotionPreferenceListener = (reduceMotion: boolean) => void;

export function useMotionPreference(): boolean {
  const [reduceMotion, setReduceMotion] = useState(false);

  useEffect(() => {
    let mounted = true;
    AccessibilityInfo.isReduceMotionEnabled()
      .then((value) => {
        if (mounted) setReduceMotion(value);
      })
      .catch(() => undefined);

    const listener: MotionPreferenceListener = (value) => {
      if (mounted) setReduceMotion(value);
    };
    AccessibilityInfo.addEventListener('reduceMotionChanged', listener);

    return () => {
      mounted = false;
      AccessibilityInfo.removeEventListener('reduceMotionChanged', listener);
    };
  }, []);

  return reduceMotion;
}
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/hooks/useMotionPreference.ts src/__tests__/useMotionPreference.test.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(hooks): useMotionPreference for reduce motion respect"

F2 — Atomic components (TDD)

Task F2.1: usePulseAnimation hook

Files:

  • Create: src/hooks/usePulseAnimation.ts

  • Test: src/__tests__/usePulseAnimation.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/usePulseAnimation.test.tsx:

import { act, renderHook } from '@testing-library/react-native';
import { usePulseAnimation } from '../hooks/usePulseAnimation';

jest.mock('react-native-reanimated', () => {
  const values: Record<number, { value: number }> = {};
  let counter = 0;
  return {
    Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
    useSharedValue: (initial: number) => {
      const ref = { value: initial };
      values[counter] = ref;
      counter += 1;
      return ref;
    },
    useAnimatedStyle: (fn: () => unknown) => fn(),
    withTiming: (to: number) => to,
    withRepeat: (to: number) => to,
    cancelAnimation: () => undefined,
  };
});

describe('usePulseAnimation', () => {
  it('returns initial opacity 0.5 and scale 1 when inactive', () => {
    const { result } = renderHook(() => usePulseAnimation(false));
    const style = result.current as { transform: Array<{ scale: number }>; opacity: number };
    expect(style.transform[0].scale).toBe(1);
    expect(style.opacity).toBe(0.5);
  });

  it('returns opacity 1 when active', () => {
    const { result } = renderHook(() => usePulseAnimation(true));
    const style = result.current as { opacity: number };
    expect(style.opacity).toBe(1);
  });

  it('updates opacity when active flag changes', () => {
    const { result, rerender } = renderHook(
      ({ active }: { active: boolean }) => usePulseAnimation(active),
      { initialProps: { active: false } },
    );
    let style = result.current as { opacity: number };
    expect(style.opacity).toBe(0.5);

    act(() => {
      rerender({ active: true });
    });
    style = result.current as { opacity: number };
    expect(style.opacity).toBe(1);
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/hooks/usePulseAnimation.ts:

import { useEffect } from 'react';
import {
  Easing,
  cancelAnimation,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';

type AnimatedStyle = {
  transform: Array<{ scale: number }>;
  opacity: number;
};

export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle {
  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]);

  return useAnimatedStyle<AnimatedStyle>(() => ({
    transform: [{ scale: scale.value }],
    opacity: opacity.value,
  }));
}
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/hooks/usePulseAnimation.ts src/__tests__/usePulseAnimation.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(hooks): usePulseAnimation for live indicators"

Task F2.2: LiveDot component

Files:

  • Create: src/components/LiveDot.tsx

  • Test: src/__tests__/LiveDot.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/LiveDot.test.tsx:

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { LiveDot } from '../components/LiveDot';

jest.mock('react-native-reanimated', () => {
  const ReactMod = require('react');
  return {
    __esModule: true,
    default: { View: ({ children }: { children: React.ReactNode }) => ReactMod.createElement('View', null, children) },
    Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
    useSharedValue: (initial: number) => ({ value: initial }),
    useAnimatedStyle: (fn: () => unknown) => fn(),
    withTiming: (to: number) => to,
    withRepeat: (to: number) => to,
    cancelAnimation: () => undefined,
  };
});

describe('LiveDot', () => {
  it('renders with success color when live=true', () => {
    render(<LiveDot live testID="dot" />);
    const node = screen.getByTestId('dot');
    expect(node.props.accessibilityLabel).toBe('AO VIVO');
  });

  it('renders with INATIVO label when live=false', () => {
    render(<LiveDot live={false} testID="dot" />);
    const node = screen.getByTestId('dot');
    expect(node.props.accessibilityLabel).toBe('INATIVO');
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/LiveDot.tsx:

import React from 'react';
import { StyleSheet, View, type ViewStyle } from 'react-native';
import Animated from 'react-native-reanimated';
import { colors } from '../theme';
import { usePulseAnimation } from '../hooks/usePulseAnimation';

type Props = {
  readonly live: boolean;
  readonly size?: number;
  readonly testID?: string;
};

export function LiveDot({ live, size = 8, testID }: Props) {
  const animatedStyle = usePulseAnimation(live);
  const dotStyle: ViewStyle = {
    width: size,
    height: size,
    borderRadius: size / 2,
    backgroundColor: live ? colors.success : colors.textDim,
  };

  return (
    <Animated.View
      accessibilityLabel={live ? 'AO VIVO' : 'INATIVO'}
      accessibilityRole="image"
      style={[styles.base, dotStyle, animatedStyle]}
      testID={testID}
    />
  );
}

const styles = StyleSheet.create({
  base: {
    alignSelf: 'center',
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/LiveDot.tsx src/__tests__/LiveDot.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): LiveDot pulsing indicator"

Task F2.3: PressableSurface component

Files:

  • Create: src/components/PressableSurface.tsx

  • Test: src/__tests__/PressableSurface.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/PressableSurface.test.tsx:

import React from 'react';
import { Text } from 'react-native';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { PressableSurface } from '../components/PressableSurface';

jest.mock('react-native-reanimated', () => {
  const ReactMod = require('react');
  const RN = require('react-native');
  return {
    __esModule: true,
    default: {
      View: RN.View,
      createAnimatedComponent: (Component: unknown) => Component,
    },
    Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 },
    useSharedValue: (initial: number) => ({ value: initial }),
    useAnimatedStyle: (fn: () => unknown) => fn(),
    withTiming: (to: number) => to,
    withRepeat: (to: number) => to,
    cancelAnimation: () => undefined,
  };
});

describe('PressableSurface', () => {
  it('renders children inside pressable', () => {
    render(
      <PressableSurface onPress={() => undefined} testID="surface">
        <Text>Hi</Text>
      </PressableSurface>,
    );
    expect(screen.getByText('Hi')).toBeTruthy();
  });

  it('triggers onPress when pressed', () => {
    const onPress = jest.fn();
    render(
      <PressableSurface onPress={onPress} testID="surface">
        <Text>Hi</Text>
      </PressableSurface>,
    );
    fireEvent.press(screen.getByTestId('surface'));
    expect(onPress).toHaveBeenCalledTimes(1);
  });

  it('has accessibilityRole=button when accessibilityLabel is set', () => {
    render(
      <PressableSurface onPress={() => undefined} accessibilityLabel="Tap me" testID="surface">
        <Text>Hi</Text>
      </PressableSurface>,
    );
    expect(screen.getByTestId('surface').props.accessibilityRole).toBe('button');
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/PressableSurface.tsx:

import React, { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

type Props = {
  readonly children: ReactNode;
  readonly onPress?: () => void;
  readonly disabled?: boolean;
  readonly style?: StyleProp<ViewStyle>;
  readonly accessibilityLabel?: string;
  readonly testID?: string;
};

export function PressableSurface({
  children,
  onPress,
  disabled,
  style,
  accessibilityLabel,
  testID,
}: Props) {
  const scale = useSharedValue(1);
  const opacity = useSharedValue(1);

  const onPressIn = useCallback(() => {
    scale.value = withTiming(0.96, { duration: 80, easing: Easing.out(Easing.quad) });
    opacity.value = withTiming(0.82, { duration: 80, easing: Easing.out(Easing.quad) });
  }, [scale, opacity]);

  const onPressOut = useCallback(() => {
    scale.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) });
    opacity.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) });
  }, [scale, opacity]);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    opacity: opacity.value,
  }));

  return (
    <Animated.View style={animatedStyle}>
      <Pressable
        accessibilityLabel={accessibilityLabel}
        accessibilityRole={accessibilityLabel ? 'button' : undefined}
        disabled={disabled}
        onPress={onPress}
        onPressIn={onPressIn}
        onPressOut={onPressOut}
        style={[styles.base, style]}
        testID={testID}
      >
        {children}
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  base: {
    alignSelf: 'stretch',
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/PressableSurface.tsx src/__tests__/PressableSurface.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): PressableSurface with refined press feedback"

Task F2.4: EmptyState component

Files:

  • Create: src/components/EmptyState.tsx

  • Test: src/__tests__/EmptyState.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/EmptyState.test.tsx:

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { EmptyState } from '../components/EmptyState';

describe('EmptyState', () => {
  it('renders eyebrow, title, and copy', () => {
    render(
      <EmptyState
        eyebrow="SEM USO"
        title="Nenhum treino salvo"
        copy="Comece uma janela para ver seus dados."
      />,
    );
    expect(screen.getByText('SEM USO')).toBeTruthy();
    expect(screen.getByText('Nenhum treino salvo')).toBeTruthy();
    expect(screen.getByText('Comece uma janela para ver seus dados.')).toBeTruthy();
  });

  it('uses amber tone by default', () => {
    const { toJSON } = render(
      <EmptyState eyebrow="X" title="Y" copy="Z" testID="es" />,
    );
    const tree = JSON.stringify(toJSON());
    expect(tree).toContain('rgba(255, 180, 61');
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/EmptyState.tsx:

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, Line, Rect } from 'react-native-svg';
import { colors, radius, spacing, typography } from '../theme';

type Tone = 'amber' | 'red' | 'green';

type Props = {
  readonly eyebrow: string;
  readonly title: string;
  readonly copy: string;
  readonly tone?: Tone;
  readonly testID?: string;
};

function toneColor(tone: Tone): string {
  if (tone === 'red') return colors.action;
  if (tone === 'green') return colors.success;
  return colors.warning;
}

function toneBackground(tone: Tone): string {
  if (tone === 'red') return 'rgba(226, 59, 46, 0.12)';
  if (tone === 'green') return 'rgba(52, 199, 123, 0.12)';
  return 'rgba(255, 180, 61, 0.10)';
}

export function EmptyState({
  eyebrow,
  title,
  copy,
  tone = 'amber',
  testID,
}: Props) {
  return (
    <View
      accessibilityLabel={`${eyebrow}. ${title}. ${copy}`}
      style={[styles.wrap, { backgroundColor: toneBackground(tone) }]}
      testID={testID}
    >
      <View style={styles.geom}>
        <Svg height={64} viewBox="0 0 64 64" width={64}>
          <Rect
            x={6}
            y={6}
            width={52}
            height={52}
            stroke={toneColor(tone)}
            strokeWidth={1}
            fill="none"
          />
          <Circle
            cx={32}
            cy={32}
            r={20}
            stroke={toneColor(tone)}
            strokeWidth={1}
            fill="none"
          />
          <Line
            x1={6}
            y1={58}
            x2={58}
            y2={6}
            stroke={toneColor(tone)}
            strokeWidth={1}
          />
        </Svg>
      </View>
      <Text style={[styles.eyebrow, { color: toneColor(tone) }]}>{eyebrow}</Text>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.copy}>{copy}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    borderRadius: radius.md,
    paddingVertical: spacing.xl,
    paddingHorizontal: spacing.md,
    minHeight: 220,
    alignItems: 'center',
    justifyContent: 'center',
    gap: spacing.sm,
  },
  geom: {
    marginBottom: spacing.sm,
  },
  eyebrow: {
    ...typography.label,
  },
  title: {
    ...typography.display,
    fontSize: 36,
    lineHeight: 38,
    textAlign: 'center',
  },
  copy: {
    ...typography.body,
    color: colors.textMuted,
    textAlign: 'center',
    maxWidth: 280,
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/EmptyState.tsx src/__tests__/EmptyState.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): EmptyState with instrument geometry"

Task F2.5: DialTile component

Files:

  • Create: src/components/DialTile.tsx

  • Test: src/__tests__/DialTile.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/DialTile.test.tsx:

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { DialTile } from '../components/DialTile';

describe('DialTile', () => {
  it('renders label and value', () => {
    render(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
    expect(screen.getByText('RSSI')).toBeTruthy();
    expect(screen.getByText('-61 dBm')).toBeTruthy();
  });

  it('combines label and value into accessibilityLabel', () => {
    render(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
    expect(screen.getByTestId('dial').props.accessibilityLabel).toBe('RSSI: -61 dBm');
  });

  it('uses action color when tone=red', () => {
    const { toJSON } = render(
      <DialTile label="X" value="Y" tone="red" testID="dial" />,
    );
    const tree = JSON.stringify(toJSON());
    expect(tree).toContain('#E23B2E');
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/DialTile.tsx:

import React from 'react';
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { colors, spacing, typography } from '../theme';

type Tone = 'amber' | 'green' | 'red';

type Props = {
  readonly label: string;
  readonly value: string;
  readonly tone?: Tone;
  readonly testID?: string;
};

function toneColor(tone: Tone | undefined): string {
  if (tone === 'red') return colors.action;
  if (tone === 'green') return colors.success;
  return colors.warning;
}

export function DialTile({ label, value, tone = 'amber', testID }: Props) {
  const markerStyle: ViewStyle = { backgroundColor: toneColor(tone) };
  return (
    <View
      accessibilityLabel={`${label}: ${value}`}
      style={styles.row}
      testID={testID}
    >
      <View style={styles.markerWrap}>
        <View style={[styles.marker, markerStyle]} />
      </View>
      <Text style={styles.value}>{value}</Text>
      <Text style={styles.label}>{label}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  row: {
    minHeight: 56,
    flexDirection: 'row',
    alignItems: 'center',
    borderTopWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.sm,
    paddingHorizontal: spacing.md,
    gap: spacing.md,
  },
  markerWrap: {
    width: 4,
    height: 32,
    justifyContent: 'center',
  },
  marker: {
    width: 2,
    height: 32,
  },
  value: {
    ...typography.detailNumeric,
    flex: 1,
  },
  label: {
    ...typography.label,
    color: colors.textDim,
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/DialTile.tsx src/__tests__/DialTile.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): DialTile for sensor signal rows"

Task F2.6: TrackRow component

Files:

  • Create: src/components/TrackRow.tsx

  • Test: src/__tests__/TrackRow.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/TrackRow.test.tsx:

import React from 'react';
import { Text } from 'react-native';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { TrackRow } from '../components/TrackRow';

describe('TrackRow', () => {
  const baseProps = {
    trackNumber: 3,
    day: '15/06',
    hour: '19:30',
    title: 'Sparring',
    meta: '30 min · 600 amostras',
    metrics: [
      { label: 'MÉD 152' },
      { label: 'PICO 184', tone: 'hot' as const },
    ],
    isOpen: false,
    onPress: jest.fn(),
    accessibilityLabel: 'Janela 3',
  };

  beforeEach(() => {
    baseProps.onPress.mockClear();
  });

  it('renders track number zero-padded', () => {
    render(
      <TrackRow {...baseProps} testID="row">
        <Text />
      </TrackRow>,
    );
    expect(screen.getByText('T03')).toBeTruthy();
  });

  it('renders day, hour, title, and meta', () => {
    render(
      <TrackRow {...baseProps} testID="row">
        <Text />
      </TrackRow>,
    );
    expect(screen.getByText('15/06')).toBeTruthy();
    expect(screen.getByText('19:30')).toBeTruthy();
    expect(screen.getByText('Sparring')).toBeTruthy();
    expect(screen.getByText('30 min · 600 amostras')).toBeTruthy();
  });

  it('renders metrics labels', () => {
    render(
      <TrackRow {...baseProps} testID="row">
        <Text />
      </TrackRow>,
    );
    expect(screen.getByText('MÉD 152')).toBeTruthy();
    expect(screen.getByText('PICO 184')).toBeTruthy();
  });

  it('calls onPress when pressed', () => {
    render(
      <TrackRow {...baseProps} testID="row">
        <Text />
      </TrackRow>,
    );
    fireEvent.press(screen.getByTestId('row'));
    expect(baseProps.onPress).toHaveBeenCalledTimes(1);
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/TrackRow.tsx:

import React, { type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { ChevronDown, ChevronUp } from 'lucide-react-native';
import { colors, spacing, typography } from '../theme';

type MetricTone = 'normal' | 'hot';

type Metric = {
  readonly label: string;
  readonly tone?: MetricTone;
};

type Props = {
  readonly trackNumber: number;
  readonly day: string;
  readonly hour: string;
  readonly title: string;
  readonly meta: string;
  readonly metrics: ReadonlyArray<Metric>;
  readonly isOpen: boolean;
  readonly onPress: () => void;
  readonly accessibilityLabel: string;
  readonly children?: ReactNode;
  readonly testID?: string;
};

export function TrackRow({
  trackNumber,
  day,
  hour,
  title,
  meta,
  metrics,
  isOpen,
  onPress,
  accessibilityLabel,
  children,
  testID,
}: Props) {
  const trackNumberLabel = `T${String(trackNumber).padStart(2, '0')}`;
  const sideRailStyle: ViewStyle = {
    backgroundColor: isOpen ? colors.action : colors.line,
  };
  return (
    <View>
      <Pressable
        accessibilityLabel={accessibilityLabel}
        accessibilityRole="button"
        onPress={onPress}
        style={({ pressed }) => [styles.row, pressed ? styles.pressed : null]}
        testID={testID}
      >
        <View style={[styles.sideRail, sideRailStyle]} />
        <View style={styles.trackNumberWrap}>
          <Text style={styles.trackNumber}>{trackNumberLabel}</Text>
        </View>
        <View style={styles.dateBlock}>
          <Text style={styles.day}>{day}</Text>
          <Text style={styles.hour}>{hour}</Text>
        </View>
        <View style={styles.body}>
          <Text style={styles.title}>{title}</Text>
          <Text style={styles.meta}>{meta}</Text>
          <View style={styles.metricLine}>
            {metrics.map((metric, index) => (
              <Text
                key={index}
                style={[
                  styles.metricText,
                  metric.tone === 'hot' ? styles.metricTextHot : null,
                ]}
              >
                {metric.label}
              </Text>
            ))}
          </View>
        </View>
        <View style={styles.chevron}>
          {isOpen ? (
            <ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />
          ) : (
            <ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />
          )}
        </View>
      </Pressable>
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: spacing.sm,
    paddingHorizontal: spacing.md,
    gap: spacing.sm,
    borderTopWidth: 1,
    borderTopColor: colors.line,
  },
  pressed: {
    opacity: 0.72,
  },
  sideRail: {
    width: 2,
    height: 40,
  },
  trackNumberWrap: {
    width: 28,
    alignItems: 'flex-start',
  },
  trackNumber: {
    ...typography.label,
    color: colors.textMuted,
  },
  dateBlock: {
    width: 56,
  },
  day: {
    color: colors.warning,
    fontSize: 18,
    lineHeight: 21,
    fontWeight: '900',
    letterSpacing: -0.6,
  },
  hour: {
    ...typography.label,
    color: colors.textDim,
    marginTop: 2,
  },
  body: {
    flex: 1,
  },
  title: {
    color: colors.text,
    fontSize: 18,
    lineHeight: 22,
    fontWeight: '900',
    letterSpacing: -0.6,
  },
  meta: {
    ...typography.body,
    color: colors.textMuted,
    fontSize: 13,
    lineHeight: 17,
    marginTop: 2,
  },
  metricLine: {
    flexDirection: 'row',
    gap: spacing.sm,
    marginTop: spacing.xs,
  },
  metricText: {
    ...typography.label,
    color: colors.textMuted,
  },
  metricTextHot: {
    color: colors.action,
  },
  chevron: {
    width: 18,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/TrackRow.tsx src/__tests__/TrackRow.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): TrackRow for history sessions"

Task F2.7: InsightRow component

Files:

  • Create: src/components/InsightRow.tsx

  • Test: src/__tests__/InsightRow.test.tsx

  • Step 1: Write the failing test

Create src/__tests__/InsightRow.test.tsx:

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { InsightRow } from '../components/InsightRow';

describe('InsightRow', () => {
  it('renders index zero-padded', () => {
    render(
      <InsightRow
        index={3}
        label="Carga"
        statusLabel="ok"
        summary="Carga alta mas dentro do padrão"
        detail="TRIMP 145"
        value="145"
        unit="TRIMP"
        tone="good"
        testID="row"
      />,
    );
    expect(screen.getByText('03')).toBeTruthy();
  });

  it('renders label, status, summary, detail, and value', () => {
    render(
      <InsightRow
        index={1}
        label="Carga"
        statusLabel="ok"
        summary="Carga alta"
        detail="TRIMP 145"
        value="145"
        unit="TRIMP"
        tone="good"
        testID="row"
      />,
    );
    expect(screen.getByText('Carga')).toBeTruthy();
    expect(screen.getByText('ok')).toBeTruthy();
    expect(screen.getByText('Carga alta')).toBeTruthy();
    expect(screen.getByText('TRIMP 145')).toBeTruthy();
    expect(screen.getByText('145')).toBeTruthy();
    expect(screen.getByText('TRIMP')).toBeTruthy();
  });

  it('combines label, status, and value into accessibilityLabel', () => {
    render(
      <InsightRow
        index={1}
        label="Carga"
        statusLabel="ok"
        summary="Carga alta"
        detail="TRIMP 145"
        value="145"
        tone="good"
        testID="row"
      />,
    );
    const label = screen.getByTestId('row').props.accessibilityLabel;
    expect(label).toContain('Carga');
    expect(label).toContain('ok');
    expect(label).toContain('145');
  });
});
  • Step 2: Run test to verify it fails

Run: cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -10

Expected: FAIL.

  • Step 3: Write minimal implementation

Create src/components/InsightRow.tsx:

import React, { useEffect } from 'react';
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withTiming,
} from 'react-native-reanimated';
import { colors, motion, spacing, typography } from '../theme';

type Tone = 'good' | 'warning' | 'danger' | 'neutral';

type Props = {
  readonly index: number;
  readonly label: string;
  readonly statusLabel: string;
  readonly summary: string;
  readonly detail: string;
  readonly value: string;
  readonly unit?: string;
  readonly tone: Tone;
  readonly testID?: string;
};

function toneColor(tone: Tone): string {
  if (tone === 'good') return colors.success;
  if (tone === 'danger') return colors.action;
  if (tone === 'warning') return colors.warning;
  return colors.text;
}

export function InsightRow({
  index,
  label,
  statusLabel,
  summary,
  detail,
  value,
  unit,
  tone,
  testID,
}: Props) {
  const indexLabel = String(index).padStart(2, '0');
  const opacity = useSharedValue(0);
  const translateY = useSharedValue(8);
  const color = toneColor(tone);

  useEffect(() => {
    const delay = index < 8 ? index * motion.stagger.row : 0;
    opacity.value = withDelay(
      delay,
      withTiming(1, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
    );
    translateY.value = withDelay(
      delay,
      withTiming(0, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }),
    );
  }, [index, opacity, translateY]);

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ translateY: translateY.value }],
  }));

  const indexStyle: ViewStyle = { color };
  return (
    <Animated.View
      accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
      style={[styles.row, animatedStyle]}
      testID={testID}
    >
      <Text style={styles.index}>{indexLabel}</Text>
      <View style={styles.body}>
        <View style={styles.topLine}>
          <Text style={styles.label}>{label}</Text>
          <Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
        </View>
        <Text style={styles.summary}>{summary}</Text>
        <Text style={styles.detail}>{detail}</Text>
      </View>
      <View style={styles.valueBox}>
        <Text style={[styles.value, indexStyle]}>{value}</Text>
        {unit ? <Text style={styles.unit}>{unit}</Text> : null}
      </View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  row: {
    minHeight: 92,
    borderBottomWidth: 1,
    borderBottomColor: colors.line,
    flexDirection: 'row',
    gap: spacing.sm,
    paddingVertical: spacing.sm,
  },
  index: {
    ...typography.label,
    color: colors.textDim,
    width: 28,
  },
  body: {
    flex: 1,
    gap: spacing.xxs,
  },
  topLine: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    gap: spacing.sm,
  },
  label: {
    ...typography.label,
    color: colors.text,
    flexShrink: 1,
  },
  status: {
    ...typography.label,
    textAlign: 'right',
  },
  summary: {
    color: colors.text,
    fontSize: 16,
    lineHeight: 20,
    fontWeight: '800',
    letterSpacing: -0.45,
  },
  detail: {
    color: colors.textMuted,
    fontSize: 12,
    lineHeight: 16,
    fontWeight: '600',
  },
  valueBox: {
    width: 84,
    alignItems: 'flex-end',
    justifyContent: 'center',
  },
  value: {
    ...typography.metricNumeric,
    textAlign: 'right',
  },
  unit: {
    ...typography.label,
    color: colors.textDim,
    textAlign: 'right',
  },
});
  • Step 4: Run test to verify it passes

Run: cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -5

Expected: PASS.

  • Step 5: Commit
cd /d/h && git add src/components/InsightRow.tsx src/__tests__/InsightRow.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): InsightRow with stagger reveal animation"

F3 — Existing component updates

Task F3.1: ActionButton — add stopAlert variant

Files:

  • Modify: src/components/ActionButton.tsx

  • Step 1: Add stopAlert variant + AlertRing component

Replace the entire src/components/ActionButton.tsx with:

import React, { useEffect } from 'react';
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';
import { colors, motion, radius, spacing, typography } from '../theme';

type ActionButtonVariant = 'primary' | 'secondary' | 'stop' | 'stopAlert';

type Props = {
  readonly label: string;
  readonly onPress?: () => void;
  readonly variant?: ActionButtonVariant;
  readonly fullWidth?: boolean;
  readonly showIcon?: boolean;
};

export function ActionButton({
  label,
  onPress,
  variant = 'primary',
  fullWidth = false,
  showIcon = true,
}: Props) {
  return (
    <View style={[styles.shell, fullWidth ? styles.fullWidth : null]}>
      {variant === 'stopAlert' ? <AlertRing /> : null}
      <Pressable
        accessibilityRole="button"
        onPress={onPress}
        style={({ pressed }) => [
          styles.base,
          styles[variant],
          fullWidth ? styles.fullWidth : null,
          pressed ? styles.pressed : null,
        ]}
      >
        <Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
        {variant === 'primary' && showIcon ? (
          <View style={styles.iconBubble}>
            <Text style={styles.iconText}>›</Text>
          </View>
        ) : null}
      </Pressable>
    </View>
  );
}

function AlertRing() {
  const scale = useSharedValue(1);
  const opacity = useSharedValue(0.55);

  useEffect(() => {
    scale.value = withRepeat(
      withTiming(1.06, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
      -1,
      true,
    );
    opacity.value = withRepeat(
      withTiming(0.2, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }),
      -1,
      true,
    );
  }, [scale, opacity]);

  const ringStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    opacity: opacity.value,
  }));

  return <Animated.View style={[styles.alertRing, ringStyle]} pointerEvents="none" />;
}

const styles = StyleSheet.create({
  shell: {
    position: 'relative',
    alignSelf: 'flex-start',
  },
  fullWidth: {
    alignSelf: 'stretch',
  },
  base: {
    minHeight: 48,
    borderRadius: radius.xl,
    paddingVertical: spacing.sm,
    paddingHorizontal: spacing.md,
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
    gap: spacing.sm,
  },
  primary: {
    backgroundColor: colors.action,
  },
  secondary: {
    backgroundColor: 'transparent',
    borderColor: colors.line,
    borderWidth: 1,
  },
  stop: {
    backgroundColor: colors.stopSurface,
  },
  stopAlert: {
    backgroundColor: colors.action,
  },
  pressed: {
    transform: [{ scale: 0.96 }],
    opacity: 0.82,
  },
  label: {
    ...typography.button,
  },
  primaryLabel: {
    color: colors.text,
  },
  secondaryLabel: {
    color: colors.text,
  },
  stopLabel: {
    color: colors.stopText,
  },
  stopAlertLabel: {
    color: colors.text,
  },
  iconBubble: {
    width: 24,
    height: 24,
    borderRadius: 12,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'rgba(255,255,255,0.16)',
  },
  iconText: {
    color: colors.text,
    fontWeight: '900',
    fontSize: 20,
    lineHeight: 22,
  },
  alertRing: {
    position: 'absolute',
    top: -4,
    left: -4,
    right: -4,
    bottom: -4,
    borderRadius: radius.xl + 4,
    borderWidth: 2,
    borderColor: colors.ringWarn,
  } as ViewStyle,
});
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, all tests pass.

  • Step 3: Commit
cd /d/h && git add src/components/ActionButton.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): ActionButton stopAlert variant with pulsing ring"

Task F3.2: StatusTag — size prop + red tone refinement

Files:

  • Modify: src/components/StatusTag.tsx

  • Step 1: Add size and refine red tone

Replace the entire src/components/StatusTag.tsx with:

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';

type StatusTone = 'amber' | 'green' | 'red';
type StatusSize = 'sm' | 'md';

type Props = {
  readonly label: string;
  readonly tone?: StatusTone;
  readonly size?: StatusSize;
};

export function StatusTag({ label, tone = 'amber', size = 'md' }: Props) {
  return (
    <View
      accessibilityLabel={label}
      style={[styles.base, styles[tone], size === 'sm' ? styles.sm : styles.md]}
    >
      <Text style={[styles.text, styles[`${tone}Text`], size === 'sm' ? styles.smText : null]}>
        {label}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  base: {
    alignSelf: 'flex-start',
    borderRadius: radius.xl,
    borderWidth: 1,
  },
  sm: {
    paddingHorizontal: spacing.xs,
    paddingVertical: 2,
  },
  md: {
    paddingHorizontal: spacing.sm,
    paddingVertical: spacing.xs,
  },
  amber: {
    backgroundColor: colors.surfaceHot,
    borderColor: colors.line,
  },
  green: {
    backgroundColor: 'rgba(52,199,123,0.12)',
    borderColor: 'rgba(52,199,123,0.34)',
  },
  red: {
    backgroundColor: 'rgba(226,59,46,0.12)',
    borderColor: 'rgba(226,59,46,0.42)',
  },
  text: {
    ...typography.label,
  },
  smText: {
    fontSize: 9,
    lineHeight: 12,
  },
  amberText: {
    color: colors.warning,
  },
  greenText: {
    color: colors.success,
  },
  redText: {
    color: colors.action,
  },
});
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 errors, all tests pass.

  • Step 3: Commit
cd /d/h && git add src/components/StatusTag.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): StatusTag size prop + red tone aligned with palette"

Task F3.3: SectionPanel — divider and subtle variants

Files:

  • Modify: src/components/SectionPanel.tsx

  • Step 1: Add divider and subtle props

Replace the entire src/components/SectionPanel.tsx with:

import React, { type ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';

type Props = {
  readonly title: string;
  readonly eyebrow?: string;
  readonly children: ReactNode;
  readonly divider?: boolean;
  readonly subtle?: boolean;
};

export function SectionPanel({ title, eyebrow, children, divider, subtle }: Props) {
  if (divider) {
    return (
      <View style={styles.dividerWrap}>
        {eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
        <Text style={styles.title}>{title}</Text>
        <View style={styles.dividerContent}>{children}</View>
      </View>
    );
  }
  return (
    <View
      style={[
        styles.panel,
        subtle ? styles.subtlePanel : styles.raisedPanel,
      ]}
    >
      {eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
      <Text style={styles.title}>{title}</Text>
      <View style={styles.content}>{children}</View>
    </View>
  );
}

const styles = StyleSheet.create({
  panel: {
    borderWidth: 1,
    borderColor: colors.line,
    borderRadius: radius.lg,
    padding: spacing.md,
  },
  raisedPanel: {
    backgroundColor: colors.surfaceRaised,
  },
  subtlePanel: {
    backgroundColor: 'transparent',
  },
  dividerWrap: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    paddingHorizontal: spacing.md,
    paddingVertical: spacing.sm,
  },
  dividerContent: {
    marginTop: spacing.sm,
    gap: spacing.sm,
  },
  eyebrow: {
    ...typography.label,
    color: colors.warning,
    marginBottom: spacing.xs,
  },
  title: {
    color: colors.text,
    fontSize: 24,
    lineHeight: 27,
    fontWeight: '900',
    letterSpacing: -1.2,
  },
  content: {
    marginTop: spacing.md,
    gap: spacing.sm,
  },
});
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: Commit
cd /d/h && git add src/components/SectionPanel.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): SectionPanel divider and subtle variants"

Task F3.4: MetricTile — numeric prop

Files:

  • Modify: src/components/MetricTile.tsx

  • Step 1: Add numeric prop

Replace the entire src/components/MetricTile.tsx with:

import React from 'react';
import { StyleSheet, Text, View, type TextStyle } from 'react-native';
import { colors, radius, spacing, typography } from '../theme';

type Props = {
  readonly value: string;
  readonly label: string;
  readonly numeric?: boolean;
};

export function MetricTile({ value, label, numeric }: Props) {
  return (
    <View style={styles.tile}>
      <Text style={[styles.value, numeric ? styles.numeric : null]}>{value}</Text>
      <Text style={styles.label}>{label}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  tile: {
    flex: 1,
    minHeight: 74,
    borderWidth: 1,
    borderColor: colors.line,
    borderRadius: radius.md,
    backgroundColor: colors.surface,
    padding: spacing.sm,
    justifyContent: 'center',
  },
  value: {
    color: colors.text,
    fontSize: 24,
    lineHeight: 28,
    fontWeight: '900',
    letterSpacing: -1,
  },
  numeric: {
    ...(typography.metricNumeric as TextStyle),
  },
  label: {
    ...typography.label,
    color: colors.textMuted,
    marginTop: spacing.xxs,
  },
});
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: Commit
cd /d/h && git add src/components/MetricTile.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(components): MetricTile numeric prop for JetBrains Mono"

F4 — Tela Captura (T4.1 + T4.2)

Task F4.1: TrainingChart — furos laterais + LiveDot readout + pin pulse

Files:

  • Modify: src/components/TrainingChart.tsx

  • Step 1: Read current TrainingChart and add furos laterais + LiveDot + pin pulse

In src/components/TrainingChart.tsx, find the <Svg> block and add after the </SvgText> for the minBpm (or before closing the SVG), this new group of "furos" (holes), right before the closing </Svg>:

{ticks.map((tick, index) => (
  <React.Fragment key={`${tick.timestampMs}-${index}`}>
    <Line
      stroke="rgba(255,255,255,0.06)"
      strokeWidth="1"
      x1={tick.x}
      x2={tick.x}
      y1={PAD_Y}
      y2={CHART_HEIGHT - PAD_Y}
    />
    <SvgText ...>{tick.label}</SvgText>
  </React.Fragment>
))}
{/* Furos laterais — inspiração ECG paper */}
{[0, 1, 2, 3, 4, 5].map(index => {
  const x = PAD_X + 12 + index * 48;
  return (
    <Rect
      key={`hole-${index}`}
      x={x}
      y={CHART_HEIGHT - 4}
      width={1.5}
      height={4}
      fill={colors.line}
    />
  );
})}

Also, add Rect to the existing import from react-native-svg at the top of the file:

import Svg, { Circle, Line, Path, Rect, Text as SvgText } from 'react-native-svg';

Also, in the readout (the <View style={styles.readout}> near the end of the component), update it to add a LiveDot before the label. Replace the existing readout:

<View style={styles.readout}>
  <Text style={styles.readoutLabel}>
    {pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
  </Text>
  ...
</View>

with:

<View style={styles.readout}>
  {viewport.liveTail ? <LiveDot live={true} size={6} /> : <LiveDot live={false} size={6} />}
  <Text style={styles.readoutLabel}>
    {pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
  </Text>
  <Text style={styles.readoutValue}>
    {pinPoint
      ? `${formatSampleTime(pinPoint.sample.timestamp)} · ${pinPoint.sample.bpm} BPM`
      : `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${
          renderable.lastPoint.sample.bpm
        } BPM`}
  </Text>
</View>

And add the import at the top:

import { LiveDot } from './LiveDot';
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, all tests pass.

  • Step 3: Commit
cd /d/h && git add src/components/TrainingChart.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(chart): TrainingChart ECG furos laterais + LiveDot readout"

Task F4.2: CaptureScreen refactor

Files:

  • Modify: src/screens/CaptureScreen.tsx

  • Step 1: Replace the file with the refactored version

Write the entire src/screens/CaptureScreen.tsx with this content:

import React, { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
  Activity,
  Battery,
  RadioTower,
  ShieldCheck,
  Timer,
} from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { ActionButton } from '../components/ActionButton';
import { DialTile } from '../components/DialTile';
import { LiveDot } from '../components/LiveDot';
import { TrainingChart } from '../components/TrainingChart';
import { colors, radius, spacing, typography } from '../theme';
import {
  calculateLiveMetrics,
  formatDuration,
} from '../training/trainingMetrics';

const BPM_EMPTY = '--';

type Props = {
  readonly monitor: HeartRateMonitorState;
  readonly onCaptureAction: () => Promise<void>;
  readonly onMonitorAction: () => Promise<void>;
};

export function CaptureScreen({
  monitor,
  onCaptureAction,
  onMonitorAction,
}: Props) {
  const now = useTicker(monitor.isMonitoring || monitor.isCapturing);
  const status = getStatusCopy(monitor);
  const windowSamples = useMemo(
    () =>
      monitor.startedAt
        ? monitor.samples.filter(
            (sample) => sample.timestamp >= monitor.startedAt!,
          )
        : monitor.samples,
    [monitor.samples, monitor.startedAt],
  );
  const windowMetrics = calculateLiveMetrics(windowSamples);
  const firstSampleAt = monitor.samples[0]?.timestamp ?? null;
  const monitorElapsedMs = firstSampleAt ? now - firstSampleAt : 0;
  const workoutElapsedMs = monitor.startedAt ? now - monitor.startedAt : 0;
  const actionLabel = monitor.isCapturing
    ? 'Fechar janela de treino'
    : 'Iniciar janela de treino';

  const isReceiving = monitor.phase === 'receiving';
  const isLive = monitor.isMonitoring || monitor.isCapturing;

  return (
    <View style={styles.screen}>
      <View style={styles.topRail}>
        <View style={styles.stateBlock}>
          <View style={styles.eyebrowRow}>
            <Text
              style={[
                styles.eyebrowDot,
                { color: isReceiving ? colors.success : colors.textDim },
              ]}
            >
              •
            </Text>
            <Text style={styles.eyebrow}>{status.label}</Text>
          </View>
          <Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
        </View>
        <View style={styles.timeStack}>
          <View style={styles.timerRow}>
            <LiveDot live={isLive} size={6} />
            <Text style={styles.timerText}>
              {monitor.isCapturing
                ? formatDuration(workoutElapsedMs)
                : formatDuration(monitorElapsedMs)}
            </Text>
          </View>
          <Text style={styles.timerLabel}>
            {monitor.isCapturing ? 'janela' : 'monitor'}
          </Text>
        </View>
      </View>

      <View style={styles.bpmRow}>
        <View style={styles.bpmBlock}>
          <Text
            style={[
              styles.bpmValue,
              isReceiving ? styles.bpmPulse : null,
            ]}
          >
            {monitor.currentBpm ?? BPM_EMPTY}
          </Text>
        </View>
        <View style={styles.bpmSide}>
          <Text style={styles.bpmUnit}>BPM</Text>
          <Text style={styles.statusLine}>
            {monitor.errorMessage ?? status.detail}
          </Text>
        </View>
      </View>

      <View style={styles.captureRail}>
        <RailMetric
          label="MÉDIA"
          value={windowMetrics.averageBpm ?? BPM_EMPTY}
        />
        <RailMetric
          label="PICO"
          value={windowMetrics.peakBpm ?? BPM_EMPTY}
          tone="hot"
        />
        <RailMetric
          label="AMOSTRAS"
          value={windowSamples.length || BPM_EMPTY}
        />
      </View>

      <TrainingChart samples={windowSamples} />

      <View style={styles.dialColumn}>
        <DialTile
          label="BLE"
          value={getBleShortState(monitor.phase, monitor.isMonitoring)}
          tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
        />
        <DialTile label="BATERIA" value={getBatteryLabel(monitor)} />
        <DialTile
          label="FUNDO"
          value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
          tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
        />
      </View>

      {!monitor.batteryOptimizationIgnored ? (
        <Pressable
          onPress={() => {
            monitor.openBatterySettings().catch(() => undefined);
          }}
          style={styles.batteryPrompt}
        >
          <Text style={styles.batteryPromptTitle}>
            Manter com tela bloqueada
          </Text>
          <Text style={styles.batteryPromptText}>
            Toque para abrir a configuração de bateria e remover restrições do
            FightRate.
          </Text>
        </Pressable>
      ) : null}

      <View style={styles.actionGrid}>
        {!monitor.isMonitoring ? (
          <ActionButton
            fullWidth
            label="Ativar monitor contínuo"
            onPress={() => {
              onMonitorAction().catch(() => undefined);
            }}
          />
        ) : null}
        <ActionButton
          fullWidth
          label={actionLabel}
          onPress={() => {
            onCaptureAction().catch(() => undefined);
          }}
          variant={monitor.isCapturing ? 'stopAlert' : 'primary'}
        />
      </View>

      <View style={styles.truthLine}>
        <Activity color={colors.warning} size={18} strokeWidth={2.5} />
        <Text style={styles.truthText}>
          Cinta reconhecida = cada BPM real é salvo. Toque em Iniciar apenas
          para destacar uma janela para análise.
        </Text>
      </View>
    </View>
  );
}

function RailMetric({
  label,
  value,
  tone = 'normal',
}: {
  readonly label: string;
  readonly value: string | number;
  readonly tone?: 'normal' | 'hot';
}) {
  return (
    <View style={styles.railMetric}>
      <Text
        style={[styles.railValue, tone === 'hot' ? styles.railValueHot : null]}
      >
        {value}
      </Text>
      <Text style={styles.railLabel}>{label}</Text>
    </View>
  );
}

function getStatusCopy(monitor: HeartRateMonitorState) {
  if (monitor.isCapturing) {
    return {
      label: 'JANELA DE TREINO',
      detail: 'Treino destacado enquanto o monitor contínuo segue gravando.',
    };
  }
  if (monitor.phase === 'receiving') {
    return {
      label: 'LEITURA ATIVA',
      detail: 'BPM real chegando e sendo salvo continuamente em SQLite.',
    };
  }
  if (monitor.phase === 'scanning') {
    return {
      label: 'PROCURANDO CINTA',
      detail: 'O serviço continua procurando até encontrar a cinta.',
    };
  }
  if (monitor.phase === 'connecting') {
    return {
      label: 'CONECTANDO 2A37',
      detail:
        'Abrindo Heart Rate Service 180D e armando notify da característica 2A37.',
    };
  }
  if (monitor.phase === 'permission-denied') {
    return {
      label: 'PERMISSÃO NEGADA',
      detail: 'Autorize Bluetooth, Localização e Notificações.',
    };
  }
  if (monitor.phase === 'bluetooth-off') {
    return {
      label: 'BLUETOOTH DESLIGADO',
      detail: 'Ligue o Bluetooth antes de ativar o monitor.',
    };
  }
  if (monitor.phase === 'error') {
    return {
      label: 'ERRO DE CAPTURA',
      detail: monitor.errorMessage ?? 'Falha no serviço BLE.',
    };
  }
  return {
    label: 'MONITOR DESATIVADO',
    detail: 'Ative o monitor contínuo; depois marque a janela de treino.',
  };
}

function formatDeviceName(monitor: HeartRateMonitorState) {
  const name = monitor.connectedDevice?.name;
  if (!name) return 'Magene H003 ou cinta BLE 180D';
  if (/magene|h003/i.test(name)) return 'Magene H003 · cinta cardíaca';
  if (/heart|hrm/i.test(name)) return `${name} · cinta cardíaca`;
  return `${name} · BLE`;
}

function getBleShortState(
  phase: HeartRateMonitorState['phase'],
  isMonitoring: boolean,
) {
  if (!isMonitoring) return 'parado';
  if (phase === 'receiving') return 'lendo';
  if (phase === 'scanning') return 'busca';
  if (phase === 'connecting') return 'conectando';
  if (phase === 'connected') return '2A37';
  if (phase === 'error') return 'erro';
  return 'ativo';
}

function getBatteryLabel(monitor: HeartRateMonitorState) {
  const percent = monitor.connectedDevice?.batteryPercent;
  if (typeof percent === 'number') return `${percent}%`;
  return 'sem leitura';
}

function useTicker(active: boolean) {
  const [now, setNow] = useState(Date.now());

  useEffect(() => {
    if (!active) {
      setNow(Date.now());
      return;
    }

    const interval = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(interval);
  }, [active]);

  return now;
}

const styles = StyleSheet.create({
  screen: {
    gap: spacing.md,
  },
  topRail: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.md,
    flexDirection: 'row',
    justifyContent: 'space-between',
    gap: spacing.md,
  },
  stateBlock: {
    flex: 1,
  },
  eyebrowRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
  },
  eyebrowDot: {
    fontSize: 18,
    lineHeight: 18,
    fontWeight: '900',
  },
  eyebrow: {
    ...typography.label,
    color: colors.action,
  },
  sensorName: {
    color: colors.text,
    fontSize: 18,
    lineHeight: 23,
    fontWeight: '900',
    letterSpacing: -0.5,
    marginTop: spacing.xs,
  },
  timeStack: {
    alignItems: 'flex-end',
    gap: spacing.xxs,
    minWidth: 84,
  },
  timerRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
  },
  timerText: {
    ...typography.label,
    color: colors.text,
  },
  timerLabel: {
    ...typography.label,
    color: colors.textDim,
    fontSize: 9,
  },
  bpmRow: {
    flexDirection: 'row',
    alignItems: 'flex-end',
    gap: spacing.md,
  },
  bpmBlock: {
    flex: 1,
  },
  bpmValue: {
    ...typography.displayNumeric,
  },
  bpmPulse: {
    opacity: 1,
  },
  bpmSide: {
    flex: 1,
    paddingBottom: spacing.sm,
    gap: spacing.xs,
  },
  bpmUnit: {
    ...typography.label,
    color: colors.textMuted,
  },
  statusLine: {
    ...typography.body,
    color: colors.textMuted,
  },
  captureRail: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    flexDirection: 'row',
  },
  railMetric: {
    flex: 1,
    paddingVertical: spacing.sm,
    paddingRight: spacing.sm,
  },
  railValue: {
    ...typography.metricNumeric,
  },
  railValueHot: {
    color: colors.action,
  },
  railLabel: {
    ...typography.label,
    color: colors.textMuted,
    marginTop: spacing.xxs,
  },
  dialColumn: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
  },
  batteryPrompt: {
    borderWidth: 1,
    borderColor: colors.warning,
    borderRadius: radius.md,
    backgroundColor: colors.surfaceHot,
    padding: spacing.md,
  },
  batteryPromptTitle: {
    ...typography.label,
    color: colors.warning,
  },
  batteryPromptText: {
    ...typography.body,
    color: colors.textMuted,
    marginTop: spacing.xs,
  },
  actionGrid: {
    gap: spacing.sm,
  },
  truthLine: {
    flexDirection: 'row',
    alignItems: 'flex-start',
    gap: spacing.sm,
  },
  truthText: {
    ...typography.body,
    color: colors.textMuted,
    flex: 1,
  },
});
  • Step 2: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, all tests pass.

  • Step 3: Commit
cd /d/h && git add src/screens/CaptureScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(capture): CaptureScreen with LiveDot, JetBrains Mono BPM, stopAlert ring"

F5 — Histórico + Sensor (T5.1 + T5.2)

Task F5.1: HistoryScreen refactor

Files:

  • Modify: src/screens/HistoryScreen.tsx

  • Step 1: Apply TrackRow and EmptyState replacements

In src/screens/HistoryScreen.tsx, the only changes required are:

  1. Add TrackRow and EmptyState imports.
  2. Replace EmptyHistory function body with delegation to EmptyState.
  3. Replace the inner Pressable ... styles.sessionStrip blocks in both WindowHistory and ContinuousHistory with TrackRow components.

This is a complex refactor. To keep it tractable, do the following focused edits:

Edit 1: imports

Add these at the top of src/screens/HistoryScreen.tsx (after the existing Activity, ChevronDown, ChevronUp import from lucide):

import { TrackRow } from '../components/TrackRow';
import { EmptyState } from '../components/EmptyState';

Edit 2: replace EmptyHistory function

Find the function function EmptyHistory({...}) near the bottom of the file. Replace its body (everything from return ( to its closing }) with:

export function EmptyHistory({...} should stay) {
  return (
    <EmptyState
      eyebrow={eyebrow}
      title={title}
      copy={copy}
      tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
    />
  );
}

In practice, since EmptyHistory is currently used in three places (WindowHistory, ContinuousHistory, RangeHistory) with different eyebrows, the easiest approach is to leave the function signature as is and rewrite its body to render EmptyState. Replace the entire function body (from return ( to the matching }) with:

return (
  <EmptyState
    eyebrow={eyebrow}
    title={title}
    copy={copy}
    tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
  />
);

Edit 3: replace sessionStrip with TrackRow in WindowHistory

Find the JSX inside WindowHistory's .map(session => { ... }) block. The current code is approximately:

<Pressable
  accessibilityRole="button"
  onPress={() => onOpenSession(session)}
  style={({ pressed }) => [
    styles.sessionStrip,
    pressed ? styles.pressed : null,
  ]}
>
  <View style={styles.dateBlock}>...</View>
  <View style={styles.sessionBody}>...</View>
  {isOpen ? <OpenIcon /> : <ClosedIcon />}
</Pressable>

{isOpen ? (
  <WindowDetail ... />
) : null}

Replace the entire <Pressable ...>...</Pressable> block with:

<TrackRow
  accessibilityLabel={`Janela ${formatSessionDay(session.startedAt)} ${formatSessionHour(session.startedAt)}`}
  day={formatSessionDay(session.startedAt)}
  hour={formatSessionHour(session.startedAt)}
  isOpen={isOpen}
  meta={`${formatDuration(session.durationMs)} · ${session.sampleCount} amostras`}
  metrics={[
    { label: `MÉD ${session.averageBpm}` },
    { label: `PICO ${session.peakBpm}`, tone: 'hot' as const },
    { label: `MIN ${session.minBpm}` },
  ]}
  onPress={() => onOpenSession(session)}
  title={session.deviceName || 'Cinta cardíaca BLE'}
  trackNumber={index + 1}
>
  {isOpen ? (
    <WindowDetail
      loading={loadingId === session.id}
      metrics={selectedMetrics}
      samples={selectedSamples}
      zones={zones}
    />
  ) : null}
</TrackRow>

Edit 4: replace sessionStrip with TrackRow in ContinuousHistory

Apply the same pattern as Edit 3, but for ContinuousHistory's map. Replace the <Pressable ...>...</Pressable> block with:

<TrackRow
  accessibilityLabel={`Uso contínuo ${formatSessionDay(block.startedAt)}`}
  day={formatSessionDay(block.startedAt)}
  hour={formatSessionHour(block.startedAt)}
  isOpen={isOpen}
  meta={formatUsageRange(block) + ' · ' + block.sampleCount + ' amostras'}
  metrics={[
    { label: `MÉD ${block.averageBpm}` },
    { label: `PICO ${block.peakBpm}`, tone: 'hot' as const },
    { label: `MIN ${block.minBpm}` },
  ]}
  onPress={() => onOpenBlock(block)}
  title={block.deviceName || 'Uso contínuo'}
  trackNumber={index + 1}
>
  {isOpen && selectedBlock ? (
    <UsageDetail block={selectedBlock} />
  ) : null}
</TrackRow>
  • Step 2: Remove unused styles

In styles, the following style keys are no longer used (because TrackRow carries its own): sessionStrip, pressed, dateBlock, sessionDay, sessionHour, sessionBody, sessionTitle, sessionMeta, metricLine, metricText, metricTextHot. Delete these from the StyleSheet.create({...}) block to satisfy npm run lint.

  • Step 3: Run typecheck and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, 0 lint errors, all tests pass.

  • Step 4: Commit
cd /d/h && git add src/screens/HistoryScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(history): HistoryScreen with TrackRow + EmptyState"

Task F5.2: SensorScreen refactor

Files:

  • Modify: src/screens/SensorScreen.tsx

  • Step 1: Replace the entire file

Write the entire src/screens/SensorScreen.tsx with this content:

import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
  Battery,
  Bluetooth,
  RadioReceiver,
  ShieldCheck,
  Signal,
  Activity,
} from 'lucide-react-native';
import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor';
import { formatRssi } from '../ble/rssi';
import { ActionButton } from '../components/ActionButton';
import { DialTile } from '../components/DialTile';
import { LiveDot } from '../components/LiveDot';
import { colors, radius, spacing, typography } from '../theme';

type Props = {
  readonly monitor: HeartRateMonitorState;
  readonly onMonitorAction: () => Promise<void>;
};

export function SensorScreen({ monitor, onMonitorAction }: Props) {
  const device = monitor.connectedDevice;
  const services = monitor.services.length
    ? monitor.services
    : ['Heart Rate Service · 0x180D · aguardando conexão'];
  const characteristics = monitor.characteristics.length
    ? monitor.characteristics
    : ['Heart Rate Measurement · 0x2A37 · notify esperado'];

  const isReceiving = monitor.phase === 'receiving';

  return (
    <View style={styles.screen}>
      <View style={styles.sensorConsole}>
        <View style={styles.consoleTopline}>
          <View style={styles.consoleLeft}>
            <View style={styles.eyebrowRow}>
              <Text
                style={[
                  styles.eyebrowDot,
                  { color: isReceiving ? colors.success : colors.textDim },
                ]}
              >
                •
              </Text>
              <Text style={styles.eyebrow}>SENSOR REAL</Text>
            </View>
            <Text style={styles.title}>
              {friendlyDeviceTitle(device?.name)}
            </Text>
          </View>
          <View style={styles.phaseBadge}>
            <Bluetooth color={colors.warning} size={14} strokeWidth={2.4} />
            <Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
            {isReceiving ? <LiveDot live size={5} /> : null}
          </View>
        </View>

        <View style={styles.signalColumn}>
          <DialTile
            label="RSSI"
            value={formatRssi(device?.rssi)}
            tone="amber"
          />
          <DialTile
            label="LEITURAS"
            value={`${monitor.samples.length}`}
            tone="amber"
          />
          <DialTile
            label="BATERIA"
            value={formatBattery(device?.batteryPercent)}
            tone="amber"
          />
          <DialTile
            label="FUNDO"
            value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
            tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
          />
        </View>

        <Text style={styles.explainer}>
          {monitor.errorMessage ?? sensorExplanation(monitor)}
        </Text>

        <View style={styles.actionRow}>
          {!monitor.isMonitoring ? (
            <ActionButton
              label="Buscar sensor"
              onPress={() => {
                onMonitorAction().catch(() => undefined);
              }}
              showIcon={false}
              variant="primary"
            />
          ) : (
            <View style={styles.livePill}>
              <LiveDot live size={6} />
              <Text style={styles.livePillText}>Monitor contínuo ativo</Text>
            </View>
          )}
          {!monitor.batteryOptimizationIgnored ? (
            <Pressable
              onPress={() =>
                monitor.openBatterySettings().catch(() => undefined)
              }
              style={styles.secondaryButton}
            >
              <Text style={styles.secondaryButtonText}>Bateria</Text>
            </Pressable>
          ) : null}
        </View>
      </View>

      {monitor.discoveredDevices.length > 0 ? (
        <View style={styles.discoveryBlock}>
          <Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
          {monitor.discoveredDevices.map((candidate) => (
            <Pressable
              key={candidate.id}
              onPress={() =>
                monitor.connectToDevice(candidate.id).catch(() => undefined)
              }
              style={styles.deviceRow}
            >
              <View style={styles.deviceDot} />
              <View style={styles.deviceInfo}>
                <Text style={styles.deviceName}>
                  {friendlyDeviceTitle(candidate.name)}
                </Text>
                <Text style={styles.deviceMeta}>
                  {candidate.name} · {formatRssi(candidate.rssi)}
                </Text>
              </View>
              <View style={styles.connectPill}>
                <Text style={styles.connectPillText}>Conectar</Text>
              </View>
            </Pressable>
          ))}
        </View>
      ) : null}

      <BleInventoryBlock title="SERVIÇOS BLE" rows={services} />
      <BleInventoryBlock title="CARACTERÍSTICAS" rows={characteristics} />
    </View>
  );
}

function BleInventoryBlock({
  title,
  rows,
}: {
  readonly title: string;
  readonly rows: readonly string[];
}) {
  return (
    <View style={styles.diagnosticBlock}>
      <Text style={styles.blockLabel}>{title}</Text>
      {rows.slice(0, 8).map((row) => {
        const parsed = parseBleRow(row);
        return (
          <View key={row} style={styles.diagnosticRow}>
            <RadioReceiver color={colors.textDim} size={14} strokeWidth={2.2} />
            <View style={styles.diagnosticBody}>
              <Text style={styles.diagnosticTitle}>{parsed.name}</Text>
              <Text style={styles.diagnosticText}>
                {parsed.uuid} · {parsed.detail}
              </Text>
            </View>
          </View>
        );
      })}
    </View>
  );
}

function friendlyDeviceTitle(name?: string | null) {
  if (!name) {
    return 'Cinta cardíaca BLE';
  }
  if (/magene|h003/i.test(name)) {
    return 'Magene H003';
  }
  if (/heart|hrm/i.test(name)) {
    return 'Cinta cardíaca BLE';
  }
  return name;
}

function parseBleRow(row: string) {
  const [name = row, uuid = 'BLE', ...rest] = row.split(' · ');
  return {
    name,
    uuid,
    detail: rest.join(' · ') || 'aguardando leitura',
  };
}

function phaseLabel(phase: HeartRateMonitorState['phase']) {
  const labels: Record<HeartRateMonitorState['phase'], string> = {
    idle: 'parado',
    'requesting-permission': 'permissões',
    'bluetooth-off': 'bluetooth off',
    scanning: 'buscando',
    connecting: 'conectando',
    connected: 'conectado',
    receiving: 'recebendo',
    'permission-denied': 'sem permissão',
    'sensor-not-found': 'sem sensor',
    error: 'erro',
  };
  return labels[phase];
}

function sensorExplanation(monitor: HeartRateMonitorState) {
  if (monitor.phase === 'receiving' && monitor.samples.length > 0) {
    return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido está sendo salvo em SQLite.`;
  }
  if (monitor.phase === 'receiving' || monitor.phase === 'connected') {
    return 'Sensor reconhecido. O app está armando a leitura 2A37; se não vier BPM, reativa notify e reconecta sozinho.';
  }
  if (monitor.phase === 'scanning') {
    return 'Busca nativa BLE ativa. Se o app for bloqueado, a notificação mantém o serviço vivo.';
  }
  return 'A tela traduz nomes técnicos: 180D é o serviço cardíaco; 2A37 é o dado de BPM.';
}

function formatBattery(percent?: number | null) {
  if (typeof percent === 'number') {
    return `${percent}%`;
  }
  return '--';
}

const styles = StyleSheet.create({
  screen: {
    gap: spacing.sm,
  },
  sensorConsole: {
    borderTopWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.md,
    gap: spacing.md,
  },
  consoleTopline: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    gap: spacing.md,
    alignItems: 'center',
  },
  consoleLeft: {
    flex: 1,
  },
  eyebrowRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
  },
  eyebrowDot: {
    fontSize: 18,
    lineHeight: 18,
    fontWeight: '900',
  },
  eyebrow: {
    ...typography.label,
    color: colors.action,
  },
  title: {
    ...typography.detailNumeric,
    color: colors.text,
    fontSize: 22,
    lineHeight: 26,
    letterSpacing: -0.8,
    marginTop: spacing.xs,
  },
  phaseBadge: {
    paddingVertical: spacing.xs,
    paddingHorizontal: spacing.sm,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    height: 32,
    borderRadius: radius.xl,
    borderWidth: 1,
    borderColor: colors.line,
    backgroundColor: colors.surface,
  },
  phaseText: {
    ...typography.label,
    color: colors.text,
  },
  signalColumn: {
    borderTopWidth: 1,
    borderColor: colors.line,
  },
  explainer: {
    ...typography.body,
    color: colors.textMuted,
  },
  actionRow: {
    flexDirection: 'row',
    gap: spacing.sm,
    alignItems: 'center',
  },
  livePill: {
    minHeight: 48,
    flex: 1,
    borderWidth: 1,
    borderColor: colors.line,
    paddingHorizontal: spacing.md,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
  },
  livePillText: {
    ...typography.label,
    color: colors.success,
  },
  secondaryButton: {
    minHeight: 48,
    borderWidth: 1,
    borderColor: colors.line,
    borderRadius: radius.xl,
    paddingHorizontal: spacing.md,
    justifyContent: 'center',
  },
  secondaryButtonText: {
    ...typography.label,
    color: colors.warning,
  },
  discoveryBlock: {
    borderTopWidth: 1,
    borderTopColor: colors.line,
    paddingTop: spacing.md,
    gap: spacing.sm,
  },
  blockLabel: {
    ...typography.label,
    color: colors.warning,
  },
  deviceRow: {
    flexDirection: 'row',
    gap: spacing.sm,
    alignItems: 'center',
    paddingVertical: spacing.sm,
    borderBottomWidth: 1,
    borderBottomColor: colors.line,
  },
  deviceDot: {
    width: 10,
    height: 10,
    borderRadius: 5,
    backgroundColor: colors.success,
  },
  deviceInfo: {
    flex: 1,
  },
  deviceName: {
    color: colors.text,
    fontSize: 18,
    lineHeight: 22,
    fontWeight: '900',
  },
  deviceMeta: {
    ...typography.body,
    color: colors.textMuted,
    marginTop: spacing.xxs,
  },
  connectPill: {
    borderWidth: 1,
    borderColor: colors.line,
    paddingHorizontal: spacing.sm,
    paddingVertical: spacing.xs,
  },
  connectPillText: {
    ...typography.label,
    color: colors.warning,
  },
  diagnosticBlock: {
    borderTopWidth: 1,
    borderTopColor: colors.line,
    paddingTop: spacing.sm,
    gap: spacing.xs,
  },
  diagnosticRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: spacing.xs,
    paddingVertical: spacing.xs,
    borderBottomWidth: 1,
    borderBottomColor: colors.line,
  },
  diagnosticBody: {
    flex: 1,
    gap: 2,
  },
  diagnosticTitle: {
    ...typography.label,
    color: colors.text,
  },
  diagnosticText: {
    flex: 1,
    color: colors.textMuted,
    fontSize: 12,
    lineHeight: 16,
    fontWeight: '700',
  },
});
  • Step 2: Run typecheck, lint, and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 type errors, 0 lint errors, all tests pass.

  • Step 3: Commit
cd /d/h && git add src/screens/SensorScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(sensor): SensorScreen with DialTile + LiveDot"

F6 — Insights + Nav (T6.1 + T6.2)

Task F6.1: InsightsScreen refactor

Files:

  • Modify: src/screens/InsightsScreen.tsx

  • Step 1: Replace the entire file

Write the entire src/screens/InsightsScreen.tsx with this content:

import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import {
  Activity,
  ChartNoAxesCombined,
  Gauge,
  ShieldCheck,
} from 'lucide-react-native';
import {
  buildInsightDashboard,
  formatDuration,
} from '../training/trainingMetrics';
import {
  type CompletedTrainingSession,
  type HeartRateSample,
  type InsightMetricRow,
  type TrainingInsight,
} from '../training/trainingTypes';
import { EmptyState } from '../components/EmptyState';
import { InsightRow } from '../components/InsightRow';
import { colors, spacing, typography } from '../theme';

const CONTINUOUS_SAMPLE_LIMIT = 2_000;

type Props = {
  readonly liveSamples: readonly HeartRateSample[];
  readonly sessions: readonly CompletedTrainingSession[];
  readonly getContinuousSamples: (
    limit?: number,
  ) => Promise<readonly HeartRateSample[]>;
};

export function InsightsScreen({
  liveSamples,
  sessions,
  getContinuousSamples,
}: Props) {
  const [continuousSamples, setContinuousSamples] = useState<
    readonly HeartRateSample[]
  >([]);

  useEffect(() => {
    let mounted = true;

    getContinuousSamples(CONTINUOUS_SAMPLE_LIMIT)
      .then((samples) => {
        if (mounted) {
          setContinuousSamples(samples);
        }
      })
      .catch(() => {
        if (mounted) {
          setContinuousSamples([]);
        }
      });

    return () => {
      mounted = false;
    };
  }, [getContinuousSamples, sessions.length]);

  const analysisSamples = useMemo(
    () => mergeSamples(continuousSamples, liveSamples),
    [continuousSamples, liveSamples],
  );

  const dashboard = useMemo(
    () =>
      buildInsightDashboard({
        liveSamples,
        continuousSamples: analysisSamples,
        sessions,
      }),
    [analysisSamples, liveSamples, sessions],
  );

  const latestSession = sessions[0] ?? null;
  const loadRow = dashboard.rows.find((row) => row.key === 'load') ?? null;
  const hrvRow = dashboard.rows.find((row) => row.key === 'hrv') ?? null;

  if (dashboard.sampleCount === 0 && sessions.length === 0) {
    return (
      <View style={styles.screen}>
        <EmptyState
          eyebrow="ANÁLISE BLOQUEADA"
          title="Sem dado real, sem conclusão"
          copy="Ative o monitor contínuo. O FightRate não inventa pico, zona, carga, VFC ou recuperação."
          tone="red"
        />
      </View>
    );
  }

  return (
    <View style={styles.screen}>
      <View style={styles.instrumentHeader}>
        <View style={styles.headerRail} />
        <View style={styles.headerCopy}>
          <Text style={styles.eyebrow}>PAINEL DE CÁLCULO</Text>
          <Text style={styles.headerTitle}>Matriz científica</Text>
          <Text style={styles.copy}>
            Leituras calculadas a partir de BPM timestampado real. VFC só é
            liberada quando o sensor entrega RR intervals.
          </Text>
        </View>
        <View style={styles.headerScore}>
          <ChartNoAxesCombined
            color={colors.warning}
            size={20}
            strokeWidth={2.4}
          />
          <Text style={styles.scoreValue}>{loadRow?.value ?? '--'}</Text>
          <Text style={styles.scoreLabel}>carga</Text>
        </View>
      </View>

      <View style={styles.evidenceStrip}>
        <EvidenceCell label="amostras" value={dashboard.sampleCount} />
        <EvidenceCell
          label="janela"
          value={formatDuration(dashboard.durationMs)}
        />
        <EvidenceCell label="range" value={formatTimeRange(dashboard)} />
      </View>

      <View style={styles.metricMatrix}>
        <View style={styles.matrixHeader}>
          <Text style={styles.sectionLabel}>CÁLCULOS</Text>
          <Text style={styles.matrixNote}>valor · status · leitura</Text>
        </View>
        {dashboard.rows.map((row, index) => (
          <InsightRow
            key={row.key}
            detail={row.detail}
            index={index + 1}
            label={row.label}
            statusLabel={row.statusLabel}
            summary={row.summary}
            testID={`insight-${row.key}`}
            tone={row.tone}
            unit={row.unit}
            value={row.value}
          />
        ))}
      </View>

      <View style={styles.zonePanel}>
        <View style={styles.matrixHeader}>
          <Text style={styles.sectionLabel}>ZONAS OPERACIONAIS</Text>
          <Text style={styles.matrixNote}>tempo real em cada trilho</Text>
        </View>
        {dashboard.zones.map((zone) => (
          <View key={zone.key} style={styles.zoneRow}>
            <Text style={styles.zoneLabel}>{zone.label}</Text>
            <View style={styles.zoneTrack}>
              <View style={[styles.zoneFill, { width: `${zone.percent}%` }]} />
            </View>
            <Text style={styles.zoneValue}>
              {zone.percent}% · {formatDuration(zone.seconds * 1000)}
            </Text>
          </View>
        ))}
      </View>

      <View style={styles.protocolPanel}>
        <View style={styles.protocolIcon}>
          {hrvRow?.status === 'ok' ? (
            <ShieldCheck color={colors.success} size={18} strokeWidth={2.4} />
          ) : (
            <Gauge color={colors.warning} size={18} strokeWidth={2.4} />
          )}
        </View>
        <View style={styles.protocolCopy}>
          <Text style={styles.protocolTitle}>Protocolo de honestidade</Text>
          <Text style={styles.copy}>
            Métricas dependentes de perfil ou RR ficam marcadas como
            necessárias. Isso impede conclusões falsas sobre FCmáx, Karvonen,
            TRIMP completo ou VFC.
          </Text>
        </View>
      </View>

      <View style={styles.insightStack}>
        <Text style={styles.sectionLabel}>LEITURAS OPERACIONAIS</Text>
        {dashboard.narrative.length > 0 ? (
          dashboard.narrative.map((insight, index) => (
            <InsightNote
              key={insight.title}
              insight={insight}
              index={index + 1}
            />
          ))
        ) : (
          <View style={styles.waitingPanel}>
            <Activity color={colors.warning} size={18} strokeWidth={2.5} />
            <Text style={styles.copy}>
              Receba mais amostras reais para gerar leitura de tendência e ação.
            </Text>
          </View>
        )}
      </View>

      {latestSession ? (
        <View style={styles.lastSessionStrip}>
          <Text style={styles.eyebrow}>ÚLTIMA JANELA SALVA</Text>
          <Text style={styles.lastSessionText}>
            {formatDuration(latestSession.durationMs)} · média{' '}
            {latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '}
            {latestSession.sampleCount} amostras
          </Text>
        </View>
      ) : null}
    </View>
  );
}

function EvidenceCell({
  label,
  value,
}: {
  readonly label: string;
  readonly value: string | number;
}) {
  return (
    <View style={styles.evidenceCell}>
      <Text style={styles.evidenceValue}>{value}</Text>
      <Text style={styles.evidenceLabel}>{label}</Text>
    </View>
  );
}

function InsightNote({
  insight,
  index,
}: {
  readonly insight: TrainingInsight;
  readonly index: number;
}) {
  return (
    <View style={styles.insightNote}>
      <Text style={styles.noteIndex}>
        {index.toString().padStart(2, '0')}
      </Text>
      <View style={styles.noteBody}>
        <Text style={styles.insightTitle}>{insight.title}</Text>
        <Text style={styles.noteText}>{insight.why}</Text>
        <Text style={styles.noteAction}>{insight.action}</Text>
        <Text style={styles.confidence}>{insight.confidence}</Text>
      </View>
    </View>
  );
}

function mergeSamples(
  persistedSamples: readonly HeartRateSample[],
  liveSamples: readonly HeartRateSample[],
) {
  const byKey = new Map<string, HeartRateSample>();

  [...persistedSamples, ...liveSamples].forEach((sample) => {
    byKey.set(`${sample.timestamp}:${sample.bpm}`, sample);
  });

  return [...byKey.values()].sort(
    (left, right) => left.timestamp - right.timestamp,
  );
}

function formatTimeRange(dashboard: {
  readonly startedAt: number | null;
  readonly endedAt: number | null;
}) {
  if (dashboard.startedAt === null || dashboard.endedAt === null) {
    return '--';
  }

  const start = new Date(dashboard.startedAt).toLocaleTimeString('pt-BR', {
    hour: '2-digit',
    minute: '2-digit',
  });
  const end = new Date(dashboard.endedAt).toLocaleTimeString('pt-BR', {
    hour: '2-digit',
    minute: '2-digit',
  });
  return `${start}–${end}`;
}

const styles = StyleSheet.create({
  screen: {
    gap: spacing.lg,
  },
  eyebrow: {
    ...typography.label,
    color: colors.action,
  },
  copy: {
    ...typography.body,
    color: colors.textMuted,
  },
  instrumentHeader: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.md,
    flexDirection: 'row',
    gap: spacing.md,
    alignItems: 'stretch',
  },
  headerRail: {
    width: 4,
    borderRadius: 1,
    backgroundColor: colors.action,
  },
  headerCopy: {
    flex: 1,
    gap: spacing.xs,
  },
  headerTitle: {
    color: colors.text,
    fontSize: 36,
    lineHeight: 37,
    fontWeight: '900',
    letterSpacing: -2,
  },
  headerScore: {
    minWidth: 86,
    borderLeftWidth: 1,
    borderLeftColor: colors.line,
    paddingLeft: spacing.md,
    alignItems: 'flex-end',
    justifyContent: 'center',
  },
  scoreValue: {
    ...typography.displaySmallNumeric,
    fontSize: 36,
    lineHeight: 38,
    marginTop: spacing.xs,
  },
  scoreLabel: {
    ...typography.label,
    color: colors.textDim,
  },
  evidenceStrip: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    flexDirection: 'row',
  },
  evidenceCell: {
    flex: 1,
    paddingVertical: spacing.sm,
    paddingHorizontal: spacing.xs,
    borderRightWidth: 1,
    borderRightColor: colors.line,
  },
  evidenceValue: {
    ...typography.detailNumeric,
  },
  evidenceLabel: {
    ...typography.label,
    color: colors.textDim,
    marginTop: 2,
  },
  metricMatrix: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
  },
  matrixHeader: {
    paddingVertical: spacing.sm,
    borderBottomWidth: 1,
    borderBottomColor: colors.line,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    gap: spacing.sm,
  },
  sectionLabel: {
    ...typography.label,
    color: colors.warning,
  },
  matrixNote: {
    ...typography.label,
    color: colors.textDim,
    textAlign: 'right',
    flexShrink: 1,
  },
  zonePanel: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
  },
  zoneRow: {
    paddingVertical: spacing.sm,
    borderBottomWidth: 1,
    borderBottomColor: colors.line,
    gap: spacing.xs,
  },
  zoneLabel: {
    ...typography.label,
    color: colors.text,
  },
  zoneTrack: {
    height: 9,
    backgroundColor: colors.surfaceRaised,
    overflow: 'hidden',
    borderRadius: 2,
  },
  zoneFill: {
    height: '100%',
    backgroundColor: colors.action,
  },
  zoneValue: {
    ...typography.body,
    color: colors.textMuted,
  },
  protocolPanel: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.md,
    flexDirection: 'row',
    gap: spacing.md,
  },
  protocolIcon: {
    width: 34,
    alignItems: 'center',
    paddingTop: 2,
  },
  protocolCopy: {
    flex: 1,
    gap: spacing.xs,
  },
  protocolTitle: {
    color: colors.text,
    fontSize: 18,
    lineHeight: 22,
    fontWeight: '900',
    letterSpacing: -0.7,
  },
  insightStack: {
    gap: spacing.md,
  },
  insightNote: {
    borderTopWidth: 1,
    borderTopColor: colors.line,
    paddingTop: spacing.md,
    flexDirection: 'row',
    gap: spacing.md,
  },
  noteIndex: {
    color: colors.action,
    fontSize: 18,
    lineHeight: 24,
    fontWeight: '900',
    letterSpacing: -0.5,
    width: 28,
  },
  noteBody: {
    flex: 1,
    gap: spacing.xs,
  },
  insightTitle: {
    color: colors.text,
    fontSize: 22,
    lineHeight: 25,
    fontWeight: '900',
    letterSpacing: -1,
  },
  noteText: {
    ...typography.body,
    color: colors.textMuted,
  },
  noteAction: {
    color: colors.text,
    fontSize: 14,
    lineHeight: 19,
    fontWeight: '800',
  },
  confidence: {
    ...typography.label,
    color: colors.textDim,
    marginTop: spacing.xs,
  },
  waitingPanel: {
    borderTopWidth: 1,
    borderTopColor: colors.line,
    paddingTop: spacing.md,
    flexDirection: 'row',
    alignItems: 'flex-start',
    gap: spacing.sm,
  },
  lastSessionStrip: {
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: colors.line,
    paddingVertical: spacing.md,
  },
  lastSessionText: {
    ...typography.detailNumeric,
    color: colors.text,
    fontSize: 17,
    lineHeight: 23,
    marginTop: spacing.xs,
  },
});
  • Step 2: Run typecheck, lint, and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: Commit
cd /d/h && git add src/screens/InsightsScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(insights): InsightsScreen with InsightRow + EmptyState"

Task F6.2: FightRateApp — tab fade, brand LiveDot, tab live indicator

Files:

  • Modify: src/app/FightRateApp.tsx

  • Step 1: Replace the entire file

Write the entire src/app/FightRateApp.tsx with this content:

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
  Image,
  Pressable,
  ScrollView,
  StatusBar,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import {
  Bluetooth,
  ChartNoAxesCombined,
  History,
  Radio,
  type LucideIcon,
} from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';
import { useHeartRateMonitor } from '../ble/useHeartRateMonitor';
import { LiveDot } from '../components/LiveDot';
import { CaptureScreen } from '../screens/CaptureScreen';
import { HistoryScreen } from '../screens/HistoryScreen';
import { InsightsScreen } from '../screens/InsightsScreen';
import { SensorScreen } from '../screens/SensorScreen';
import { colors, motion, radius, spacing, typography } from '../theme';

type TabKey = 'capture' | 'history' | 'sensor' | 'insights';

type Tab = {
  readonly key: TabKey;
  readonly label: string;
  readonly icon: LucideIcon;
};

const tabs: readonly Tab[] = [
  { key: 'capture', label: 'Captura', icon: Radio },
  { key: 'history', label: 'Histórico', icon: History },
  { key: 'sensor', label: 'Sensor', icon: Bluetooth },
  { key: 'insights', label: 'Insights', icon: ChartNoAxesCombined },
];

export function FightRateApp() {
  const insets = useSafeAreaInsets();
  const monitor = useHeartRateMonitor();
  const [activeTab, setActiveTab] = useState<TabKey>('capture');
  const opacity = useSharedValue(1);

  useEffect(() => {
    opacity.value = 0;
    opacity.value = withTiming(1, {
      duration: motion.duration.fast,
      easing: Easing.out(Easing.quad),
    });
  }, [activeTab, opacity]);

  const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));

  const onCaptureAction = useCallback(async () => {
    if (monitor.isCapturing) {
      await monitor.stopCapture();
      return;
    }

    await monitor.startCapture();
  }, [monitor]);

  const onMonitorAction = useCallback(async () => {
    if (monitor.isMonitoring) {
      await monitor.stopMonitoring();
      return;
    }

    await monitor.startMonitoring();
  }, [monitor]);

  const activeTitle = useMemo(() => {
    if (activeTab === 'capture') {
      if (monitor.isCapturing) {
        return 'Janela de treino ativa';
      }
      if (monitor.isMonitoring) {
        return 'Monitor contínuo';
      }
      return 'Ativar sensor';
    }

    if (activeTab === 'history') {
      return 'Histórico real';
    }

    if (activeTab === 'sensor') {
      return 'Sensor BLE';
    }

    return 'Análise real';
  }, [activeTab, monitor.isCapturing, monitor.isMonitoring]);

  const isAnyLive = monitor.isMonitoring || monitor.isCapturing;

  return (
    <View style={styles.root}>
      <StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
      <ScrollView
        contentContainerStyle={[
          styles.content,
          {
            paddingTop: insets.top + spacing.lg,
            paddingBottom: insets.bottom + 118,
          },
        ]}
        showsVerticalScrollIndicator={false}
      >
        <View style={styles.brandRow}>
          <Image
            source={require('../assets/fightrate-logo.png')}
            style={styles.logo}
          />
          <View style={styles.titleColumn}>
            <View style={styles.kickerRow}>
              {isAnyLive ? <LiveDot live size={6} /> : null}
              <Text style={styles.kicker}>FightRate</Text>
            </View>
            <Text style={styles.title}>{activeTitle}</Text>
          </View>
        </View>

        <Animated.View style={fadeStyle} key={`tab-${activeTab}`}>
          {activeTab === 'capture' ? (
            <CaptureScreen
              monitor={monitor}
              onCaptureAction={onCaptureAction}
              onMonitorAction={onMonitorAction}
            />
          ) : null}
          {activeTab === 'history' ? (
            <HistoryScreen
              sessions={monitor.sessions}
              getSessionSamples={monitor.getSessionSamples}
              getContinuousSamples={monitor.getContinuousSamples}
              liveSamples={monitor.samples}
            />
          ) : null}
          {activeTab === 'sensor' ? (
            <SensorScreen
              monitor={monitor}
              onMonitorAction={onMonitorAction}
            />
          ) : null}
          {activeTab === 'insights' ? (
            <InsightsScreen
              liveSamples={monitor.samples}
              sessions={monitor.sessions}
              getContinuousSamples={monitor.getContinuousSamples}
            />
          ) : null}
        </Animated.View>
      </ScrollView>

      <View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
        <View style={styles.navRail}>
          {tabs.map((tab) => {
            const selected = tab.key === activeTab;
            const Icon = tab.icon;
            const isTabLive =
              (tab.key === 'capture' && monitor.isCapturing) ||
              (tab.key === 'sensor' && monitor.isMonitoring);
            return (
              <Pressable
                accessibilityRole="tab"
                accessibilityState={{ selected }}
                key={tab.key}
                onPress={() => setActiveTab(tab.key)}
                style={[styles.navItem, selected ? styles.navItemActive : null]}
              >
                <View style={styles.navIconRow}>
                  <Icon
                    color={selected ? colors.text : colors.textMuted}
                    size={18}
                    strokeWidth={2.4}
                  />
                  {isTabLive ? (
                    <View style={styles.tabLiveDot}>
                      <LiveDot live size={4} />
                    </View>
                  ) : null}
                </View>
                <Text
                  style={[
                    styles.navLabel,
                    selected ? styles.navLabelActive : null,
                  ]}
                >
                  {tab.label}
                </Text>
              </Pressable>
            );
          })}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: colors.canvas,
  },
  content: {
    paddingHorizontal: spacing.md,
    gap: spacing.lg,
  },
  brandRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: spacing.md,
  },
  logo: {
    width: 54,
    height: 54,
    borderRadius: radius.sm,
  },
  titleColumn: {
    flex: 1,
  },
  kickerRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
  },
  kicker: {
    ...typography.label,
    color: colors.action,
  },
  title: {
    marginTop: spacing.xs,
    color: colors.text,
    fontSize: 34,
    lineHeight: 36,
    fontWeight: '900',
    letterSpacing: -2.1,
  },
  navWrap: {
    position: 'absolute',
    left: spacing.md,
    right: spacing.md,
  },
  navRail: {
    borderWidth: 1,
    borderColor: colors.line,
    borderRadius: radius.xl,
    backgroundColor: colors.surfaceRaised,
    padding: spacing.xs,
    flexDirection: 'row',
    gap: spacing.xs,
    shadowColor: colors.black,
    shadowOpacity: 0.45,
    shadowRadius: 24,
    elevation: 18,
  },
  navItem: {
    flex: 1,
    minHeight: 54,
    borderRadius: radius.xl,
    alignItems: 'center',
    justifyContent: 'center',
    gap: spacing.xxs,
  },
  navItemActive: {
    backgroundColor: colors.action,
  },
  navIconRow: {
    position: 'relative',
  },
  tabLiveDot: {
    position: 'absolute',
    top: -2,
    right: -6,
  },
  navLabel: {
    ...typography.label,
    color: colors.textMuted,
    fontSize: 9,
  },
  navLabelActive: {
    color: colors.text,
  },
});
  • Step 2: Run typecheck, lint, and tests

Run: cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: Commit
cd /d/h && git add src/app/FightRateApp.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "feat(app): FightRateApp tab fade + brand LiveDot + tab live indicator"

F7 — Verify + Ship (T7.1 + T7.2 + T7.3 + T7.4)

Task F7.1: Update FIGHTRATE_DESIGN_SYSTEM.md with new tokens

Files:

  • Modify: FIGHTRATE_DESIGN_SYSTEM.md

  • Step 1: Append new section after section 4 (Tipografia)

Use edit to insert this block immediately after the end of section 4 (the #### Texto explicativo subsection in FIGHTRATE_DESIGN_SYSTEM.md):

### Tipografia numérica tabular (v2)

Para todos os valores numéricos do app (BPM, métricas, zone, timestamps, IDs, UUIDs), o app usa **JetBrains Mono** com `fontVariant: ['tabular-nums']` para alinhamento perfeito de dígitos. Esta fonte está bundled em `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` e linkada via `react-native.config.js` + `react-native-asset`.

Tokens em `src/theme/typography.ts`:

```ts
displayNumeric:  { fontSize: 110, lineHeight: 100, fontWeight: '900', letterSpacing: -7 }  // BPM principal
displaySmallNumeric: { fontSize: 56, lineHeight: 54, fontWeight: '900', letterSpacing: -3 }  // Métricas grandes
metricNumeric: { fontSize: 28, lineHeight: 30, fontWeight: '800', letterSpacing: -1.2 }  // Tiles
detailNumeric: { fontSize: 18, lineHeight: 22, fontWeight: '800', letterSpacing: -0.8 }  // Detalhes

display, displaySmall, body, label, button permanecem em Inter (fallback do sistema). Apenas os tokens numéricos exigem JetBrains Mono.


- [ ] **Step 2: Append a new section after section 9 (Motion)**

Insert this block after section 9:

```markdown
### Motion presets (v2)

`src/theme/motion.ts` define easing e durações canônicas:

```ts
easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)'  // entradas
easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)'   // transições longas
easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)' // pin (overshoot sutil)
easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)' // pulse loops

duration: { instant: 80, fast: 160, normal: 240, slow: 380, pulse: 800, pulseSlow: 1600 }
stagger: { row: 50, item: 80 }

Animações registradas:

  • bpmPulse (loop, 800ms) — BPM quando receiving
  • liveDotPulse (loop, 1600ms) — LiveDot quando live = true
  • stopAlertRing (loop, 1600ms) — anel de aviso em volta do botão Parar quando isCapturing
  • tabFade (200ms cross-fade) — entre tabs
  • rowEnter (240ms fade+translateY 8→0, stagger 50ms) — listas de Histórico e Insights
  • pinEnter (500ms easeOutBack 1.4→1) — pin do TrainingChart
  • pressDown/Up (80/160ms scale 1→0.96→1, opacity 1→0.82→1) — feedback físico de todos os botões

Todas respeitam AccessibilityInfo.isReduceMotionEnabled() (instantâneas quando true).


- [ ] **Step 3: Commit**

```bash
cd /d/h && git add FIGHTRATE_DESIGN_SYSTEM.md
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "docs(design): update FIGHTRATE_DESIGN_SYSTEM.md with numeric typography + motion presets"

Task F7.2: Final verify (typecheck + lint + test + audit)

Files: none (verification only)

  • Step 1: typecheck

Run: cd /d/h && npm run typecheck 2>&1 | tail -5

Expected: 0 errors.

  • Step 2: lint

Run: cd /d/h && npm run lint 2>&1 | tail -5

Expected: 0 errors.

  • Step 3: tests

Run: cd /d/h && npm test -- --runInBand 2>&1 | tail -10

Expected: all tests pass (55 antigos + 9 novos ≈ 64).

  • Step 4: audit

Run: cd /d/h && npm audit --audit-level=moderate 2>&1 | tail -5

Expected: 0 vulnerabilities at moderate or higher.

  • Step 5: If any step fails, fix inline and rerun until all 4 pass

  • Step 6: Commit any fixes

cd /d/h && git add -A
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "chore: final verify pass (typecheck + lint + test + audit)" --allow-empty

Task F7.3: Build release APK

Files: none (build verification only)

  • Step 1: Confirm react-native-asset linked fonts (if Step F1.1.3 used direct copy)

Run: ls /d/h/android/app/src/main/assets/fonts/

Expected: 4 ttf files.

  • Step 2: Build release APK

Run:

cd /d/h/android
$env:JAVA_HOME = "C:\Users\Evandro\AppData\Local\Programs\Android Studio\jbr"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a 2>&1 | tail -20

Expected: BUILD SUCCESSFUL and app-arm64-v8a-release.apk produced in android/app/build/outputs/apk/release/.

  • Step 3: Verify APK contains fonts

Run: unzip -l /d/h/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk | grep -i jetbrains

Expected: 4 entries for assets/fonts/JetBrainsMono-*.ttf.

  • Step 4: Compress APK to 7z for Telegram

Run:

cd /d/h
"C:/Users/Evandro/AppData/Local/Microsoft/WindowsApps/7z.exe" a -t7z -mx=9 -m0=lzma2 "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk.7z" "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk" 2>&1 | tail -3
ls -lh android/app/build/outputs/apk/release/

Expected: 7z file ~ 9-12 MB (depends on font delta).

  • Step 5: Commit the 7z archive to a build artifact dir (optional, for traceability)
cd /d/h && git add android/app/build/outputs/apk/release/*.7z 2>/dev/null
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "build: release APK v2 (Instrumento Forte)" 2>&1 | tail -3

Task F7.4: Send APK via Telegram and update .impeccable.md

Files:

  • Modify: .impeccable.md

  • Step 1: Send via Telegram

Call the telegram_attach tool with the 7z path:

telegram_attach({
  paths: ['D:\\h\\android\\app\\build\\outputs\\apk\\release\\app-arm64-v8a-release.apk.7z'],
  caption: '🎯 FightRate v2 — Instrumento Forte (release APK, ~9-12 MB)\n\nRefinamento UI/UX concluído:\n- JetBrains Mono tabular em todos os displays numéricos\n- LiveDot pulsante (LED) em AO VIVO, BPM, tab bar\n- Anel de aviso no botão Parar durante captura\n- Furos laterais no TrainingChart (estilo ECG)\n- DialTile no SensorScreen\n- TrackRow T01..Tn no Histórico\n- InsightRow com stagger reveal\n- Press feedback refinado em todos os botões\n- applicationId preservado: com.healthtracking.fightrate\n\nPara instalar: descompacte o 7z e rode `adb install -r app-arm64-v8a-release.apk`',
})
  • Step 2: Update .impeccable.md

Append this paragraph at the end of .impeccable.md:


## Estado atual (2026-06-15)

Direção 01 — Corner Instrument refinada para **Instrumento Forte** (release v2). Mudanças:
- JetBrains Mono tabular em displays numéricos.
- 6 novos componentes (LiveDot, PressableSurface, EmptyState, DialTile, TrackRow, InsightRow) e 2 hooks (usePulseAnimation, useMotionPreference).
- Animações orquestradas (LiveDot pulse, BPM pulse, stop alert ring, tab cross-fade, stagger reveal em listas).
- TrainingChart refinado (furos laterais estilo ECG, LiveDot no readout).
- BLE, SQLite, hooks de monitor NÃO foram tocados.

Próxima iteração: avaliar Direção C (Cinemática) — entrada de tela com reveal, transições slide, sombras direcionais.
  • Step 3: Commit
cd /d/h && git add .impeccable.md
git -c user.name="fightrate-bot" -c user.email="[email protected]" commit -m "docs: update .impeccable.md with v2 status"
  • Step 4: Final summary to user

After all tasks complete, present a final summary in PT-BR with:

  • Number of new tests (and total).
  • APK size before/after.
  • Files changed.
  • Brief description of each refinement.

Self-Review

Spec coverage

Each spec section maps to a task:

  • §3.1 Tipografia → F1.1 (font link) + F1.3 (typography tokens).
  • §3.2 Motion → F1.2 (motion.ts).
  • §3.3 Cores → F1.4 (ringWarn).
  • §3.4 Spacing → no changes (covered by existing tokens).
  • §4.1 LiveDot → F2.2.
  • §4.2 PressableSurface → F2.3.
  • §4.3 EmptyState → F2.4.
  • §4.4 DialTile → F2.5.
  • §4.5 TrackRow → F2.6.
  • §4.6 InsightRow → F2.7.
  • §4.7 usePulseAnimation → F2.1.
  • §5.1 ActionButton → F3.1.
  • §5.2 TrainingChart → F4.1.
  • §5.3 SectionPanel → F3.3.
  • §5.4 MetricTile → F3.4.
  • §5.5 StatusTag → F3.2.
  • §6.1 CaptureScreen → F4.2.
  • §6.2 HistoryScreen → F5.1.
  • §6.3 SensorScreen → F5.2.
  • §6.4 InsightsScreen → F6.1.
  • §6.5 FightRateApp → F6.2.
  • §7 Animações → spread across F2 (hooks) + F3 (ActionButton ring) + F4 (chart readout) + F6 (tab fade, stagger).
  • §9 Critérios de aceitação → verified by F7.2.
  • §11 Riscos → mitigated by F1.1.3 fallback to direct copy.

Placeholder scan

Searched the plan for "TBD", "TODO", "implement later", "fill in details", "appropriate error handling", "similar to Task N". No matches.

Type consistency

  • LiveDot props: live, size, testID — used consistently.
  • PressableSurface props: children, onPress, disabled, style, accessibilityLabel, testID — used consistently.
  • EmptyState props: eyebrow, title, copy, tone, testID — used consistently.
  • DialTile props: label, value, tone, testID — used consistently.
  • TrackRow props: trackNumber, day, hour, title, meta, metrics, isOpen, onPress, accessibilityLabel, children, testID — used consistently.
  • InsightRow props: index, label, statusLabel, summary, detail, value, unit?, tone, testID — used consistently.
  • usePulseAnimation(active, durationMs?) — single signature.
  • useMotionPreference() — no args, returns boolean.

No backwards-incompatible changes

  • All new props are optional with sensible defaults.
  • Existing imports stay working.
  • BLE, SQLite, monitor hooks untouched.