git clone https://github.com/Ricardo-Marques/betaflight-tuning-helper
T=$(mktemp -d) && git clone --depth=1 https://github.com/Ricardo-Marques/betaflight-tuning-helper "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude" ~/.claude/skills/ricardo-marques-betaflight-tuning-helper-claude && rm -rf "$T"
.claude/codebase-skill.mdCodebase Skill — Complete Operational Guide
KEEP THIS FILE UP TO DATE. When architectural decisions change, files move, new patterns are adopted, or conventions shift, update this file immediately. Stale guidance is worse than no guidance. After any structural change (new store, renamed file, new dependency, changed workflow), check if this file needs updating.
Quick Reference Commands
pnpm dev # Dev server → localhost:5173 pnpm build # tsc && vite build → dist/ pnpm lint # ESLint (--max-warnings 0, zero tolerance) pnpm test # Playwright E2E (full suite, ~10 min) pnpm test:headed # E2E with visible browser pnpm test:ui # E2E Playwright UI debug mode pnpm test:unit # Vitest unit tests (src/**/*.test.ts) npx tsx scripts/take-screenshots.ts # All 8 showcase screenshots npx tsx scripts/take-screenshots.ts 2 # Single screenshot by index npx tsx scripts/take-screenshots.ts composite # Re-composite from existing raws
File Map — Where to Find Everything
By Purpose
| I need to... | Look here |
|---|---|
| Add/change a React component | |
| Add/change chart logic | + |
| Add/change chart styles | |
| Add a new tuning rule | (create new file, register in RuleEngine) |
| Change how analysis works | |
| Add/change a Betaflight parameter | (BetaflightParameter type) |
| Map parameter to CLI command | |
| Add CLI option metadata/ranges | |
| Change how .bbl/.bfl files parse | |
| Change how .txt/.csv files parse | |
| Add/change a MobX store | |
| Wire a new store into the app | |
| Add/change theme colors | , |
| Add/change theme type | (ThemeColors interface) |
| Add global CSS animations | |
| Add/change MobX reactive primitives | |
| Add/change serial (USB) communication | |
| Add/change quad profile thresholds | |
| Add/change issue descriptions on chart | |
| Update changelog | |
| Add/change glossary terms | |
| Change build/deploy | , |
| Change virtual:changelog module | |
| Add/change E2E tests | (specs + helpers) |
| Add/change unit tests | Co-located with source: |
| Change Playwright config | |
| Change ESLint rules | |
| Check TS config | () |
Key Files (Sorted by Importance)
| File | Lines | Role |
|---|---|---|
| ~613 | Root layout, panel resize, drag-drop, modal orchestration |
| ~96 | Composes all stores, provides React context + hooks |
| ~200 | Analysis results, issue/recommendation selection, reanalyze |
| ~130 | Parsed frames, metadata, worker communication |
| ~294 | Axis, zoom, panel state, modals, mobile layout, toasts |
| ~200 | Imported Betaflight settings, pending/accepted values |
| ~200 | USB serial connection, read/write to FC |
| ~160 | Download logs from FC flash memory |
| ~500 | Orchestrates analysis: segment → detect → dedup → recommend |
| ~245 | DetectedIssue, Recommendation, ParameterChange, BetaflightParameter |
| ~167 | LogFrame, LogMetadata, AxisData interfaces |
| ~60 | TuningRule interface (condition, detect, recommend) |
| ~1429 | Tuning recommendations UI (tabs, CLI export, accept tune) |
| ~385 | Recharts line chart with issue markers |
| ~100 | Downsample frames → chart data points |
| ~330 | Mouse drag/click handlers, issue selection on chart click |
| ~135 | Compute label positions, stacking, severity sorting |
| ~245 | Hover/forced popover HTML, glow effect on selection |
| ~230 | Styled components for chart, labels, popover, overlays |
| ~770 | Drag-drop upload, parse progress, metadata display |
| ~400 | Web Worker: parses .bbl/.bfl/.txt/.csv in background |
| ~384 | Map recommendations → Betaflight CLI commands |
| ~299 | Cooley-Tukey FFT, RMS, band energy |
| ~391 | Bounceback detection, settling time analysis |
| ~1000 | Betaflight 4.5 CLI param definitions, ranges, enums |
Architecture at a Glance
User uploads .bbl/.bfl/.txt/.csv file ↓ LogStore.uploadFile() → spawns Web Worker ↓ Worker parses binary/text → postMessage({ type: 'complete', frames, metadata }) ↓ LogStore sets frames + metadata → triggers AnalysisStore.analyze() ↓ RuleEngine.analyzeLog(): 1. Segment log into 100ms windows (50% overlap) 2. Classify flight phase per window (idle/hover/cruise/punch/propwash/flip/roll) 3. Run each rule: condition() → detect() → recommend() 4. Temporal dedup issues (100ms gap merge, then collapse by type+axis) 5. Cross-axis correlation (annotate patterns, generate hardware recs) 6. Frequency issue merge (collapse same-freq frameResonance/bearingNoise across axes) 7. Temporal progression analysis (annotate trends, generate meta-issues) 8. Generate recommendations (settings-aware, with currentValue populated) 9. Dedup recommendations (key on parameter:axis, not title) 10. Generate summary + flight segments ↓ AnalysisStore.result populated → observer() components re-render ↓ LogChart shows traces + issue markers RecommendationsPanel shows issues + recommendations + CLI export
Layer Rules
- Domain (
): Pure TypeScript. NO React imports, NO MobX imports. Testable in isolation.src/domain/ - Stores (
): MobXsrc/stores/
. Business logic orchestration. Can reference domain layer.makeAutoObservable - Components (
): React +src/components/
. Access stores via hooks. Emotion styled components.observer() - Workers (
): Background threads. Can import domain layer. Communicate viasrc/workers/
.postMessage
How to Implement Common Tasks
New Tuning Rule
- Create
conforming tosrc/domain/rules/YourRule.ts
interface:TuningRuleexport const YourRule: TuningRule = { id: 'your-rule', name: 'Your Rule', description: '...', baseConfidence: 0.7, issueTypes: ['yourIssueType'], applicableAxes: ['roll', 'pitch', 'yaw'], condition(window, frames) { return /* should this window be checked? */ }, detect(window, frames, profile) { return /* DetectedIssue[] */ }, recommend(issues, frames, profile) { return /* Recommendation[] */ }, } - Add issue type to
union inIssueTypesrc/domain/types/Analysis.ts - Register rule in
constructor:src/domain/engine/RuleEngine.tsthis.registerRule(YourRule) - Add chart description in
src/domain/utils/issueChartDescriptions.ts - If new parameters: add to
union inBetaflightParameter
, map inAnalysis.tsCliExport.ts - Add thresholds per quad profile in
src/domain/profiles/quadProfiles.ts
New React Component
import { observer } from 'mobx-react-lite' import styled from '@emotion/styled' import { useStores } from '../stores/RootStore' import { useObservableState, useComputed, useAutorun } from '../lib/mobx-reactivity' const Wrapper = styled.div` color: ${p => p.theme.colors.text.primary}; ` export const MyComponent = observer(() => { const { uiStore, analysisStore } = useStores() const [localState, setLocalState] = useObservableState(false) const derived = useComputed(() => analysisStore.issues.filter(i => i.severity === 'high')) useAutorun(() => { if (analysisStore.selectedIssue) { // side effect reacting to observable change } }) return <Wrapper>...</Wrapper> })
Rules:
- ALWAYS wrap with
observer() - NEVER use
,useState
,useEffect
,useMemouseCallback - ALWAYS use
,useObservableState
,useComputeduseAutorun - ALWAYS use Emotion styled components (no inline styles, no CSS files, no Tailwind)
- Keep under 300 lines — split by domain/concern
New MobX Store
import { makeAutoObservable, runInAction } from 'mobx' export class MyStore { publicField: string = '' private privateField: SomeType | null = null constructor() { makeAutoObservable<this, 'privateField'>(this, { privateField: false, // exclude from observation }) } get computed(): string { return this.publicField.toUpperCase() } someAction = (value: string): void => { this.publicField = value } someAsyncAction = async (): Promise<void> => { const result = await fetch(...) runInAction(() => { this.publicField = result }) } reset = (): void => { this.publicField = '' } }
Then wire in
src/stores/RootStore.ts:
- Add field:
myStore: MyStore - Instantiate in constructor:
this.myStore = new MyStore() - Add hook:
export function useMyStore() { return useStores().myStore } - Add to
if applicablereset()
New Betaflight Parameter
- Add to
union inBetaflightParametersrc/domain/types/Analysis.ts - Add CLI mapping in
:src/domain/utils/CliExport.ts- Per-axis →
mapPER_AXIS_PARAMS - Global →
GLOBAL_PARAM_MAP
- Per-axis →
- Add display name in
PARAMETER_DISPLAY_NAMES - Add CLI option metadata in
src/lib/betaflight/cliOptions.ts - Add value lookup in
orgetPidValue()getGlobalValue()
New Theme Color
- Add to
interface inThemeColorssrc/theme/types.ts - Add values in
andsrc/theme/lightTheme.tssrc/theme/darkTheme.ts - Use in styled components:
${p => p.theme.colors.your.new.color}
New Modal
- Add boolean toggle to
:UIStoreyourModalOpen = false - Add open/close actions in UIStore
- Create component in
src/components/YourModal.tsx - Render conditionally in
based onsrc/App.tsxuiStore.yourModalOpen
Store Access Hooks
useStores() // Full RootStore (all stores) useLogStore() // Parsed frames, metadata, parse status useAnalysisStore() // Analysis results, issues, recommendations, selection useUIStore() // Axis, zoom, panel state, modals, toasts useThemeStore() // Dark/light mode, theme object useSettingsStore() // Imported Betaflight settings useSerialStore() // USB serial connection state useFlashDownloadStore() // Flash download progress
8 Registered Tuning Rules
| Rule | File | Issue Type | Detects |
|---|---|---|---|
| BouncebackRule | | | Overshoot after stick release |
| PropwashRule | | | Low-throttle descent oscillation |
| WobbleRule | | | Mid-throttle cruise wobble |
| TrackingQualityRule | | | Gyro-setpoint tracking error |
| MotorSaturationRule | | | Motors hitting max output |
| DTermNoiseRule | | | D-term amplifying noise |
| HighThrottleOscillationRule | | | High-throttle vibration |
| GyroNoiseRule | | | Gyro noise floor elevation |
Testing Guide
Unit Tests (Vitest)
- Co-located with source:
src/**/*.test.ts - Run:
pnpm test:unit - Key test files:
— Binary parsersrc/domain/blackbox/BblParser.test.ts
— Deduplication, segmentationsrc/domain/engine/RuleEngine.test.ts
— Profile thresholdssrc/domain/engine/RuleEngineProfiles.test.ts
— Specific rulesrc/domain/rules/TrackingQualityRule.test.ts
— CLI generationsrc/domain/utils/CliExport.test.ts
— Label collisionsrc/components/logChart/useIssueLabels.test.ts
E2E Tests (Playwright)
- Located in
e2e/*.spec.ts - Helpers:
,e2e/helpers.tse2e/data-verification-helpers.ts - Sample log:
test-logs/shortLog.BFL - Run:
(full),pnpm test
(visible browser)pnpm test:headed - Config:
— Chromium only, 1920x1080, 60s timeoutplaywright.config.ts - Selectors: Use
attributesdata-testid - No mocking: Real file uploads, real parsing, real analysis
- Upload helper:
— uploads BFL and waits for analysisuploadAndAnalyze(page, filePath?)
Key test files:
| File | Tests |
|---|---|
| Drag-drop, file selection, parse progress |
| Summary panel, issue counts |
| Issue details, severity, metrics |
| Lines, grid, tooltips |
| Zoom, pan, scroll |
| Issue detection, stacking, popover |
| Panel resize, axis switch, segments |
| Import/export settings |
| Correct issues/recommendations detected |
When to run tests:
- After changing domain logic → run unit tests + targeted E2E
- After changing UI → run targeted E2E spec
- Don't re-run ALL tests unless there's a strong reason they might fail
Commit Workflow
Writing Commit Messages
- Plain language for non-technical users
- One concern per commit — split unrelated changes
- Good: "Show build time in user's timezone on What's New modal"
- Bad: "Emit full ISO datetime from changelogPlugin and add formatBuildDate"
After Committing
If the change is user-facing, add an entry to
src/data/changelog.ts:
{ hash: 'abc1234', // git short hash date: '2026-02-19', // ISO date message: 'What the user sees changed', category: 'feature' | 'improvement' | 'fix', }
CI/CD Pipeline
Push to
main triggers .github/workflows/deploy.yml:
- build —
tsc && vite build - unit-tests —
pnpm test:unit - integration-tests — Playwright across 16 shards
- deploy — GitHub Pages (all 3 above must pass)
Key Deduplication Logic
Understanding this prevents confusion when working on analysis:
Issue Deduplication (RuleEngine)
- Temporal merge: Issues of same type+axis within 100ms gap are merged into one
- Collapse: Multiple occurrences become
array with countoccurrences[] - Result: One
per type+axis with occurrence countDetectedIssue
Recommendation Deduplication (RuleEngine)
- Key:
fromparameter:axis
array (NOT title string)changes[] - Multiple rules recommending same parameter change are merged
- Conflicting changes use weighted merge based on confidence
Dependencies
| Package | Version | Role |
|---|---|---|
| 18.2.0 | UI framework |
| 18.2.0 | DOM rendering |
| 6.12.0 | Reactive state management |
| 4.0.5 | HOC |
| 11.14.0 | CSS-in-JS (css prop) |
| 11.14.1 | Styled components |
| 2.10.3 | SVG chart library |
| 1.58.2 | E2E testing |
| 4.0.18 | Unit testing |
| 5.0.8 | Build tool |
| 1.2.0 | PWA manifest + service worker |
| 5.3.3 | Type checking |
Critical Constraints
| Constraint | Reason |
|---|---|
in tsconfig | MobX needs getter/setter pattern |
| No React hooks (useState, useEffect, etc.) | MobX reactive primitives replace them |
All components wrapped in | MobX reactivity requires it |
| Emotion styled components only | Theme-aware, dynamic dark/light mode |
| Files under 300 lines | Maintainability, split by domain |
No in domain layer | Type safety, ESLint warning-level |
| Chart adaptive downsampling (300–2500 pts) | Progressive formula + FPS feedback loop in |
| FFT capped at 2048 samples | Avoid slowdown on large logs |
| Web Worker for parsing | Prevent main thread blocking on large files |
| No backend | Everything client-side, works offline (PWA) |
Zoom System
Zoom is percentage-based (0–100%).
UIStore.zoomStart / zoomEnd define the visible window.
Minimum zoom window is enforced in 3 places — all must agree:
| Location | What it controls |
|---|---|
| Safety floor when start ≥ end (0.01% absolute minimum) |
(wheel handler) | Scroll-to-zoom on the chart area |
(minWindow prop) | Handle drag + scroll-to-zoom on the slider |
The minimum is dynamic based on log duration so that full zoom always shows a 0.2s window:
const minZoomPct = (0.2 / totalDuration) * 100
LogChart.tsx computes this and passes it to RangeSlider via the minWindow prop. The chart scroll handler in useChartInteractions computes the same value from logStore.duration.
Mobile Layout
- Breakpoint:
→ mobile layoutmax-width: 1599px
observable (media query listener)UIStore.isMobileLayout- Mobile: 3-tab bottom bar (Upload / Chart / Tune) via
BottomTabBar - Desktop: 3-panel layout (left / chart / right) with drag-to-resize
- Touch targets: minimum 36x36px, 18px font on
pointer: coarse - No serial options on mobile (WebSerial is desktop Chrome/Edge only)
Serial Communication (USB)
— WebSerial API wrappersrc/serial/SerialPort.ts
— MSP protocol (read FC state)src/serial/MspProtocol.ts
— Enter/exit CLI, read/write settingssrc/serial/CliProtocol.ts
— Download blackbox from FC flash memorysrc/serial/DataflashReader.ts- Chrome/Edge only (WebSerial API)
- Flow: connect → enter CLI → dump settings → parse → show in SettingsImportModal
Vite Plugins
changelogPlugin (vite-plugins/changelogPlugin.ts
)
vite-plugins/changelogPlugin.ts- Provides
modulevirtual:changelog - Injects:
,entries[]
(ISO),buildDate
(git short hash)buildHash - Watches
for HMRsrc/data/changelog.ts - Used by ChangelogModal for "What's New" feature
Panel Resize Pattern (Drag Performance)
When resizing panels adjacent to the chart (expensive Recharts component):
- Freeze inner chart wrapper at current pixel width on drag start
- During drag: only change panel width via direct DOM (no MobX, no React re-renders)
- On mouseup: clear inline styles, commit final width to MobX in single
runInAction - Result: zero ResizeObserver fires, zero React re-renders during drag
Quad Profiles
5 profiles in
src/domain/profiles/quadProfiles.ts:
| Profile | Multiplier Range | Notes |
|---|---|---|
| Whoop (65-85mm) | 1.5-2.5x (relaxed) | More noise tolerance |
| 3" (micro) | 1.1-1.3x | Balanced |
| 5" (baseline) | 1.0x | All thresholds calibrated here |
| 7" (long-range) | 0.7-1.3x | Varies per issue |
| X-Class (10"+) | 0.6-1.5x | More propwash, less noise |
actualThreshold = baseThreshold * profile.thresholds[issueType]