Skip to content

Commit 415be60

Browse files
committed
docs: full UX polish pass — copy-link anchors, back-to-top, scrollspy
Three coordinated additions to the /docs surface, finishing the docs UX polish thread. 1. Copy-link on `.docs-anchor` clicks The `#` next to every h2 was a dead navigation link. Now a delegated click handler at DocsPage level intercepts every click on `.docs-anchor`: - copies `window.location.origin + pathname + href` to the clipboard via navigator.clipboard.writeText - updates the URL bar via history.pushState so the deep-link is shareable from the address bar too - smooth-scrolls the target heading into view - fires a toast confirmation Single handler instead of touching 19 individual section files. Cursor now pointer + accent-green hover so the affordance is discoverable. 2. Back-to-top floating button New BackToTopButton component fixed bottom-right. Hidden until `window.scrollY > 600`, then fades in with a small upward slide. Smooth-scrolls to top on click. Circular accent-green chip with hover lift. Mobile-safe inset via clamp(). z-index 50 so it sits above body content but below modals. 3. Sidebar scrollspy useScrollspy hook backed by IntersectionObserver, observing every section id in DOC_SECTIONS. Active section is the topmost one whose `boundingClientRect.top` is <= 80px (i.e. the heading just crossed the page-top zone). Falls back to the first intersecting section when scrolled to the very top of the page. Uses both IO boundary events AND a passive scroll listener to recompute, since IO alone misses slow scrolls between two co-intersecting sections. Active state in CSS: stronger background + bold + 3px inset accent-green left bar so the active group is visible from peripheral vision. useToasts hook reused from existing infrastructure. No new deps, no new state-management surface. All three features are progressive enhancement — page works fully without JS (copy-link falls back to URL-bar visibility, back-to-top simply doesn't render, scrollspy just doesn't highlight).
1 parent d8e243c commit 415be60

2 files changed

Lines changed: 305 additions & 21 deletions

File tree

frontend/src/pages/DocsPage.jsx

Lines changed: 234 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { useEffect, useState } from "react"
12
import { Link } from "react-router-dom"
23

34
import { DocsProvider } from "./docs/context"
5+
import { useToasts } from "../hooks/useToasts.jsx"
46

57
// One file per <section>. Each component is self-contained — sections that
68
// need shared state (the OS-tabs choice, the Copy-button toast) pull it from
@@ -27,7 +29,133 @@ import ApiRateLimits from "./docs/ApiRateLimits"
2729
import Resources from "./docs/Resources"
2830

2931

