feat(mobile): redesign menu with sidebar integration

- Move mobile nav controls to left side (hamburger, search, theme)
- Add sidebar TOC to mobile menu when page has sidebar layout
- Hide desktop sidebar on mobile since accessible via hamburger
- Standardize mobile menu typography with CSS variables
- Use font-family inherit for consistent fonts across menu elements
This commit is contained in:
Wayne Sutton
2025-12-23 14:01:37 -08:00
parent 3b9f140fe1
commit bdf5378a9a
17 changed files with 1447 additions and 106 deletions

View File

@@ -6,6 +6,7 @@ import Blog from "./pages/Blog";
import Write from "./pages/Write";
import Layout from "./components/Layout";
import { usePageTracking } from "./hooks/usePageTracking";
import { SidebarProvider } from "./context/SidebarContext";
import siteConfig from "./config/siteConfig";
function App() {
@@ -19,18 +20,20 @@ function App() {
}
return (
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} />
)}
{/* Catch-all for post/page slugs - must be last */}
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>
<SidebarProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} />
)}
{/* Catch-all for post/page slugs - must be last */}
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>
</SidebarProvider>
);
}

View File

@@ -405,6 +405,14 @@ export default function BlogPost({ content }: BlogPostProps) {
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
{children}
</h6>
);
},
ul({ children }) {
return <ul className="blog-ul">{children}</ul>;
},

View File

