Skip to content

Commit 2055487

Browse files
authored
Merge pull request #216 from fix/betabloom-feedback
feat: BetaBloom feedback — seekable timeline, Ultra Fast speed, algorithm use-case tips, and landing page critical-path improvements
2 parents 64ae009 + 16f7aff commit 2055487

36 files changed

Lines changed: 1547 additions & 282 deletions

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ When docs drift, trust runtime config:
6767
- Complexity panel: interactive SVG chart with log/linear toggle; best/average/worst time + space complexity
6868
- Graph algorithms: `GraphAlgorithmCategoryVisualizer` routes node-link to `GraphVisualizer` and matrix (Floyd-Warshall) to `GraphAlgorithmMatrixVisualizer`; `GraphScenarioDropdown` for preset scenarios
6969
- Searching category: `SearchingCategoryVisualizer` routes array-based to `ArrayVisualizer` and node-link (DFS/BFS graph) to `GraphVisualizer`
70-
- Animation speeds: SLOW 8000ms, MEDIUM 4800ms, FAST 2400ms, VERY_FAST 1200ms
70+
- Animation speeds: SLOW 5000ms, MEDIUM 3000ms, FAST 1500ms, VERY_FAST 700ms, ULTRA_FAST 350ms
7171

7272
## Ship It test ladder
7373

@@ -113,13 +113,13 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t
113113
- **Anonymous tier (no account):**
114114
- 18 of 45 algorithms (curated starter set across all 5 categories; see `src/constants/algorithmEntitlements.js`)
115115
- 12 visualizations per session (localStorage counter `anon_viz_count`, resets on sign-in)
116-
- Autoplay only, default speed (MEDIUM: 4800ms)
116+
- Autoplay only, default speed (MEDIUM: 3000ms)
117117
- Complexity panel: 2 views per completion (localStorage `anon_complexity_views`), then blur overlay + sign-in gate
118118
- No manual controls, speed adjustment, or category-specific controls (grid size locked to MEDIUM, sort order locked to ascending, graph scenarios disabled)
119119
- No Code Panel, Insight Panel, Video Export, Sound, or Fullscreen
120120
- **Free tier (Google sign-in):**
121121
- All 45 algorithms, unlimited visualizations
122-
- Manual controls, all 4 speed presets
122+
- Manual controls, all 5 speed presets
123123
- Full complexity panel access, all category-specific controls
124124
- Code Panel, Insight Panel (with My Notes tab), Sound, Fullscreen
125125
- Favorite algorithms: up to 20 slots (`FREE_TIER_FAVORITE_SLOT_LIMIT` in `src/constants/personalLearning.js`); stored in Supabase `favorite_algorithms`

docs/AGENTS_REFERENCE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868

6969
### Shared product features
7070

