diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx
index 1545d58..da778a5 100644
--- a/src/components/AppShell.jsx
+++ b/src/components/AppShell.jsx
@@ -20,6 +20,7 @@ import {
NAV_SURFACE,
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
+import PageMinimap from './PageMinimap';
const SIDEBAR_WIDTH = 144;
@@ -322,6 +323,7 @@ export default function AppShell() {
+
);
}
diff --git a/src/components/PageMinimap.jsx b/src/components/PageMinimap.jsx
new file mode 100644
index 0000000..ec181f1
--- /dev/null
+++ b/src/components/PageMinimap.jsx
@@ -0,0 +1,389 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { matchPath, useLocation } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+import MapRoundedIcon from '@mui/icons-material/MapRounded';
+import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
+import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
+
+const MINIMAP_WIDTH = 220;
+const MINIMAP_MAX_HEIGHT = 300;
+const REBUILD_DEBOUNCE_MS = 600;
+/** Hide the minimap this long after the last scroll event. */
+const IDLE_HIDE_MS = 1200;
+
+/** Clamp a number into [min, max]. */
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+/**
+ * Floating whole-page minimap, centered at the bottom of the viewport.
+ *
+ * Renders a live scaled-down clone of `sourceSelector` (default ``)
+ * with a viewport rectangle that tracks `window.scrollY`. Clicking or
+ * dragging inside the thumbnail jumps the page to that position.
+ *
+ * Only renders when the page is actually scrollable
+ * (`documentHeight > 1.3 * viewportHeight`).
+ */
+export default function PageMinimap({ sourceSelector = 'main' }) {
+ const [open, setOpen] = useState(true);
+ const [enabled, setEnabled] = useState(false);
+ const [thumb, setThumb] = useState({ width: MINIMAP_WIDTH, height: 0, scale: 1 });
+ const [scrollActive, setScrollActive] = useState(false);
+ const [hovering, setHovering] = useState(false);
+ const [dragging, setDragging] = useState(false);
+
+ const thumbBodyRef = useRef(null);
+ const viewportRef = useRef(null);
+ const draggingRef = useRef(false);
+ const rebuildTimerRef = useRef(null);
+ const idleTimerRef = useRef(null);
+ const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
+
+ // SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
+ // default tab resolves to seo.
+ const { pathname } = useLocation();
+ const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
+ const isSeoPage = Boolean(
+ articleMatch &&
+ (articleMatch.params?.tab === ARTICLE_TAB.seo ||
+ (!articleMatch.params?.tab &&
+ readPreferredArticleTab() === ARTICLE_TAB.seo)),
+ );
+
+ /** Move the viewport rectangle without re-rendering (called on scroll). */
+ const updateViewport = useCallback(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+ const { docH, winH, thumbH } = metricsRef.current;
+ if (docH <= 0 || thumbH <= 0) return;
+ const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
+ const top = clamp((scrollY / docH) * thumbH, 0, thumbH);
+ const height = clamp((winH / docH) * thumbH, 12, thumbH);
+ el.style.top = `${clamp(top, 0, Math.max(0, thumbH - height))}px`;
+ el.style.height = `${height}px`;
+ }, []);
+
+ /** Scroll the window so the clicked thumbnail offset lands mid-viewport. */
+ const jumpToThumbOffset = useCallback((offsetY) => {
+ const { docH, winH, thumbH } = metricsRef.current;
+ if (thumbH <= 0) return;
+ const ratio = clamp(offsetY / thumbH, 0, 1);
+ const target = ratio * docH - winH / 2;
+ window.scrollTo({ top: clamp(target, 0, Math.max(0, docH - winH)), behavior: 'auto' });
+ }, []);
+
+ const handleThumbPointerDown = useCallback(
+ (event) => {
+ if (event.button !== 0) return;
+ draggingRef.current = true;
+ setDragging(true);
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ const rect = event.currentTarget.getBoundingClientRect();
+ jumpToThumbOffset(event.clientY - rect.top);
+ },
+ [jumpToThumbOffset],
+ );
+
+ const handleThumbPointerMove = useCallback(
+ (event) => {
+ if (!draggingRef.current) return;
+ const rect = event.currentTarget.getBoundingClientRect();
+ jumpToThumbOffset(event.clientY - rect.top);
+ },
+ [jumpToThumbOffset],
+ );
+
+ const endDrag = useCallback(() => {
+ draggingRef.current = false;
+ setDragging(false);
+ }, []);
+
+ /** Rebuild the scaled clone and recompute geometry. */
+ const rebuild = useCallback(() => {
+ const source = document.querySelector(sourceSelector);
+ if (!source) return;
+
+ const docH = document.documentElement.scrollHeight || 1;
+ const winH = window.innerHeight || 1;
+ const sourceW = source.offsetWidth || source.getBoundingClientRect().width || 1;
+
+ const isLong = docH > winH * 1.3;
+ setEnabled((prev) => (prev === isLong ? prev : isLong));
+ if (!isLong) return;
+
+ // Host only exists once enabled+open render the thumbnail. Geometry is
+ // already stored above, so the enabled->render->rebuild pass clones it.
+ const host = thumbBodyRef.current;
+ if (!host) return;
+
+ const scale = Math.min(MINIMAP_WIDTH / sourceW, MINIMAP_MAX_HEIGHT / docH);
+ const thumbW = Math.max(1, Math.round(sourceW * scale));
+ const thumbH = Math.max(1, Math.round(docH * scale));
+ metricsRef.current = { docH, winH, thumbH };
+ setThumb({ width: thumbW, height: thumbH, scale });
+
+ host.innerHTML = '';
+ const clone = source.cloneNode(true);
+ clone.removeAttribute('id');
+ clone.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
+ clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
+ clone.querySelectorAll('a').forEach((el) => {
+ el.removeAttribute('href');
+ });
+ clone.setAttribute('aria-hidden', 'true');
+ clone.style.width = `${sourceW}px`;
+ clone.style.maxWidth = 'none';
+ clone.style.margin = '0';
+ clone.style.pointerEvents = 'none';
+ clone.style.userSelect = 'none';
+ clone.style.transform = `scale(${scale})`;
+ clone.style.transformOrigin = 'top left';
+ host.appendChild(clone);
+
+ requestAnimationFrame(updateViewport);
+ }, [sourceSelector, updateViewport]);
+
+ const scheduleRebuild = useCallback(() => {
+ if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
+ rebuildTimerRef.current = setTimeout(() => {
+ rebuildTimerRef.current = null;
+ rebuild();
+ }, REBUILD_DEBOUNCE_MS);
+ }, [rebuild]);
+
+ // Second pass: first rebuild() only flips `enabled` on (no host yet);
+ // once the thumbnail mounts, rebuild again to actually clone content.
+ useEffect(() => {
+ if (enabled && open) rebuild();
+ }, [enabled, open, rebuild]);
+
+ // Reset scroll visibility on navigation; the new page starts hidden
+ // until the user scrolls again.
+ useEffect(() => {
+ setScrollActive(false);
+ }, [pathname]);
+
+ useEffect(() => {
+ rebuild();
+ // Article content loads async (automat cache), so re-check shortly
+ // after mount in case the page grows past the threshold late.
+ const t1 = setTimeout(rebuild, 500);
+ const t2 = setTimeout(rebuild, 1500);
+ let raf = 0;
+ const onScroll = () => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(updateViewport);
+ // Show while scrolling; hide ~1s after the last scroll event.
+ setScrollActive(true);
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
+ idleTimerRef.current = setTimeout(() => {
+ idleTimerRef.current = null;
+ setScrollActive(false);
+ }, IDLE_HIDE_MS);
+ };
+ const onResize = () => rebuild();
+ window.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onResize);
+
+ const source = document.querySelector(sourceSelector);
+ const observer = new MutationObserver(scheduleRebuild);
+ if (source) {
+ observer.observe(source, { childList: true, subtree: true, characterData: true });
+ }
+
+ return () => {
+ cancelAnimationFrame(raf);
+ clearTimeout(t1);
+ clearTimeout(t2);
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
+ window.removeEventListener('scroll', onScroll);
+ window.removeEventListener('resize', onResize);
+ observer.disconnect();
+ if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
+ };
+ }, [rebuild, scheduleRebuild, sourceSelector, updateViewport]);
+
+ // Keep the viewport rect in sync when the thumbnail geometry changes.
+ useEffect(() => {
+ updateViewport();
+ }, [thumb, open, updateViewport]);
+
+ // Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
+ const visible = scrollActive || hovering || dragging;
+
+ if (!enabled || !isSeoPage) return null;
+
+ // Collapsed: a single centered pill to reopen the overview.
+ if (!open) {
+ return (
+
+ setOpen(true)}
+ sx={(theme) => ({
+ bgcolor: theme.palette.background.paper,
+ border: '1px solid',
+ borderColor: theme.palette.divider,
+ boxShadow: theme.shadows[6],
+ opacity: 0.85,
+ '&:hover': { opacity: 1 },
+ })}
+ >
+
+
+
+ );
+ }
+
+ return (
+
+ setHovering(true)}
+ onMouseLeave={() => setHovering(false)}
+ sx={(theme) => ({
+ pointerEvents: visible ? 'auto' : 'none',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden',
+ borderRadius: 2,
+ border: '1px solid',
+ borderColor: theme.palette.divider,
+ bgcolor: theme.palette.background.paper,
+ boxShadow: theme.shadows[8],
+ opacity: 0.96,
+ })}
+ >
+
+ setOpen(false)}
+ sx={{ p: 0.25 }}
+ >
+
+
+
+
+ {
+ const { docH, winH } = metricsRef.current;
+ const y = window.scrollY || 0;
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ window.scrollTo({ top: y + winH * 0.8 });
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ window.scrollTo({ top: y - winH * 0.8 });
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ window.scrollTo({ top: 0 });
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ window.scrollTo({ top: docH });
+ }
+ }}
+ sx={(theme) => ({
+ position: 'relative',
+ width: thumb.width,
+ height: thumb.height,
+ overflow: 'hidden',
+ borderRadius: 1,
+ border: '1px solid',
+ borderColor: theme.palette.divider,
+ bgcolor: theme.palette.action.hover,
+ cursor: 'pointer',
+ outline: 'none',
+ touchAction: 'none',
+ '&:focus-visible': {
+ borderColor: theme.palette.primary.main,
+ boxShadow: `0 0 0 2px ${theme.palette.primary.main}55`,
+ },
+ })}
+ >
+
+ ({
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ height: 24,
+ border: '2px solid',
+ borderColor: theme.palette.primary.main,
+ bgcolor: `${theme.palette.primary.main}22`,
+ borderRadius: 0.5,
+ pointerEvents: 'none',
+ boxSizing: 'border-box',
+ })}
+ />
+
+
+
+
+ );
+}
diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx
index 1d61fd7..c820eda 100644
--- a/src/routes/artikel.jsx
+++ b/src/routes/artikel.jsx
@@ -607,6 +607,9 @@ class ArtikelSearchPage extends Component {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
+ // Match PanelTile (PANEL_TILE_BORDER_RADIUS: borderRadius 4 →
+ // theme.shape.borderRadius × 4 = 16px) so both columns line up.
+ borderRadius: 4,
}}
>