Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f5f64cf
Add onboarding and live-view i18n strings.
Cormac131 Aug 26, 2026
bb8946c
Drop unrelated metric i18n keys from the onboarding string commit.
Cormac131 Aug 26, 2026
441a346
Add persisted live-view mode and preview duration.
Cormac131 Aug 26, 2026
3a8ef84
Add first-run onboarding completed flag.
Cormac131 Aug 26, 2026
9e4e413
Restore Live selected-metric overlay for timed and hold modes.
Cormac131 Aug 26, 2026
424eaee
Add live-view settings to the menu and drop sheet shutdown.
Cormac131 Aug 26, 2026
9c5621e
Restore footer shutdown opener dropped from the menu live-view commit.
Cormac131 Aug 26, 2026
77e6767
Add fullscreen first-run onboarding wizard.
Cormac131 Aug 26, 2026
88a4d82
Show first-run setup before the kiosk shell.
Cormac131 Aug 26, 2026
f9a1304
Cover first-run setup and live overlay modes in Playwright.
Cormac131 Aug 26, 2026
036fde7
Document first-run setup and live preview modes.
Cormac131 Aug 26, 2026
24f03db
Fix onboarding leak of experimental tiles and footer power CSS.
Cormac131 Aug 26, 2026
4f9473a
Move shutdown to header and polish onboarding
Cormac131 Aug 26, 2026
16d1d2e
Move duration chips inside timed preview tile
Cormac131 Aug 26, 2026
18176bb
Refine menu live view controls
Cormac131 Aug 26, 2026
ede1f8d
Merge remote-tracking branch 'upstream/main' into feat/ui-setup-wizard
Cormac131 Aug 26, 2026
6b09b47
Potential fix for pull request finding
Cormac131 Aug 26, 2026
205bf06
Refine kiosk docs and spotlight state
Cormac131 Aug 26, 2026
761f7a7
Merge branch 'feat/ui-setup-wizard' of https://github.com/Cormac131/o…
Cormac131 Aug 26, 2026
d0659c8
Fix shot flash layering over spotlight
Cormac131 Aug 26, 2026
3b89ea9
Merge branch 'main' into feat/ui-setup-wizard
Cormac131 Aug 27, 2026
ff2fcf9
Merge remote-tracking branch 'origin/main' into claude/wizard-screen-…
claude Aug 30, 2026
f2853e2
Use splash screen logo on wizard welcome screen
claude Aug 30, 2026
cd310e5
Hide redundant OpenFlight title on wizard welcome screen
claude Aug 30, 2026
fd9d172
Remove Launch Monitor eyebrow and widen logo-to-button spacing
claude Aug 30, 2026
2b824f3
Merge branch 'main' into feat/ui-setup-wizard
Cormac131 Aug 31, 2026
69f7c26
Enhance LivePanel styling and tests for confidence labels
Cormac131 Aug 31, 2026
aebe014
Merge branch 'feat/ui-setup-wizard' of https://github.com/Cormac131/o…
Cormac131 Aug 31, 2026
54e8833
Implement experimental tile marking in LivePanel and enhance MenuShee…
Cormac131 Sep 1, 2026
a7a3a61
Enhance simulator status display in PanelHeader and StatusMenu
Cormac131 Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ wrong values bias the result instead of producing an obvious startup error.

Then open http://localhost:8080 or use the touchscreen. Footer tabs switch
between Live, Stats, Shots, Camera, Players, and Debug. Tap the footer logo for
units, theme, and language; the footer power icon opens shutdown confirmation.
units, theme, and language; the header power icon opens shutdown confirmation.
On Live, tap a metric to pin it top-left while keeping all metrics visible. Use
the Replay action on camera-backed shots to open a touch-friendly slow-motion
impact clip. The MP4 is generated only when Replay is selected and is cached
Expand Down
13 changes: 10 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
full horizontal speed, overstating attack angle on any shot with club path.

### Added
- **First-run setup.** A new kiosk walks through language, units, theme, and
Live view (tiles, timed large preview, or hold preview) before the first
shot. Choices persist and remain in the footer menu. Live can show the
selected metric full-screen after a new shot; tap or the chosen duration
returns to the tile grid. Shut down was removed from the menu sheet; use
the header power button.
- **Automatic OV9281 exposure control.** High-speed camera capture now measures
the impact area every five seconds, restores the last known-good setting at
startup, and selects a shutter/gain combination that preserves club contrast
Expand All @@ -23,9 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Instrument-panel kiosk UI.** The dashboard is a tabbed shell (Live, Stats,
Shots, Camera, Players, Debug) instead of the previous stacked shot and stats
views. Tap a Live metric to pin it top-left while keeping all ten metrics
visible. The footer logo opens units, dark/light theme, language, simulator,
and ball-detection status; a persistent footer power button opens the shutdown
confirmation. Club (or training implement) selection is a Live header action.
visible. The footer logo opens units, dark/light theme, language, and live
view; simulator and ball-detection status live in the header status menu. A
persistent header power button opens the shutdown confirmation. Club (or
training implement) selection is a Live header action.
See the [UI README](../ui/README.md).
- **Kiosk languages.** English, Spanish, French, and Portuguese. Choice is
stored in `localStorage` (`openflight.locale:v1`).
Expand Down
17 changes: 11 additions & 6 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,24 @@ The app is entirely client-side. Everything flows through one socket connection.
as a no-op by the Node mock).

