Add Svelte GUI, FIT encoder and README

Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app } from './lib/app.svelte';
import { api, inTauri } from './lib/bridge';
import ConnectionScreen from './components/ConnectionScreen.svelte';
import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte';
import RideScreen from './components/RideScreen.svelte';
import Toasts from './components/Toasts.svelte';
let ready = $state(false);
let fatal = $state<string | null>(null);
onMount(async () => {
if (!inTauri) {
fatal = 'Not running inside the Tauri shell — start the app with `cargo tauri dev`.';
return;
}
try {
await app.init();
ready = true;
} catch (e) {
fatal = String(e);
}
});
/**
* Keyboard control (FR-3.19). Handled once, here, and dispatched straight to
* Rust — the frontend never applies a step itself, it only asks.
*/
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const step = e.shiftKey ? 2 : 0.5;
const run = (fn: () => Promise<unknown>) => {
e.preventDefault();
app.run(fn);
};
switch (e.key) {
case 'ArrowUp':
return run(() => api.nudgeGradient(step));
case 'ArrowDown':
return run(() => api.nudgeGradient(-step));
case '0':
return run(() => api.resetGradient());
case ' ':
return run(() => api.togglePause());
case 'm':
case 'M':
return run(() => api.cycleMode());
case 'l':
case 'L':
return run(() => api.markLap());
case ']':
return run(() => bumpTarget(1));
case '[':
return run(() => bumpTarget(-1));
case 'p':
case 'P':
e.preventDefault();
app.showProfiles = !app.showProfiles;
return;
case 'd':
case 'D':
app.screen = 'connect';
return;
case 'r':
case 'R':
app.screen = 'ride';
return;
case '?':
app.showHelp = !app.showHelp;
return;
case 'Escape':
app.showHelp = false;
app.showProfiles = false;
return;
}
}
/** `[` / `]` adjust whichever target the active mode actually uses. */
async function bumpTarget(dir: number): Promise<unknown> {
const ride = app.ride;
if (!ride) return;
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
return api.nudgeGradient(dir * 0.5);
}
</script>
<svelte:window on:keydown={onKey} />
<main>
{#if fatal}
<div class="fatal">
<h1>BikeControl could not start</h1>
<p>{fatal}</p>
</div>
{:else if !ready}
<div class="boot"><span class="label">Starting…</span></div>
{:else if app.screen === 'ride'}
<RideScreen />
{:else}
<ConnectionScreen />
{/if}
{#if app.showProfiles}<ProfileDrawer />{/if}
{#if app.showHelp}<HelpOverlay />{/if}
<Toasts />
</main>
<style>
main {
height: 100%;
min-height: 0;
}
.boot,
.fatal {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
height: 100%;
text-align: center;
padding: 2rem;
}
.fatal h1 {
margin: 0;
font-size: 1.4rem;
font-weight: 300;
}
.fatal p {
margin: 0;
color: var(--ink-dim);
max-width: 40rem;
}
</style>