diff --git a/.impeccable/design.json b/.impeccable/design.json index 617ae6a5..e264e887 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -110,9 +110,9 @@ "narrative": { "northStar": "The Clarity Lab", "overview": "Bayan Flow looks like a focused lab instrument: generous whitespace, tinted neutrals, and a single confident blue primary. The visualization canvas owns attention; panels and controls recede until needed.", - "keyCharacteristics": [ + "keyCharacteristics": [ "CSS-variable tokens in src/index.css", - "Inter Variable for all UI copy", + "Inter Variable for product UI; Source Serif 4 for landing display headings", "Phosphor icons only", "Semantic visualization colors are sacred", "RTL via rtlManager" @@ -125,7 +125,7 @@ }, { "name": "The Inter Lock Rule", - "body": "Inter is the established brand face. Do not swap fonts without an explicit rebrand request.", + "body": "Inter is the established brand face for product UI. Landing display headings (.landing-h1/.landing-h2) may use Source Serif 4 Variable.", "section": "typography" }, { diff --git a/AGENTS.md b/AGENTS.md index 97676e3d..4e46589c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t - Favorite algorithms: up to 20 slots (`FREE_TIER_FAVORITE_SLOT_LIMIT` in `src/constants/personalLearning.js`); stored in Supabase `favorite_algorithms` - Per-algorithm study notes: stored in Supabase `algorithm_notes`; sanitized HTML via `notesService.js` - Self-service account deletion: Profile Settings danger zone → Supabase Edge Function `delete-account` (CASCADE removes profile, favorites, notes) - - Video Export: unlimited for Free tier with mandatory watermark; Pro tier adds watermark customization/removal; internal daily abuse guard exists (not user-facing) + - Video Export: unlimited for Free tier with mandatory watermark; Pro tier adds watermark customization/removal - **Auth PRs must not reduce Free tier functionality** — only Anonymous tier can be further restricted - **Secrets** — publishable anon key via `VITE_*` only; service role key never in repo or client bundle - **CSP** — Supabase origin in `connect-src`; Google profile photos in `img-src` (lh3.googleusercontent.com); GIS scripts/connect/frame in respective directives; asserted via `scripts/cspHeaders.js` at build time diff --git a/DESIGN.md b/DESIGN.md index 04ff4863..72089924 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -21,9 +21,32 @@ colors: viz-pivot: "#8b5cf6" typography: display: - fontFamily: "Inter Variable, Inter, system-ui, sans-serif" - fontWeight: 700 + fontFamily: "Source Serif 4 Variable, Source Serif 4, Georgia, Times New Roman, serif" + fontWeight: 800 + fontSize: "3.5rem" lineHeight: 1.1 + letterSpacing: "-0.02em" + displayMobile: + fontFamily: "Source Serif 4 Variable, Source Serif 4, Georgia, Times New Roman, serif" + fontWeight: 800 + fontSize: "2.5rem" + lineHeight: 1.1 + heading: + fontFamily: "Source Serif 4 Variable, Source Serif 4, Georgia, Times New Roman, serif" + fontWeight: 700 + fontSize: "2.5rem" + lineHeight: 1.2 + letterSpacing: "-0.01em" + headingMobile: + fontFamily: "Source Serif 4 Variable, Source Serif 4, Georgia, Times New Roman, serif" + fontWeight: 700 + fontSize: "2rem" + lineHeight: 1.2 + landingBody: + fontFamily: "Inter Variable, Inter, system-ui, sans-serif" + fontWeight: 400 + fontSize: "1.125rem" + lineHeight: 1.7 body: fontFamily: "Inter Variable, Inter, system-ui, sans-serif" fontWeight: 400 @@ -35,6 +58,8 @@ typography: fontSize: "0.875rem" letterSpacing: "0.02em" rounded: + xs: "0.25rem" + scrollbar: "3px" sm: "0.375rem" md: "0.5rem" lg: "0.75rem" @@ -109,21 +134,21 @@ A cool slate-and-blue product palette with saturated accents reserved for algori ## Typography -**Display Font:** Inter Variable (system-ui fallback) -**Body Font:** Inter Variable (system-ui fallback) +**Display Font (landing / marketing headings):** Source Serif 4 Variable — academic lab character on `.landing-h1` / `.landing-h2` only. +**Body Font:** Inter Variable (system-ui fallback) — product UI, body copy, controls. -**Character:** Clean, legible, technical-but-warm. Optimized for step descriptions and control labels at a glance. +**Character:** Clean, legible, technical-but-warm. Optimized for step descriptions and control labels at a glance. Marketing display adds a restrained serif for Clarity Lab presence without replacing Inter in the app. ### Hierarchy -- **Display** (700, clamp on landing heroes): Marketing headlines only. -- **Title** (600, 1.125–1.25rem): Panel titles, algorithm names. -- **Body** (400, 1rem, line-height 1.5): Descriptions, insight copy, settings. -- **Label** (600, 0.875rem, slight tracking): Control groups, tabs, chips. +- **Display** (700–800, clamp on landing heroes): Marketing headlines only (Source Serif 4). +- **Title** (600, 1.125–1.25rem): Panel titles, algorithm names (Inter). +- **Body** (400, 1rem, line-height 1.5): Descriptions, insight copy, settings (Inter). +- **Label** (600, 0.875rem, slight tracking): Control groups, tabs, chips (Inter). ### Named Rules -**The Inter Lock Rule.** Inter is the established brand face. Do not swap fonts unless the user explicitly requests a rebrand. +**The Inter Lock Rule.** Inter is the established brand face for product UI and body copy. Do not swap Inter app-wide unless the user explicitly requests a rebrand. **Exception:** landing/marketing display headings (`.landing-h1`, `.landing-h2`) may use Source Serif 4 Variable for academic-lab character. ## Elevation @@ -180,5 +205,5 @@ Depth comes from tinted surface steps and restrained shadows, plus selective gla - **Don't** nest cards inside cards in the visualizer shell; use spacing and dividers. - **Don't** apply gradient text to headings or step descriptions. - **Don't** use bounce or elastic easing on UI transitions. -- **Don't** swap Inter or replace Phosphor with emoji icons without an explicit rebrand request. +- **Don't** swap Inter app-wide or replace Phosphor with emoji icons without an explicit rebrand request (landing display serif on `.landing-h1` / `.landing-h2` is the documented exception). - **Don't** add UI click sounds — audio is for visualization steps only. diff --git a/docs/AGENTS_REFERENCE.md b/docs/AGENTS_REFERENCE.md index 35af1b5a..127fb294 100644 --- a/docs/AGENTS_REFERENCE.md +++ b/docs/AGENTS_REFERENCE.md @@ -231,7 +231,7 @@ Migrations: `20260710140000_pro_waitlist.sql`, `20260710150000_pro_waitlist_attr ### Landing and roadmap components -- `src/components/landing/` — `Hero`, `Features`, `AlgorithmTypes`, `ClaritySection`, `LearnYourWay`, `RoadmapCTA`, `TechPattern` +- `src/components/landing/` — `Hero`, `HeroVisualizerDemo`, `AlgorithmTypes`, `Features`, `ProPreview`, `ClaritySection`, `FAQ`, `RoadmapCTA`, `TechPattern`, `SocialProofStrip` (gated by `SHOW_LANDING_SOCIAL_PROOF`) - `src/components/roadmap/` — `RoadmapHero`, `Timeline`, `TimelineItem` - `src/components/ui/` — shared `Button`, `Container`, `Section` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4c90b0bd..ec09d2a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ Bayan Flow is built as a single-page application (SPA) with multiple routes: - No visualizer functionality **Components:** -- Hero, LearnYourWay, AlgorithmTypes, Features, ClaritySection, RoadmapCTA, GitHubRepoBadge +- Hero, AlgorithmTypes, Features, ProPreview, ClaritySection, FAQ, RoadmapCTA, SocialProofStrip (gated), GitHubRepoBadge - Footer, ThemeToggle, LanguageSwitcher - TechPattern (animated background) @@ -150,10 +150,12 @@ LandingPage ├── LanguageSwitcher ├── TechPattern (background) ├── Hero -├── LearnYourWay +├── SocialProofStrip (gated off) ├── AlgorithmTypes ├── Features +├── ProPreview ├── ClaritySection +├── FAQ ├── RoadmapCTA └── Footer diff --git a/package.json b/package.json index 981dfb47..48fab545 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@dicebear/core": "^10.3.0", "@dicebear/styles": "^10.2.0", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/source-serif-4": "^5.3.0", "@monaco-editor/react": "^4.7.0", "@octokit/rest": "^22.0.1", "@phosphor-icons/react": "^2.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c501ab2e..a0435d84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/source-serif-4': + specifier: ^5.3.0 + version: 5.3.0 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -649,6 +652,9 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@fontsource-variable/source-serif-4@5.3.0': + resolution: {integrity: sha512-9vch9WqxjaaA+1o9Ur8pOgIGbCYLjRReOUel23A6lOpD1syptgjtkORevvNmldJ5kGXQL29onQUqI5Ltz0s3bQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -3394,6 +3400,8 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@fontsource-variable/source-serif-4@5.3.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': diff --git a/src/components/AlgorithmTipToast.jsx b/src/components/AlgorithmTipToast.jsx index bd8804bf..c1074d57 100644 --- a/src/components/AlgorithmTipToast.jsx +++ b/src/components/AlgorithmTipToast.jsx @@ -39,7 +39,7 @@ export default function AlgorithmTipToast({ algorithmKey, onClose }) { }; }, [algorithmKey, onClose]); - const message = t(`algorithmUses.${algorithmKey}`, { + const message = t(`algorithmTips.${algorithmKey}`, { defaultValue: '', }); diff --git a/src/components/AlgorithmTipToast.test.jsx b/src/components/AlgorithmTipToast.test.jsx index 0eb9c9f7..23334349 100644 --- a/src/components/AlgorithmTipToast.test.jsx +++ b/src/components/AlgorithmTipToast.test.jsx @@ -17,13 +17,13 @@ describe('AlgorithmTipToast', () => { vi.useRealTimers(); }); - it('renders the tip title and use-case message for the algorithm', () => { + it('renders the tip title and interview-focused message for the algorithm', () => { renderWithI18n( ); expect(screen.getByText('Why it matters')).toBeInTheDocument(); - expect(screen.getByText(/GPS routing/)).toBeInTheDocument(); + expect(screen.getByText(/every navigation app/)).toBeInTheDocument(); }); it('exposes the message to assistive technology', () => { diff --git a/src/components/ArrayVisualizer.appDefaults.test.jsx b/src/components/ArrayVisualizer.appDefaults.test.jsx new file mode 100644 index 00000000..925aa779 --- /dev/null +++ b/src/components/ArrayVisualizer.appDefaults.test.jsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Bayan Flow + * Licensed under Elastic License 2.0 OR Commercial + * See LICENSE for details. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderWithProviders, screen } from '../test/testUtils'; +import ArrayVisualizer from './ArrayVisualizer'; +import i18n from '../i18n'; + +vi.mock('./ComplexityPanel', () => ({ + default: () =>
Complexity
, +})); + +vi.mock('./SwipeTutorial', () => ({ + default: ({ show }) => + show ?
Swipe
: null, +})); + +describe('ArrayVisualizer /app defaults', () => { + beforeEach(async () => { + localStorage.clear(); + await i18n.changeLanguage('en'); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const baseProps = { + array: [3, 1, 2], + states: ['default', 'comparing', 'default'], + description: 'algorithms.descriptions.bubbleSort', + algorithm: 'bubbleSort', + onStepForward: vi.fn(), + onStepBackward: vi.fn(), + mode: 'manual', + isComplete: false, + }; + + it('keeps legend, caption, and application role when demo props are omitted', () => { + renderWithProviders(); + + // Legend auto-expands on mount; close control proves chrome is present + expect(screen.getByLabelText(i18n.t('legend.close'))).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect( + screen.getByRole('application', { + name: 'Array visualization - Swipe left/right to navigate steps', + }) + ).toBeInTheDocument(); + }); + + it('still opens the complexity panel after completion', async () => { + renderWithProviders(); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.getByTestId('complexity-panel')).toBeInTheDocument(); + }); + + it('hero demo flags hide legend and complexity while allowing caption', async () => { + renderWithProviders( + + ); + + expect( + screen.queryByLabelText(i18n.t('legend.show')) + ).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByRole('img')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByTestId('complexity-panel')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ArrayVisualizer.jsx b/src/components/ArrayVisualizer.jsx index ce193d50..d95960ff 100644 --- a/src/components/ArrayVisualizer.jsx +++ b/src/components/ArrayVisualizer.jsx @@ -36,6 +36,9 @@ import { * @param {number|null} [targetValue] - Search target (searching category) * @param {'sorting'|'searching'} [visualizerVariant] * @param {string} [complexityDataset] - ComplexityPanel dataset key + * @param {boolean} [showChrome=true] - Legend, complexity panel, swipe tutorial + * @param {boolean} [showCaption=true] - Step status caption under the bars + * @param {boolean} [interactive=true] - Swipe / keyboard step navigation */ function ArrayVisualizer({ array, @@ -50,6 +53,9 @@ function ArrayVisualizer({ visualizerVariant = 'sorting', complexityDataset = 'sorting', onGatedFeatureClick, + showChrome = true, + showCaption = true, + interactive = true, }) { const { t } = useTranslation(); const { user } = useAuth(); @@ -62,6 +68,8 @@ function ArrayVisualizer({ // Show swipe tutorial on mobile when user scrolls to visualization area useEffect(() => { + if (!showChrome || !interactive) return undefined; + const isMobile = window.innerWidth < 640; const hasSteps = array.length > 0; const isManualMode = mode === 'manual'; @@ -85,7 +93,8 @@ function ArrayVisualizer({ window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); } - }, [mode, array.length, isComplete]); + return undefined; + }, [mode, array.length, isComplete, showChrome, interactive]); const handleDismissTutorial = () => { setShowSwipeTutorial(false); @@ -93,6 +102,11 @@ function ArrayVisualizer({ }; useEffect(() => { + if (!showChrome) { + setShowComplexityPanel(false); + return undefined; + } + if (isComplete) { const timer = setTimeout(() => { setShowComplexityPanel(true); @@ -103,7 +117,8 @@ function ArrayVisualizer({ setIsComplexityGated(false); hasCountedThisCompletion.current = false; } - }, [isComplete]); + return undefined; + }, [isComplete, showChrome]); useEffect(() => { if (user) { @@ -113,6 +128,8 @@ function ArrayVisualizer({ // Increment complexity view count once per completion for anonymous users useEffect(() => { + if (!showChrome) return; + if (isComplete && !hasCountedThisCompletion.current && !user) { if (canViewComplexityPanel(user)) { incrementComplexityViewCount(); @@ -122,7 +139,7 @@ function ArrayVisualizer({ } hasCountedThisCompletion.current = true; } - }, [isComplete, user, onGatedFeatureClick]); + }, [isComplete, user, onGatedFeatureClick, showChrome]); const legendItems = visualizerVariant === 'searching' @@ -163,17 +180,26 @@ function ArrayVisualizer({ { state: ELEMENT_STATES.PIVOT, label: t('legend.sorting.pivot') }, ]; + const canSwipe = + interactive && mode === 'manual' && (onStepForward || onStepBackward); + // Swipe gesture support for manual mode const swipe = useSwipe({ - onLeft: mode === 'manual' && onStepBackward ? onStepBackward : undefined, - onRight: mode === 'manual' && onStepForward ? onStepForward : undefined, + onLeft: canSwipe && onStepBackward ? onStepBackward : undefined, + onRight: canSwipe && onStepForward ? onStepForward : undefined, threshold: 50, }); + const arrayPaneProps = canSwipe ? swipe : {}; + const arrayPaneRole = interactive ? 'application' : 'img'; + const arrayPaneLabel = interactive + ? t('visualization.arrayPaneLabelInteractive') + : t('visualization.arrayPaneLabel'); + return (
- {showComplexityPanel ? ( + {showChrome && showComplexityPanel ? (
- {/* Auto-hiding Legend */} - ({ - ...item, - color: item.color ?? STATE_COLORS[item.state], - }))} - isComplete={isComplete} - /> + {showChrome && ( + ({ + ...item, + color: item.color ?? STATE_COLORS[item.state], + }))} + isComplete={isComplete} + /> + )} {visualizerVariant === 'searching' && targetValue != null && (
@@ -235,7 +262,11 @@ function ArrayVisualizer({ )} {/* Array Visualization */} -
+
{array.map((value, index) => ( - {/* Description */} - {description && ( + {showCaption && description && ( - {/* Swipe Tutorial Overlay */} - + {showChrome && ( + + )}
); } diff --git a/src/components/Header.jsx b/src/components/Header.jsx index a14066fb..95aa7a87 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -131,7 +131,7 @@ function Header({ hideLanguageSwitcher = false }) {
)} - {isAppPage && !hideLanguageSwitcher && } + {!hideLanguageSwitcher && } {isAppPage && }
- {ALGORITHM_TYPE_LIST.map((type, index) => { + {ALGORITHM_TYPE_LIST.map(type => { const cfg = CATEGORY_CONFIG[type]; const Icon = cfg.icon; const isActive = algorithmType === type; - const isLoneLastItem = - ALGORITHM_TYPE_LIST.length % 2 === 1 && - index === ALGORITHM_TYPE_LIST.length - 1; return ( @@ -306,7 +304,7 @@ function SettingsPanel({ ) : null}
-
- - {t('featureGate.signIn')} + {isSigningIn ? ( +