From f5f64cf9562705756da1f4c100ab0e7a5ef4d00a Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:04:46 +0100 Subject: [PATCH 01/24] Add onboarding and live-view i18n strings. Co-authored-by: Cursor --- ui/src/i18n/en.ts | 28 ++++++++++++++++++++++++++++ ui/src/i18n/es.ts | 28 ++++++++++++++++++++++++++++ ui/src/i18n/fr.ts | 28 ++++++++++++++++++++++++++++ ui/src/i18n/pt.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index 8da9e2f7..86d200c3 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -48,6 +48,9 @@ export const en = { 'metric.readingsCount': '{count} readings', 'metric.trigger': '{speed} {unit} trigger', 'metric.estimated': 'Estimated', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Camera', + 'metric.cameraFused': 'Fused', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Straight', @@ -136,6 +139,31 @@ export const en = { 'menu.enable': 'Enable', 'menu.shutdown': 'Shut down', + 'onboarding.getStarted': 'Get started', + 'onboarding.welcomeTitle': 'OpenFlight', + 'onboarding.welcomeDetail': 'Set language, units, theme, and how shots appear on Live.', + 'onboarding.continue': 'Continue', + 'onboarding.back': 'Back', + 'onboarding.start': 'Start', + 'onboarding.languageTitle': 'Language', + 'onboarding.unitsTitle': 'Units', + 'onboarding.themeTitle': 'Theme', + 'onboarding.liveViewTitle': 'Live view', + 'onboarding.liveTiles': 'Tiles', + 'onboarding.liveTilesDetail': 'All metrics. No large preview.', + 'onboarding.liveTimed': 'Timed preview', + 'onboarding.liveTimedDetail': 'Large selected metric, then tiles.', + 'onboarding.liveHold': 'Hold preview', + 'onboarding.liveHoldDetail': 'Large selected metric until you tap.', + 'onboarding.duration': 'Preview duration', + 'onboarding.duration5': '5s', + 'onboarding.duration10': '10s', + 'onboarding.duration15': '15s', + 'onboarding.doneTitle': "You're ready", + 'onboarding.doneDetail': 'Driver is selected. Change club anytime from Live.', + 'onboarding.step': 'Step {current} of {total}', + 'menu.liveView': 'Live view', + 'app.changeImplement': 'Change implement', 'app.changeClub': 'Change club', 'app.clearSession': 'Clear session', diff --git a/ui/src/i18n/es.ts b/ui/src/i18n/es.ts index cb8fbe61..6bbd5258 100644 --- a/ui/src/i18n/es.ts +++ b/ui/src/i18n/es.ts @@ -50,6 +50,9 @@ export const es: Messages = { 'metric.readingsCount': '{count} lecturas', 'metric.trigger': 'umbral {speed} {unit}', 'metric.estimated': 'Estimado', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Cámara', + 'metric.cameraFused': 'Fusión', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Recto', @@ -138,6 +141,31 @@ export const es: Messages = { 'menu.enable': 'Activar', 'menu.shutdown': 'Apagar', + 'onboarding.getStarted': 'Empezar', + 'onboarding.welcomeTitle': 'OpenFlight', + 'onboarding.welcomeDetail': 'Elige idioma, unidades, tema y cómo se muestran los golpes en En vivo.', + 'onboarding.continue': 'Continuar', + 'onboarding.back': 'Atrás', + 'onboarding.start': 'Comenzar', + 'onboarding.languageTitle': 'Idioma', + 'onboarding.unitsTitle': 'Unidades', + 'onboarding.themeTitle': 'Tema', + 'onboarding.liveViewTitle': 'Vista en vivo', + 'onboarding.liveTiles': 'Mosaicos', + 'onboarding.liveTilesDetail': 'Todas las métricas. Sin vista grande.', + 'onboarding.liveTimed': 'Vista temporizada', + 'onboarding.liveTimedDetail': 'Métrica grande, luego mosaicos.', + 'onboarding.liveHold': 'Vista fija', + 'onboarding.liveHoldDetail': 'Métrica grande hasta que toques.', + 'onboarding.duration': 'Duración de la vista', + 'onboarding.duration5': '5s', + 'onboarding.duration10': '10s', + 'onboarding.duration15': '15s', + 'onboarding.doneTitle': 'Listo', + 'onboarding.doneDetail': 'Driver está seleccionado. Cambia el palo cuando quieras desde En vivo.', + 'onboarding.step': 'Paso {current} de {total}', + 'menu.liveView': 'Vista en vivo', + 'app.changeImplement': 'Cambiar implemento', 'app.changeClub': 'Cambiar palo', 'app.clearSession': 'Borrar sesión', diff --git a/ui/src/i18n/fr.ts b/ui/src/i18n/fr.ts index cd19b1f0..381b2c06 100644 --- a/ui/src/i18n/fr.ts +++ b/ui/src/i18n/fr.ts @@ -50,6 +50,9 @@ export const fr: Messages = { 'metric.readingsCount': '{count} lectures', 'metric.trigger': 'seuil {speed} {unit}', 'metric.estimated': 'Estimé', + 'metric.experimental': 'Expérimental', + 'metric.cameraAssisted': 'Caméra', + 'metric.cameraFused': 'Fusion', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Droit', @@ -138,6 +141,31 @@ export const fr: Messages = { 'menu.enable': 'Activer', 'menu.shutdown': 'Éteindre', + 'onboarding.getStarted': 'Commencer', + 'onboarding.welcomeTitle': 'OpenFlight', + 'onboarding.welcomeDetail': 'Choisissez la langue, les unités, le thème et l’affichage des coups en Direct.', + 'onboarding.continue': 'Continuer', + 'onboarding.back': 'Retour', + 'onboarding.start': 'C’est parti', + 'onboarding.languageTitle': 'Langue', + 'onboarding.unitsTitle': 'Unités', + 'onboarding.themeTitle': 'Thème', + 'onboarding.liveViewTitle': 'Vue Direct', + 'onboarding.liveTiles': 'Tuiles', + 'onboarding.liveTilesDetail': 'Toutes les métriques. Pas de grand aperçu.', + 'onboarding.liveTimed': 'Aperçu minuté', + 'onboarding.liveTimedDetail': 'Grande métrique, puis tuiles.', + 'onboarding.liveHold': 'Aperçu fixe', + 'onboarding.liveHoldDetail': 'Grande métrique jusqu’au toucher.', + 'onboarding.duration': 'Durée de l’aperçu', + 'onboarding.duration5': '5s', + 'onboarding.duration10': '10s', + 'onboarding.duration15': '15s', + 'onboarding.doneTitle': 'Vous êtes prêt', + 'onboarding.doneDetail': 'Le driver est sélectionné. Changez de club depuis Direct à tout moment.', + 'onboarding.step': 'Étape {current} sur {total}', + 'menu.liveView': 'Vue Direct', + 'app.changeImplement': 'Changer d’outil', 'app.changeClub': 'Changer de club', 'app.clearSession': 'Effacer la session', diff --git a/ui/src/i18n/pt.ts b/ui/src/i18n/pt.ts index 22cf20c9..12cbf9ed 100644 --- a/ui/src/i18n/pt.ts +++ b/ui/src/i18n/pt.ts @@ -50,6 +50,9 @@ export const pt: Messages = { 'metric.readingsCount': '{count} leituras', 'metric.trigger': 'limite {speed} {unit}', 'metric.estimated': 'Estimado', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Câmera', + 'metric.cameraFused': 'Fusão', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Reto', @@ -138,6 +141,31 @@ export const pt: Messages = { 'menu.enable': 'Ativar', 'menu.shutdown': 'Desligar', + 'onboarding.getStarted': 'Começar', + 'onboarding.welcomeTitle': 'OpenFlight', + 'onboarding.welcomeDetail': 'Defina idioma, unidades, tema e como as tacadas aparecem em Ao vivo.', + 'onboarding.continue': 'Continuar', + 'onboarding.back': 'Voltar', + 'onboarding.start': 'Iniciar', + 'onboarding.languageTitle': 'Idioma', + 'onboarding.unitsTitle': 'Unidades', + 'onboarding.themeTitle': 'Tema', + 'onboarding.liveViewTitle': 'Visão ao vivo', + 'onboarding.liveTiles': 'Mosaicos', + 'onboarding.liveTilesDetail': 'Todas as métricas. Sem prévia grande.', + 'onboarding.liveTimed': 'Prévia cronometrada', + 'onboarding.liveTimedDetail': 'Métrica grande, depois mosaicos.', + 'onboarding.liveHold': 'Prévia fixa', + 'onboarding.liveHoldDetail': 'Métrica grande até você tocar.', + 'onboarding.duration': 'Duração da prévia', + 'onboarding.duration5': '5s', + 'onboarding.duration10': '10s', + 'onboarding.duration15': '15s', + 'onboarding.doneTitle': 'Tudo pronto', + 'onboarding.doneDetail': 'O driver está selecionado. Troque o taco quando quiser em Ao vivo.', + 'onboarding.step': 'Passo {current} de {total}', + 'menu.liveView': 'Visão ao vivo', + 'app.changeImplement': 'Trocar implemento', 'app.changeClub': 'Trocar taco', 'app.clearSession': 'Limpar sessão', From bb8946c079884726f7ecdaf39cc804c8f634e816 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:07:58 +0100 Subject: [PATCH 02/24] Drop unrelated metric i18n keys from the onboarding string commit. Co-authored-by: Cursor --- ui/src/i18n/en.ts | 3 --- ui/src/i18n/es.ts | 3 --- ui/src/i18n/fr.ts | 3 --- ui/src/i18n/pt.ts | 3 --- 4 files changed, 12 deletions(-) diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index 86d200c3..2012d846 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -48,9 +48,6 @@ export const en = { 'metric.readingsCount': '{count} readings', 'metric.trigger': '{speed} {unit} trigger', 'metric.estimated': 'Estimated', - 'metric.experimental': 'Experimental', - 'metric.cameraAssisted': 'Camera', - 'metric.cameraFused': 'Fused', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Straight', diff --git a/ui/src/i18n/es.ts b/ui/src/i18n/es.ts index 6bbd5258..92bbe871 100644 --- a/ui/src/i18n/es.ts +++ b/ui/src/i18n/es.ts @@ -50,9 +50,6 @@ export const es: Messages = { 'metric.readingsCount': '{count} lecturas', 'metric.trigger': 'umbral {speed} {unit}', 'metric.estimated': 'Estimado', - 'metric.experimental': 'Experimental', - 'metric.cameraAssisted': 'Cámara', - 'metric.cameraFused': 'Fusión', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Recto', diff --git a/ui/src/i18n/fr.ts b/ui/src/i18n/fr.ts index 381b2c06..ca89997e 100644 --- a/ui/src/i18n/fr.ts +++ b/ui/src/i18n/fr.ts @@ -50,9 +50,6 @@ export const fr: Messages = { 'metric.readingsCount': '{count} lectures', 'metric.trigger': 'seuil {speed} {unit}', 'metric.estimated': 'Estimé', - 'metric.experimental': 'Expérimental', - 'metric.cameraAssisted': 'Caméra', - 'metric.cameraFused': 'Fusion', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Droit', diff --git a/ui/src/i18n/pt.ts b/ui/src/i18n/pt.ts index 12cbf9ed..1a162ebf 100644 --- a/ui/src/i18n/pt.ts +++ b/ui/src/i18n/pt.ts @@ -50,9 +50,6 @@ export const pt: Messages = { 'metric.readingsCount': '{count} leituras', 'metric.trigger': 'limite {speed} {unit}', 'metric.estimated': 'Estimado', - 'metric.experimental': 'Experimental', - 'metric.cameraAssisted': 'Câmera', - 'metric.cameraFused': 'Fusão', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Reto', From 441a346c3998c0786ba1fb08271a3b3175176ba5 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:10:38 +0100 Subject: [PATCH 03/24] Add persisted live-view mode and preview duration. Co-authored-by: Cursor --- ui/src/stores/useLiveViewStore.test.ts | 96 ++++++++++++++++++++++++++ ui/src/stores/useLiveViewStore.ts | 78 +++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 ui/src/stores/useLiveViewStore.test.ts create mode 100644 ui/src/stores/useLiveViewStore.ts diff --git a/ui/src/stores/useLiveViewStore.test.ts b/ui/src/stores/useLiveViewStore.test.ts new file mode 100644 index 00000000..beda0829 --- /dev/null +++ b/ui/src/stores/useLiveViewStore.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const STORAGE_KEY = 'openflight.live-view:v1'; + +function installBrowser(initial: Record = {}, options: { failWrites?: boolean } = {}) { + const store = { ...initial }; + const localStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + if (options.failWrites) { + throw new Error('quota exceeded'); + } + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + }; + vi.stubGlobal('localStorage', localStorage); + vi.stubGlobal('window', { localStorage }); + return store; +} + +async function loadStore() { + const module = await import('./useLiveViewStore'); + return module.useLiveViewStore; +} + +describe('useLiveViewStore', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('defaults to tiles and 10s', async () => { + installBrowser(); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('tiles'); + expect(useLiveViewStore.getState().durationMs).toBe(10000); + }); + + it('restores a valid stored choice', async () => { + installBrowser({ [STORAGE_KEY]: JSON.stringify({ mode: 'sticky', durationMs: 5000 }) }); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('sticky'); + expect(useLiveViewStore.getState().durationMs).toBe(5000); + }); + + it('falls back when JSON is invalid', async () => { + installBrowser({ [STORAGE_KEY]: '{not-json' }); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('tiles'); + expect(useLiveViewStore.getState().durationMs).toBe(10000); + }); + + it('falls back when mode or duration is unknown', async () => { + installBrowser({ [STORAGE_KEY]: JSON.stringify({ mode: 'hero', durationMs: 2500 }) }); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('tiles'); + expect(useLiveViewStore.getState().durationMs).toBe(10000); + }); + + it('persists mode without clearing duration', async () => { + const store = installBrowser(); + const useLiveViewStore = await loadStore(); + + useLiveViewStore.getState().setDurationMs(15000); + useLiveViewStore.getState().setMode('timed'); + + expect(useLiveViewStore.getState().durationMs).toBe(15000); + expect(JSON.parse(store[STORAGE_KEY])).toEqual({ mode: 'timed', durationMs: 15000 }); + }); + + it('keeps the choice when storage rejects the write', async () => { + installBrowser({}, { failWrites: true }); + const useLiveViewStore = await loadStore(); + + expect(() => useLiveViewStore.getState().setMode('sticky')).not.toThrow(); + expect(useLiveViewStore.getState().mode).toBe('sticky'); + }); + + it('falls back when there is no window', async () => { + vi.stubGlobal('window', undefined); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('tiles'); + expect(useLiveViewStore.getState().durationMs).toBe(10000); + }); +}); diff --git a/ui/src/stores/useLiveViewStore.ts b/ui/src/stores/useLiveViewStore.ts new file mode 100644 index 00000000..df8243be --- /dev/null +++ b/ui/src/stores/useLiveViewStore.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; + +export const LIVE_VIEW_STORAGE_KEY = 'openflight.live-view:v1'; +export const LIVE_VIEW_DURATIONS_MS = [5000, 10000, 15000] as const; + +export type LiveViewMode = 'tiles' | 'timed' | 'sticky'; +export type LiveViewDurationMs = (typeof LIVE_VIEW_DURATIONS_MS)[number]; + +const MODES = new Set(['tiles', 'timed', 'sticky']); +const DURATIONS = new Set(LIVE_VIEW_DURATIONS_MS); + +export function isLiveViewMode(value: unknown): value is LiveViewMode { + return typeof value === 'string' && MODES.has(value as LiveViewMode); +} + +export function isLiveViewDurationMs(value: unknown): value is LiveViewDurationMs { + return typeof value === 'number' && DURATIONS.has(value); +} + +interface StoredLiveView { + mode: LiveViewMode; + durationMs: LiveViewDurationMs; +} + +function readStoredLiveView(): StoredLiveView { + const fallback: StoredLiveView = { mode: 'tiles', durationMs: 10000 }; + if (typeof window === 'undefined') { + return fallback; + } + + try { + const raw = window.localStorage.getItem(LIVE_VIEW_STORAGE_KEY); + if (!raw) { + return fallback; + } + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return fallback; + } + const { mode, durationMs } = parsed as { mode?: unknown; durationMs?: unknown }; + return { + mode: isLiveViewMode(mode) ? mode : fallback.mode, + durationMs: isLiveViewDurationMs(durationMs) ? durationMs : fallback.durationMs, + }; + } catch { + return fallback; + } +} + +function persist(state: StoredLiveView): void { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem(LIVE_VIEW_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Storage can be unavailable; the choice still applies for this session. + } +} + +interface LiveViewState extends StoredLiveView { + setMode: (mode: LiveViewMode) => void; + setDurationMs: (durationMs: LiveViewDurationMs) => void; +} + +export const useLiveViewStore = create((set, get) => ({ + ...readStoredLiveView(), + setMode: (mode) => { + const next = { mode, durationMs: get().durationMs }; + persist(next); + set(next); + }, + setDurationMs: (durationMs) => { + const next = { mode: get().mode, durationMs }; + persist(next); + set(next); + }, +})); From 3a8ef8471d1421353121f9caab69d1de926f732b Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:13:08 +0100 Subject: [PATCH 04/24] Add first-run onboarding completed flag. Co-authored-by: Cursor --- ui/src/stores/useOnboardingStore.test.ts | 67 ++++++++++++++++++++++++ ui/src/stores/useOnboardingStore.ts | 33 ++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 ui/src/stores/useOnboardingStore.test.ts create mode 100644 ui/src/stores/useOnboardingStore.ts diff --git a/ui/src/stores/useOnboardingStore.test.ts b/ui/src/stores/useOnboardingStore.test.ts new file mode 100644 index 00000000..f14c0bda --- /dev/null +++ b/ui/src/stores/useOnboardingStore.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const STORAGE_KEY = 'openflight.onboarding.completed:v1'; + +function installBrowser(initial: Record = {}, options: { failWrites?: boolean } = {}) { + const store = { ...initial }; + const localStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + if (options.failWrites) { + throw new Error('quota exceeded'); + } + store[key] = value; + }, + }; + vi.stubGlobal('localStorage', localStorage); + vi.stubGlobal('window', { localStorage }); + return store; +} + +async function loadStore() { + const module = await import('./useOnboardingStore'); + return module.useOnboardingStore; +} + +describe('useOnboardingStore', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts incomplete when nothing is stored', async () => { + installBrowser(); + const useOnboardingStore = await loadStore(); + expect(useOnboardingStore.getState().completed).toBe(false); + }); + + it('restores a completed flag', async () => { + installBrowser({ [STORAGE_KEY]: '1' }); + const useOnboardingStore = await loadStore(); + expect(useOnboardingStore.getState().completed).toBe(true); + }); + + it('writes the flag on complete', async () => { + const store = installBrowser(); + const useOnboardingStore = await loadStore(); + useOnboardingStore.getState().complete(); + expect(useOnboardingStore.getState().completed).toBe(true); + expect(store[STORAGE_KEY]).toBe('1'); + }); + + it('keeps completed true when storage rejects the write', async () => { + installBrowser({}, { failWrites: true }); + const useOnboardingStore = await loadStore(); + expect(() => useOnboardingStore.getState().complete()).not.toThrow(); + expect(useOnboardingStore.getState().completed).toBe(true); + }); + + it('starts incomplete when there is no window', async () => { + vi.stubGlobal('window', undefined); + const useOnboardingStore = await loadStore(); + expect(useOnboardingStore.getState().completed).toBe(false); + }); +}); diff --git a/ui/src/stores/useOnboardingStore.ts b/ui/src/stores/useOnboardingStore.ts new file mode 100644 index 00000000..bb80c1eb --- /dev/null +++ b/ui/src/stores/useOnboardingStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; + +export const ONBOARDING_STORAGE_KEY = 'openflight.onboarding.completed:v1'; + +function readCompleted(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + return window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +interface OnboardingState { + completed: boolean; + complete: () => void; +} + +export const useOnboardingStore = create((set) => ({ + completed: readCompleted(), + complete: () => { + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, '1'); + } catch { + // Storage can be unavailable; this session still counts as complete. + } + } + set({ completed: true }); + }, +})); From 9e4e4133d1900210eb5f42f6819f254ea0d042e9 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:19:59 +0100 Subject: [PATCH 05/24] Restore Live selected-metric overlay for timed and hold modes. Co-authored-by: Cursor --- ui/src/components/panel/LivePanel.test.tsx | 55 ++++++- ui/src/components/panel/LivePanel.tsx | 27 +++- ui/src/components/panel/panel.css | 141 +++++++++++++----- .../components/panel/useShotSpotlight.test.ts | 44 ++++++ ui/src/components/panel/useShotSpotlight.ts | 39 +++++ ui/src/i18n/en.ts | 3 + ui/src/i18n/es.ts | 3 + ui/src/i18n/fr.ts | 3 + ui/src/i18n/pt.ts | 3 + 9 files changed, 279 insertions(+), 39 deletions(-) create mode 100644 ui/src/components/panel/useShotSpotlight.test.ts create mode 100644 ui/src/components/panel/useShotSpotlight.ts diff --git a/ui/src/components/panel/LivePanel.test.tsx b/ui/src/components/panel/LivePanel.test.tsx index 56f099ae..5ad2cbbf 100644 --- a/ui/src/components/panel/LivePanel.test.tsx +++ b/ui/src/components/panel/LivePanel.test.tsx @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { renderToString } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import type { Shot } from '../../types/shot'; +import { useLiveViewStore } from '../../stores/useLiveViewStore'; import { LivePanel } from './LivePanel'; /** React SSR splits interpolated text with comment markers; drop them. */ @@ -42,8 +43,10 @@ function render( shot: Shot | null, shots: Shot[] = shot ? [shot] : [], selectedMetricId: string | null = null, - isNewShot = false + isNewShot = false, + mode: 'tiles' | 'timed' | 'sticky' = 'tiles' ) { + useLiveViewStore.setState({ mode, durationMs: 10000 }); return text( renderToString( { expect(html).toMatch(/metric-card--selected[\s\S]*?>Ball speed { - const html = render(makeShot(), undefined, 'spin', true); + it('keeps every metric visible after a new shot in tiles mode', () => { + const html = render(makeShot(), undefined, 'spin', true, 'tiles'); expect(html).not.toContain('live-panel__spotlight'); expect(html).toContain('live-panel__grid--of-10'); @@ -135,13 +138,37 @@ describe('LivePanel', () => { expect(html).toContain('shot-flash'); }); + it('overlays the selected metric after a new shot in timed mode', () => { + const html = render(makeShot(), undefined, 'spin', true, 'timed'); + + expect(html).toContain('live-panel__spotlight'); + expect(html).toContain('aria-label="Hide shot overlay"'); + expect(html).toContain('>2,650<'); + expect(html).toContain('live-panel__grid--of-10'); + expect(html.match(/metric-card--interactive/g)).toHaveLength(10); + }); + + it('overlays the selected metric after a new shot in sticky mode', () => { + const html = render(makeShot(), undefined, 'spin', true, 'sticky'); + + expect(html).toContain('live-panel__spotlight'); + expect(html).toContain('live-panel__grid--of-10'); + }); + it('does not show the spotlight for a restored session shot', () => { - const html = render(makeShot(), undefined, null, false); + const html = render(makeShot(), undefined, null, false, 'timed'); expect(html).not.toContain('live-panel__spotlight'); expect(html).not.toContain('shot-flash'); }); + it('does not show the spotlight on ready in timed mode', () => { + const html = render(null, [], null, false, 'timed'); + + expect(html).toContain('Ready'); + expect(html).not.toContain('live-panel__spotlight'); + }); + it('marks estimated tiles with an icon instead of provenance copy', () => { const html = render(makeShot({ angle_source: 'estimated', spin_source: 'calculated' })); @@ -150,6 +177,26 @@ describe('LivePanel', () => { expect(html).not.toContain('>radar<'); }); + it('marks experimental tiles with a flask icon instead of caption text', () => { + const html = render( + makeShot({ + 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', + }) + ); + + expect(html).toContain('metric-card__experimental'); + expect(html).toMatch(/metric-card__subtext[^>]*>Fused]*>Cameraexperimental { const html = render(makeShot()); diff --git a/ui/src/components/panel/LivePanel.tsx b/ui/src/components/panel/LivePanel.tsx index 38188cfe..a6935345 100644 --- a/ui/src/components/panel/LivePanel.tsx +++ b/ui/src/components/panel/LivePanel.tsx @@ -4,9 +4,11 @@ import { computeSwingSpeedStats, filterShotsByPlayer } from '../../types/shot'; import { useUnitPreference } from '../../state/useUnitPreference'; import { useI18n } from '../../i18n/useI18n'; import { useSharedFitFontSize } from '../../hooks/useFitFontSize'; -import { MetricCard } from '../ui/MetricCard'; +import { useLiveViewStore } from '../../stores/useLiveViewStore'; +import { EstimatedMark, MetricCard } from '../ui/MetricCard'; import { PanelHeader } from './PanelHeader'; import { buildLiveMetrics, pinSelectedMetric } from './liveMetrics'; +import { useShotSpotlight } from './useShotSpotlight'; interface LivePanelProps { shot: Shot | null; @@ -47,6 +49,9 @@ export function LivePanel({ }: LivePanelProps) { const { locale, t } = useI18n(); const { unitSystem } = useUnitPreference(); + useLiveViewStore((state) => state.mode); + useLiveViewStore((state) => state.durationMs); + const { mode, durationMs } = useLiveViewStore.getState(); const playerShots = useMemo(() => filterShotsByPlayer(shots, playerName), [shots, playerName]); const displayedShot = playerShots[playerShots.length - 1] ?? null; const isPlayersNewShot = Boolean(isNewShot && shot && displayedShot && shot.timestamp === displayedShot.timestamp); @@ -64,6 +69,7 @@ export function LivePanel({ [displayedShot, unitSystem, swingStats, selectedMetricId, locale] ); const selected = metrics[0] ?? null; + const { open: spotlightOpen, dismiss } = useShotSpotlight(mode, durationMs, isPlayersNewShot); const gridRef = useSharedFitFontSize( metrics.length > 0, metrics.map((metric) => `${metric.value}:${metric.unit ?? ''}`).join('|') @@ -97,6 +103,24 @@ export function LivePanel({
{ballWarning} {isPlayersNewShot ?
: null} + {spotlightOpen && selected ? ( + + ) : null}
{metrics.map((metric) => ( .panel-header__divider { + height: 18px; +} + +.panel-header__power { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + box-sizing: border-box; + flex: 0 0 var(--panel-control-height, 32px); + width: var(--panel-control-height, 32px); + height: var(--panel-control-height, 32px); + padding: 0; + border: 1px solid var(--color-border); + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} + +.panel-header__power::after { + content: ''; + position: absolute; + inset: calc((44px - var(--panel-control-height, 32px)) / -2); +} + +.panel-header__power:hover, +.panel-header__power:focus-visible { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.panel-header__power svg { + width: 1.05rem; + height: 1.05rem; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + .panel-header__actions .panel-action { height: var(--panel-control-height, 32px); padding: 0 14px; @@ -365,40 +406,6 @@ border-right: 1px solid var(--color-border); } -.panel-footer__meta > .power-status--chrome { - padding-right: 0.9vw; - border-right: 1px solid var(--color-border); -} - -.panel-footer__power { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 44px; - width: 44px; - height: 44px; - padding: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: transparent; - color: var(--color-text-muted); - cursor: pointer; -} - -.panel-footer__power:hover, -.panel-footer__power:focus-visible { - border-color: var(--color-danger); - color: var(--color-danger); -} - -.panel-footer__power svg { - width: 1.05rem; - height: 1.05rem; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} - /* Shutdown confirmation is app chrome, so it lives with the panel shell. */ .shutdown-overlay { position: fixed; @@ -534,6 +541,72 @@ min-height: 0; } +.live-panel__spotlight { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 2rem; + border: none; + background: var(--color-bg); + color: inherit; + font: inherit; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.live-panel__spotlight-label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + font-weight: 700; + font-size: 1.8rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-accent); +} + +.live-panel__spotlight .metric-card__estimated svg { + width: 1.75rem; + height: 1.125rem; +} + +.live-panel__spotlight-value-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: 1rem; +} + +.live-panel__spotlight-value { + font-weight: 700; + font-size: clamp(6rem, 42vh, 20rem); + line-height: 0.95; + letter-spacing: -0.03em; + color: var(--color-text); +} + +.live-panel__spotlight-unit { + font-weight: 500; + font-size: clamp(1.5rem, 6vh, 3rem); + color: var(--color-text-muted); +} + +.live-panel__spotlight-subtext { + margin-top: 1rem; + font-weight: 600; + font-size: 1.5rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); +} + .shot-flash { position: absolute; inset: 0; diff --git a/ui/src/components/panel/useShotSpotlight.test.ts b/ui/src/components/panel/useShotSpotlight.test.ts new file mode 100644 index 00000000..fdd97d6a --- /dev/null +++ b/ui/src/components/panel/useShotSpotlight.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSpotlightController, shouldOpenSpotlight } from './useShotSpotlight'; + +describe('shouldOpenSpotlight', () => { + it('never opens for tiles', () => { + expect(shouldOpenSpotlight('tiles', true)).toBe(false); + expect(shouldOpenSpotlight('tiles', false)).toBe(false); + }); + + it('opens timed and sticky only for a new shot', () => { + expect(shouldOpenSpotlight('timed', true)).toBe(true); + expect(shouldOpenSpotlight('sticky', true)).toBe(true); + expect(shouldOpenSpotlight('timed', false)).toBe(false); + expect(shouldOpenSpotlight('sticky', false)).toBe(false); + }); +}); + +describe('createSpotlightController', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('timed controller fires hide after durationMs', () => { + vi.useFakeTimers(); + const hide = vi.fn(); + const { openInitially, start } = createSpotlightController('timed', 5000, true, hide); + expect(openInitially).toBe(true); + const stop = start(); + vi.advanceTimersByTime(4999); + expect(hide).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(hide).toHaveBeenCalledTimes(1); + stop(); + }); + + it('sticky controller never starts a timer', () => { + vi.useFakeTimers(); + const hide = vi.fn(); + const { start } = createSpotlightController('sticky', 5000, true, hide); + start(); + vi.advanceTimersByTime(60_000); + expect(hide).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/panel/useShotSpotlight.ts b/ui/src/components/panel/useShotSpotlight.ts new file mode 100644 index 00000000..4c4be5d1 --- /dev/null +++ b/ui/src/components/panel/useShotSpotlight.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import type { LiveViewMode } from '../../stores/useLiveViewStore'; + +export function shouldOpenSpotlight(mode: LiveViewMode, isNewShot: boolean): boolean { + return isNewShot && mode !== 'tiles'; +} + +export function createSpotlightController( + mode: LiveViewMode, + durationMs: number, + isNewShot: boolean, + hide: () => void +): { openInitially: boolean; start: () => () => void } { + const openInitially = shouldOpenSpotlight(mode, isNewShot); + return { + openInitially, + start: () => { + if (!openInitially || mode !== 'timed') { + return () => {}; + } + const timer = setTimeout(hide, durationMs); + return () => clearTimeout(timer); + }, + }; +} + +export function useShotSpotlight(mode: LiveViewMode, durationMs: number, isNewShot: boolean) { + const [open, setOpen] = useState(() => shouldOpenSpotlight(mode, isNewShot)); + + useEffect(() => { + if (!open || mode !== 'timed') { + return; + } + const timer = setTimeout(() => setOpen(false), durationMs); + return () => clearTimeout(timer); + }, [open, mode, durationMs]); + + return { open, dismiss: () => setOpen(false) }; +} diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index 2012d846..86d200c3 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -48,6 +48,9 @@ export const en = { 'metric.readingsCount': '{count} readings', 'metric.trigger': '{speed} {unit} trigger', 'metric.estimated': 'Estimated', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Camera', + 'metric.cameraFused': 'Fused', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Straight', diff --git a/ui/src/i18n/es.ts b/ui/src/i18n/es.ts index 92bbe871..6bbd5258 100644 --- a/ui/src/i18n/es.ts +++ b/ui/src/i18n/es.ts @@ -50,6 +50,9 @@ export const es: Messages = { 'metric.readingsCount': '{count} lecturas', 'metric.trigger': 'umbral {speed} {unit}', 'metric.estimated': 'Estimado', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Cámara', + 'metric.cameraFused': 'Fusión', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Recto', diff --git a/ui/src/i18n/fr.ts b/ui/src/i18n/fr.ts index ca89997e..381b2c06 100644 --- a/ui/src/i18n/fr.ts +++ b/ui/src/i18n/fr.ts @@ -50,6 +50,9 @@ export const fr: Messages = { 'metric.readingsCount': '{count} lectures', 'metric.trigger': 'seuil {speed} {unit}', 'metric.estimated': 'Estimé', + 'metric.experimental': 'Expérimental', + 'metric.cameraAssisted': 'Caméra', + 'metric.cameraFused': 'Fusion', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Droit', diff --git a/ui/src/i18n/pt.ts b/ui/src/i18n/pt.ts index 1a162ebf..12cbf9ed 100644 --- a/ui/src/i18n/pt.ts +++ b/ui/src/i18n/pt.ts @@ -50,6 +50,9 @@ export const pt: Messages = { 'metric.readingsCount': '{count} leituras', 'metric.trigger': 'limite {speed} {unit}', 'metric.estimated': 'Estimado', + 'metric.experimental': 'Experimental', + 'metric.cameraAssisted': 'Câmera', + 'metric.cameraFused': 'Fusão', 'shape.fade': 'Fade', 'shape.draw': 'Draw', 'shape.straight': 'Reto', From 424eaee0fadc6c412e44333f3981b511bfb02d0d Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:26:36 +0100 Subject: [PATCH 06/24] Add live-view settings to the menu and drop sheet shutdown. Co-authored-by: Cursor --- ui/src/App.tsx | 21 +++-------- ui/src/components/panel/MenuSheet.test.tsx | 33 +++++++++++++++- ui/src/components/panel/MenuSheet.tsx | 44 ++++++++++++++++++---- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ffb8a196..0d1bda69 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -45,13 +45,14 @@ import './components/panel/panel.css'; function AppContent() { const { t } = useI18n(); const { shutdown } = useSocket(); - const { connected, mockMode, debugMode, latestSimShots, serverClub } = useSystemStore( + const { connected, mockMode, debugMode, latestSimShots, serverClub, shutdownDialogOpen } = useSystemStore( useShallow((state) => ({ connected: state.connected, mockMode: state.mockMode, debugMode: state.debugMode, latestSimShots: state.latestSimShots, serverClub: state.serverClub, + shutdownDialogOpen: state.shutdownDialogOpen, })) ); const { latestShot, shots, isNewShot, shotProcessingPhase, shotVersion } = useShotStore( @@ -107,7 +108,6 @@ function AppContent() { const [selectedClub, setSelectedClub] = useState('driver'); const [selectedTrainingImplement, setSelectedTrainingImplement] = useState('driver'); const [menuOpen, setMenuOpen] = useState(false); - const [showShutdown, setShowShutdown] = useState(false); const [shutdownState, setShutdownState] = useState('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, @@ -216,7 +216,7 @@ function AppContent() { }; const closeShutdown = () => { - setShowShutdown(false); + useSystemStore.getState().closeShutdownDialog(); setShutdownState('confirm'); }; @@ -266,7 +266,7 @@ function AppContent() {
)} - {showShutdown ? ( + {shutdownDialogOpen ? ( ) : null} @@ -365,14 +365,7 @@ function AppContent() { * 6a draws them over the whole card. */} {menuOpen ? ( - setMenuOpen(false)} - onShutdown={() => { - setMenuOpen(false); - setShutdownState('confirm'); - setShowShutdown(true); - }} - /> + setMenuOpen(false)} /> ) : null} {addPlayerOpen ? ( @@ -416,10 +409,6 @@ function AppContent() { ballDetected={cameraStatus.ball_detected} debugRecording={debugMode} brand={isLaunchDaddyMode ? : undefined} - onShutdown={() => { - setShutdownState('confirm'); - setShowShutdown(true); - }} />
); diff --git a/ui/src/components/panel/MenuSheet.test.tsx b/ui/src/components/panel/MenuSheet.test.tsx index 8c02c365..4c8d6fb9 100644 --- a/ui/src/components/panel/MenuSheet.test.tsx +++ b/ui/src/components/panel/MenuSheet.test.tsx @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest'; import { renderToString } from 'react-dom/server'; import { MenuSheet } from './MenuSheet'; import { useSystemStore } from '../../stores/useSystemStore'; +import { useLiveViewStore } from '../../stores/useLiveViewStore'; import type { PowerStatus } from '../../types/power'; function renderMenu() { - return renderToString( {}} onShutdown={() => {}} />); + return renderToString( {}} />); } describe('MenuSheet players', () => { @@ -54,3 +55,33 @@ describe('MenuSheet battery', () => { useSystemStore.setState({ powerStatus: null }); }); }); + +describe('MenuSheet live view', () => { + it('offers live view modes and hides duration unless timed', () => { + useLiveViewStore.setState({ mode: 'tiles', durationMs: 10000 }); + const html = renderMenu(); + + expect(html).toContain('menu-sheet__section-title">Live view'); + expect(html).toContain('>Tiles<'); + expect(html).toContain('>Timed preview<'); + expect(html).toContain('>Hold preview<'); + expect(html).not.toContain('>5s<'); + }); + + it('shows duration chips when timed is selected', () => { + useLiveViewStore.setState({ mode: 'timed', durationMs: 10000 }); + const html = renderMenu(); + + expect(html).toContain('>5s<'); + expect(html).toContain('>10s<'); + expect(html).toContain('>15s<'); + }); +}); + +describe('MenuSheet shutdown', () => { + it('does not offer shut down in the sheet', () => { + const html = renderMenu(); + expect(html).not.toContain('menu-sheet__shutdown'); + expect(html).not.toContain('Shut down'); + }); +}); diff --git a/ui/src/components/panel/MenuSheet.tsx b/ui/src/components/panel/MenuSheet.tsx index 787d0b20..2976e19b 100644 --- a/ui/src/components/panel/MenuSheet.tsx +++ b/ui/src/components/panel/MenuSheet.tsx @@ -4,6 +4,7 @@ import { useSystemStore } from '../../stores/useSystemStore'; import { useCameraStore } from '../../stores/useCameraStore'; import { useThemeStore } from '../../stores/useThemeStore'; import { useLocaleStore } from '../../stores/useLocaleStore'; +import { isLiveViewDurationMs, useLiveViewStore } from '../../stores/useLiveViewStore'; import { useUnitPreference } from '../../state/useUnitPreference'; import { socketService } from '../../services/socketService'; import { ballDetectionStatusLabel } from '../../utils/ballDetectionStatus'; @@ -12,7 +13,6 @@ import { SimStatus } from '../SimStatus'; interface MenuSheetProps { onClose: () => void; - onShutdown: () => void; } /** @@ -21,15 +21,18 @@ interface MenuSheetProps { * 6a draws Units / Shut down. Players live on their own panel. The System * block is an addition: the mockup replaced the old top header, and simulator * and ball-detection state had nowhere else to go. Battery lives in the footer. - * Socket connection lives on the panel header LED. + * Socket connection lives on the panel header LED. Shutdown is footer power. */ -export function MenuSheet({ onClose, onShutdown }: MenuSheetProps) { +export function MenuSheet({ onClose }: MenuSheetProps) { const simStatuses = useSystemStore((state) => state.simStatuses); const cameraStatus = useCameraStore((state) => state.cameraStatus); const { t } = useI18n(); const { unitSystem, setUnitSystem } = useUnitPreference(); const { theme, setTheme } = useThemeStore(); const { locale, setLocale } = useLocaleStore(); + useLiveViewStore((state) => state.mode); + useLiveViewStore((state) => state.durationMs); + const { mode, durationMs, setMode, setDurationMs } = useLiveViewStore.getState(); const ballDetectionValue = ballDetectionStatusLabel(cameraStatus); @@ -79,6 +82,37 @@ export function MenuSheet({ onClose, onShutdown }: MenuSheetProps) { +
+ {t('menu.liveView')} + + {mode === 'timed' ? ( + { + const next = Number(id); + if (isLiveViewDurationMs(next)) { + setDurationMs(next); + } + }} + /> + ) : null} +
+
{t('menu.system')}
@@ -97,10 +131,6 @@ export function MenuSheet({ onClose, onShutdown }: MenuSheetProps) {
) : null}
- -
); From 9c5621eae7c2a4c25e5ec1789a3a5c6fe66a6bbf Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:34:22 +0100 Subject: [PATCH 07/24] Restore footer shutdown opener dropped from the menu live-view commit. Co-authored-by: Cursor --- ui/src/App.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0d1bda69..aed437d0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -45,14 +45,13 @@ import './components/panel/panel.css'; function AppContent() { const { t } = useI18n(); const { shutdown } = useSocket(); - const { connected, mockMode, debugMode, latestSimShots, serverClub, shutdownDialogOpen } = useSystemStore( + const { connected, mockMode, debugMode, latestSimShots, serverClub } = useSystemStore( useShallow((state) => ({ connected: state.connected, mockMode: state.mockMode, debugMode: state.debugMode, latestSimShots: state.latestSimShots, serverClub: state.serverClub, - shutdownDialogOpen: state.shutdownDialogOpen, })) ); const { latestShot, shots, isNewShot, shotProcessingPhase, shotVersion } = useShotStore( @@ -108,6 +107,7 @@ function AppContent() { const [selectedClub, setSelectedClub] = useState('driver'); const [selectedTrainingImplement, setSelectedTrainingImplement] = useState('driver'); const [menuOpen, setMenuOpen] = useState(false); + const [showShutdown, setShowShutdown] = useState(false); const [shutdownState, setShutdownState] = useState('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, @@ -216,7 +216,7 @@ function AppContent() { }; const closeShutdown = () => { - useSystemStore.getState().closeShutdownDialog(); + setShowShutdown(false); setShutdownState('confirm'); }; @@ -266,7 +266,7 @@ function AppContent() { )} - {shutdownDialogOpen ? ( + {showShutdown ? ( ) : null} @@ -409,6 +409,10 @@ function AppContent() { ballDetected={cameraStatus.ball_detected} debugRecording={debugMode} brand={isLaunchDaddyMode ? : undefined} + onShutdown={() => { + setShutdownState('confirm'); + setShowShutdown(true); + }} /> ); From 77e67670a07dbd862e1d8281315448dffec94b39 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:41:48 +0100 Subject: [PATCH 08/24] Add fullscreen first-run onboarding wizard. Co-authored-by: Cursor --- .../components/onboarding/OnboardingFlow.css | 226 ++++++++++++++++ .../onboarding/OnboardingFlow.test.tsx | 60 +++++ .../components/onboarding/OnboardingFlow.tsx | 250 ++++++++++++++++++ ui/src/components/onboarding/index.ts | 1 + 4 files changed, 537 insertions(+) create mode 100644 ui/src/components/onboarding/OnboardingFlow.css create mode 100644 ui/src/components/onboarding/OnboardingFlow.test.tsx create mode 100644 ui/src/components/onboarding/OnboardingFlow.tsx create mode 100644 ui/src/components/onboarding/index.ts diff --git a/ui/src/components/onboarding/OnboardingFlow.css b/ui/src/components/onboarding/OnboardingFlow.css new file mode 100644 index 00000000..30270200 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.css @@ -0,0 +1,226 @@ +.onboarding { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + flex-direction: column; + background: var(--color-bg); + color: var(--color-text); + padding: var(--space-xl); + min-height: 0; +} + +.onboarding__body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + gap: var(--space-md); +} + +.onboarding__welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: var(--space-md); +} + +.onboarding__title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.onboarding__subtitle { + margin: var(--space-sm) 0 0; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.onboarding__detail { + margin: 0; + max-width: 28rem; + font-size: 0.95rem; + color: var(--color-text-muted); +} + +.onboarding__grid { + display: grid; + gap: var(--space-sm); + min-height: 0; +} + +.onboarding__grid--locales { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.onboarding__grid--units, +.onboarding__grid--theme { + grid-template-columns: repeat(2, minmax(0, 1fr)); + flex: 1; +} + +.onboarding__grid--live { + grid-template-columns: repeat(3, minmax(0, 1fr)); + flex: 1; +} + +.onboarding__tile { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: var(--space-xs); + min-height: 2.75rem; + min-width: 0; + padding: var(--space-md); + font: inherit; + text-align: left; + color: inherit; + cursor: pointer; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + -webkit-appearance: none; + appearance: none; +} + +.onboarding__tile-label { + font-size: 1rem; + font-weight: 600; +} + +.onboarding__tile-detail { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.onboarding__tile--selected { + box-shadow: inset 5px 0 0 var(--color-accent-block); +} + +.onboarding__tile--selected .onboarding__tile-label { + color: var(--color-accent); +} + +.onboarding__durations { + display: flex; + gap: var(--space-sm); +} + +.onboarding__chip { + min-height: 2.75rem; + min-width: 3.5rem; + padding: 0 var(--space-md); + font: inherit; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text); + cursor: pointer; + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.onboarding__chip--selected { + background: var(--color-accent-block); + border-color: var(--color-accent-block); + color: var(--color-accent-fg); +} + +.onboarding__primary, +.onboarding__nav { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.75rem; + padding: 0 var(--space-lg); + font: inherit; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + cursor: pointer; + border-radius: var(--radius); +} + +.onboarding__primary, +.onboarding__nav--primary { + background: var(--color-accent-block); + border: 1px solid var(--color-accent-block); + color: var(--color-accent-fg); +} + +.onboarding__nav--back { + background: transparent; + border: 1px solid var(--color-accent); + color: var(--color-text); +} + +.onboarding__chrome { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--space-sm); + padding-top: var(--space-md); +} + +.onboarding__chrome .onboarding__nav--back { + justify-self: start; +} + +.onboarding__chrome .onboarding__nav--primary { + justify-self: end; +} + +.onboarding__dots { + display: flex; + gap: 0.4rem; +} + +.onboarding__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-border); +} + +.onboarding__dot--active { + background: var(--color-accent-block); +} + +@media (max-height: 500px) { + .onboarding { + padding: var(--space-sm) var(--space-md); + } + + .onboarding__body { + gap: var(--space-sm); + } + + .onboarding__welcome { + gap: var(--space-sm); + } + + .onboarding__tile { + min-height: 2.375rem; + padding: var(--space-sm) var(--space-md); + } + + .onboarding__primary, + .onboarding__nav, + .onboarding__chip { + min-height: 2.375rem; + } + + .onboarding__chrome { + padding-top: var(--space-sm); + } +} diff --git a/ui/src/components/onboarding/OnboardingFlow.test.tsx b/ui/src/components/onboarding/OnboardingFlow.test.tsx new file mode 100644 index 00000000..c56e1be3 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.test.tsx @@ -0,0 +1,60 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { OnboardingFlow } from './OnboardingFlow'; +import { useLiveViewStore } from '../../stores/useLiveViewStore'; + +function render(initialStep?: 'welcome' | 'language' | 'theme' | 'live' | 'done') { + return renderToString( {}} initialStep={initialStep} />).replace( + //g, + '' + ); +} + +describe('OnboardingFlow', () => { + it('shows welcome with Get started and no footer chrome', () => { + const html = render(); + expect(html).toContain('Get started'); + expect(html).toContain('OpenFlight'); + expect(html).not.toContain('Continue'); + expect(html).not.toContain('panel-footer'); + }); + + it('puts language and units on one screen', () => { + const html = render('language'); + expect(html).toContain('English'); + expect(html).toContain('Español'); + expect(html).toContain('Français'); + expect(html).toContain('Português'); + expect(html).toContain('MPH / YDS'); + expect(html).toContain('KMH / M'); + expect(html).toContain('Continue'); + expect(html).toContain('Back'); + }); + + it('offers dark and light theme tiles', () => { + const html = render('theme'); + expect(html).toContain('Dark'); + expect(html).toContain('Light'); + }); + + it('hides duration chips unless timed preview is selected', () => { + useLiveViewStore.setState({ mode: 'tiles', durationMs: 10000 }); + expect(render('live')).not.toContain('>5s<'); + + useLiveViewStore.setState({ mode: 'timed', durationMs: 10000 }); + const html = render('live'); + expect(html).toContain('>5s<'); + expect(html).toContain('>10s<'); + expect(html).toContain('>15s<'); + expect(html).toContain('Tiles'); + expect(html).toContain('Timed preview'); + expect(html).toContain('Hold preview'); + }); + + it('uses Start on the done screen', () => { + const html = render('done'); + expect(html).toContain("You're ready"); + expect(html).toContain('Start'); + expect(html).not.toContain('Continue'); + }); +}); diff --git a/ui/src/components/onboarding/OnboardingFlow.tsx b/ui/src/components/onboarding/OnboardingFlow.tsx new file mode 100644 index 00000000..1b919662 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.tsx @@ -0,0 +1,250 @@ +import { useState, type MouseEventHandler } from 'react'; +import { LOCALES } from '../../i18n'; +import { useI18n } from '../../i18n/useI18n'; +import Logo from '../../logo/Logo'; +import { useUnitPreference } from '../../state/useUnitPreference'; +import { + isLiveViewDurationMs, + LIVE_VIEW_DURATIONS_MS, + useLiveViewStore, + type LiveViewDurationMs, + type LiveViewMode, +} from '../../stores/useLiveViewStore'; +import { useLocaleStore } from '../../stores/useLocaleStore'; +import { useOnboardingStore } from '../../stores/useOnboardingStore'; +import { useThemeStore } from '../../stores/useThemeStore'; +import './OnboardingFlow.css'; + +export type OnboardingStep = 'welcome' | 'language' | 'theme' | 'live' | 'done'; + +const STEPS = ['welcome', 'language', 'theme', 'live', 'done'] as const; + +const DURATION_KEYS: Record = + { + 5000: 'onboarding.duration5', + 10000: 'onboarding.duration10', + 15000: 'onboarding.duration15', + }; + +const LIVE_MODES: ReadonlyArray<{ + id: LiveViewMode; + label: 'onboarding.liveTiles' | 'onboarding.liveTimed' | 'onboarding.liveHold'; + detail: 'onboarding.liveTilesDetail' | 'onboarding.liveTimedDetail' | 'onboarding.liveHoldDetail'; +}> = [ + { id: 'tiles', label: 'onboarding.liveTiles', detail: 'onboarding.liveTilesDetail' }, + { id: 'timed', label: 'onboarding.liveTimed', detail: 'onboarding.liveTimedDetail' }, + { id: 'sticky', label: 'onboarding.liveHold', detail: 'onboarding.liveHoldDetail' }, +]; + +function Tile({ + selected, + onClick, + label, + detail, +}: { + selected: boolean; + onClick: MouseEventHandler; + label: string; + detail?: string; +}) { + return ( + + ); +} + +export function OnboardingFlow({ + onFinished, + initialStep = 'welcome', +}: { + onFinished: () => void; + initialStep?: OnboardingStep; +}) { + const [step, setStep] = useState(initialStep); + const { t } = useI18n(); + const { locale, setLocale } = useLocaleStore(); + const { theme, setTheme } = useThemeStore(); + const { unitSystem, setUnitSystem } = useUnitPreference(); + useLiveViewStore((state) => state.mode); + useLiveViewStore((state) => state.durationMs); + const { mode, durationMs, setMode, setDurationMs } = useLiveViewStore.getState(); + const complete = useOnboardingStore((state) => state.complete); + + const stepIndex = STEPS.indexOf(step); + const current = stepIndex + 1; + + function goNext() { + const next = STEPS[stepIndex + 1]; + if (next) { + setStep(next); + } + } + + function goBack() { + const prev = STEPS[stepIndex - 1]; + if (prev) { + setStep(prev); + } + } + + function handleStart() { + complete(); + onFinished(); + } + + function handleDuration(id: string) { + const next = Number(id); + if (isLiveViewDurationMs(next)) { + setDurationMs(next); + } + } + + return ( +
+
+ {step === 'welcome' ? ( +
+ +

+ {t('onboarding.welcomeTitle')} +

+

{t('onboarding.welcomeDetail')}

+ +
+ ) : null} + + {step === 'language' ? ( + <> +

+ {t('onboarding.languageTitle')} +

+
+ {LOCALES.map((option) => ( + setLocale(option.id)} + /> + ))} +
+

{t('onboarding.unitsTitle')}

+
+ setUnitSystem('imperial')} + /> + setUnitSystem('metric')} + /> +
+ + ) : null} + + {step === 'theme' ? ( + <> +

+ {t('onboarding.themeTitle')} +

+
+ setTheme('dark')} + /> + setTheme('light')} + /> +
+ + ) : null} + + {step === 'live' ? ( + <> +

+ {t('onboarding.liveViewTitle')} +

+
+ {LIVE_MODES.map((option) => ( + setMode(option.id)} + /> + ))} +
+ {mode === 'timed' ? ( +
+ {LIVE_VIEW_DURATIONS_MS.map((ms) => ( + + ))} +
+ ) : null} + + ) : null} + + {step === 'done' ? ( +
+

+

{t('onboarding.doneDetail')}

+

+ ) : null} +
+ + {step !== 'welcome' ? ( +
+ +
+ {STEPS.map((id, index) => ( + + ))} +
+ {step === 'done' ? ( + + ) : ( + + )} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/onboarding/index.ts b/ui/src/components/onboarding/index.ts new file mode 100644 index 00000000..9c30b5fa --- /dev/null +++ b/ui/src/components/onboarding/index.ts @@ -0,0 +1 @@ +export { OnboardingFlow, type OnboardingStep } from './OnboardingFlow'; From 88a4d8210ee28f7f42e09bf7cfcfff85089ead2f Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 18:49:05 +0100 Subject: [PATCH 09/24] Show first-run setup before the kiosk shell. Co-authored-by: Cursor --- ui/src/App.test.tsx | 10 ++++++++++ ui/src/App.tsx | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 1edcce85..35ace955 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -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 }); + useOnboardingStore.setState({ completed: true }); }); it('renders the bottom bar instead of the old top header', () => { @@ -80,4 +82,12 @@ describe('App shell', () => { expect(html).not.toContain('Clear Player 1'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(); + expect(html).toContain('Get started'); + expect(html).not.toContain('aria-label="Select club"'); + expect(html).not.toContain('panel-footer'); + }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index aed437d0..da210afa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -7,10 +7,12 @@ 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 { 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'; @@ -103,16 +105,21 @@ 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; const [currentView, setCurrentView] = useState('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('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); @@ -230,6 +237,10 @@ function AppContent() { return ; } + if (!onboardingCompleted) { + return setPickerOpen(false)} />; + } + const changeClubAction = ( setPickerOpen(true)}> {isSwingSpeedMode ? t('app.changeImplement') : t('app.changeClub')} From f9a13040f9eb748e3ea713e866c4ac8f262a0e03 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 19:00:50 +0100 Subject: [PATCH 10/24] Cover first-run setup and live overlay modes in Playwright. Co-authored-by: Cursor --- ui/tests/e2e/helpers.ts | 12 ++++- ui/tests/e2e/onboarding.spec.ts | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 ui/tests/e2e/onboarding.spec.ts diff --git a/ui/tests/e2e/helpers.ts b/ui/tests/e2e/helpers.ts index 2a39b696..be705c8c 100644 --- a/ui/tests/e2e/helpers.ts +++ b/ui/tests/e2e/helpers.ts @@ -86,7 +86,17 @@ export async function resetSession(socket: Socket) { } } -export async function gotoApp(page: Page, path = '/') { +export async function gotoApp( + page: Page, + path = '/', + options: { onboarded?: boolean } = {} +) { + const onboarded = options.onboarded ?? true; + if (onboarded) { + await page.addInitScript(() => { + window.localStorage.setItem('openflight.onboarding.completed:v1', '1'); + }); + } await page.goto(`${UI_URL}${path}`); } diff --git a/ui/tests/e2e/onboarding.spec.ts b/ui/tests/e2e/onboarding.spec.ts new file mode 100644 index 00000000..114b3d23 --- /dev/null +++ b/ui/tests/e2e/onboarding.spec.ts @@ -0,0 +1,95 @@ +import { test } from '@playwright/test'; +import { expect, gotoApp, simulateShot, withControlSocket } from './helpers'; + +test('first visit shows welcome, not the club picker', async ({ page }) => { + await gotoApp(page, '/', { onboarded: false }); + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Select club' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Live' })).toHaveCount(0); +}); + +test('walks setup and lands on Live with Driver and no picker', async ({ page }) => { + await gotoApp(page, '/', { onboarded: false }); + await page.getByRole('button', { name: 'Get started' }).click(); + await page.getByRole('button', { name: 'Español' }).click(); + await expect(page.getByRole('button', { name: 'Continuar' })).toBeVisible(); + await page.getByRole('button', { name: 'English' }).click(); + await page.getByRole('button', { name: 'KMH / M' }).click(); + await page.getByRole('button', { name: 'MPH / YDS' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Light' }).click(); + await page.getByRole('button', { name: 'Dark' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('button', { name: '5s', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Timed preview' }).click(); + await expect(page.getByRole('button', { name: '5s', exact: true })).toBeVisible(); + await page.getByRole('button', { name: /^Tiles/ }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Start' }).click(); + await expect(page.getByRole('dialog', { name: 'Select club' })).toHaveCount(0); + await expect(page.locator('.panel-header__club')).toContainText('Driver'); + await expect(page.getByRole('button', { name: 'Live' })).toBeVisible(); +}); + +test('later launch opens the club picker', async ({ page }) => { + await gotoApp(page, '/', { onboarded: false }); + await page.getByRole('button', { name: 'Get started' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Start' }).click(); + await page.reload(); + await expect(page.getByRole('dialog', { name: 'Select club' })).toBeVisible(); +}); + +test('display route never shows onboarding', async ({ page }) => { + await gotoApp(page, '/display', { onboarded: false }); + await expect(page.getByText('OpenFlight Display')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Get started' })).toHaveCount(0); +}); + +test('menu has live view and no shutdown; footer still shuts down', async ({ page }) => { + await gotoApp(page); + await page.getByRole('button', { name: 'Close Select club' }).click(); + await page.getByRole('button', { name: 'Open menu' }).click(); + const menu = page.getByRole('dialog', { name: 'Menu' }); + await expect(menu.getByText('Live view', { exact: true })).toBeVisible(); + await expect(menu.getByRole('button', { name: 'Shut down' })).toHaveCount(0); + await page.getByRole('button', { name: 'Close menu' }).click(); + await page.locator('.panel-footer__power').click(); + await expect(page.getByRole('dialog', { name: 'Shut down OpenFlight?' })).toBeVisible(); +}); + +test('timed overlay hides after the chosen duration; hold stays until tap', async ({ page }) => { + await page.clock.install(); + await gotoApp(page); + await page.getByRole('button', { name: 'Close Select club' }).click(); + + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + await expect(page.locator('.live-panel__spotlight')).toHaveCount(0); + + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('button', { name: 'Timed preview' }).click(); + await page.getByRole('button', { name: '5s', exact: true }).click(); + await page.getByRole('button', { name: 'Close menu' }).click(); + + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + await expect(page.locator('.live-panel__spotlight')).toBeVisible(); + await page.clock.fastForward(5000); + await expect(page.locator('.live-panel__spotlight')).toHaveCount(0); + + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('button', { name: 'Hold preview' }).click(); + await page.getByRole('button', { name: 'Close menu' }).click(); + + await withControlSocket(async (socket) => { + await simulateShot(socket); + }); + await expect(page.locator('.live-panel__spotlight')).toBeVisible(); + await page.locator('.live-panel__spotlight').click(); + await expect(page.locator('.live-panel__spotlight')).toHaveCount(0); +}); From 036fde77e570cbf8c462a6dc939497dc55775d8a Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 19:04:47 +0100 Subject: [PATCH 11/24] Document first-run setup and live preview modes. Co-authored-by: Cursor --- docs/CHANGELOG.md | 6 ++++++ ui/README.md | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e452ead3..45c7be54 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 footer 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 diff --git a/ui/README.md b/ui/README.md index 30af9d60..0d6f09c5 100644 --- a/ui/README.md +++ b/ui/README.md @@ -89,10 +89,15 @@ The app is entirely client-side. Everything flows through one socket connection. 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. -The pin is stored in +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. 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 footer power button only. **Display mode** lives at `/display`: a compact, fullscreen-friendly dashboard for mounted screens and TVs. The [root README](../README.md#tv-display-mode) From 24f03dba01f8afa560ddfdda127523f23f687938 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 19:19:26 +0100 Subject: [PATCH 12/24] Fix onboarding leak of experimental tiles and footer power CSS. Co-authored-by: Cursor --- .../onboarding/OnboardingFlow.test.tsx | 2 +- .../components/onboarding/OnboardingFlow.tsx | 9 ++--- ui/src/components/panel/LivePanel.test.tsx | 20 ----------- ui/src/components/panel/LivePanel.tsx | 1 - ui/src/components/panel/panel.css | 34 +++++++++++++++++++ .../components/panel/useShotSpotlight.test.ts | 14 ++++++++ ui/src/components/panel/useShotSpotlight.ts | 20 ++++++----- ui/src/stores/useLiveViewStore.test.ts | 15 ++++++++ 8 files changed, 78 insertions(+), 37 deletions(-) diff --git a/ui/src/components/onboarding/OnboardingFlow.test.tsx b/ui/src/components/onboarding/OnboardingFlow.test.tsx index c56e1be3..2e6deee9 100644 --- a/ui/src/components/onboarding/OnboardingFlow.test.tsx +++ b/ui/src/components/onboarding/OnboardingFlow.test.tsx @@ -53,7 +53,7 @@ describe('OnboardingFlow', () => { it('uses Start on the done screen', () => { const html = render('done'); - expect(html).toContain("You're ready"); + expect(html).toContain('You're ready'); expect(html).toContain('Start'); expect(html).not.toContain('Continue'); }); diff --git a/ui/src/components/onboarding/OnboardingFlow.tsx b/ui/src/components/onboarding/OnboardingFlow.tsx index 1b919662..a63e02de 100644 --- a/ui/src/components/onboarding/OnboardingFlow.tsx +++ b/ui/src/components/onboarding/OnboardingFlow.tsx @@ -209,12 +209,9 @@ export function OnboardingFlow({ {step === 'done' ? (
-

+

+ {t('onboarding.doneTitle')} +

{t('onboarding.doneDetail')}

) : null} diff --git a/ui/src/components/panel/LivePanel.test.tsx b/ui/src/components/panel/LivePanel.test.tsx index 5ad2cbbf..90ecaef4 100644 --- a/ui/src/components/panel/LivePanel.test.tsx +++ b/ui/src/components/panel/LivePanel.test.tsx @@ -177,26 +177,6 @@ describe('LivePanel', () => { expect(html).not.toContain('>radar<'); }); - it('marks experimental tiles with a flask icon instead of caption text', () => { - const html = render( - makeShot({ - 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', - }) - ); - - expect(html).toContain('metric-card__experimental'); - expect(html).toMatch(/metric-card__subtext[^>]*>Fused]*>Cameraexperimental { const html = render(makeShot()); diff --git a/ui/src/components/panel/LivePanel.tsx b/ui/src/components/panel/LivePanel.tsx index a6935345..1b5b70cf 100644 --- a/ui/src/components/panel/LivePanel.tsx +++ b/ui/src/components/panel/LivePanel.tsx @@ -130,7 +130,6 @@ export function LivePanel({ unit={metric.unit} subtext={metric.subtext} estimated={metric.estimated} - experimental={metric.experimental} confidence={metric.confidence} confidenceLabel={metric.confidenceLabel} labelPosition="above" diff --git a/ui/src/components/panel/panel.css b/ui/src/components/panel/panel.css index 0f0fccee..f9c380fc 100644 --- a/ui/src/components/panel/panel.css +++ b/ui/src/components/panel/panel.css @@ -406,6 +406,40 @@ border-right: 1px solid var(--color-border); } +.panel-footer__meta > .power-status--chrome { + padding-right: 0.9vw; + border-right: 1px solid var(--color-border); +} + +.panel-footer__power { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; +} + +.panel-footer__power:hover, +.panel-footer__power:focus-visible { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.panel-footer__power svg { + width: 1.05rem; + height: 1.05rem; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + /* Shutdown confirmation is app chrome, so it lives with the panel shell. */ .shutdown-overlay { position: fixed; diff --git a/ui/src/components/panel/useShotSpotlight.test.ts b/ui/src/components/panel/useShotSpotlight.test.ts index fdd97d6a..6a37a313 100644 --- a/ui/src/components/panel/useShotSpotlight.test.ts +++ b/ui/src/components/panel/useShotSpotlight.test.ts @@ -41,4 +41,18 @@ describe('createSpotlightController', () => { vi.advanceTimersByTime(60_000); expect(hide).not.toHaveBeenCalled(); }); + + it('keeps mode and duration frozen on the instance', () => { + vi.useFakeTimers(); + const hide = vi.fn(); + const { openInitially, start } = createSpotlightController('timed', 10000, true, hide); + expect(openInitially).toBe(true); + start(); + // A later store change to 5s / sticky is never passed into this instance. + createSpotlightController('sticky', 5000, true, hide); + vi.advanceTimersByTime(5000); + expect(hide).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5000); + expect(hide).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/src/components/panel/useShotSpotlight.ts b/ui/src/components/panel/useShotSpotlight.ts index 4c4be5d1..b24cceee 100644 --- a/ui/src/components/panel/useShotSpotlight.ts +++ b/ui/src/components/panel/useShotSpotlight.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { LiveViewMode } from '../../stores/useLiveViewStore'; export function shouldOpenSpotlight(mode: LiveViewMode, isNewShot: boolean): boolean { @@ -25,15 +25,17 @@ export function createSpotlightController( } export function useShotSpotlight(mode: LiveViewMode, durationMs: number, isNewShot: boolean) { - const [open, setOpen] = useState(() => shouldOpenSpotlight(mode, isNewShot)); + const hideRef = useRef(() => {}); + const controllerRef = useRef | null>(null); + if (controllerRef.current === null) { + // Freeze mode/duration for this hook instance. LivePanel remounts on a new + // shot, so menu changes apply then; an overlay already on screen is left alone. + controllerRef.current = createSpotlightController(mode, durationMs, isNewShot, () => hideRef.current()); + } + const [open, setOpen] = useState(controllerRef.current.openInitially); + hideRef.current = () => setOpen(false); - useEffect(() => { - if (!open || mode !== 'timed') { - return; - } - const timer = setTimeout(() => setOpen(false), durationMs); - return () => clearTimeout(timer); - }, [open, mode, durationMs]); + useEffect(() => controllerRef.current?.start(), []); return { open, dismiss: () => setOpen(false) }; } diff --git a/ui/src/stores/useLiveViewStore.test.ts b/ui/src/stores/useLiveViewStore.test.ts index beda0829..8e8422cb 100644 --- a/ui/src/stores/useLiveViewStore.test.ts +++ b/ui/src/stores/useLiveViewStore.test.ts @@ -67,6 +67,21 @@ describe('useLiveViewStore', () => { expect(useLiveViewStore.getState().durationMs).toBe(10000); }); + it('restores mixed-validity storage per field', async () => { + installBrowser({ [STORAGE_KEY]: JSON.stringify({ mode: 'sticky', durationMs: 2500 }) }); + const useLiveViewStore = await loadStore(); + + expect(useLiveViewStore.getState().mode).toBe('sticky'); + expect(useLiveViewStore.getState().durationMs).toBe(10000); + + vi.resetModules(); + installBrowser({ [STORAGE_KEY]: JSON.stringify({ mode: 'hero', durationMs: 5000 }) }); + const useLiveViewStoreAgain = await loadStore(); + + expect(useLiveViewStoreAgain.getState().mode).toBe('tiles'); + expect(useLiveViewStoreAgain.getState().durationMs).toBe(5000); + }); + it('persists mode without clearing duration', async () => { const store = installBrowser(); const useLiveViewStore = await loadStore(); From 4f9473a1cd057b0279d7c133a944b448ec7d428f Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 26 Aug 2026 21:54:35 +0100 Subject: [PATCH 13/24] Move shutdown to header and polish onboarding Relocates the shutdown control from the footer to the panel header and manages dialog visibility through `useSystemStore`, keeping behavior consistent across app shell components. Updates docs/tests to reflect the new header power UX. Also refreshes onboarding presentation (new welcome mark/eyebrow, richer theme/live previews) and refines metric provenance UI by replacing verbose experimental confidence text with a dedicated experimental icon and shorter localized subtext (e.g., Fused/Camera), including updates across Display mode, Live metrics, MetricCard styling, and locale strings. --- README.md | 2 +- docs/CHANGELOG.md | 4 +- ui/README.md | 6 +- ui/src/App.test.tsx | 15 +- ui/src/App.tsx | 11 +- ui/src/components/DisplayMode.test.tsx | 28 +- ui/src/components/DisplayMode.tsx | 31 +- .../components/onboarding/OnboardingFlow.css | 333 ++++++++++++++++++ .../onboarding/OnboardingFlow.test.tsx | 10 + .../components/onboarding/OnboardingFlow.tsx | 77 +++- ui/src/components/panel/MenuSheet.tsx | 3 +- ui/src/components/panel/PanelFooter.test.tsx | 13 +- ui/src/components/panel/PanelFooter.tsx | 26 +- ui/src/components/panel/PanelHeader.test.tsx | 36 ++ ui/src/components/panel/PanelHeader.tsx | 29 +- ui/src/components/panel/liveMetrics.test.ts | 48 ++- ui/src/components/panel/liveMetrics.ts | 23 +- ui/src/components/panel/panel.css | 34 -- ui/src/components/ui/MetricCard.css | 34 +- ui/src/components/ui/MetricCard.test.tsx | 29 +- ui/src/components/ui/MetricCard.tsx | 49 ++- ui/src/i18n/en.ts | 1 + ui/src/i18n/es.ts | 1 + ui/src/i18n/fr.ts | 1 + ui/src/i18n/pt.ts | 1 + ui/src/stores/useSystemStore.ts | 6 + ui/tests/e2e/onboarding.spec.ts | 4 +- 27 files changed, 690 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index de93ed1e..8a499164 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,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. For a TV or tablet, use [TV Display Mode](#tv-display-mode). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 45c7be54..1bcf4d78 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 footer power button. + 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 @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 + and ball-detection status; 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 diff --git a/ui/README.md b/ui/README.md index 0d6f09c5..89db7ab4 100644 --- a/ui/README.md +++ b/ui/README.md @@ -87,8 +87,8 @@ The app is entirely client-side. Everything flows through one socket connection. **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 +ball-detection status. 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. The pin is stored in @@ -97,7 +97,7 @@ after each new shot. The pin is stored in (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 footer power button only. +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) diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 35ace955..ceafe3a4 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -7,7 +7,7 @@ import { useSystemStore } from './stores/useSystemStore'; describe('App shell', () => { beforeEach(() => { - useSystemStore.setState({ serverClub: null }); + useSystemStore.setState({ serverClub: null, shutdownDialogOpen: false }); useOnboardingStore.setState({ completed: true }); }); @@ -68,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(); - 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(); + + expect(html).toContain('Shut down OpenFlight?'); }); it('does not ask to clear a session until the stats action is used', () => { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index da210afa..0353a1cd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -109,11 +109,12 @@ function AppContent() { // 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('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('confirm'); // Open on later launches after onboarding so the club is confirmed before // the first shot; first-run starts closed because the wizard replaces the @@ -223,7 +224,7 @@ function AppContent() { }; const closeShutdown = () => { - setShowShutdown(false); + useSystemStore.getState().closeShutdownDialog(); setShutdownState('confirm'); }; @@ -277,7 +278,7 @@ function AppContent() { )} - {showShutdown ? ( + {shutdownDialogOpen ? ( ) : null} @@ -420,10 +421,6 @@ function AppContent() { ballDetected={cameraStatus.ball_detected} debugRecording={debugMode} brand={isLaunchDaddyMode ? : undefined} - onShutdown={() => { - setShutdownState('confirm'); - setShowShutdown(true); - }} /> ); diff --git a/ui/src/components/DisplayMode.test.tsx b/ui/src/components/DisplayMode.test.tsx index 25a856aa..b6642b83 100644 --- a/ui/src/components/DisplayMode.test.tsx +++ b/ui/src/components/DisplayMode.test.tsx @@ -61,7 +61,31 @@ describe('DisplayMode', () => { ); - 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( + + ); + + expect(html).toContain('metric-card__experimental'); + expect(html).toMatch(/metric-card__subtext[^>]*>Fused]*>Camera ); diff --git a/ui/src/components/onboarding/OnboardingFlow.css b/ui/src/components/onboarding/OnboardingFlow.css index 30270200..b044e555 100644 --- a/ui/src/components/onboarding/OnboardingFlow.css +++ b/ui/src/components/onboarding/OnboardingFlow.css @@ -28,6 +28,79 @@ gap: var(--space-md); } +.onboarding__welcome--start { + gap: 0; +} + +.onboarding__mark { + position: relative; + display: grid; + place-items: center; + width: 8.25rem; + height: 8.25rem; + margin-bottom: var(--space-lg); + color: var(--color-accent); +} + +.onboarding__mark::before, +.onboarding__mark::after { + content: ''; + position: absolute; + border-radius: 50%; + pointer-events: none; +} + +.onboarding__mark::before { + inset: 0; + border: 1px solid color-mix(in srgb, var(--color-accent) 38%, transparent); +} + +.onboarding__mark::after { + inset: 0.65rem; + border: 1px solid color-mix(in srgb, var(--color-accent) 16%, transparent); +} + +.onboarding__mark svg { + width: 4.6rem; + height: auto; +} + +.onboarding__eyebrow { + margin: 0 0 var(--space-xs); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--color-accent); +} + +.onboarding__welcome--start .onboarding__title { + font-size: clamp(1.85rem, 4.6vw, 2.75rem); + font-weight: 700; + letter-spacing: 0.12em; +} + +.onboarding__welcome--start .onboarding__title::after { + content: ''; + display: block; + width: 2.75rem; + height: 0.12rem; + margin: var(--space-md) auto 0; + background: var(--color-accent-block); +} + +.onboarding__welcome--start .onboarding__detail { + margin-top: var(--space-md); + max-width: 24rem; +} + +.onboarding__welcome--start .onboarding__primary { + margin-top: var(--space-xl); + min-width: 13rem; + min-height: 3rem; + padding: 0 var(--space-xl); +} + .onboarding__title { margin: 0; font-size: 1.5rem; @@ -92,6 +165,26 @@ appearance: none; } +.onboarding__tile--preview { + justify-content: space-between; + gap: var(--space-md); +} + +.onboarding__grid--live .onboarding__tile--preview { + justify-content: flex-start; +} + +.onboarding__tile-copy { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.onboarding__grid--live .onboarding__tile-copy { + flex: 1; + justify-content: center; +} + .onboarding__tile-label { font-size: 1rem; font-weight: 600; @@ -110,6 +203,202 @@ color: var(--color-accent); } +.onboarding__tile--dark, +.onboarding__tile--light { + justify-content: space-between; +} + +.onboarding__tile--dark { + background: #0e0f10; + color: #ffffff; + border-color: rgba(242, 241, 236, 0.32); +} + +.onboarding__tile--light { + background: #f4f2ec; + color: #14140f; + border-color: rgba(20, 20, 15, 0.28); +} + +.onboarding__tile--dark .onboarding__tile-label, +.onboarding__tile--light .onboarding__tile-label { + font-size: 1.35rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.onboarding__tile--dark.onboarding__tile--selected .onboarding__tile-label { + color: #ffd400; +} + +.onboarding__tile--light.onboarding__tile--selected .onboarding__tile-label { + color: #14140f; +} + +.onboarding__theme-swatch, +.onboarding__live-demo { + position: relative; + display: block; + width: 100%; + overflow: hidden; + border: 1px solid currentColor; + border-radius: calc(var(--radius) - 1px); + opacity: 0.92; +} + +.onboarding__theme-swatch { + flex: 1; + min-height: 4.25rem; +} + +.onboarding__live-demo { + flex: 0 0 auto; + aspect-ratio: 8 / 5; +} + +.onboarding__theme-swatch--dark { + background: #16181a; + color: rgba(242, 241, 236, 0.32); +} + +.onboarding__theme-swatch--light { + background: #eae7df; + color: rgba(20, 20, 15, 0.2); +} + +.onboarding__mini-grid { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 0.2rem; + padding: 0.35rem; +} + +.onboarding__mini-cell { + min-height: 0; + border-radius: 0.12rem; + background: color-mix(in srgb, currentColor 55%, transparent); +} + +.onboarding__mini-cell--selected { + background: #ffd400; + box-shadow: inset 2px 0 0 #ffd400; +} + +.onboarding__theme-swatch--light .onboarding__mini-cell--selected { + background: #ffbe1b; + box-shadow: inset 2px 0 0 #ffbe1b; +} + +.onboarding__live-demo { + background: var(--color-bg); + color: var(--color-border); + border-color: var(--color-border); +} + +.onboarding__live-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg); +} + +.onboarding__live-overlay-label { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} + +.onboarding__live-overlay-value { + font-size: 3.25rem; + font-weight: 700; + line-height: 0.9; + letter-spacing: -0.03em; + color: var(--color-text); +} + +.onboarding__live-demo--timed .onboarding__live-overlay { + animation: onboarding-timed-overlay 5.5s ease-in-out infinite; +} + +.onboarding__live-tap { + position: absolute; + right: 12%; + bottom: 14%; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--color-accent-block); +} + +.onboarding__live-tap::after { + content: ''; + position: absolute; + inset: -0.35rem; + border: 0.1rem solid var(--color-accent-block); + border-radius: 50%; + animation: onboarding-tap-ring 1.6s ease-out infinite; +} + +@keyframes onboarding-timed-overlay { + 0%, + 18% { + opacity: 0; + } + 28%, + 62% { + opacity: 1; + } + 78%, + 100% { + opacity: 0; + } +} + +@keyframes onboarding-tap-ring { + 0% { + transform: scale(0.7); + opacity: 0.9; + } + 100% { + transform: scale(1.7); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .onboarding__live-demo--timed .onboarding__live-overlay { + animation: none !important; + opacity: 1; + } + + .onboarding__live-tap::after { + animation: none !important; + opacity: 0.55; + transform: scale(1.15); + } +} + +.onboarding__tile-detail { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.onboarding__tile--selected { + box-shadow: inset 5px 0 0 var(--color-accent-block); +} + +.onboarding__tile--selected .onboarding__tile-label { + color: var(--color-accent); +} + .onboarding__durations { display: flex; gap: var(--space-sm); @@ -209,11 +498,55 @@ gap: var(--space-sm); } + .onboarding__mark { + width: 5.5rem; + height: 5.5rem; + margin-bottom: var(--space-sm); + } + + .onboarding__mark svg { + width: 3.1rem; + } + + .onboarding__welcome--start .onboarding__title { + font-size: 1.55rem; + } + + .onboarding__welcome--start .onboarding__title::after { + margin-top: var(--space-sm); + } + + .onboarding__welcome--start .onboarding__detail { + margin-top: var(--space-sm); + } + + .onboarding__welcome--start .onboarding__primary { + margin-top: var(--space-md); + min-height: 2.5rem; + min-width: 11rem; + } + .onboarding__tile { min-height: 2.375rem; padding: var(--space-sm) var(--space-md); } + .onboarding__theme-swatch { + min-height: 3rem; + } + + .onboarding__live-overlay-label { + font-size: 0.9rem; + } + + .onboarding__live-overlay-value { + font-size: 2.6rem; + } + + .onboarding__grid--live .onboarding__tile-detail { + display: none; + } + .onboarding__primary, .onboarding__nav, .onboarding__chip { diff --git a/ui/src/components/onboarding/OnboardingFlow.test.tsx b/ui/src/components/onboarding/OnboardingFlow.test.tsx index 2e6deee9..ea7f3bb0 100644 --- a/ui/src/components/onboarding/OnboardingFlow.test.tsx +++ b/ui/src/components/onboarding/OnboardingFlow.test.tsx @@ -15,6 +15,10 @@ describe('OnboardingFlow', () => { const html = render(); expect(html).toContain('Get started'); expect(html).toContain('OpenFlight'); + expect(html).toContain('Launch monitor'); + expect(html).toContain('onboarding__mark'); + expect(html).toContain('fill="currentColor"'); + expect(html).not.toContain('#360185'); expect(html).not.toContain('Continue'); expect(html).not.toContain('panel-footer'); }); @@ -35,6 +39,9 @@ describe('OnboardingFlow', () => { const html = render('theme'); expect(html).toContain('Dark'); expect(html).toContain('Light'); + expect(html).toContain('onboarding__tile--dark'); + expect(html).toContain('onboarding__tile--light'); + expect(html).toContain('onboarding__theme-swatch'); }); it('hides duration chips unless timed preview is selected', () => { @@ -49,6 +56,9 @@ describe('OnboardingFlow', () => { expect(html).toContain('Tiles'); expect(html).toContain('Timed preview'); expect(html).toContain('Hold preview'); + expect(html).toContain('onboarding__live-demo--tiles'); + expect(html).toContain('onboarding__live-demo--timed'); + expect(html).toContain('onboarding__live-demo--sticky'); }); it('uses Start on the done screen', () => { diff --git a/ui/src/components/onboarding/OnboardingFlow.tsx b/ui/src/components/onboarding/OnboardingFlow.tsx index a63e02de..af3159b4 100644 --- a/ui/src/components/onboarding/OnboardingFlow.tsx +++ b/ui/src/components/onboarding/OnboardingFlow.tsx @@ -1,4 +1,4 @@ -import { useState, type MouseEventHandler } from 'react'; +import { useState, type MouseEventHandler, type ReactNode } from 'react'; import { LOCALES } from '../../i18n'; import { useI18n } from '../../i18n/useI18n'; import Logo from '../../logo/Logo'; @@ -36,26 +36,73 @@ const LIVE_MODES: ReadonlyArray<{ { id: 'sticky', label: 'onboarding.liveHold', detail: 'onboarding.liveHoldDetail' }, ]; +function MiniGrid() { + return ( + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + + ); +} + +function ThemeSwatch({ appearance }: { appearance: 'dark' | 'light' }) { + return ( + + ); +} + +function LiveViewDemo({ mode }: { mode: LiveViewMode }) { + return ( +