**Kiosk shell.** Footer tabs switch views. The footer logo opens a sheet for
units (MPH/YDS vs KMH/M), dark/light theme, language, simulator and
ball-detection status. The footer power icon is always visible and opens a
shutdown confirmation. Change club (or training implement) lives on the Live
header. Tap a Live metric to pin it top-left while keeping all metrics visible.
Camera-backed shots add a **Replay** action in the Live header and a play button
units (MPH/YDS vs KMH/M), dark/light theme, language, and live view. Simulator
and ball-detection status live in the header status menu. The header power icon
is always visible on the right and opens a shutdown confirmation. Change club (or training implement) lives on the Live
header. Tap a Live metric to pin it top-left while keeping all metrics visible;
timed or sticky Live view can optionally show a large preview of that metric
after each new shot. Camera-backed shots add a **Replay** action in the Live header and a play button
in Shots. Selecting either action asks the backend to lazily create and cache a
60 FPS MP4; no video conversion runs automatically after a shot. The full-screen
player includes touch controls, a scrubber, an impact marker, and retryable
preparation/playback errors.

The pin is stored in
`localStorage` under `openflight.hero-metric`. Theme is stored under
`openflight.theme` (default dark).
`openflight.theme` (default dark). First launch shows a fullscreen setup
(language, units, theme, Live view). Completing it writes
`openflight.onboarding.completed:v1`. Live view mode is
`openflight.live-view:v1` (`tiles` | `timed` | `sticky`, duration 5/10/15s).
Change those later in the footer menu. Shut down is the header power button.

