diff --git a/README.md b/README.md index 9e6c9063..a37bcb13 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ wrong values bias the result instead of producing an obvious startup error. Then open http://localhost:8080 or use the touchscreen. Footer tabs switch between Live, Stats, Shots, Camera, Players, and Debug. Tap the footer logo for -units, theme, and language; the footer power icon opens shutdown confirmation. +units, theme, and language; the header power icon opens shutdown confirmation. On Live, tap a metric to pin it top-left while keeping all metrics visible. Use the Replay action on camera-backed shots to open a touch-friendly slow-motion impact clip. The MP4 is generated only when Replay is selected and is cached diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6bdd9ad2..676d2044 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 full horizontal speed, overstating attack angle on any shot with club path. ### Added +- **First-run setup.** A new kiosk walks through language, units, theme, and + Live view (tiles, timed large preview, or hold preview) before the first + shot. Choices persist and remain in the footer menu. Live can show the + selected metric full-screen after a new shot; tap or the chosen duration + returns to the tile grid. Shut down was removed from the menu sheet; use + the header power button. - **Automatic OV9281 exposure control.** High-speed camera capture now measures the impact area every five seconds, restores the last known-good setting at startup, and selects a shutter/gain combination that preserves club contrast @@ -23,9 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Instrument-panel kiosk UI.** The dashboard is a tabbed shell (Live, Stats, Shots, Camera, Players, Debug) instead of the previous stacked shot and stats views. Tap a Live metric to pin it top-left while keeping all ten metrics - visible. The footer logo opens units, dark/light theme, language, simulator, - and ball-detection status; a persistent footer power button opens the shutdown - confirmation. Club (or training implement) selection is a Live header action. + visible. The footer logo opens units, dark/light theme, language, and live + view; simulator and ball-detection status live in the header status menu. A + persistent header power button opens the shutdown confirmation. Club (or + training implement) selection is a Live header action. See the [UI README](../ui/README.md). - **Kiosk languages.** English, Spanish, French, and Portuguese. Choice is stored in `localStorage` (`openflight.locale:v1`). diff --git a/ui/README.md b/ui/README.md index be6969fc..5e04622b 100644 --- a/ui/README.md +++ b/ui/README.md @@ -86,11 +86,12 @@ The app is entirely client-side. Everything flows through one socket connection. as a no-op by the Node mock). **Kiosk shell.** Footer tabs switch views. The footer logo opens a sheet for -units (MPH/YDS vs KMH/M), dark/light theme, language, simulator and -ball-detection status. The footer power icon is always visible and opens a -shutdown confirmation. Change club (or training implement) lives on the Live -header. Tap a Live metric to pin it top-left while keeping all metrics visible. -Camera-backed shots add a **Replay** action in the Live header and a play button +units (MPH/YDS vs KMH/M), dark/light theme, language, and live view. Simulator +and ball-detection status live in the header status menu. The header power icon +is always visible on the right and opens a shutdown confirmation. Change club (or training implement) lives on the Live +header. Tap a Live metric to pin it top-left while keeping all metrics visible; +timed or sticky Live view can optionally show a large preview of that metric +after each new shot. Camera-backed shots add a **Replay** action in the Live header and a play button in Shots. Selecting either action asks the backend to lazily create and cache a 60 FPS MP4; no video conversion runs automatically after a shot. The full-screen player includes touch controls, a scrubber, an impact marker, and retryable @@ -98,7 +99,11 @@ preparation/playback errors. The pin is stored in `localStorage` under `openflight.hero-metric`. Theme is stored under -`openflight.theme` (default dark). +`openflight.theme` (default dark). First launch shows a fullscreen setup +(language, units, theme, Live view). Completing it writes +`openflight.onboarding.completed:v1`. Live view mode is +`openflight.live-view:v1` (`tiles` | `timed` | `sticky`, duration 5/10/15s). +Change those later in the footer menu. Shut down is the header power button. **Display mode** lives at `/display`: a compact, fullscreen-friendly dashboard for mounted screens and TVs. The [root README](../README.md#tv-display-mode) diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 1edcce85..ceafe3a4 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 }); + useSystemStore.setState({ serverClub: null, shutdownDialogOpen: false }); + useOnboardingStore.setState({ completed: true }); }); it('renders the bottom bar instead of the old top header', () => { @@ -66,11 +68,20 @@ describe('App shell', () => { expect(html).not.toContain('panel-footer__count'); }); - it('keeps the shutdown power control in the footer', () => { + it('keeps the shutdown power control in the header, not the footer', () => { const html = renderToString(); - 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', () => { @@ -80,4 +91,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 07e1357d..e21e21da 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -7,11 +7,13 @@ import { useCameraStore } from './stores/useCameraStore'; import { useDebugStore } from './stores/useDebugStore'; import { usePlayerStore } from './stores/usePlayerStore'; import { useHeroMetricStore } from './stores/useHeroMetricStore'; +import { useOnboardingStore } from './stores/useOnboardingStore'; import { useCameraReplayController } from './hooks/useCameraReplayController'; import { socketService } from './services/socketService'; import { shouldEchoSelectionToServer } from './services/playerSocketSync'; import { DebugPanel } from './components/DebugPanel'; import { DisplayMode } from './components/DisplayMode'; +import { OnboardingFlow } from './components/onboarding'; import { SimShotBadges } from './components/SimShotBadges'; import { ShotProcessingArea } from './components/ShotProcessingArea'; import { ShutdownDialog, type ShutdownState } from './components/ShutdownDialog'; @@ -105,16 +107,22 @@ function AppContent() { })) ); + // Hook subscribe so complete() re-renders on the client. Gate with getState() + // because renderToString uses Zustand's initial snapshot, not later setState. + useOnboardingStore((state) => state.completed); + const onboardingCompleted = useOnboardingStore.getState().completed; + useSystemStore((state) => state.shutdownDialogOpen); + const shutdownDialogOpen = useSystemStore.getState().shutdownDialogOpen; const [currentView, setCurrentView] = useState('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); @@ -219,7 +227,7 @@ function AppContent() { }; const closeShutdown = () => { - setShowShutdown(false); + useSystemStore.getState().closeShutdownDialog(); setShutdownState('confirm'); }; @@ -233,6 +241,10 @@ function AppContent() { return ; } + if (!onboardingCompleted) { + return setPickerOpen(false)} />; + } + const changeClubAction = ( setPickerOpen(true)}> {isSwingSpeedMode ? t('app.changeImplement') : t('app.changeClub')} @@ -281,7 +293,7 @@ function AppContent() { )} - {showShutdown ? ( + {shutdownDialogOpen ? ( ) : null} @@ -392,16 +404,7 @@ function AppContent() { * Overlays sit outside
so they cover the footer too, matching how * 6a draws them over the whole card. */} - {menuOpen ? ( - setMenuOpen(false)} - onShutdown={() => { - setMenuOpen(false); - setShutdownState('confirm'); - setShowShutdown(true); - }} - /> - ) : null} + {menuOpen ? setMenuOpen(false)} /> : null} {addPlayerOpen ? ( : 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/SimStatus.css b/ui/src/components/SimStatus.css index 74d5bb40..5d0cc075 100644 --- a/ui/src/components/SimStatus.css +++ b/ui/src/components/SimStatus.css @@ -2,6 +2,7 @@ .sim-status { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--space-xs); } @@ -83,4 +84,7 @@ .sim-status__state { display: none; } + .panel-header__status-sims .sim-status__state { + display: inline; + } } diff --git a/ui/src/components/onboarding/OnboardingFlow.css b/ui/src/components/onboarding/OnboardingFlow.css new file mode 100644 index 00000000..c5bdb8e5 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.css @@ -0,0 +1,520 @@ +.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__welcome--start { + gap: 0; +} + +.onboarding__mark { + display: grid; + place-items: center; + width: 100%; + margin-bottom: var(--space-xl); +} + +.onboarding__mark-logo { + width: min(60vw, 20rem); + height: auto; + filter: drop-shadow(0 14px 32px rgb(0 0 0 / 38%)); +} + +.onboarding__title--visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.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; + 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: stretch; + justify-content: center; + min-height: 2.75rem; + min-width: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.onboarding__tile-hit { + display: flex; + flex: 1; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: var(--space-xs); + width: 100%; + min-height: 2.75rem; + min-width: 0; + padding: var(--space-md); + font: inherit; + text-align: left; + color: inherit; + cursor: pointer; + background: transparent; + border: none; + border-radius: inherit; + -webkit-appearance: none; + appearance: none; +} + +.onboarding__tile--preview .onboarding__tile-hit { + justify-content: space-between; + gap: var(--space-md); +} + +.onboarding__grid--live .onboarding__tile--preview .onboarding__tile-hit { + 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; +} + +.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__tile--dark .onboarding__tile-hit, +.onboarding__tile--light .onboarding__tile-hit { + 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__durations { + display: flex; + gap: var(--space-xs); + width: 100%; + padding: 0 var(--space-md) var(--space-md); +} + +.onboarding__chip { + flex: 1; + min-height: 2.375rem; + min-width: 0; + padding: 0; + font: inherit; + font-size: 0.8rem; + 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__mark { + margin-bottom: var(--space-md); + } + + .onboarding__mark-logo { + width: min(50vw, 14rem); + } + + .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-hit { + 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 { + 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..3ebd1f8c --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.test.tsx @@ -0,0 +1,67 @@ +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).toContain('onboarding__mark'); + expect(html).toContain('src="/openflightlogo.svg"'); + 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'); + 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', () => { + 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.indexOf('Timed preview')).toBeLessThan(html.indexOf('>5s<')); + expect(html.indexOf('>5s<')).toBeLessThan(html.indexOf('Hold preview')); + 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', () => { + 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..7d300bdc --- /dev/null +++ b/ui/src/components/onboarding/OnboardingFlow.tsx @@ -0,0 +1,297 @@ +import { useState, type MouseEventHandler, type ReactNode } from 'react'; +import { LOCALES } from '../../i18n'; +import { useI18n } from '../../i18n/useI18n'; +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< + LiveViewDurationMs, + 'onboarding.duration5' | 'onboarding.duration10' | 'onboarding.duration15' +> = { + 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 MiniGrid() { + return ( + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + + ); +} + +function ThemeSwatch({ appearance }: { appearance: 'dark' | 'light' }) { + return ( + + ); +} + +function LiveViewDemo({ mode }: { mode: LiveViewMode }) { + return ( +