- {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 (
-
+ {sections.map((section, index) => (
+ router.push(section.href)}
+ onClick={() => !section.inProgress && router.push(section.href)}
type='button'
+ variants={cardVariant}
+ whileHover={section.inProgress ? {} : { scale: 1.01 }}
+ whileTap={section.inProgress ? {} : { scale: 0.99 }}
>
-
-
- {section.title}{' '}
- {section.inProgress && (
- (in progress)
- )}
-
-
- {section.description}
-
+ {/* Status badge */}
+
+
+ {String(index + 1).padStart(2, '0')}
+
+ {section.inProgress ? (
+
+
+ Coming soon
+
+ ) : (
+
+
+ Available
+
+ )}
-
- Learn more →
-
-
- );
- })}
+
+
+ {section.title}
+
+
+ {section.description}
+
+
+ {!section.inProgress && (
+
+ )}
+
+ ))}
+
);
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 && (
router.push(topicHome)}
type='button'
>
-
+
)}
router.push('/')}
type='button'
>
-
+
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 (
-
-
- {/* Hover/edge affordance – no explicit button */}
-
setOpen(true)}
- onTouchStart={handleTriggerClick}
- style={{
- height: `calc(100dvh - var(--page-header-height, ${headerHeight}px))`,
- }}
- />
+ <>
+ {/* Toggle button - visible on mobile and desktop when TOC is closed */}
+
setOpen(true)}
+ style={{
+ top: 'calc(var(--page-header-height, 128px) + 12px)',
+ transition: 'top 0.28s ease-in-out, opacity 0.2s ease',
+ }}
+ type='button'
+ >
+
+
- {/* Preview hint when closed - same height as open state */}
- {!open && (
+ {/* Backdrop on mobile */}
+
+ {open && (
{
+ if (!pinned) setOpen(false);
}}
- transition={{ type: 'tween', ease: 'easeInOut', duration: 0.35 }}
- >
- {/* Soft glow hint */}
-
-
-
+ />
)}
+
- {/* Main menu */}
-
setOpen(true)}
- onMouseEnter={() => setOpen(true)}
- onMouseLeave={handleMouseLeave}
- style={{
- scrollbarWidth: 'none',
- msOverflowStyle: 'none',
- width: 'fit-content',
- height: `calc(100dvh - var(--page-header-height, ${headerHeight}px))`,
- }}
- transition={{
- type: 'tween',
- ease: 'easeInOut',
- duration: 0.3,
- }}
- >
-
- {/* Pin icon aligned with first section */}
-
setPinned(!pinned)}
- type='button'
- >
- {pinned ? (
-
- ) : (
-
- )}
-
+ {/* TOC panel */}
+
+ {open && (
+
+ {/* Header row */}
+
+
+ Contents
+
+
+
setPinned(!pinned)}
+ type='button'
+ >
+ {pinned ? : }
+
+
setOpen(false)}
+ type='button'
+ >
+
+
+
+
-
-
-
-
+
+ )}
+
+ >
);
};
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/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) => (
-
onChangeWidth(preset)}
- type='button'
- >
- {preset}
-
- )
- )}
+
+ {presets.map((preset) => (
+ onChangeWidth(preset.key)}
+ type='button'
+ >
+ {preset.key}
+
+ ))}
);