**Display mode** lives at `/display`: a compact, fullscreen-friendly dashboard
for mounted screens and TVs. The [root README](../README.md#tv-display-mode)
Expand Down
25 changes: 22 additions & 3 deletions ui/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { renderToString } from 'react-dom/server';
import { beforeEach, describe, expect, it } from 'vitest';
import App from './App';
import { PANEL_VIEWS } from './components/panel';
import { useOnboardingStore } from './stores/useOnboardingStore';
import { useSystemStore } from './stores/useSystemStore';

describe('App shell', () => {
beforeEach(() => {
useSystemStore.setState({ serverClub: null });
useSystemStore.setState({ serverClub: null, shutdownDialogOpen: false });
useOnboardingStore.setState({ completed: true });
});

it('renders the bottom bar instead of the old top header', () => {
Expand Down Expand Up @@ -66,11 +68,20 @@ describe('App shell', () => {
expect(html).not.toContain('panel-footer__count');
});

it('keeps the shutdown power control in the footer', () => {
it('keeps the shutdown power control in the header, not the footer', () => {
const html = renderToString(<App />);

expect(html).toContain('panel-footer__power');
expect(html).toContain('panel-header__power');
expect(html).not.toContain('panel-footer__power');
expect(html).toContain('aria-label="Shut down"');
expect(html).not.toContain('Shut down OpenFlight?');
});

it('opens the shutdown dialog when the header power control requests it', () => {
useSystemStore.setState({ shutdownDialogOpen: true });
const html = renderToString(<App />);

expect(html).toContain('Shut down OpenFlight?');
});

it('does not ask to clear a session until the stats action is used', () => {
Expand All @@ -80,4 +91,12 @@ describe('App shell', () => {
expect(html).not.toContain('Clear Player 1&#x27;s session?');
expect(html).not.toContain('clear-session-title');
});

it('shows onboarding instead of the club picker on first run', () => {
useOnboardingStore.setState({ completed: false });
const html = renderToString(<App />);
expect(html).toContain('Get started');
expect(html).not.toContain('aria-label="Select club"');
expect(html).not.toContain('panel-footer');
});
});
41 changes: 20 additions & 21 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { useCameraStore } from './stores/useCameraStore';
import { useDebugStore } from './stores/useDebugStore';
import { usePlayerStore } from './stores/usePlayerStore';
import { useHeroMetricStore } from './stores/useHeroMetricStore';
import { useOnboardingStore } from './stores/useOnboardingStore';
import { useCameraReplayController } from './hooks/useCameraReplayController';
import { socketService } from './services/socketService';
import { shouldEchoSelectionToServer } from './services/playerSocketSync';
import { DebugPanel } from './components/DebugPanel';
import { DisplayMode } from './components/DisplayMode';
import { OnboardingFlow } from './components/onboarding';
import { SimShotBadges } from './components/SimShotBadges';
import { ShotProcessingArea } from './components/ShotProcessingArea';
import { ShutdownDialog, type ShutdownState } from './components/ShutdownDialog';
Expand Down Expand Up @@ -105,16 +107,22 @@ function AppContent() {
}))
);

// Hook subscribe so complete() re-renders on the client. Gate with getState()
// because renderToString uses Zustand's initial snapshot, not later setState.
useOnboardingStore((state) => state.completed);
const onboardingCompleted = useOnboardingStore.getState().completed;
useSystemStore((state) => state.shutdownDialogOpen);
const shutdownDialogOpen = useSystemStore.getState().shutdownDialogOpen;
const [currentView, setCurrentView] = useState<PanelView>('live');
const [selectedClub, setSelectedClub] = useState('driver');
const [selectedTrainingImplement, setSelectedTrainingImplement] = useState('driver');
const [menuOpen, setMenuOpen] = useState(false);
const [showShutdown, setShowShutdown] = useState(false);
const [shutdownState, setShutdownState] = useState<ShutdownState>('confirm');
// Open on every app load so the user confirms their club before the first
// shot; dismissing keeps the default. The /display route returns early below,
// so this never appears in the passive TV view.
const [pickerOpen, setPickerOpen] = useState(true);
// Open on later launches after onboarding so the club is confirmed before
// the first shot; first-run starts closed because the wizard replaces the
// shell. The /display route returns early below, so this never appears in
// the passive TV view.
const [pickerOpen, setPickerOpen] = useState(() => useOnboardingStore.getState().completed);
const [addPlayerOpen, setAddPlayerOpen] = useState(false);
const [newPlayerName, setNewPlayerName] = useState('');
const [clearSessionOpen, setClearSessionOpen] = useState(false);
Expand Down Expand Up @@ -219,7 +227,7 @@ function AppContent() {
};

const closeShutdown = () => {
setShowShutdown(false);
useSystemStore.getState().closeShutdownDialog();
setShutdownState('confirm');
};

Expand All @@ -233,6 +241,10 @@ function AppContent() {
return <DisplayMode connected={connected} cameraStatus={cameraStatus} latestShot={latestShot} shots={shots} />;
}

if (!onboardingCompleted) {
return <OnboardingFlow onFinished={() => setPickerOpen(false)} />;
}

const changeClubAction = (
<PanelAction onClick={() => setPickerOpen(true)}>
{isSwingSpeedMode ? t('app.changeImplement') : t('app.changeClub')}
Expand Down Expand Up @@ -281,7 +293,7 @@ function AppContent() {
</div>
)}

{showShutdown ? (
{shutdownDialogOpen ? (
<ShutdownDialog state={shutdownState} onConfirm={handleShutdown} onCancel={closeShutdown} />
) : null}

Expand Down Expand Up @@ -392,16 +404,7 @@ function AppContent() {
* Overlays sit outside <main> so they cover the footer too, matching how
* 6a draws them over the whole card.
*/}
{menuOpen ? (
<MenuSheet
onClose={() => setMenuOpen(false)}
onShutdown={() => {
setMenuOpen(false);
setShutdownState('confirm');
setShowShutdown(true);
}}
/>
) : null}
{menuOpen ? <MenuSheet onClose={() => setMenuOpen(false)} /> : null}

{addPlayerOpen ? (
<AddPlayerDialog
Expand Down Expand Up @@ -444,10 +447,6 @@ function AppContent() {
ballDetected={cameraStatus.ball_detected}
debugRecording={debugMode}
brand={isLaunchDaddyMode ? <LaunchDaddyBrand /> : undefined}
onShutdown={() => {
setShutdownState('confirm');
setShowShutdown(true);
}}
/>
</div>
);
Expand Down
28 changes: 26 additions & 2 deletions ui/src/components/DisplayMode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,31 @@ describe('DisplayMode', () => {
<DisplayMode connected cameraStatus={cameraStatus} latestShot={rejectedShot} shots={[rejectedShot]} />
);

expect(html).toContain('experimental · rejected: no club track');
expect(html).toContain('experimental · rejected: no pre impact frames');
expect(html).toContain('metric-card__experimental');
expect(html).toContain('rejected: no club track');
expect(html).toContain('rejected: no pre impact frames');
expect(html).not.toContain('experimental ·');
});

it('marks camera-fused and camera-assisted metrics with short copy and an icon', () => {
const fusedShot: Shot = {
...shot,
club_angle_deg: null,
club_path_deg: null,
launch_angle_horizontal_source: 'camera_assisted_experimental',
experimental_fused_attack_angle_deg: -4.2,
experimental_fused_club_path_deg: 3.1,
experimental_fused_status: 'approach_mixed',
};

const html = renderToString(
<DisplayMode connected cameraStatus={cameraStatus} latestShot={fusedShot} shots={[fusedShot]} />
);

expect(html).toContain('metric-card__experimental');
expect(html).toMatch(/metric-card__subtext[^>]*>Fused</);
expect(html).toMatch(/metric-card__subtext[^>]*>Camera</);
expect(html).not.toContain('camera fused');
expect(html).not.toContain('camera assisted');
});
});
31 changes: 24 additions & 7 deletions ui/src/components/DisplayMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface DisplayMetric {
value: string;
unit?: string;
detail?: string;
experimental?: boolean;
}

const CAMERA_STREAM_URL = `${getServerOrigin()}/camera/stream`;
Expand All @@ -46,9 +47,9 @@ function formatSpin(value: number | null): string {
return value.toLocaleString(getHtmlLang(), { maximumFractionDigits: 0 });
}

function experimentalStatus(status: string | null | undefined): string {
if (!status || status === 'candidate_available') return 'experimental candidate';
return `experimental · ${status.replace(/^rejected_/, 'rejected: ').replaceAll('_', ' ')}`;
function experimentalStatus(status: string | null | undefined): string | undefined {
if (!status || status === 'candidate_available') return undefined;
return status.replace(/^rejected_/, 'rejected: ').replaceAll('_', ' ');
}

function buildMetrics(shot: Shot | null, unitSystem: 'imperial' | 'metric', t: Translate): DisplayMetric[] {
Expand All @@ -68,6 +69,19 @@ function buildMetrics(shot: Shot | null, unitSystem: 'imperial' | 'metric', t: T

const carryYards = shot.carry_spin_adjusted ?? shot.estimated_carry_yards;
const fusedDeliveryAttempted = shot.experimental_fused_status != null;
const cameraAssistedLaunch = shot.launch_angle_horizontal_source === 'camera_assisted_experimental';
const clubPathIsExperimental =
shot.club_path_deg == null &&
(shot.experimental_fused_club_path_deg != null ||
shot.experimental_fused_status != null ||
shot.experimental_club_path_deg != null ||
shot.experimental_club_path_status != null);
const attackIsExperimental =
shot.club_angle_deg == null &&
(shot.experimental_fused_attack_angle_deg != null ||
shot.experimental_fused_status != null ||
shot.experimental_attack_angle_deg != null ||
shot.experimental_attack_angle_status != null);

return [
{
Expand Down Expand Up @@ -123,11 +137,12 @@ function buildMetrics(shot: Shot | null, unitSystem: 'imperial' | 'metric', t: T
? undefined
: fusedDeliveryAttempted
? shot.experimental_fused_club_path_deg != null
? 'camera fused (exp.)'
? t('metric.cameraFused')
: experimentalStatus(shot.experimental_fused_status)
: shot.experimental_club_path_deg != null || shot.experimental_club_path_status != null
? experimentalStatus(shot.experimental_club_path_status)
: undefined,
experimental: clubPathIsExperimental || undefined,
},
{
label: t('metric.clubAoa'),
Expand All @@ -148,18 +163,19 @@ function buildMetrics(shot: Shot | null, unitSystem: 'imperial' | 'metric', t: T
? undefined
: fusedDeliveryAttempted
? shot.experimental_fused_attack_angle_deg != null
? 'camera fused (exp.)'
? t('metric.cameraFused')
: experimentalStatus(shot.experimental_fused_status)
: shot.experimental_attack_angle_deg != null || shot.experimental_attack_angle_status != null
? experimentalStatus(shot.experimental_attack_angle_status)
: undefined,
experimental: attackIsExperimental || undefined,
},
{
label: t('metric.hLaunch'),
value: formatOptionalNumber(shot.launch_angle_horizontal, 1, true),
unit: shot.launch_angle_horizontal === null ? undefined : 'deg',
detail:
shot.launch_angle_horizontal_source === 'camera_assisted_experimental' ? 'camera assisted (exp.)' : undefined,
detail: cameraAssistedLaunch ? t('metric.cameraAssisted') : undefined,
experimental: cameraAssistedLaunch || undefined,
},
];
}
Expand All @@ -172,6 +188,7 @@ function toMetricCard(metric: DisplayMetric, featured = false) {
unit={metric.unit}
label={metric.label}
subtext={metric.detail}
experimental={metric.experimental}
variant={featured ? 'emphasis' : 'default'}
/>
);
Expand Down
4 changes: 4 additions & 0 deletions ui/src/components/SimStatus.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.sim-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
}

Expand Down Expand Up @@ -83,4 +84,7 @@
.sim-status__state {
display: none;
}
.panel-header__status-sims .sim-status__state {
display: inline;
}
}
Loading
Loading