diff --git a/REDESIGN_PROMPT.md b/REDESIGN_PROMPT.md new file mode 100644 index 0000000..118da60 --- /dev/null +++ b/REDESIGN_PROMPT.md @@ -0,0 +1,83 @@ +# Redesign Prompt - Prep Interview Platform + +## Project Context + +This is "Prep" - an interview preparation platform for multiple positions and levels of experience and skills. It is built with **Next.js 16 (App Router)**, **React 19**, **TypeScript**, **Tailwind CSS v4**, and **Bun**. The current design uses a dark-first theme with glass morphism effects, yellow accent colors, and an ambient background. + +## Task + +Your task is to completely redesign the styling and visual identity of this website. Create an incredible, creative, and unique design that pushes the limits of modern web design capabilities. The goal is to transform this from a basic study resource into a visually stunning, professional interview prep platform. + +## Requirements + +### Tech Stack (already configured - do NOT reinitialize) + +- Next.js 16 with App Router (Turbopack) +- Tailwind CSS v4 (using `@theme` directive in `globals.css`, no separate config file) +- React 19 +- TypeScript +- Bun +- Framer Motion (already installed for animations) +- Lucide React (icons) + +### Design Specifications + +Create **A COMPLETE REDESIGN of the entire website** that replaces the current styling and layout. This includes the homepage, content pages, and shared layout elements. + +The redesign should include: + +1. **A cohesive visual system** applied across all pages (homepage, topic pages, documentation views) +2. **A unique color palette and typography** that feels fresh and professional +3. **Creative layout and visual hierarchy** - experiment with grids, asymmetry, overlapping elements, scroll effects +4. **Smooth animations** using Framer Motion - page transitions, hover effects, scroll-triggered reveals +5. **Responsive design** that works on mobile, tablet, and desktop +6. **Dark mode support** + +### Design Direction + +Pick the most compelling direction (or blend ideas) from the following: + +- Minimalist/Swiss design - clean grids, strong typography, lots of whitespace +- Glassmorphism/Neomorphism - frosted glass cards, depth effects, layered surfaces +- Bold/Brutalist - raw typography, harsh contrasts, unconventional layouts + +### Content to Showcase + +- Hero section with platform name and value proposition +- Topics covered: HTML & CSS, JavaScript, React, API Integration +- Study features: Code examples, interactive notes, table of contents navigation +- Call-to-action for getting started + +### Technical Constraints + +- Use the existing `src/app/` directory structure +- Reuse existing components from `src/components/` where appropriate, or create new ones +- All styles should use Tailwind utility classes and CSS variables defined in `globals.css` +- Maintain accessibility standards (focus states, semantic HTML, ARIA labels) +- Keep Framer Motion animations respectful of `prefers-reduced-motion` +- Dev server runs on the default Next.js port (use `bun run dev`) + +### What NOT to Do + +- Do NOT reinitialize the project or change the build system +- Do NOT remove existing pages or components (add new ones alongside) +- Do NOT install unnecessary dependencies - use what's already available + +## Evaluation Criteria + +After creating the redesign, review it by: + +1. Opening the dev server (`bun run dev`) +2. Navigating to `/` +3. Checking responsive behavior +4. Verifying animations work smoothly +5. Ensuring no console errors + +Iterate and refine until satisfied with the quality. + +## Delivery + +When the redesign is complete and verified: + +1. Commit all changes to the current branch +2. Create a pull request to `main` with a summary of the redesign diff --git a/src/app/(main)/home.tsx b/src/app/(main)/home.tsx index 831aacb..987877c 100644 --- a/src/app/(main)/home.tsx +++ b/src/app/(main)/home.tsx @@ -1,4 +1,7 @@ 'use client'; + +import { motion } from 'framer-motion'; +import { ArrowRight, BookOpen, Code2, Layers } from 'lucide-react'; import { useRouter } from 'next/navigation'; import type { Section } from '@/types'; @@ -13,76 +16,157 @@ const sections: Section[] = [ }, ]; -export default function HomeComponent() { - const router = useRouter(); +const features = [ + { + icon: Code2, + title: 'Code Examples', + description: 'Real-world code snippets with syntax highlighting', + }, + { + icon: BookOpen, + title: 'Interactive Notes', + description: 'Structured study notes organized by topic', + }, + { + icon: Layers, + title: 'Progressive Learning', + description: 'Topics build on each other from basics to advanced', + }, +]; - const totalItems = sections.length; - const columns = 3; +const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.1, delayChildren: 0.2 }, + }, +}; - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; +const item = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.5, ease: 'easeOut' as const }, + }, +}; - return ( -
-
-