@@ -7,6 +7,7 @@ import ThemeToggle from "./ThemeToggle";
import SearchModal from "./SearchModal";
import MobileMenu, { HamburgerButton } from "./MobileMenu";
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
import { useSidebarOptional } from "../context/SidebarContext";
import siteConfig from "../config/siteConfig";
// Scroll-to-top configuration - enabled by default
@@ -28,6 +29,11 @@ export default function Layout({ children }: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation();
// Get sidebar headings from context (if available)
const sidebarContext = useSidebarOptional();
const sidebarHeadings = sidebarContext?.headings || [];
const sidebarActiveId = sidebarContext?.activeId;
// Open search modal
const openSearch = useCallback(() => {
setIsSearchOpen(true);
@@ -116,12 +122,23 @@ export default function Layout({ children }: LayoutProps) {
<div className="layout">
{/* Top navigation bar with page links, search, and theme toggle */}
<div className="top-nav">
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
<div className="mobile-menu-trigger">
<HamburgerButton
onClick={openMobileMenu}
isOpen={isMobileMenuOpen}
/>
{/* Mobile left controls: hamburger, search, theme (visible on mobile/tablet only) */}
<div className="mobile-nav-controls">
{/* Hamburger button for mobile menu */}
<HamburgerButton onClick={openMobileMenu} isOpen={isMobileMenuOpen} />
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
{/* Page navigation links (visible on desktop only) */}
@@ -138,23 +155,31 @@ export default function Layout({ children }: LayoutProps) {
))}
</nav>
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
{/* Desktop search and theme (visible on desktop only) */}
<div className="desktop-controls desktop-only">
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
</div>
{/* Mobile menu drawer */}
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
<MobileMenu
isOpen={isMobileMenuOpen}
onClose={closeMobileMenu}
sidebarHeadings={sidebarHeadings}
sidebarActiveId={sidebarActiveId}
>
{/* Page navigation links in mobile menu (same order as desktop) */}
<nav className="mobile-nav-links">
{navItems.map((item) => (
@@ -171,7 +196,11 @@ export default function Layout({ children }: LayoutProps) {
</MobileMenu>
{/* Use wider layout for stats page, normal layout for other pages */}
<main className={location.pathname === "/stats" ? "main-content-wide" : "main-content"}>
<main
className={
location.pathname === "/stats" ? "main-content-wide" : "main-content"
}
>
{children}
</main>

View File

@@ -1,10 +1,14 @@
import { ReactNode, useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { Heading } from "../utils/extractHeadings";
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
sidebarHeadings?: Heading[];
sidebarActiveId?: string;
}
/**
@@ -16,8 +20,11 @@ export default function MobileMenu({
isOpen,
onClose,
children,
sidebarHeadings = [],
sidebarActiveId,
}: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const hasSidebar = sidebarHeadings.length > 0;
// Handle escape key to close menu
useEffect(() => {
@@ -56,6 +63,30 @@ export default function MobileMenu({
}
};
// Navigate to heading and close menu
const navigateToHeading = useCallback(
(id: string) => {
const element = document.getElementById(id);
if (element) {
// Close menu first
onClose();
// Scroll after menu closes
setTimeout(() => {
const headerOffset = 80;
const elementTop =
element.getBoundingClientRect().top + window.scrollY;
const targetPosition = elementTop - headerOffset;
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: "smooth",
});
window.history.pushState(null, "", `#${id}`);
}, 100);
}
},
[onClose],
);
return (
<>
{/* Backdrop overlay */}
@@ -102,7 +133,30 @@ export default function MobileMenu({
</div>
{/* Menu content */}
<div className="mobile-menu-content">{children}</div>
<div className="mobile-menu-content">
{children}
{/* Table of contents from sidebar (if page has sidebar) */}
{hasSidebar && (
<div className="mobile-menu-toc">
<div className="mobile-menu-toc-title">On this page</div>
<nav className="mobile-menu-toc-links">
{sidebarHeadings.map((heading) => (
<button
key={heading.id}
onClick={() => navigateToHeading(heading.id)}
className={`mobile-menu-toc-link mobile-menu-toc-level-${heading.level} ${
sidebarActiveId === heading.id ? "active" : ""
}`}
>
<ChevronRight size={12} className="mobile-menu-toc-icon" />
{heading.text}
</button>
))}
</nav>
</div>
)}
</div>
</div>
</>
);

View File

@@ -1,27 +1,318 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { Heading } from "../utils/extractHeadings";
import { ChevronRight } from "lucide-react";
interface PageSidebarProps {
headings: Heading[];
activeId?: string;
}
interface HeadingNode extends Heading {
children: HeadingNode[];
}
// Build a tree structure from flat headings array
function buildHeadingTree(headings: Heading[]): HeadingNode[] {
const tree: HeadingNode[] = [];
const stack: HeadingNode[] = [];
headings.forEach((heading) => {
const node: HeadingNode = { ...heading, children: [] };
// Pop stack until we find the parent (heading with lower level)
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
if (stack.length === 0) {
// Root level heading
tree.push(node);
} else {
// Child of the last heading in stack
stack[stack.length - 1].children.push(node);
}
stack.push(node);
});
return tree;
}
// Load expanded state from localStorage
function loadExpandedState(headings: Heading[]): Set<string> {
const stored = localStorage.getItem("page-sidebar-expanded-state");
if (stored) {
try {
const storedIds = new Set(JSON.parse(stored));
// Only return stored IDs that still exist in headings
return new Set(
headings.filter((h) => storedIds.has(h.id)).map((h) => h.id),
);
} catch {
// If parse fails, return empty (collapsed)
}
}
// Default: all headings collapsed
return new Set();
}
// Save expanded state to localStorage
function saveExpandedState(expanded: Set<string>): void {
localStorage.setItem(
"page-sidebar-expanded-state",
JSON.stringify(Array.from(expanded)),
);
}
// Get absolute top position of an element
function getElementTop(element: HTMLElement): number {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY;
}
// Render a heading node recursively
function HeadingItem({
node,
activeId,
expanded,
onToggle,
onNavigate,
depth = 0,
}: {
node: HeadingNode;
activeId?: string;
expanded: Set<string>;
onToggle: (id: string) => void;
onNavigate: (id: string) => void;
depth?: number;
}) {
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id);
const isActive = activeId === node.id;
return (
<li className="page-sidebar-item">
<div className="page-sidebar-item-wrapper">
{hasChildren && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(node.id);
}}
onMouseDown={(e) => {
// Prevent link click when clicking button
e.preventDefault();
}}
className={`page-sidebar-expand ${isExpanded ? "expanded" : ""}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
<ChevronRight size={14} />
</button>
)}
{!hasChildren && <span className="page-sidebar-spacer" />}
<a
href={`#${node.id}`}
onClick={(e) => {
e.preventDefault();
onNavigate(node.id);
}}
className={`page-sidebar-link page-sidebar-item-level-${node.level} ${
isActive ? "active" : ""
}`}
>
{node.text}
</a>
</div>
{hasChildren && isExpanded && (
<ul className="page-sidebar-sublist">
{node.children.map((child) => (
<HeadingItem
key={child.id}
node={child}
activeId={activeId}
expanded={expanded}
onToggle={onToggle}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
const [activeHeading, setActiveHeading] = useState<string | undefined>(activeId);
const [activeHeading, setActiveHeading] = useState<string | undefined>(
activeId,
);
const [expanded, setExpanded] = useState<Set<string>>(() =>
loadExpandedState(headings),
);
// Track if we're currently navigating to prevent scroll handler interference
const isNavigatingRef = useRef(false);
// Build tree structure from headings
const headingTree = useMemo(() => buildHeadingTree(headings), [headings]);
// Get all heading IDs for scroll tracking
const allHeadingIds = useMemo(() => headings.map((h) => h.id), [headings]);
// Create a map for quick heading ID validation
const headingIdSet = useMemo(() => new Set(allHeadingIds), [allHeadingIds]);
// Find path to a heading ID in the tree (for expanding ancestors)
const findPathToId = useCallback(
(
nodes: HeadingNode[],
targetId: string,
path: HeadingNode[] = [],
): HeadingNode[] | null => {
for (const node of nodes) {
const currentPath = [...path, node];
if (node.id === targetId) {
return currentPath;
}
const found = findPathToId(node.children, targetId, currentPath);
if (found) return found;
}
return null;
},
[],
);
// Expand ancestors to make a heading visible in sidebar
const expandAncestors = useCallback(
(targetId: string) => {
const path = findPathToId(headingTree, targetId);
if (path && path.length > 1) {
const newExpanded = new Set(expanded);
let changed = false;
// Expand all ancestors (not the target itself)
path.slice(0, -1).forEach((node) => {
if (!newExpanded.has(node.id)) {
newExpanded.add(node.id);
changed = true;
}
});
if (changed) {
setExpanded(newExpanded);
saveExpandedState(newExpanded);
}
}
},
[expanded, headingTree, findPathToId],
);
// Toggle expand/collapse
const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
saveExpandedState(newExpanded);
return newExpanded;
});
}, []);
// Navigate to heading - scroll to element and update state
const navigateToHeading = useCallback(
(id: string) => {
// Expand ancestors first so sidebar shows the target
expandAncestors(id);
// Use requestAnimationFrame to ensure DOM updates are complete
requestAnimationFrame(() => {
const element = document.getElementById(id);
if (!element) {
return;
}
// Set flag to prevent scroll handler from changing active heading
isNavigatingRef.current = true;
// Calculate scroll position with offset for fixed header (80px)
const headerOffset = 80;
const elementTop = getElementTop(element);
const targetPosition = elementTop - headerOffset;
// Scroll to the target position
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: "smooth",
});
// Update URL hash
window.history.pushState(null, "", `#${id}`);
// Update active heading
setActiveHeading(id);
// Reset navigation flag after scroll completes
setTimeout(() => {
isNavigatingRef.current = false;
}, 1000);
});
},
[expandAncestors],
);
// Handle initial URL hash on page load
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && headingIdSet.has(hash)) {
// Delay to ensure DOM is ready and headings are rendered
const timeoutId = setTimeout(() => {
navigateToHeading(hash);
}, 200);
return () => clearTimeout(timeoutId);
}
}, [headingIdSet, navigateToHeading]);
// Handle hash changes (back/forward navigation)
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && headingIdSet.has(hash)) {
navigateToHeading(hash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [headingIdSet, navigateToHeading]);
// Update active heading on scroll
useEffect(() => {
if (headings.length === 0) return;
if (allHeadingIds.length === 0) return;
const handleScroll = () => {
const scrollPosition = window.scrollY + 100; // Offset for header
// Don't update if we're in the middle of navigating
if (isNavigatingRef.current) return;
const scrollPosition = window.scrollY + 120; // Offset for header
// Find the heading that's currently in view
for (let i = headings.length - 1; i >= 0; i--) {
const element = document.getElementById(headings[i].id);
if (element && element.offsetTop <= scrollPosition) {
setActiveHeading(headings[i].id);
break;
for (let i = allHeadingIds.length - 1; i >= 0; i--) {
const element = document.getElementById(allHeadingIds[i]);
if (element) {
const elementTop = getElementTop(element);
if (elementTop <= scrollPosition) {
const newActiveId = allHeadingIds[i];
setActiveHeading((prev) => {
// Only update if different - don't expand ancestors on scroll
// User can manually collapse/expand sections
return prev !== newActiveId ? newActiveId : prev;
});
break;
}
}
}
};
@@ -30,39 +321,31 @@ export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
handleScroll(); // Initial check
return () => window.removeEventListener("scroll", handleScroll);
}, [headings]);
}, [allHeadingIds]);
// Auto-expand to show active heading from props
useEffect(() => {
if (activeId) {
expandAncestors(activeId);
}
}, [activeId, expandAncestors]);
if (headings.length === 0) return null;
return (
<nav className="page-sidebar">
<ul className="page-sidebar-list">
{headings.map((heading) => (
<li
key={heading.id}
className={`page-sidebar-item page-sidebar-item-level-${heading.level} ${
activeHeading === heading.id ? "active" : ""
}`}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL without scrolling
window.history.pushState(null, "", `#${heading.id}`);
}
}}
className="page-sidebar-link"
>
{heading.text}
</a>
</li>
{headingTree.map((node) => (
<HeadingItem
key={node.id}
node={node}
activeId={activeHeading}
expanded={expanded}
onToggle={toggleExpand}
onNavigate={navigateToHeading}
/>
))}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,38 @@
import { createContext, useContext, useState, ReactNode } from "react";
import { Heading } from "../utils/extractHeadings";
interface SidebarContextType {
headings: Heading[];
setHeadings: (headings: Heading[]) => void;
activeId: string | undefined;
setActiveId: (id: string | undefined) => void;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string | undefined>(undefined);
return (
<SidebarContext.Provider
value={{ headings, setHeadings, activeId, setActiveId }}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}
// Optional hook that returns undefined if not within provider (for Layout)
export function useSidebarOptional() {
return useContext(SidebarContext);
}

View File

@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar";
import { extractHeadings } from "../utils/extractHeadings";
import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Twitter, Rss } from "lucide-react";
import { useState, useEffect } from "react";
@@ -18,6 +19,7 @@ export default function Post() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const location = useLocation();
const { setHeadings, setActiveId } = useSidebar();
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
@@ -40,6 +42,33 @@ export default function Post() {
return () => clearTimeout(timer);
}, [location.hash, page, post]);
// Update sidebar context with headings for mobile menu
useEffect(() => {
// Extract headings for pages with sidebar layout
if (page && page.layout === "sidebar") {
const pageHeadings = extractHeadings(page.content);
setHeadings(pageHeadings);
setActiveId(location.hash.slice(1) || undefined);
}
// Extract headings for posts with sidebar layout
else if (post && post.layout === "sidebar") {
const postHeadings = extractHeadings(post.content);
setHeadings(postHeadings);
setActiveId(location.hash.slice(1) || undefined);
}
// Clear headings when no sidebar
else if (page !== undefined || post !== undefined) {
setHeadings([]);
setActiveId(undefined);
}
// Cleanup: clear headings when leaving page
return () => {
setHeadings([]);
setActiveId(undefined);
};
}, [page, post, location.hash, setHeadings, setActiveId]);
// Update page title for static pages
useEffect(() => {
if (!page) return;

View File

@@ -61,6 +61,7 @@
--font-size-blog-h3: var(--font-size-xl);
--font-size-blog-h4: var(--font-size-base);
--font-size-blog-h5: var(--font-size-md);
--font-size-blog-h6: var(--font-size-sm);
/* Table font sizes */
--font-size-table: var(--font-size-md);
@@ -114,6 +115,8 @@
/* Mobile menu font sizes */
--font-size-mobile-nav-link: var(--font-size-base);
--font-size-mobile-home-link: var(--font-size-md);
--font-size-mobile-toc-title: var(--font-size-2xs);
--font-size-mobile-toc-link: var(--font-size-sm);
/* Copy dropdown font sizes */
--font-size-copy-trigger: var(--font-size-sm);
@@ -327,6 +330,20 @@ body {
border-radius: 8px;
}
/* Mobile nav controls (search, theme, hamburger) - shown on left on mobile */
.mobile-nav-controls {
display: none;
align-items: center;
gap: 4px;
}
/* Desktop controls (search, theme) - shown on right on desktop */
.desktop-controls {
display: flex;
align-items: center;
gap: 8px;
}
/* Page navigation links (About, Projects, Contact, etc.) */
.page-nav {
display: flex;
@@ -802,12 +819,54 @@ body {
padding: 0;
}
.page-sidebar-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
.page-sidebar-expand {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0;
}
.page-sidebar-expand:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.page-sidebar-expand svg {
transition: transform 0.15s ease;
}
.page-sidebar-expand.expanded svg {
transform: rotate(90deg);
}
.page-sidebar-spacer {
width: 20px;
flex-shrink: 0;
}
.page-sidebar-link {
display: block;
padding: 6px 12px;
flex: 1;
padding: 6px 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
font-family: inherit;
font-size: var(--font-size-md);
line-height: 1.5;
border-radius: 4px;
transition:
@@ -820,24 +879,35 @@ body {
background-color: var(--bg-hover);
}
.page-sidebar-item.active .page-sidebar-link {
.page-sidebar-link.active {
color: var(--text-primary);
background-color: var(--bg-hover);
font-weight: 500;
}
/* Indentation for nested headings */
.page-sidebar-item-level-1 .page-sidebar-link {
padding-left: 12px;
.page-sidebar-link.page-sidebar-item-level-1 {
font-weight: 500;
}
.page-sidebar-item-level-2 .page-sidebar-link {
padding-left: 24px;
.page-sidebar-sublist {
list-style: none;
padding: 0;
margin: 0;
margin-left: 20px;
}
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 36px;
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-2 {
font-size: var(--font-size-sm);
}
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-3 {
font-size: var(--font-size-xs);
}
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-4,
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-5,
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-6 {
font-size: var(--font-size-xs);
}
@@ -854,6 +924,11 @@ body {
gap: 24px;
}
/* Hide sidebar on mobile - it's now in the hamburger menu */
.post-sidebar-wrapper {
display: none;
}
.post-sidebar-left {
position: static;
width: 100%;
@@ -887,11 +962,27 @@ body {
white-space: nowrap;
}
.page-sidebar-item-level-1 .page-sidebar-link,
.page-sidebar-item-level-2 .page-sidebar-link,
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 8px;
padding-right: 8px;
.page-sidebar-expand {
width: 18px;
height: 18px;
}
.page-sidebar-expand svg {
width: 12px;
height: 12px;
}
.page-sidebar-spacer {
width: 18px;
}
.page-sidebar-link {
padding: 4px 6px;
font-size: var(--font-size-xs);
}
.page-sidebar-sublist {
margin-left: 16px;
}
}
@@ -992,6 +1083,13 @@ body {
line-height: 1.4;
}
.blog-h6 {
font-size: var(--font-size-blog-h6);
font-weight: 300;
margin: 16px 0 8px;
line-height: 1.4;
}
.blog-link {
color: var(--link-color);
text-decoration: underline;
@@ -1499,7 +1597,7 @@ body {
/* Responsive styles */
@media (max-width: 768px) {
.main-content {
padding: 40px 16px 16px 24px;
padding: 48px 16px 16px 24px;
}
.top-nav {
@@ -1572,6 +1670,10 @@ body {
font-size: var(--font-size-blog-h5);
}
.blog-h6 {
font-size: var(--font-size-blog-h6);
}
/* Table mobile styles */
.blog-table {
font-size: var(--font-size-table);
@@ -3441,8 +3543,8 @@ body {
Left-side drawer for mobile/tablet navigation
=========================================== */
/* Hide hamburger on desktop, show on mobile/tablet */
.mobile-menu-trigger {
/* Hide mobile controls on desktop, show on mobile/tablet */
.mobile-nav-controls {
display: none;
}
@@ -3452,9 +3554,18 @@ body {
}
@media (max-width: 768px) {
.mobile-menu-trigger {
/* Move top-nav to left side on mobile */
.top-nav {
right: auto;
left: 13px;
gap: 4px;
padding: 6px 10px;
}
.mobile-nav-controls {
display: flex !important;
align-items: center;
gap: 2px;
}
.desktop-only {
@@ -3553,7 +3664,7 @@ body {
/* Menu content area */
.mobile-menu-content {
flex: 1;
padding: 60px 24px 24px;
padding: 1px 16px 16px;
overflow-y: auto;
}
@@ -3561,17 +3672,18 @@ body {
.mobile-nav-links {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.mobile-nav-link {
display: block;
padding: 12px 16px;
padding: 8px 12px;
color: var(--text-primary);
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-nav-link);
font-weight: 500;
border-radius: 8px;
border-radius: 6px;
transition:
background-color 0.15s ease,
color 0.15s ease;
@@ -3581,9 +3693,93 @@ body {
background-color: var(--bg-hover);
}
/* Mobile menu table of contents */
.mobile-menu-toc {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.mobile-menu-toc-title {
font-family: inherit;
font-size: var(--font-size-mobile-toc-title);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 4px 12px 8px;
}
.mobile-menu-toc-links {
display: flex;
flex-direction: column;
gap: 1px;
}
.mobile-menu-toc-link {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
color: var(--text-secondary);
background: transparent;
border: none;
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-toc-link);
text-align: left;
border-radius: 4px;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mobile-menu-toc-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.mobile-menu-toc-link.active {
color: var(--text-primary);
font-weight: 500;
}
.mobile-menu-toc-icon {
flex-shrink: 0;
opacity: 0.5;
}
/* TOC level indentation and font sizes */
.mobile-menu-toc-level-1 {
padding-left: 12px;
font-size: var(--font-size-mobile-toc-link);
}
.mobile-menu-toc-level-2 {
padding-left: 20px;
font-size: var(--font-size-mobile-toc-link);
}
.mobile-menu-toc-level-3 {
padding-left: 28px;
font-size: var(--font-size-xs);
}
.mobile-menu-toc-level-4,
.mobile-menu-toc-level-5,
.mobile-menu-toc-level-6 {
padding-left: 36px;
font-size: var(--font-size-xs);
}
.mobile-menu-toc-level-5 {
padding-left: 44px;
}
.mobile-menu-toc-level-6 {
padding-left: 52px;
}
/* Menu header with home link */
.mobile-menu-header {
padding: 16px 24px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
@@ -3597,11 +3793,12 @@ body {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
padding: 8px 12px;
color: var(--text-secondary);
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-home-link);
border-radius: 8px;
border-radius: 6px;
transition:
background-color 0.15s ease,
color 0.15s ease;
@@ -3652,20 +3849,29 @@ body {
}
.mobile-menu-content {
padding: 56px 20px 20px;
padding: 1px 12px 12px;
}
.mobile-menu-footer {
padding: 12px 20px;
padding: 10px 12px;
}
.mobile-nav-link {
padding: 6px 10px;
font-size: 14px;
}
.mobile-menu-toc-link {
padding: 5px 10px;
font-size: var(--font-size-xs);
}
}
/* Desktop - hide mobile menu components */
@media (min-width: 769px) {
.mobile-menu-trigger,
.mobile-nav-controls,
.mobile-menu-backdrop,
.mobile-menu-drawer,
.hamburger-button {
.mobile-menu-drawer {
display: none !important;
}

View File

@@ -1,5 +1,5 @@
export interface Heading {
level: number; // 1, 2, or 3
level: number; // 1, 2, 3, 4, 5, or 6
text: string;
id: string;
}
@@ -14,17 +14,57 @@ function generateSlug(text: string): string {
.trim();
}
// Extract headings from markdown content
// Remove code blocks from content to avoid extracting headings from examples
function removeCodeBlocks(content: string): string {
// Remove fenced code blocks (``` or ~~~)
let result = content.replace(/```[\s\S]*?```/g, "");
result = result.replace(/~~~[\s\S]*?~~~/g, "");
// Remove indented code blocks (lines starting with 4+ spaces after a blank line)
// This is a simplified approach - we remove lines with 4+ leading spaces that aren't list items
const lines = result.split("\n");
const cleanedLines: string[] = [];
let inCodeBlock = false;
let prevLineBlank = true;
for (const line of lines) {
const isIndented = /^( |\t)/.test(line) && !line.trim().startsWith("-");
const isBlank = line.trim() === "";
if (isBlank) {
inCodeBlock = false;
prevLineBlank = true;
cleanedLines.push(line);
} else if (isIndented && prevLineBlank) {
inCodeBlock = true;
// Skip indented code block line
} else if (inCodeBlock && isIndented) {
// Skip continued indented code block line
} else {
inCodeBlock = false;
prevLineBlank = false;
cleanedLines.push(line);
}
}
return cleanedLines.join("\n");
}
// Extract headings from markdown content (supports H1-H6)
// Ignores headings inside code blocks
export function extractHeadings(content: string): Heading[] {
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
// First remove code blocks to avoid extracting headings from code examples
const cleanContent = removeCodeBlocks(content);
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings: Heading[] = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
while ((match = headingRegex.exec(cleanContent)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = generateSlug(text);
headings.push({ level, text, id });
}