feat: Added ReadingHeader sticky bar that slides in when scrolling past…

- "frontend/src/components/ReadingHeader.tsx"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S10/T01
This commit is contained in:
jlightner 2026-04-04 15:14:05 +00:00
parent 4a3bb8208a
commit 526fd0a58c
3 changed files with 167 additions and 0 deletions

View file

@ -2021,6 +2021,76 @@ a.app-footer__repo:hover {
} }
} }
/* ── Reading Header ────────────────────────────────────────────────────── */
.reading-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 60;
height: 40px;
background: var(--color-bg-surface);
border-bottom: 1px solid var(--color-border);
transform: translateY(-100%);
transition: transform 300ms ease;
display: flex;
align-items: center;
pointer-events: none;
}
.reading-header--visible {
transform: translateY(0);
pointer-events: auto;
}
.reading-header__inner {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 0;
overflow: hidden;
white-space: nowrap;
font-size: 0.8125rem;
line-height: 1;
}
.reading-header__title {
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.reading-header__sep {
color: var(--color-text-secondary);
flex-shrink: 0;
}
.reading-header__section {
color: var(--color-accent);
flex-shrink: 0;
}
@media (max-width: 600px) {
.reading-header__inner {
font-size: 0.75rem;
padding: 0 1rem;
}
.reading-header__section {
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
}
/* ── Table of Contents ────────────────────────────────────────────────────── */ /* ── Table of Contents ────────────────────────────────────────────────────── */
.technique-toc { .technique-toc {

View file

@ -0,0 +1,64 @@
/**
* Thin sticky reading header that slides in when the user scrolls past the
* technique page title bar. Shows truncated article title + current section
* name derived from the activeId scroll-spy state.
*/
import type { BodySectionV2 } from "../api";
import { slugify } from "./TableOfContents";
interface ReadingHeaderProps {
title: string;
activeId: string;
sections: BodySectionV2[];
visible: boolean;
}
/** Resolve an activeId slug back to a human-readable section or subsection name. */
function resolveActiveSection(
activeId: string,
sections: BodySectionV2[],
): string {
if (!activeId) return "";
for (const section of sections) {
const sectionSlug = slugify(section.heading);
if (activeId === sectionSlug) return section.heading;
// Check compound slug for subsections: "sectionSlug--subSlug"
if (activeId.startsWith(sectionSlug + "--")) {
for (const sub of section.subsections) {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
if (activeId === subSlug) return sub.heading;
}
}
}
return "";
}
export default function ReadingHeader({
title,
activeId,
sections,
visible,
}: ReadingHeaderProps) {
const sectionName = resolveActiveSection(activeId, sections);
return (
<div
className={`reading-header${visible ? " reading-header--visible" : ""}`}
aria-hidden={!visible}
>
<div className="reading-header__inner">
<span className="reading-header__title">{title}</span>
{sectionName && (
<>
<span className="reading-header__sep"> · </span>
<span className="reading-header__section">{sectionName}</span>
</>
)}
</div>
</div>
);
}

View file

@ -25,6 +25,7 @@ import CreatorAvatar from "../components/CreatorAvatar";
import VideoPlayer from "../components/VideoPlayer"; import VideoPlayer from "../components/VideoPlayer";
import PlayerControls from "../components/PlayerControls"; import PlayerControls from "../components/PlayerControls";
import TableOfContents, { slugify } from "../components/TableOfContents"; import TableOfContents, { slugify } from "../components/TableOfContents";
import ReadingHeader from "../components/ReadingHeader";
import { parseCitations } from "../utils/citations"; import { parseCitations } from "../utils/citations";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
import { useMediaSync } from "../hooks/useMediaSync"; import { useMediaSync } from "../hooks/useMediaSync";
@ -225,6 +226,26 @@ export default function TechniquePage() {
// --- Scroll-spy: activeId for ToC --- // --- Scroll-spy: activeId for ToC ---
const [activeId, setActiveId] = useState<string>(""); const [activeId, setActiveId] = useState<string>("");
const titleBarRef = useRef<HTMLDivElement>(null); const titleBarRef = useRef<HTMLDivElement>(null);
const [titlePastView, setTitlePastView] = useState(false);
const titleObserverRef = useRef<IntersectionObserver | null>(null);
// Callback ref for sentinel — creates observer when element mounts
const titleSentinelRef = useCallback((node: HTMLDivElement | null) => {
if (titleObserverRef.current) {
titleObserverRef.current.disconnect();
titleObserverRef.current = null;
}
if (node) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry) setTitlePastView(!entry.isIntersecting);
},
{ threshold: 0 },
);
observer.observe(node);
titleObserverRef.current = observer;
}
}, []);
// Overlay snapshot fields when viewing a historical version // Overlay snapshot fields when viewing a historical version
const isHistorical = selectedVersion !== "current" && versionDetail != null; const isHistorical = selectedVersion !== "current" && versionDetail != null;
@ -319,6 +340,15 @@ export default function TechniquePage() {
return ( return (
<article className="technique-page"> <article className="technique-page">
{/* Reading header — slides in when title bar scrolls out of view */}
{isV2 && (
<ReadingHeader
title={displayTitle}
activeId={activeId}
sections={displaySections as BodySectionV2[]}
visible={titlePastView}
/>
)}
{/* Historical version banner */} {/* Historical version banner */}
{isHistorical && ( {isHistorical && (
<div className="technique-banner technique-banner--version"> <div className="technique-banner technique-banner--version">
@ -342,6 +372,9 @@ export default function TechniquePage() {
</div> </div>
)} )}
{/* Sentinel for reading-header scroll detection (above sticky title bar) */}
<div ref={titleSentinelRef} style={{ height: 1, marginBottom: -1, overflow: "hidden" }} />
{/* Sticky title bar — sits at top of article, becomes sticky on scroll */} {/* Sticky title bar — sits at top of article, becomes sticky on scroll */}
<div className="technique-title-bar" ref={titleBarRef}> <div className="technique-title-bar" ref={titleBarRef}>
<div className="technique-title-bar__inner"> <div className="technique-title-bar__inner">