71-
- Playback: manual vs autoplay (`VISUALIZATION_MODES`), four speed presets (`ANIMATION_SPEEDS`)
71+
- Playback: manual vs autoplay (`VISUALIZATION_MODES`), five speed presets (`ANIMATION_SPEEDS`)
7272
- Mobile: horizontal swipe for manual step forward/back (`useSwipe`), one-time swipe tutorial (`SwipeTutorial`)
7373
- Full-screen visualization mode (`useFullScreen`) reuses the same control panel and visualizer registry
7474
- Light/dark theme with system preference fallback (`ThemeContext`, `ThemeToggle`, `useTheme`)
@@ -419,6 +419,7 @@ Migrations: `20260710140000_pro_waitlist.sql`, `20260710150000_pro_waitlist_attr
419419
- Full-screen mode uses the same `ControlPanel` and visualizer registry as normal mode.
420420
- Lazy panels (`PythonCodePanel`, `AlgorithmInsightPanel`) must remain optional overlays and should not block the main visualization path.
421421
- Swipe stepping applies only in manual playback mode on touch devices.
422+
- The step progress bar is seekable: Free-tier users (non-gated) get an invisible full-width range input driving seeking, a circular grab handle (`seek-thumb`) marking the fill edge, and the `controls.dragToSeek` microcopy under the bar. Anonymous/gated users see the **same grab handle** and a persistent `controls.dragToSeekLocked` microcopy ("Sign in to skip steps"), but no range input; the timeline wrapper is a focusable button (`role="button"`) whose click or Enter/Space fires `onGatedFeatureClick('timeline_scrub')``SignInPromptModal` (`featureGate.timeline_scrub`).
422423

423424
## Sound And Export Notes
424425

docs/ARCHITECTURE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -688,10 +688,11 @@ Speed is user-configurable via constants:
688688
689689
```javascript
690690
export const ANIMATION_SPEEDS = {
691-
SLOW: 8000,
692-
MEDIUM: 4800,
693-
FAST: 2400,
694-
VERY_FAST: 1200,
691+
SLOW: 5000,
692+
MEDIUM: 3000,
693+
FAST: 1500,
694+
VERY_FAST: 700,
695+
ULTRA_FAST: 350,
695696
};
696697
```
697698

public/markdown/app.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
## Features
1313

14-
- Step-by-step animation with configurable speed (Slow/Medium/Fast/Very Fast)
14+
- Step-by-step animation with configurable speed (Slow/Medium/Fast/Very Fast/Ultra Fast)
1515
- Real-time time and space complexity analysis
1616
- Python code execution in the browser via Pyodide (WebAssembly)
1717
- HD video export with optional sound

src/AppRoutes.jsx

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,38 @@
44
* See LICENSE for details.
55
*/
66

7+
import { lazy, Suspense } from 'react';
78
import { Routes, Route } from 'react-router-dom';
9+
import { SpinnerGap } from '@phosphor-icons/react';
810
import { useAuth } from './hooks/useAuth.js';
911
import { AUTH_CALLBACK_PATH } from './services/authService.js';
1012
import BannedScreen from './components/BannedScreen.jsx';
1113
import LandingPage from './pages/LandingPage.jsx';
12-
import VisualizerApp from './pages/VisualizerApp.jsx';
13-
import Roadmap from './pages/Roadmap.jsx';
14-
import PrivacyPolicy from './pages/PrivacyPolicy.jsx';
15-
import TermsOfUse from './pages/TermsOfUse.jsx';
16-
import GoogleAuthCallback from './pages/GoogleAuthCallback.jsx';
17-
import ProfileSettingsPage from './pages/ProfileSettingsPage.jsx';
18-
import ProComingSoonPage from './pages/ProComingSoonPage.jsx';
19-
import NotFoundPage from './pages/NotFoundPage.jsx';
2014
import RequireAuth from './components/RequireAuth.jsx';
2115

16+
const VisualizerApp = lazy(() => import('./pages/VisualizerApp.jsx'));
17+
const Roadmap = lazy(() => import('./pages/Roadmap.jsx'));
18+
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy.jsx'));
19+
const TermsOfUse = lazy(() => import('./pages/TermsOfUse.jsx'));
20+
const GoogleAuthCallback = lazy(() => import('./pages/GoogleAuthCallback.jsx'));
21+
const ProfileSettingsPage = lazy(
22+
() => import('./pages/ProfileSettingsPage.jsx')
23+
);
24+
const ProComingSoonPage = lazy(() => import('./pages/ProComingSoonPage.jsx'));
25+
const NotFoundPage = lazy(() => import('./pages/NotFoundPage.jsx'));
26+
27+
function RouteFallback() {
28+
return (
29+
<div
30+
className="flex min-h-40 items-center justify-center"
31+
role="status"
32+
aria-label="Loading page"
33+
>
34+
<SpinnerGap className="size-6 animate-spin text-text-secondary" />
35+
</div>
36+
);
37+
}
38+
2239
function AppRoutes() {
2340
const { accessBlock, isLoading } = useAuth();
2441

@@ -27,24 +44,26 @@ function AppRoutes() {
2744
}
2845

2946
return (
30-
<Routes>
31-
<Route path="/" element={<LandingPage />} />
32-
<Route path="/app" element={<VisualizerApp />} />
33-
<Route path="/roadmap" element={<Roadmap />} />
34-
<Route path="/pro" element={<ProComingSoonPage />} />
35-
<Route path={AUTH_CALLBACK_PATH} element={<GoogleAuthCallback />} />
36-
<Route path="/privacy" element={<PrivacyPolicy />} />
37-
<Route path="/terms" element={<TermsOfUse />} />
38-
<Route
39-
path="/settings/profile"
40-
element={
41-
<RequireAuth>
42-
<ProfileSettingsPage />
43-
</RequireAuth>
44-
}
45-
/>
46-
<Route path="*" element={<NotFoundPage />} />
47-
</Routes>
47+
<Suspense fallback={<RouteFallback />}>
48+
<Routes>
49+
<Route path="/" element={<LandingPage />} />
50+
<Route path="/app" element={<VisualizerApp />} />
51+
<Route path="/roadmap" element={<Roadmap />} />
52+
<Route path="/pro" element={<ProComingSoonPage />} />
53+
<Route path={AUTH_CALLBACK_PATH} element={<GoogleAuthCallback />} />
54+
<Route path="/privacy" element={<PrivacyPolicy />} />
55+
<Route path="/terms" element={<TermsOfUse />} />
56+
<Route
57+
path="/settings/profile"
58+
element={
59+
<RequireAuth>
60+
<ProfileSettingsPage />
61+
</RequireAuth>
62+
}
63+
/>
64+
<Route path="*" element={<NotFoundPage />} />
65+
</Routes>
66+
</Suspense>
4867
);
4968
}
5069

src/AppRoutes.test.jsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('AppRoutes', () => {
9090
expect(screen.queryByTestId('banned-screen')).not.toBeInTheDocument();
9191
});
9292

93-
it('does not show BannedScreen when no access block', () => {
93+
it('does not show BannedScreen when no access block', async () => {
9494
vi.mocked(useAuth).mockReturnValue({
9595
accessBlock: null,
9696
isLoading: false,
@@ -99,7 +99,7 @@ describe('AppRoutes', () => {
9999
renderRoutes('/app');
100100

101101
expect(screen.queryByTestId('banned-screen')).not.toBeInTheDocument();
102-
expect(screen.getByTestId('visualizer-app')).toBeInTheDocument();
102+
await screen.findByTestId('visualizer-app');
103103
});
104104

105105
it('renders LandingPage on /', () => {
@@ -113,48 +113,48 @@ describe('AppRoutes', () => {
113113
expect(screen.getByTestId('landing-page')).toBeInTheDocument();
114114
});
115115

116-
it('renders ProComingSoonPage on /pro', () => {
116+
it('renders ProComingSoonPage on /pro', async () => {
117117
vi.mocked(useAuth).mockReturnValue({
118118
accessBlock: null,
119119
isLoading: false,
120120
});
121121

122122
renderRoutes('/pro');
123123

124-
expect(screen.getByTestId('pro-page')).toBeInTheDocument();
124+
await screen.findByTestId('pro-page');
125125
});
126126

127-
it('renders PrivacyPolicy on /privacy', () => {
127+
it('renders PrivacyPolicy on /privacy', async () => {
128128
vi.mocked(useAuth).mockReturnValue({
129129
accessBlock: null,
130130
isLoading: false,
131131
});
132132

133133
renderRoutes('/privacy');
134134

135-
expect(screen.getByTestId('privacy')).toBeInTheDocument();
135+
await screen.findByTestId('privacy');
136136
});
137137

138-
it('renders TermsOfUse on /terms', () => {
138+
it('renders TermsOfUse on /terms', async () => {
139139
vi.mocked(useAuth).mockReturnValue({
140140
accessBlock: null,
141141
isLoading: false,
142142
});
143143

144144
renderRoutes('/terms');
145145

146-
expect(screen.getByTestId('terms')).toBeInTheDocument();
146+
await screen.findByTestId('terms');
147147
});
148148

149-
it('wraps /settings/profile in RequireAuth', () => {
149+
it('wraps /settings/profile in RequireAuth', async () => {
150150
vi.mocked(useAuth).mockReturnValue({
151151
accessBlock: null,
152152
isLoading: false,
153153
});
154154

155155
renderRoutes('/settings/profile');
156156

157-
expect(screen.getByTestId('require-auth')).toBeInTheDocument();
157+
await screen.findByTestId('require-auth');
158158
expect(screen.getByTestId('profile-settings')).toBeInTheDocument();
159159
});
160160
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { useEffect, useRef } from 'react';
8+
import { motion, useReducedMotion } from 'framer-motion';
9+
import { useTranslation } from 'react-i18next';
10+
import { Lightbulb, X } from '@phosphor-icons/react';
11+
import {
12+
getChromeTransition,
13+
CHROME_DURATION_FAST,
14+
} from '../motion/chromeMotion';
15+
16+
const TOAST_DURATION = 4000;
17+
18+
/**
19+
* Top-right motivational toast shown once per algorithm per session.
20+
* Highlights the chosen algorithm's real-world value and career/interview angle.
21+
*/
22+
export default function AlgorithmTipToast({ algorithmKey, onClose }) {
23+
const { t, i18n } = useTranslation();
24+
const reduceMotion = useReducedMotion();
25+
const isRTL = i18n.dir() === 'rtl';
26+
const timerRef = useRef(null);
27+
28+
useEffect(() => {
29+
if (timerRef.current) {
30+
window.clearTimeout(timerRef.current);
31+
}
32+
timerRef.current = window.setTimeout(() => {
33+
onClose();
34+
}, TOAST_DURATION);
35+
return () => {
36+
if (timerRef.current) {
37+
window.clearTimeout(timerRef.current);
38+
}
39+
};
40+
}, [algorithmKey, onClose]);
41+
42+
const message = t(`algorithmUses.${algorithmKey}`, {
43+
defaultValue: '',
44+
});
45+
46+
return (
47+
<motion.div
48+
role="status"
49+
aria-live="polite"
50+
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
51+
animate={{ opacity: 1, y: 0 }}
52+
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
53+
transition={getChromeTransition(reduceMotion, CHROME_DURATION_FAST)}
54+
className={`fixed top-4 ${isRTL ? 'left-4' : 'right-4'} z-50 max-w-sm rounded-xl border border-[var(--color-border-strong)] bg-surface-elevated px-4 py-3 shadow-lg`}
55+
>
56+
<div className="flex items-start gap-2.5">
57+
<Lightbulb
58+
size={18}
59+
weight="fill"
60+
className="shrink-0 mt-0.5 text-amber-500"
61+
aria-hidden="true"
62+
/>
63+
<div className="min-w-0 flex-1">
64+
<p className="text-xs font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400">
65+
{t('app.algorithmTipTitle')}
66+
</p>
67+
{message ? (
68+
<p className="mt-0.5 text-sm leading-relaxed text-text-primary">
69+
{message}
70+
</p>
71+
) : null}
72+
</div>
73+
<button
74+
type="button"
75+
onClick={onClose}
76+
aria-label={t('common.close')}
77+
className="shrink-0 rounded p-0.5 text-text-tertiary transition-colors hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
78+
>
79+
<X size={14} weight="bold" />
80+
</button>
81+
</div>
82+
</motion.div>
83+
);
84+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
8+
import { act, renderWithI18n, screen, fireEvent } from '../test/testUtils';
9+
import AlgorithmTipToast from './AlgorithmTipToast';
10+
11+
describe('AlgorithmTipToast', () => {
12+
beforeEach(() => {
13+
vi.useFakeTimers();
14+
});
15+
16+
afterEach(() => {
17+
vi.useRealTimers();
18+
});
19+
20+
it('renders the tip title and use-case message for the algorithm', () => {
21+
renderWithI18n(
22+
<AlgorithmTipToast algorithmKey="dijkstra" onClose={vi.fn()} />
23+
);
24+
25+
expect(screen.getByText('Why it matters')).toBeInTheDocument();
26+
expect(screen.getByText(/GPS routing/)).toBeInTheDocument();
27+
});
28+
29+
it('exposes the message to assistive technology', () => {
30+
renderWithI18n(
31+
<AlgorithmTipToast algorithmKey="dijkstra" onClose={vi.fn()} />
32+
);
33+
34+
const toast = screen.getByRole('status');
35+
expect(toast).toHaveAttribute('aria-live', 'polite');
36+
});
37+
38+
it('calls onClose when the close button is clicked', () => {
39+
const onClose = vi.fn();
40+
renderWithI18n(
41+
<AlgorithmTipToast algorithmKey="dijkstra" onClose={onClose} />
42+
);
43+
44+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
45+
expect(onClose).toHaveBeenCalledTimes(1);
46+
});
47+
48+
it('auto-dismisses after 4 seconds', () => {
49+
const onClose = vi.fn();
50+
renderWithI18n(
51+
<AlgorithmTipToast algorithmKey="dijkstra" onClose={onClose} />
52+
);
53+
54+
expect(onClose).not.toHaveBeenCalled();
55+
act(() => {
56+
vi.advanceTimersByTime(4000);
57+
});
58+
expect(onClose).toHaveBeenCalledTimes(1);
59+
});
60+
});

0 commit comments

Comments
 (0)