- Any interview prep -

-
-
- {sections.map((section, index) => { - const rowNumber = Math.floor(index / columns); - const isLastRow = - rowNumber === Math.floor((totalItems - 1) / columns); +export default function HomeComponent() { + const router = useRouter(); - const itemsOnLastRow = totalItems % columns; - const itemsOnThisRow = - isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns; + return ( +
+ {/* Hero */} + + + + Interview Prep Platform + + - const colSpan = Math.round(6 / itemsOnThisRow); + + Prepare +
+ for any interview +
- const className = `rounded-lg border border-zinc-800 bg-zinc-900/90 p-6 shadow-sm transition-colors duration-200 hover:border-zinc-600 ${ - colSpanClasses[colSpan] - }`; + + Structured study notes, code examples, and hands-on practice for + frontend developer interviews. + - return ( - - ); - })} -
+

+ {feature.title} +

+

+ {feature.description} +

+ + ))} +
+
); } diff --git a/src/app/frontend/junior/frontend-junior.tsx b/src/app/frontend/junior/frontend-junior.tsx index 86969bd..65e56d0 100644 --- a/src/app/frontend/junior/frontend-junior.tsx +++ b/src/app/frontend/junior/frontend-junior.tsx @@ -1,4 +1,7 @@ 'use client'; + +import { motion } from 'framer-motion'; +import { ArrowLeft, ArrowRight, CheckCircle2, Clock } from 'lucide-react'; import { useRouter } from 'next/navigation'; import type { Section } from '@/types'; @@ -36,68 +39,128 @@ const sections: Section[] = [ }, ]; +const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { staggerChildren: 0.08, delayChildren: 0.15 }, + }, +}; + +const cardVariant = { + hidden: { opacity: 0, y: 16 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: 'easeOut' as const }, + }, +}; + export default function FrontendJunior() { const router = useRouter(); - const totalItems = sections.length; - const columns = 3; - - const colSpanClasses: { [key: number]: string } = { - 2: 'md:col-span-2', - 3: 'md:col-span-3', - 6: 'md:col-span-6', - }; + const completedCount = sections.filter((s) => !s.inProgress).length; return ( -
-
-

- Junior Frontend Developer Preparation -

-

- (in development, 'in progress' parts are not completed) -

-
-
- {sections.map((section, index) => { - const rowNumber = Math.floor(index / columns); - const isLastRow = - rowNumber === Math.floor((totalItems - 1) / columns); - - const itemsOnLastRow = totalItems % columns; - const itemsOnThisRow = - isLastRow && itemsOnLastRow > 0 ? itemsOnLastRow : columns; - - const colSpan = Math.round(6 / itemsOnThisRow); +
+
+ {/* Back nav */} + router.push('/')} + transition={{ duration: 0.3 }} + type='button' + > + + Back to home + - const className = `rounded-lg border border-zinc-800 bg-zinc-900/90 p-6 shadow-sm transition-colors duration-200 hover:border-zinc-600 ${ - colSpanClasses[colSpan] - }`; + {/* Header */} + +

+ Junior Frontend +
+ Developer Prep +

+
+

+ {completedCount} of {sections.length} topics available +

+
+
+
+
+ - return ( - - ); - })} + +

+ {section.title} +

+

+ {section.description} +

+ + {!section.inProgress && ( +
+ Start learning + +
+ )} + + ))} +
); diff --git a/src/app/globals.css b/src/app/globals.css index e1274dc..9956bec 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,29 +1,49 @@ @import "tailwindcss"; :root { - /* Default to dark theme */ - --background: #0a0a0a; - --foreground: #ededed; - --accent: #eab308; /* yellow-500 */ - --muted: #a1a1aa; /* zinc-400 */ - --muted-foreground: #d4d4d8; /* zinc-300 */ - --border: rgba(255, 255, 255, 0.08); - --glass-bg: rgba(0, 0, 0, 0.1); - --glass-strong-bg: rgba(0, 0, 0, 0.22); + /* ── Core palette ── */ + --background: #09090b; + --background-secondary: #0f0f14; + --foreground: #f4f4f5; + --foreground-muted: #a1a1aa; + + /* ── Accent: indigo → violet gradient endpoints ── */ + --accent: #818cf8; /* indigo-400 */ + --accent-strong: #6366f1; /* indigo-500 */ + --accent-glow: rgba(99, 102, 241, 0.25); + + /* ── Secondary accent: emerald (success, highlights) ── */ + --success: #34d399; + --success-bg: rgba(52, 211, 153, 0.12); + + /* ── Surface layers ── */ + --surface-0: rgba(255, 255, 255, 0.02); + --surface-1: rgba(255, 255, 255, 0.04); + --surface-2: rgba(255, 255, 255, 0.06); + --surface-3: rgba(255, 255, 255, 0.09); + + /* ── Borders ── */ + --border: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.12); + --border-accent: rgba(129, 140, 248, 0.3); + + /* ── Glass ── */ + --glass-bg: rgba(15, 15, 22, 0.6); + --glass-strong-bg: rgba(15, 15, 22, 0.8); + + /* ── Misc ── */ --ring: var(--accent); - /* Current page header height (controlled by PageHeader) */ --page-header-height: 128px; - /* Offset built-in anchor navigation and scrollIntoView to account for sticky header */ scroll-padding-top: var(--page-header-height, 128px); - /* Ambient background hues */ - --ambient-1: 255 90 31; /* warm orange */ - --ambient-2: 234 179 8; /* yellow-500 */ - --ambient-3: 168 85 247; /* purple */ - --ambient-4: 14 165 233; /* cyan */ + /* ── Ambient mesh gradient hues ── */ + --mesh-1: #4f46e5; /* indigo-600 */ + --mesh-2: #7c3aed; /* violet-600 */ + --mesh-3: #06b6d4; /* cyan-500 */ + --mesh-4: #8b5cf6; /* violet-500 */ } -/* Smooth scrolling for anchor navigation; respect reduced motion */ +/* ── Smooth scroll ── */ html { scroll-behavior: smooth; } @@ -33,13 +53,14 @@ html { } } -/* Ensure headings with anchors don't hide under the sticky header */ +/* ── Heading anchor offset ── */ h2[id], h3[id], h4[id] { - scroll-margin-top: calc(var(--page-header-height, 128px) + 16px); + scroll-margin-top: calc(var(--page-header-height, 128px) + 24px); } +/* ── Tailwind v4 theme ── */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -47,19 +68,33 @@ h4[id] { --font-mono: var(--font-geist-mono); } -/* Respect system light preference while favoring dark by default */ +/* ── Light mode ── */ @media (prefers-color-scheme: light) { :root { - --background: #ffffff; - --foreground: #171717; - --muted: #52525b; /* zinc-600 */ - --muted-foreground: #3f3f46; /* zinc-700 */ - --border: rgba(0, 0, 0, 0.08); - --glass-bg: rgba(255, 255, 255, 0.5); - --glass-strong-bg: rgba(255, 255, 255, 0.6); + --background: #fafafa; + --background-secondary: #f4f4f5; + --foreground: #18181b; + --foreground-muted: #71717a; + --accent: #6366f1; + --accent-strong: #4f46e5; + --accent-glow: rgba(99, 102, 241, 0.15); + --surface-0: rgba(0, 0, 0, 0.01); + --surface-1: rgba(0, 0, 0, 0.03); + --surface-2: rgba(0, 0, 0, 0.05); + --surface-3: rgba(0, 0, 0, 0.08); + --border: rgba(0, 0, 0, 0.06); + --border-strong: rgba(0, 0, 0, 0.12); + --border-accent: rgba(99, 102, 241, 0.25); + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-strong-bg: rgba(255, 255, 255, 0.85); + --mesh-1: #c7d2fe; + --mesh-2: #ddd6fe; + --mesh-3: #a5f3fc; + --mesh-4: #c4b5fd; } } +/* ── Body ── */ body { background: var(--background); color: var(--foreground); @@ -76,26 +111,7 @@ body { "Segoe UI Emoji"; } -/* Image + gradient background is rendered via AmbientBackground component */ - -/* subtle vignette to highlight glass layers */ -body::after { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - background: - radial-gradient( - 60% 60% at 50% 0%, - rgba(255, 255, 255, 0.03), - transparent 70% - ), - radial-gradient(80% 50% at 50% 100%, rgba(0, 0, 0, 0.25), transparent 70%); - pointer-events: none; - transition: filter 0.28s ease-in-out; -} - -/* Accessible focus styles */ +/* ── Focus styles ── */ button:focus-visible, a:focus-visible, [role="button"]:focus-visible { @@ -103,28 +119,50 @@ a:focus-visible, outline-offset: 2px; } -/* Glass utility classes */ +/* ── Glass utilities ── */ .glass { background: var(--glass-bg); border: 1px solid var(--border); - backdrop-filter: blur(12px) saturate(140%); + backdrop-filter: blur(16px) saturate(150%); + -webkit-backdrop-filter: blur(16px) saturate(150%); } .glass-strong { background: var(--glass-strong-bg); - border-bottom: 1px solid var(--border); - backdrop-filter: blur(14px) saturate(160%); + border-bottom: 1px solid var(--border-strong); + backdrop-filter: blur(20px) saturate(170%); + -webkit-backdrop-filter: blur(20px) saturate(170%); +} + +/* ── Surface cards ── */ +.surface-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; } -/* Mobile: allow full viewport width */ +.surface-card-hover { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + transition: all 0.2s ease; +} + +.surface-card-hover:hover { + background: var(--surface-2); + border-color: var(--border-accent); + box-shadow: + 0 0 0 1px var(--accent-glow), + 0 8px 32px -8px rgba(99, 102, 241, 0.12); +} + +/* ── Width presets (desktop only) ── */ @media (max-width: 640px) { .content-container { max-width: 100vw; } } -/* SSR-safe initial width via data attribute (desktop only to avoid descending specificity with mobile override) */ -/* Runtime inline style from PageContainer drives width; these are fallback for SSR/first paint */ @media (min-width: 641px) { html[data-content-width="narrow"] .content-container { max-width: 50vw; @@ -140,7 +178,7 @@ a:focus-visible, } } -/* Hide scrollbars */ +/* ── Scrollbar hide ── */ .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; @@ -149,3 +187,23 @@ a:focus-visible, .scrollbar-hide::-webkit-scrollbar { display: none; } + +/* ── Gradient text utility ── */ +.text-gradient { + background: linear-gradient( + 135deg, + var(--accent) 0%, + #a78bfa 50%, + #c084fc 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── Glow effect ── */ +.glow-accent { + box-shadow: + 0 0 20px var(--accent-glow), + 0 0 60px rgba(99, 102, 241, 0.08); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fb796d..d59b38b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,7 +18,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: 'Prep', - description: 'Prep for interviews and practice', + description: 'Interview preparation platform for developers', }; export default async function RootLayout({ diff --git a/src/components/ambient-background/ambient-background.tsx b/src/components/ambient-background/ambient-background.tsx index bef1f56..c0d78e6 100644 --- a/src/components/ambient-background/ambient-background.tsx +++ b/src/components/ambient-background/ambient-background.tsx @@ -1,7 +1,5 @@ 'use client'; -import Image from 'next/image'; - export function AmbientBackground() { return (
- +
+
+
+ {/* Noise texture overlay for depth */}
diff --git a/src/components/code-block/code-block.tsx b/src/components/code-block/code-block.tsx index c83e946..7a84fa1 100644 --- a/src/components/code-block/code-block.tsx +++ b/src/components/code-block/code-block.tsx @@ -3,7 +3,6 @@ import { coldarkDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { parseHighlightLines } from '@/helpers/parse-highlight-lines'; import type { CodeBlockProps } from '@/types'; -// Move regex patterns to top level for better performance const COMMENT_REGEX = /^\/\*\s*\w+\s*\*\/\s*\n?/; const LEADING_SPACES_REGEX = /^\s+/; const TRAILING_SPACES_REGEX = /\s+$/; @@ -45,7 +44,6 @@ export const CodeBlock = ({ const highlightedLines = parseHighlightLines(highlightLines); const highlightedLinesEnd = parseHighlightLines(highlightLinesEnd); - // Line props function for highlighting const getLineProps = (lineNumber: number) => { const isHighlighted = highlightedLines.includes(lineNumber); const isHighlightedEnd = highlightedLinesEnd.includes(lineNumber); @@ -54,12 +52,11 @@ export const CodeBlock = ({ let borderLeft = 'none'; if (isHighlightedEnd) { - backgroundColor = 'rgba(34, 197, 94, 0.15)'; // Green for end lines - borderLeft = '3px solid rgb(34, 197, 94)'; + backgroundColor = 'rgba(52, 211, 153, 0.12)'; + borderLeft = '3px solid rgb(52, 211, 153)'; } else if (isHighlighted) { - // Yellow accent for highlighted lines - backgroundColor = 'rgba(234, 179, 8, 0.18)'; - borderLeft = '3px solid rgb(234, 179, 8)'; + backgroundColor = 'rgba(129, 140, 248, 0.15)'; + borderLeft = '3px solid rgb(129, 140, 248)'; } return { @@ -76,16 +73,16 @@ export const CodeBlock = ({ }; return ( -
+
{comment && ( -
+
{`/* ${comment} */`}
)} +
= { full: '100vw', }; +const VALID_PRESETS = new Set(Object.keys(presetToMaxWidth)); +const STORAGE_KEY = 'prep:content-width'; +const COOKIE_KEY = 'prep-content-width'; + +function readPersistedWidth(): WidthPreset | null { + if (typeof window === 'undefined') return null; + try { + const fromStorage = window.localStorage.getItem(STORAGE_KEY); + if (fromStorage && VALID_PRESETS.has(fromStorage)) { + return fromStorage as WidthPreset; + } + } catch { + /* ignore */ + } + const attr = document.documentElement.dataset.contentWidth; + if (attr && VALID_PRESETS.has(attr)) { + return attr as WidthPreset; + } + return null; +} + export function PageContainer({ children, className = '', initialWidth = 'comfortable', allowWidthToggle = true, }: PageContainerProps) { - const storageKey = 'prep:content-width'; - const [width, setWidth] = useState(() => { - if (typeof document !== 'undefined') { - const attr = document.documentElement.dataset.contentWidth as - | WidthPreset - | undefined; - if (attr && attr in presetToMaxWidth) { - return attr; - } - } - return initialWidth; - }); - - const [headerHeight, setHeaderHeight] = useState(120); + const [width, setWidth] = useState(initialWidth); + const [hydrated, setHydrated] = useState(false); + // Sync with persisted value once on mount (intentionally ignoring width to avoid re-running) useEffect(() => { - if (typeof window === 'undefined') { - return; - } - const header = document.getElementById('page-header'); - if (!header) { - return; + const persisted = readPersistedWidth(); + if (persisted) { + setWidth(persisted); + document.documentElement.dataset.contentWidth = persisted; } - - const update = () => setHeaderHeight(header.getBoundingClientRect().height); - update(); - const observer = new ResizeObserver(update); - observer.observe(header); - return () => { - observer.disconnect(); - }; + setHydrated(true); + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect }, []); const applyWidthPreference = (next: WidthPreset) => { setWidth(next); try { if (typeof window !== 'undefined') { - window.localStorage.setItem(storageKey, next); + window.localStorage.setItem(STORAGE_KEY, next); } document.documentElement.dataset.contentWidth = next; if ('cookieStore' in globalThis) { @@ -72,18 +74,18 @@ export function PageContainer({ if (cookieStore) { cookieStore .set({ - name: 'prep-content-width', + name: COOKIE_KEY, value: next, path: '/', expires, }) .catch(() => { - /* ignore Cookie Store failures */ + /* Cookie Store write failure is non-critical */ }); } } } catch { - /* ignore SSR/storage write errors */ + /* ignore */ } }; @@ -97,14 +99,12 @@ export function PageContainer({ return (
- {allowWidthToggle && ( + {allowWidthToggle && hydrated && ( )} -
{children}
); diff --git a/src/components/notes-area/notes-area.tsx b/src/components/notes-area/notes-area.tsx index 6644a05..e0c9370 100644 --- a/src/components/notes-area/notes-area.tsx +++ b/src/components/notes-area/notes-area.tsx @@ -6,9 +6,9 @@ export const NotesArea = ({ }: NotesAreaProps) => { return (
-

{placeholder}

+

{placeholder}

); }; diff --git a/src/components/page-header/page-header.tsx b/src/components/page-header/page-header.tsx index 219d2b2..df9a13b 100644 --- a/src/components/page-header/page-header.tsx +++ b/src/components/page-header/page-header.tsx @@ -11,11 +11,6 @@ export const PageHeader = ({ title, topicHome, }: PageHeaderProps) => { - type MediaQueryListWithLegacy = MediaQueryList & { - addListener: (listener: (e: MediaQueryListEvent) => void) => void; - removeListener: (listener: (e: MediaQueryListEvent) => void) => void; - }; - const router = useRouter(); const [isScrolled, setIsScrolled] = useState(false); const [isInitialLoad, setIsInitialLoad] = useState(true); @@ -28,7 +23,6 @@ export const PageHeader = ({ const isHiddenOnMobileRef = useRef(false); const lastCssHeaderHeight = useRef(null); - // Threshold constants for mobile hysteresis const JITTER_PX = 5; const HIDE_THRESHOLD_PX = 24; const SHOW_THRESHOLD_PX = 64; @@ -84,13 +78,11 @@ export const PageHeader = ({ resetNonMobileState(); return; } - const absDelta = Math.abs(delta); if (absDelta <= JITTER_PX) { maybeResetAtTop(currentScrollY); return; } - if (delta > 0) { accumulateDown(delta); maybeHideOnMobile(currentScrollY); @@ -98,7 +90,6 @@ export const PageHeader = ({ accumulateUp(delta); maybeShowOnMobile(); } - maybeResetAtTop(currentScrollY); }, [ @@ -111,7 +102,6 @@ export const PageHeader = ({ ] ); - // Track mobile breakpoint to enable full hide behavior on small screens useEffect(() => { if (typeof window === 'undefined') return; const mq: MediaQueryList = window.matchMedia('(max-width: 639px)'); @@ -120,22 +110,12 @@ export const PageHeader = ({ setIsMobileScreen(mq.matches); }; setMobileFlag(); - const hasModernListener = typeof mq.addEventListener === 'function'; - if (hasModernListener) { - mq.addEventListener('change', setMobileFlag); - } else { - (mq as MediaQueryListWithLegacy).addListener(setMobileFlag); - } + mq.addEventListener('change', setMobileFlag); return () => { - if (hasModernListener) { - mq.removeEventListener('change', setMobileFlag); - } else { - (mq as MediaQueryListWithLegacy).removeListener(setMobileFlag); - } + mq.removeEventListener('change', setMobileFlag); }; }, []); - // Scroll listener with rAF batching useEffect(() => { if (typeof window === 'undefined') return; let rafId: number | null = null; @@ -145,7 +125,6 @@ export const PageHeader = ({ rafId = null; const currentScrollY = Math.max(0, window.scrollY); const delta = currentScrollY - lastScrollY.current; - // Avoid toggling collapse state on mobile to reduce flicker; rely on full-hide if (!isMobile.current) { setIsScrolled(currentScrollY > 20 && delta > 0); } @@ -153,7 +132,6 @@ export const PageHeader = ({ lastScrollY.current = currentScrollY; }); }; - setIsInitialLoad(false); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); @@ -163,18 +141,14 @@ export const PageHeader = ({ }; }, [handleMobileScroll]); - // Expose current header height as a CSS variable for other components useEffect(() => { - if (typeof document === 'undefined') { - return; - } + if (typeof document === 'undefined') return; const expandedHeight = '128px'; const collapsedHeight = '72px'; let current: string; if (isHiddenOnMobile) { current = '0px'; } else if (isMobileScreen) { - // On mobile, when visible, keep the header at full height for clear tap targets current = expandedHeight; } else if (isInitialLoad || !isScrolled) { current = expandedHeight; @@ -206,7 +180,6 @@ export const PageHeader = ({ style={{ willChange: 'height', overflow: 'hidden', - // Remove bottom border when fully hidden on mobile to avoid a 1px line borderBottomWidth: isHiddenOnMobile ? 0 : 1, transform: 'translateZ(0)', backfaceVisibility: 'hidden', @@ -217,11 +190,11 @@ export const PageHeader = ({ ease: 'easeOut', }} > -
+
@@ -230,7 +203,7 @@ export const PageHeader = ({ {(isInitialLoad || !isScrolled) && (
-
+
{topicHome && ( )}
diff --git a/src/components/section-card/section-card.tsx b/src/components/section-card/section-card.tsx index 26e0617..01bb7fb 100644 --- a/src/components/section-card/section-card.tsx +++ b/src/components/section-card/section-card.tsx @@ -5,16 +5,18 @@ export const SectionCard = ({ title, children }: SectionCardProps) => { const id = slugify(title); return (

- {title} + {title}

-
{children}
+
+ {children} +
); }; diff --git a/src/components/table-of-contents/table-of-contents.tsx b/src/components/table-of-contents/table-of-contents.tsx index 8bde15c..cdeb2fa 100644 --- a/src/components/table-of-contents/table-of-contents.tsx +++ b/src/components/table-of-contents/table-of-contents.tsx @@ -1,38 +1,32 @@ 'use client'; -import { motion } from 'framer-motion'; -import { Pin, PinOff } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { List, Pin, PinOff, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { TocHeading, TocItem } from '@/types'; export const TableOfContents = () => { const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [pinned, setPinned] = useState(false); - const [headerHeight, setHeaderHeight] = useState(0); + const [activeId, setActiveId] = useState(''); const [isLoaded, setIsLoaded] = useState(false); - const touchStartRef = useRef(null); - const openRef = useRef(open); - const pinnedRef = useRef(pinned); + const navRef = useRef(null); + const observerRef = useRef(null); + // Build TOC from headings useEffect(() => { const getHeadingLevel = (tagName: string): number => { - if (tagName === 'H2') { - return 2; - } - if (tagName === 'H3') { - return 3; - } + if (tagName === 'H2') return 2; + if (tagName === 'H3') return 3; return 4; }; - const createTocItem = (el: HTMLElement): TocHeading => { - return { - id: el.id, - text: el.textContent || '', - level: getHeadingLevel(el.tagName), - }; - }; + const createTocItem = (el: HTMLElement): TocHeading => ({ + id: el.id, + text: el.textContent || '', + level: getHeadingLevel(el.tagName), + }); const updateTOC = () => { const headings = Array.from( @@ -40,10 +34,8 @@ export const TableOfContents = () => { ); const mapped: TocItem[] = []; - for (const el of headings) { const item = createTocItem(el); - if (item.level === 2) { mapped.push({ id: item.id, text: item.text, children: [] }); } else { @@ -57,29 +49,63 @@ export const TableOfContents = () => { setIsLoaded(true); }; - // Initial update with delay to ensure content is rendered const timeoutId = setTimeout(updateTOC, 0); + return () => clearTimeout(timeoutId); + }, []); - const header = document.getElementById('page-header'); - let resizeObserver: ResizeObserver | null = null; - let updateHeight: (() => void) | null = null; + // Active section tracking via IntersectionObserver + useEffect(() => { + if (!isLoaded || items.length === 0) return; + + const allIds = items.flatMap((item) => [ + item.id, + ...item.children.map((c) => c.id), + ]); + + const headingElements = allIds + .map((id) => document.getElementById(id)) + .filter(Boolean) as HTMLElement[]; + + if (headingElements.length === 0) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { + rootMargin: '-20% 0px -70% 0px', + threshold: 0, + } + ); - if (header) { - updateHeight = () => - setHeaderHeight(header.getBoundingClientRect().height); - updateHeight(); - resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(header); + for (const el of headingElements) { + observerRef.current.observe(el); } return () => { - clearTimeout(timeoutId); - if (resizeObserver) { - resizeObserver.disconnect(); + observerRef.current?.disconnect(); + }; + }, [isLoaded, items]); + + // Close on click outside (using ref, not fragile query) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (pinned) return; + if (!open) return; + if (navRef.current && !navRef.current.contains(event.target as Node)) { + setOpen(false); } }; - }, []); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open, pinned]); + // Mobile swipe to open + const touchStartRef = useRef(null); useEffect(() => { const onTouchStart = (e: TouchEvent) => { if (window.innerWidth < 768) { @@ -103,183 +129,132 @@ export const TableOfContents = () => { }; }, []); - // Also open on single tap anywhere near the left edge on mobile + // Disable pinning on mobile useEffect(() => { - const onTouchTap = (e: TouchEvent) => { - if (window.innerWidth >= 768) return; - if (e.touches.length !== 1) return; - const x = e.touches[0].clientX; - if (x < 30) { - setOpen(true); - } + const mq = window.matchMedia('(max-width: 767px)'); + const handleChange = () => { + if (mq.matches && pinned) setPinned(false); }; - window.addEventListener('touchstart', onTouchTap, { passive: true }); - return () => { - window.removeEventListener('touchstart', onTouchTap); - }; - }, []); - - const handleMouseLeave = () => { - if (!pinned) { - setOpen(false); - } - }; - - const handleLinkClick = (e: React.MouseEvent) => { - if (!pinned) { - setOpen(false); - } + handleChange(); + mq.addEventListener('change', handleChange); + return () => mq.removeEventListener('change', handleChange); + }, [pinned]); - // Prevent default navigation and use CSS scroll-margin/scroll-padding - e.preventDefault(); - const href = e.currentTarget.getAttribute('href'); - if (href?.startsWith('#')) { - const targetId = href.substring(1); - const targetElement = document.getElementById(targetId); - if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); - // Update the address bar without triggering a jump - if ( - typeof history !== 'undefined' && - typeof history.replaceState === 'function' - ) { + const handleLinkClick = useCallback( + (e: React.MouseEvent) => { + if (!pinned) setOpen(false); + e.preventDefault(); + const href = e.currentTarget.getAttribute('href'); + if (href?.startsWith('#')) { + const targetId = href.substring(1); + const targetElement = document.getElementById(targetId); + if (targetElement) { + targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); history.replaceState(null, '', href); } } - } - }; + }, + [pinned] + ); - const handleTriggerClick = () => { - setOpen(!open); - }; + const isActive = (id: string) => activeId === id; - // Handle click outside on mobile and always unpin when mobile - useEffect(() => { - openRef.current = open; - pinnedRef.current = pinned; - }, [open, pinned]); + const isParentActive = (section: TocItem) => + activeId === section.id || section.children.some((c) => c.id === activeId); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (window.innerWidth >= 768) return; - // On mobile ensure menu is unpinned - if (pinnedRef.current) { - setPinned(false); - } - if (!openRef.current || pinnedRef.current) { - return; - } - const target = event.target as Element; - const tocNav = document.querySelector('nav[style*="top:"]'); - const tocContent = tocNav?.querySelector('.scrollbar-hide'); - - if (tocContent && !tocContent.contains(target)) { - setOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - // Don't render until content is loaded to prevent flickering - if (!isLoaded) { - return null; - } + if (!isLoaded) return null; return ( - + + )} + + ); }; diff --git a/src/components/typography/callout.tsx b/src/components/typography/callout.tsx index bdc7fd0..eec6014 100644 --- a/src/components/typography/callout.tsx +++ b/src/components/typography/callout.tsx @@ -3,7 +3,7 @@ import type { CalloutProps } from '@/types'; export const Callout = ({ children, className = '' }: CalloutProps) => { return (

{children}

diff --git a/src/components/typography/code-span.tsx b/src/components/typography/code-span.tsx index 13778e9..dc46403 100644 --- a/src/components/typography/code-span.tsx +++ b/src/components/typography/code-span.tsx @@ -9,7 +9,7 @@ export const CodeSpan = ({ return ( {children} diff --git a/src/components/typography/header.tsx b/src/components/typography/header.tsx index 2d7e70b..6764abb 100644 --- a/src/components/typography/header.tsx +++ b/src/components/typography/header.tsx @@ -6,7 +6,7 @@ export const Header = ({ children, className = '', id }: HeaderProps) => { const headerId = id || slugify(text); return (

{children} diff --git a/src/components/typography/subheader.tsx b/src/components/typography/subheader.tsx index 0dc75bf..e892899 100644 --- a/src/components/typography/subheader.tsx +++ b/src/components/typography/subheader.tsx @@ -7,7 +7,7 @@ export const Subheader = ({ children, className = '', id }: SubheaderProps) => { return (

{children} diff --git a/src/components/typography/text.tsx b/src/components/typography/text.tsx index f7a06bc..9367bbf 100644 --- a/src/components/typography/text.tsx +++ b/src/components/typography/text.tsx @@ -5,7 +5,10 @@ export const Text = ({ className = '', variant = 'default', }: TextProps) => { - const variantClass = variant === 'muted' ? 'text-zinc-300' : 'text-white'; + const variantClass = + variant === 'muted' + ? 'text-[var(--foreground-muted)]' + : 'text-[var(--foreground)]'; return

{children}

; }; diff --git a/src/components/width-switcher/width-switcher.tsx b/src/components/width-switcher/width-switcher.tsx index d478462..dc550a0 100644 --- a/src/components/width-switcher/width-switcher.tsx +++ b/src/components/width-switcher/width-switcher.tsx @@ -1,61 +1,49 @@ 'use client'; -import { useEffect, useState } from 'react'; import type { WidthPreset } from '@/types'; type WidthSwitcherProps = { currentWidth: WidthPreset; onChangeWidth: (next: WidthPreset) => void; - headerHeightFallback?: number; }; +const presets: { key: WidthPreset; icon: string }[] = [ + { key: 'narrow', icon: '┃' }, + { key: 'comfortable', icon: '┃┃' }, + { key: 'wide', icon: '┃┃┃' }, + { key: 'full', icon: '┃┃┃┃' }, +]; + export function WidthSwitcher({ currentWidth, onChangeWidth, - headerHeightFallback = 120, }: WidthSwitcherProps) { - // Track live header height via ResizeObserver as a fallback when the CSS var is not yet set - const [headerHeight, setHeaderHeight] = - useState(headerHeightFallback); - - useEffect(() => { - if (typeof window === 'undefined') return; - const header = document.getElementById('page-header'); - if (!header) return; - const update = () => setHeaderHeight(header.getBoundingClientRect().height); - update(); - const observer = new ResizeObserver(update); - observer.observe(header); - return () => observer.disconnect(); - }, []); - return (
-
- {(['narrow', 'comfortable', 'wide', 'full'] as WidthPreset[]).map( - (preset) => ( - - ) - )} +
+ {presets.map((preset) => ( + + ))}
);