30-
function DocsSidebar() {
32+
// Order matches the sidebar order — drives the scrollspy's "topmost
33+
// visible section" calculation. Each entry is the section's id.
34+
const DOC_SECTIONS = [
35+
"getting-started",
36+
"architecture",
37+
"cloudnode-setup",
38+
"configuration",
39+
"deployment",
40+
"motion-detection",
41+
"terminal-dashboard",
42+
"dashboard",
43+
"recording",
44+
"camera-groups",
45+
"notifications",
46+
"mcp",
47+
"sentinel",
48+
"plans",
49+
"security-procedures",
50+
"troubleshooting",
51+
"faq",
52+
"api-reference",
53+
"api-rate-limits",
54+
]
55+
56+
57+
/**
58+
* Highlights the sidebar link matching whichever section is currently
59+
* topmost-visible in the viewport. Uses a single IntersectionObserver
60+
* scoped to the section IDs in DOC_SECTIONS.
61+
*
62+
* The "active" section is the one with the smallest non-negative
63+
* `boundingClientRect.top` — i.e. the one that just entered (or is
64+
* about to enter) the top of the viewport. Falls back to the
65+
* highest-positioned section among the currently-intersecting set
66+
* when nothing has entered yet (e.g. when the page loads scrolled
67+
* mid-document).
68+
*/
69+
function useScrollspy(sectionIds) {
70+
const [activeId, setActiveId] = useState(sectionIds[0] || "")
71+
72+
useEffect(() => {
73+
if (typeof IntersectionObserver === "undefined") return
74+
75+
const visible = new Map()
76+
77+
const recompute = () => {
78+
// Pick the section whose top is closest to (but not far below)
79+
// the viewport top. Sections above the fold have negative top
80+
// values; we want the largest of those (closest to zero).
81+
let bestId = null
82+
let bestTop = -Infinity
83+
for (const [id, top] of visible) {
84+
if (top <= 80 && top > bestTop) {
85+
bestTop = top
86+
bestId = id
87+
}
88+
}
89+
// If nothing is above the fold (we're at the top of the page),
90+
// pick the topmost intersecting section.
91+
if (!bestId) {
92+
let lowestTop = Infinity
93+
for (const [id, top] of visible) {
94+
if (top < lowestTop) {
95+
lowestTop = top
96+
bestId = id
97+
}
98+
}
99+
}
100+
if (bestId) setActiveId(bestId)
101+
}
102+
103+
const observer = new IntersectionObserver(
104+
(entries) => {
105+
for (const entry of entries) {
106+
const id = entry.target.id
107+
if (entry.isIntersecting) {
108+
visible.set(id, entry.boundingClientRect.top)
109+
} else {
110+
visible.delete(id)
111+
}
112+
}
113+
recompute()
114+
},
115+
{
116+
// rootMargin top -80px so a section is considered "active" once
117+
// its heading scrolls under the page's sticky chrome (the docs
118+
// page has no sticky bar today, but this leaves headroom for
119+
// future "back to top" / breadcrumb additions).
120+
rootMargin: "-80px 0px -60% 0px",
121+
threshold: [0, 1],
122+
},
123+
)
124+
125+
const elements = sectionIds
126+
.map((id) => document.getElementById(id))
127+
.filter(Boolean)
128+
elements.forEach((el) => observer.observe(el))
129+
130+
// Recompute on plain scroll too — IntersectionObserver only fires on
131+
// boundary crossings, so a slow scroll between two intersecting
132+
// sections wouldn't update the active state otherwise.
133+
const onScroll = () => {
134+
for (const el of elements) {
135+
const rect = el.getBoundingClientRect()
136+
visible.set(el.id, rect.top)
137+
}
138+
recompute()
139+
}
140+
window.addEventListener("scroll", onScroll, { passive: true })
141+
142+
return () => {
143+
observer.disconnect()
144+
window.removeEventListener("scroll", onScroll)
145+
}
146+
}, [sectionIds])
147+
148+
return activeId
149+
}
150+
151+
152+
function DocsSidebar({ activeId }) {
153+
// Render each link with an active class when its href matches the
154+
// currently-spied section. className is computed at render time so
155+
// React doesn't have to re-mount the <a>s on every active change.
156+
const linkClass = (id) =>
157+
`docs-sidebar-link${activeId === id ? " active" : ""}`
158+
31159
return (
32160
<aside className="docs-sidebar">
33161
<div className="docs-sidebar-header">
@@ -37,46 +165,46 @@ function DocsSidebar() {
37165
<nav className="docs-sidebar-nav">
38166
<div className="docs-sidebar-group">
39167
<div className="docs-sidebar-group-label">Introduction</div>
40-
<a href="#getting-started" className="docs-sidebar-link">Getting Started</a>
41-
<a href="#architecture" className="docs-sidebar-link">Architecture</a>
168+
<a href="#getting-started" className={linkClass("getting-started")}>Getting Started</a>
169+
<a href="#architecture" className={linkClass("architecture")}>Architecture</a>
42170
</div>
43171
<div className="docs-sidebar-group">
44172
<div className="docs-sidebar-group-label">CloudNode</div>
45-
<a href="#cloudnode-setup" className="docs-sidebar-link">Setup</a>
46-
<a href="#configuration" className="docs-sidebar-link">Configuration</a>
47-
<a href="#deployment" className="docs-sidebar-link">Deployment</a>
48-
<a href="#motion-detection" className="docs-sidebar-link">Motion Detection</a>
49-
<a href="#terminal-dashboard" className="docs-sidebar-link">Terminal Dashboard</a>
173+
<a href="#cloudnode-setup" className={linkClass("cloudnode-setup")}>Setup</a>
174+
<a href="#configuration" className={linkClass("configuration")}>Configuration</a>
175+
<a href="#deployment" className={linkClass("deployment")}>Deployment</a>
176+
<a href="#motion-detection" className={linkClass("motion-detection")}>Motion Detection</a>
177+
<a href="#terminal-dashboard" className={linkClass("terminal-dashboard")}>Terminal Dashboard</a>
50178
</div>
51179
<div className="docs-sidebar-group">
52180
<div className="docs-sidebar-group-label">Command Center</div>
53-
<a href="#dashboard" className="docs-sidebar-link">Dashboard & Features</a>
54-
<a href="#recording" className="docs-sidebar-link">Recording & Retention</a>
55-
<a href="#camera-groups" className="docs-sidebar-link">Camera Groups</a>
56-
<a href="#notifications" className="docs-sidebar-link">Notifications</a>
181+
<a href="#dashboard" className={linkClass("dashboard")}>Dashboard & Features</a>
182+
<a href="#recording" className={linkClass("recording")}>Recording & Retention</a>
183+
<a href="#camera-groups" className={linkClass("camera-groups")}>Camera Groups</a>
184+
<a href="#notifications" className={linkClass("notifications")}>Notifications</a>
57185
</div>
58186
<div className="docs-sidebar-group">
59187
<div className="docs-sidebar-group-label">Integrations</div>
60-
<a href="#mcp" className="docs-sidebar-link">MCP Integration</a>
188+
<a href="#mcp" className={linkClass("mcp")}>MCP Integration</a>
61189
</div>
62190
<div className="docs-sidebar-group">
63191
<div className="docs-sidebar-group-label">AI Agent</div>
64-
<a href="#sentinel" className="docs-sidebar-link">Sentinel</a>
192+
<a href="#sentinel" className={linkClass("sentinel")}>Sentinel</a>
65193
</div>
66194
<div className="docs-sidebar-group">
67195
<div className="docs-sidebar-group-label">Account & Security</div>
68-
<a href="#plans" className="docs-sidebar-link">Plans & Limits</a>
69-
<a href="#security-procedures" className="docs-sidebar-link">Security Procedures</a>
196+
<a href="#plans" className={linkClass("plans")}>Plans & Limits</a>
197+
<a href="#security-procedures" className={linkClass("security-procedures")}>Security Procedures</a>
70198
</div>
71199
<div className="docs-sidebar-group">
72200
<div className="docs-sidebar-group-label">Help</div>
73-
<a href="#troubleshooting" className="docs-sidebar-link">Troubleshooting</a>
74-
<a href="#faq" className="docs-sidebar-link">FAQ</a>
201+
<a href="#troubleshooting" className={linkClass("troubleshooting")}>Troubleshooting</a>
202+
<a href="#faq" className={linkClass("faq")}>FAQ</a>
75203
</div>
76204
<div className="docs-sidebar-group">
77205
<div className="docs-sidebar-group-label">Reference</div>
78-
<a href="#api-reference" className="docs-sidebar-link">API Reference</a>
79-
<a href="#api-rate-limits" className="docs-sidebar-link">API Rate Limits</a>
206+
<a href="#api-reference" className={linkClass("api-reference")}>API Reference</a>
207+
<a href="#api-rate-limits" className={linkClass("api-rate-limits")}>API Rate Limits</a>
80208
</div>
81209
</nav>
82210
<div className="docs-sidebar-footer">
@@ -89,11 +217,95 @@ function DocsSidebar() {
89217
}
90218

91219

220+
/**
221+
* Floating Back-to-top button. Fades in once the page is scrolled
222+
* past ~600px; smooth-scrolls back on click. Position is fixed
223+
* bottom-right with a generous mobile-safe inset. Single instance
224+
* mounted by DocsPage — not shared across the app, since other pages
225+
* are short enough not to need it.
226+
*/
227+
function BackToTopButton() {
228+
const [visible, setVisible] = useState(false)
229+
230+
useEffect(() => {
231+
const onScroll = () => setVisible(window.scrollY > 600)
232+
onScroll()
233+
window.addEventListener("scroll", onScroll, { passive: true })
234+
return () => window.removeEventListener("scroll", onScroll)
235+
}, [])
236+
237+
return (
238+
<button
239+
type="button"
240+
className={`docs-back-to-top${visible ? " visible" : ""}`}
241+
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
242+
aria-label="Back to top"
243+
title="Back to top"
244+
>
245+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
246+
<line x1="12" y1="19" x2="12" y2="5" />
247+
<polyline points="5 12 12 5 19 12" />
248+
</svg>
249+
</button>
250+
)
251+
}
252+
253+
92254
function DocsPage() {
255+
const { showToast } = useToasts()
256+
const activeId = useScrollspy(DOC_SECTIONS)
257+
258+
// Delegated click handler for any `.docs-anchor` link inside a docs
259+
// heading. Three things on click:
260+
// 1. Copy the full URL+hash to the clipboard.
261+
// 2. Update the URL bar via history.pushState (so the link IS
262+
// shareable from the address bar too).
263+
// 3. Smooth-scroll the target heading into view.
264+
// Single handler bound to the document instead of touching 19
265+
// individual section files.
266+
useEffect(() => {
267+
const handler = (event) => {
268+
const anchor = event.target.closest(".docs-anchor")
269+
if (!anchor) return
270+
event.preventDefault()
271+
const href = anchor.getAttribute("href") || ""
272+
const targetId = href.startsWith("#") ? href.slice(1) : ""
273+
const fullUrl = `${window.location.origin}${window.location.pathname}${href}`
274+
275+
// Update history first so the URL bar shows the deep-link. We
276+
// use pushState rather than assigning location.hash because the
277+
// latter triggers a default scroll-jump that competes with our
278+
// smooth-scroll.
279+
window.history.pushState(null, "", href)
280+
281+
// Smooth-scroll to the target. Falls back silently if the
282+
// element doesn't exist (shouldn't happen — every `.docs-anchor`
283+
// we render points at its parent section's id).
284+
if (targetId) {
285+
const target = document.getElementById(targetId)
286+
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" })
287+
}
288+
289+
// Copy to clipboard + toast. Clipboard API requires a secure
290+
// context; if we don't have it (rare — local file:// preview),
291+
// we silently skip the copy and rely on the URL bar update.
292+
if (navigator.clipboard?.writeText) {
293+
navigator.clipboard.writeText(fullUrl).then(
294+
() => showToast("Link copied to clipboard", "success"),
295+
() => showToast("Couldn't copy — clipboard blocked", "error"),
296+
)
297+
} else {
298+
showToast("Link in URL bar — copy manually", "info")
299+
}
300+
}
301+
document.addEventListener("click", handler)
302+
return () => document.removeEventListener("click", handler)
303+
}, [showToast])
304+
93305
return (
94306
<DocsProvider>
95307
<div className="docs-layout">
96-
<DocsSidebar />
308+
<DocsSidebar activeId={activeId} />
97309
<main className="docs-content">
98310
<div className="docs-content-inner">
99311
<div className="docs-hero-banner" aria-hidden="true">
@@ -142,6 +354,7 @@ function DocsPage() {
142354
</div>
143355
</div>
144356
</main>
357+
<BackToTopButton />
145358
</div>
146359
</DocsProvider>
147360
)

frontend/src/styles/landing.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,77 @@
993993
color: var(--accent-green);
994994
}
995995

996+
/* Scrollspy active state — applied by useScrollspy() to whichever
997+
sidebar link matches the topmost-visible section in the viewport.
998+
Stronger visual than :hover so the user can find their place at
999+
a glance. Left-edge accent bar makes the active group visible
1000+
from peripheral vision. */
1001+
.docs-sidebar-link.active {
1002+
background: rgba(34, 197, 94, 0.16);
1003+
color: var(--accent-green);
1004+
font-weight: 600;
1005+
box-shadow: inset 3px 0 0 var(--accent-green);
1006+
}
1007+
1008+
.docs-sidebar-link.active:hover {
1009+
background: rgba(34, 197, 94, 0.22);
1010+
}
1011+
1012+
/* Floating Back-to-top button — DocsPage only. Fades in once the
1013+
page is scrolled past ~600px, fades out near the top. Bottom-right
1014+
with a generous mobile-safe inset; circular, accent-green, with a
1015+
subtle elevation shadow that lifts on hover. */
1016+
.docs-back-to-top {
1017+
position: fixed;
1018+
right: clamp(1rem, 4vw, 2rem);
1019+
bottom: clamp(1rem, 4vw, 2rem);
1020+
width: 44px;
1021+
height: 44px;
1022+
display: flex;
1023+
align-items: center;
1024+
justify-content: center;
1025+
border-radius: 50%;
1026+
border: 1px solid rgba(34, 197, 94, 0.4);
1027+
background: var(--bg-card);
1028+
color: var(--accent-green);
1029+
cursor: pointer;
1030+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
1031+
opacity: 0;
1032+
transform: translateY(8px);
1033+
pointer-events: none;
1034+
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, background 0.15s ease;
1035+
z-index: 50;
1036+
}
1037+
1038+
.docs-back-to-top.visible {
1039+
opacity: 1;
1040+
transform: translateY(0);
1041+
pointer-events: auto;
1042+
}
1043+
1044+
.docs-back-to-top:hover {
1045+
background: rgba(34, 197, 94, 0.16);
1046+
box-shadow: 0 6px 22px rgba(34, 197, 94, 0.28);
1047+
}
1048+
1049+
.docs-back-to-top:focus-visible {
1050+
outline: 2px solid var(--accent-green);
1051+
outline-offset: 3px;
1052+
}
1053+
1054+
/* Anchor `#` next to docs headings — make it clearly clickable.
1055+
Was opacity:0 by default and revealed on h2 hover only; now
1056+
reveals on h2/h3 hover AND has cursor:pointer so the click-to-
1057+
copy affordance is discoverable. Hover state on the anchor
1058+
itself shifts to the accent color. */
1059+
.docs-anchor {
1060+
cursor: pointer;
1061+
}
1062+
1063+
.docs-anchor:hover {
1064+
color: var(--accent-green) !important;
1065+
}
1066+
9961067
.docs-concepts-grid {
9971068
display: grid;
9981069
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));

0 commit comments

Comments
 (0)