From 526fd0a58c19ab30c98d0b253e40b8497cbf7905 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 15:14:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20ReadingHeader=20sticky=20bar=20?= =?UTF-8?q?that=20slides=20in=20when=20scrolling=20past=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/components/ReadingHeader.tsx" - "frontend/src/pages/TechniquePage.tsx" - "frontend/src/App.css" GSD-Task: S10/T01 --- frontend/src/App.css | 70 +++++++++++++++++++++++ frontend/src/components/ReadingHeader.tsx | 64 +++++++++++++++++++++ frontend/src/pages/TechniquePage.tsx | 33 +++++++++++ 3 files changed, 167 insertions(+) create mode 100644 frontend/src/components/ReadingHeader.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 5709ef7..28ff90a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 ────────────────────────────────────────────────────── */ .technique-toc { diff --git a/frontend/src/components/ReadingHeader.tsx b/frontend/src/components/ReadingHeader.tsx new file mode 100644 index 0000000..666c117 --- /dev/null +++ b/frontend/src/components/ReadingHeader.tsx @@ -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 ( +
+
+ {title} + {sectionName && ( + <> + · + {sectionName} + + )} +
+
+ ); +} diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx index 9317bb5..663fad3 100644 --- a/frontend/src/pages/TechniquePage.tsx +++ b/frontend/src/pages/TechniquePage.tsx @@ -25,6 +25,7 @@ import CreatorAvatar from "../components/CreatorAvatar"; import VideoPlayer from "../components/VideoPlayer"; import PlayerControls from "../components/PlayerControls"; import TableOfContents, { slugify } from "../components/TableOfContents"; +import ReadingHeader from "../components/ReadingHeader"; import { parseCitations } from "../utils/citations"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useMediaSync } from "../hooks/useMediaSync"; @@ -225,6 +226,26 @@ export default function TechniquePage() { // --- Scroll-spy: activeId for ToC --- const [activeId, setActiveId] = useState(""); const titleBarRef = useRef(null); + const [titlePastView, setTitlePastView] = useState(false); + const titleObserverRef = useRef(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 const isHistorical = selectedVersion !== "current" && versionDetail != null; @@ -319,6 +340,15 @@ export default function TechniquePage() { return (
+ {/* Reading header — slides in when title bar scrolls out of view */} + {isV2 && ( + + )} {/* Historical version banner */} {isHistorical && (
@@ -342,6 +372,9 @@ export default function TechniquePage() {
)} + {/* Sentinel for reading-header scroll detection (above sticky title bar) */} +
+ {/* Sticky title bar — sits at top of article, becomes sticky on scroll */}