Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 139 additions & 5 deletions frontend/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { Link, useRouterState } from '@tanstack/react-router';
import { Home, Box, ArrowRightLeft, Users, FileText, Layers, Globe, ChevronLeft, ChevronRight, Sun, Moon, Coins, Image, Clock, BarChart3, Code2, Pin, PinOff, Play } from 'lucide-react';
import { Home, Box, ArrowRightLeft, Users, FileText, Layers, Globe, ChevronLeft, ChevronRight, Sun, Moon, Coins, Image, Clock, BarChart3, Code2, Pin, PinOff, Play, Type, ChevronDown } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { useFont, type FontFamily, type PixelVariant } from '../contexts/FontContext';
import { useMobileMenu } from '../contexts/MobileMenuContext';
import { FlowIndexLogo } from './FlowIndexLogo';
import { motion, AnimatePresence } from 'framer-motion';
Expand All @@ -10,14 +11,16 @@ export default function Sidebar() {
const routerState = useRouterState();
const location = routerState.location;
const { theme, toggleTheme } = useTheme();
const [isCollapsed, setIsCollapsed] = useState(() => location.pathname.startsWith('/developer'));
const { fontFamily, pixelVariant, setFontFamily, setPixelVariant } = useFont();
const [fontMenuOpen, setFontMenuOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => location.pathname.startsWith('/developer') || location.pathname.startsWith('/playground'));
const [autoCollapse, setAutoCollapse] = useState(false);
const [hoverExpanded, setHoverExpanded] = useState(false);
const { isOpen: isMobileOpen, close: closeMobileMenu } = useMobileMenu();

// Auto-collapse when entering Developer Portal, auto-expand when leaving
// Auto-collapse when entering Developer Portal or Playground, auto-expand when leaving
useEffect(() => {
if (location.pathname.startsWith('/developer')) {
if (location.pathname.startsWith('/developer') || location.pathname.startsWith('/playground')) {
setIsCollapsed(true);
}
}, [location.pathname]);
Expand Down Expand Up @@ -122,14 +125,73 @@ export default function Sidebar() {
</nav>

{/* Footer */}
<div className="p-4 border-t border-zinc-200 dark:border-white/5">
<div className="p-4 border-t border-zinc-200 dark:border-white/5 space-y-2">
<button
onClick={(e) => toggleTheme(e)}
className="w-full flex items-center space-x-3 px-4 py-2 rounded-sm hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-600 dark:text-zinc-400 transition-colors"
>
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
<span className="text-sm font-medium">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
</button>
{/* Mobile Font Selector */}
<div>
<button
onClick={() => setFontMenuOpen(!fontMenuOpen)}
className="w-full flex items-center space-x-3 px-4 py-2 rounded-sm hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-600 dark:text-zinc-400 transition-colors"
>
<Type className="w-5 h-5 shrink-0" />
<span className="text-sm font-medium flex-1 text-left">
Font: {fontFamily === 'mono' ? 'Mono' : fontFamily === 'sans' ? 'Sans' : `Pixel ${pixelVariant[0].toUpperCase() + pixelVariant.slice(1)}`}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${fontMenuOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{fontMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="px-2 py-2 space-y-1">
{([['mono', 'Mono'], ['sans', 'Sans']] as [FontFamily, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => { setFontFamily(key); if (key !== 'pixel') setFontMenuOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded-sm text-xs transition-colors ${
fontFamily === key
? 'text-nothing-green bg-nothing-green/10'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
}`}
style={{ fontFamily: key === 'mono' ? 'Inconsolata, monospace' : 'Geist Sans, sans-serif' }}
>
{label}
</button>
))}
<div className="pt-1 border-t border-white/5">
<span className="px-3 text-[9px] font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-600">Pixel</span>
<div className="mt-1 space-y-0.5">
{(['square', 'grid', 'circle', 'triangle', 'line'] as PixelVariant[]).map((v) => (
<button
key={v}
onClick={() => { setFontFamily('pixel'); setPixelVariant(v); setFontMenuOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded-sm text-xs transition-colors ${
fontFamily === 'pixel' && pixelVariant === v
? 'text-nothing-green bg-nothing-green/10'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
}`}
style={{ fontFamily: `Geist Pixel ${v[0].toUpperCase() + v.slice(1)}, monospace` }}
>
{v[0].toUpperCase() + v.slice(1)}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
</>
Expand Down Expand Up @@ -237,6 +299,78 @@ export default function Sidebar() {
)}
</button>

{/* Font Selector */}
{!effectiveCollapsed && (
<div className="relative">
<button
onClick={() => setFontMenuOpen(!fontMenuOpen)}
className="w-full flex items-center space-x-3 px-4 py-2 rounded-sm hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-600 dark:text-zinc-400 transition-colors"
>
<Type className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium flex-1 text-left">
{fontFamily === 'mono' ? 'Mono' : fontFamily === 'sans' ? 'Sans' : `Pixel ${pixelVariant[0].toUpperCase() + pixelVariant.slice(1)}`}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${fontMenuOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{fontMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="px-2 py-2 space-y-1">
{([['mono', 'Mono'], ['sans', 'Sans']] as [FontFamily, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => { setFontFamily(key); if (key !== 'pixel') setFontMenuOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded-sm text-xs transition-colors ${
fontFamily === key
? 'text-nothing-green bg-nothing-green/10'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
}`}
style={{ fontFamily: key === 'mono' ? 'Inconsolata, monospace' : 'Geist Sans, sans-serif' }}
>
{label}
</button>
))}
{/* Pixel section */}
<div className="pt-1 border-t border-white/5">
<span className="px-3 text-[9px] font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-600">Pixel</span>
<div className="mt-1 space-y-0.5">
{(['square', 'grid', 'circle', 'triangle', 'line'] as PixelVariant[]).map((v) => (
<button
key={v}
onClick={() => { setFontFamily('pixel'); setPixelVariant(v); setFontMenuOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded-sm text-xs transition-colors ${
fontFamily === 'pixel' && pixelVariant === v
? 'text-nothing-green bg-nothing-green/10'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
}`}
style={{ fontFamily: `Geist Pixel ${v[0].toUpperCase() + v.slice(1)}, monospace` }}
>
{v[0].toUpperCase() + v.slice(1)}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{effectiveCollapsed && (
<button
onClick={() => { setFontMenuOpen(false); /* cycle: mono -> sans -> pixel */ const next: FontFamily = fontFamily === 'mono' ? 'sans' : fontFamily === 'sans' ? 'pixel' : 'mono'; setFontFamily(next); }}
className="w-full flex items-center justify-center py-2 rounded-sm hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-600 dark:text-zinc-400 transition-colors"
title={`Font: ${fontFamily === 'pixel' ? `Pixel ${pixelVariant}` : fontFamily}`}
>
<Type className="h-5 w-5 shrink-0" />
</button>
)}

{/* Collapse & Auto-collapse Controls */}
{effectiveCollapsed ? (
<button
Expand Down
78 changes: 78 additions & 0 deletions frontend/app/contexts/FontContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createContext, useContext, useEffect, useState } from 'react';

export type FontFamily = 'mono' | 'sans' | 'pixel';
export type PixelVariant = 'square' | 'grid' | 'circle' | 'triangle' | 'line';

const FONT_FAMILY_MAP: Record<FontFamily, string> = {
mono: 'Inconsolata, Space Mono, monospace',
sans: 'Geist Sans, sans-serif',
pixel: '', // resolved dynamically from pixelVariant
};

const PIXEL_VARIANT_MAP: Record<PixelVariant, string> = {
square: 'Geist Pixel Square, monospace',
grid: 'Geist Pixel Grid, monospace',
circle: 'Geist Pixel Circle, monospace',
triangle: 'Geist Pixel Triangle, monospace',
line: 'Geist Pixel Line, monospace',
};

interface FontContextValue {
fontFamily: FontFamily;
pixelVariant: PixelVariant;
setFontFamily: (f: FontFamily) => void;
setPixelVariant: (v: PixelVariant) => void;
}

const FontContext = createContext<FontContextValue>({
fontFamily: 'mono',
pixelVariant: 'square',
setFontFamily: () => {},
setPixelVariant: () => {},
});

export function FontProvider({ children }: { children: React.ReactNode }) {
const [fontFamily, setFontFamilyState] = useState<FontFamily>('mono');
const [pixelVariant, setPixelVariantState] = useState<PixelVariant>('square');
const [ready, setReady] = useState(false);

useEffect(() => {
const savedFont = localStorage.getItem('fontFamily') as FontFamily | null;
const savedPixel = localStorage.getItem('pixelVariant') as PixelVariant | null;
if (savedFont && FONT_FAMILY_MAP[savedFont] !== undefined) setFontFamilyState(savedFont);
if (savedPixel && PIXEL_VARIANT_MAP[savedPixel]) setPixelVariantState(savedPixel);
setReady(true);
}, []);

useEffect(() => {
if (!ready) return;
const resolved =
fontFamily === 'pixel'
? PIXEL_VARIANT_MAP[pixelVariant]
: FONT_FAMILY_MAP[fontFamily];

// Apply to <body> so code elements (which have explicit font-family) are unaffected
document.body.style.fontFamily = resolved;
}, [fontFamily, pixelVariant, ready]);

const setFontFamily = (f: FontFamily) => {
setFontFamilyState(f);
localStorage.setItem('fontFamily', f);
};

const setPixelVariant = (v: PixelVariant) => {
setPixelVariantState(v);
localStorage.setItem('pixelVariant', v);
};

return (
<FontContext.Provider value={{ fontFamily, pixelVariant, setFontFamily, setPixelVariant }}>
{children}
</FontContext.Provider>
);
}

// eslint-disable-next-line react-refresh/only-export-components
export function useFont() {
return useContext(FontContext);
}
28 changes: 28 additions & 0 deletions frontend/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@
font-display: swap;
}

/* Geist Pixel variants */
@font-face {
font-family: 'Geist Pixel Square';
src: url('/fonts/GeistPixel-Square.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Geist Pixel Grid';
src: url('/fonts/GeistPixel-Grid.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Geist Pixel Circle';
src: url('/fonts/GeistPixel-Circle.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Geist Pixel Triangle';
src: url('/fonts/GeistPixel-Triangle.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Geist Pixel Line';
src: url('/fonts/GeistPixel-Line.woff2') format('woff2');
font-display: swap;
}

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down Expand Up @@ -64,6 +91,7 @@

body {
@apply bg-background text-foreground;
font-family: Inconsolata, Space Mono, monospace;
}
}

Expand Down
5 changes: 4 additions & 1 deletion frontend/app/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Footer from '../components/Footer';
import Sidebar from '../components/Sidebar';
import { WebSocketProvider } from '../components/WebSocketProvider';
import { ThemeProvider } from '../contexts/ThemeContext';
import { FontProvider } from '../contexts/FontContext';
import { AuthProvider } from '../contexts/AuthContext';
import { MobileMenuProvider } from '../contexts/MobileMenuContext';
import { Toaster } from 'react-hot-toast';
Expand Down Expand Up @@ -43,10 +44,11 @@ function RootComponent() {
return (
<RootDocument>
<ThemeProvider>
<FontProvider>
<AuthProvider>
<WebSocketProvider>
<MobileMenuProvider>
<div className="bg-gray-50 dark:bg-black min-h-screen text-zinc-700 dark:text-zinc-300 font-mono antialiased selection:bg-nothing-green selection:text-black flex transition-colors duration-300">
<div className="bg-gray-50 dark:bg-black min-h-screen text-zinc-700 dark:text-zinc-300 antialiased selection:bg-nothing-green selection:text-black flex transition-colors duration-300">
{/* Sidebar */}
<Sidebar />

Expand All @@ -66,6 +68,7 @@ function RootComponent() {
</MobileMenuProvider>
</WebSocketProvider>
</AuthProvider>
</FontProvider>
</ThemeProvider>
<ScrollRestoration />
</RootDocument>
Expand Down
Binary file added frontend/public/fonts/GeistPixel-Circle.woff2
Binary file not shown.
Binary file added frontend/public/fonts/GeistPixel-Grid.woff2
Binary file not shown.
Binary file added frontend/public/fonts/GeistPixel-Line.woff2
Binary file not shown.
Binary file added frontend/public/fonts/GeistPixel-Square.woff2
Binary file not shown.
Binary file added frontend/public/fonts/GeistPixel-Triangle.woff2
Binary file not shown.