bugfix: reader-nav is fully fixed; no settle-on-scroll (0.21.3)

This commit is contained in:
MechaCat02
2026-05-17 20:57:05 +02:00
parent 64ccc0ba84
commit 89b8785a40
6 changed files with 73 additions and 33 deletions

View File

@@ -61,10 +61,12 @@
--icon-lg: 22px;
/* App-frame heights (fixed-position bars at the top and bottom of
the viewport). Used by the layout + reader to reserve content
space and animate fullscreen mode. Recomputed once if the
header padding/font-size ever changes — keep in sync. */
the viewport). These are first-paint fallbacks — the real
values are written by ResizeObservers on the actual elements
in +layout.svelte and the reader, so they reflect rendered
size and survive font / zoom / wrap changes. */
--app-header-h: 60px;
--reader-nav-h: 56px;
--reader-bar-h: 56px;
--z-dropdown: 10;

View File

@@ -226,9 +226,9 @@
main {
padding: var(--space-4);
/* Reserve room for the fixed header so its presence doesn't
overlap content. The min-height is a fallback that matches
the header at typical viewport sizes (6072px); resize
observers would be more accurate but the gap is forgiving. */
overlap content. The header height comes from a runtime
ResizeObserver (see onMount above) so this always tracks
the rendered size. */
padding-top: calc(var(--app-header-h) + var(--space-4));
max-width: 64rem;
margin: 0 auto;
@@ -236,6 +236,8 @@
}
:global(html[data-reader-fullscreen='true']) main {
padding-top: var(--space-4);
/* No top reservation in focus mode — the chapter image runs
edge-to-edge once the header has slid off. */
padding-top: 0;
}
</style>

View File

@@ -69,6 +69,31 @@
let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]);
let chapterBarEl: HTMLElement | undefined = $state();
let readerNavEl: HTMLElement | undefined = $state();
// Publish the reader nav's actual measured height. Sticky
// positioning had a "settle on scroll" effect: the bar's natural
// position sat 16px below the app header (main's space-4 padding),
// and only docked against the header once the user scrolled
// enough to consume that gap. The fix is to lift the bar out of
// document flow entirely (position: fixed) and reserve space in
// the chapter content via `--reader-nav-h`.
$effect(() => {
if (!readerNavEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--reader-nav-h',
`${readerNavEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(readerNavEl);
return () => {
ro.disconnect();
document.documentElement.style.removeProperty('--reader-nav-h');
};
});
// Publish the bottom chapter-bar's actual measured height so the
// continuous container's `padding-bottom` exactly matches it. Same
@@ -389,7 +414,7 @@
<title>{pageTitle}</title>
</svelte:head>
<nav class="reader-nav" aria-label="reader">
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
<a href="/manga/{manga.id}" class="back" data-testid="back-to-manga">
<ArrowLeft size={18} aria-hidden="true" />
{#if manga.cover_image_path}
@@ -605,45 +630,36 @@
{/if}
<style>
/* Sticky directly under the fixed layout header. `top: var(--app-header-h)`
pins the bar's top edge to the bottom edge of the layout
header so it never slides under it. Focus mode slides it up
off-screen via a transform AND clips its height to 0 so the
chapter pages get the full top of the viewport. */
/* Pinned to the viewport directly below the (also fixed) layout
header. `position: fixed` rather than `sticky` because the
latter would have a "settle on scroll" period until its natural
position consumes main's padding-top. Chapter content reserves
room for this bar via `--reader-nav-h` (measured at runtime by
a ResizeObserver in onMount). Focus mode slides it up
off-screen via transform — the fixed origin keeps the slide
distance equal to the bar's height regardless of scroll. */
.reader-nav {
position: sticky;
position: fixed;
top: var(--app-header-h);
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--space-2);
padding-bottom: var(--space-2);
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--border);
margin: 0 calc(-1 * var(--space-4)) var(--space-3);
padding-left: var(--space-4);
padding-right: var(--space-4);
background: var(--bg);
gap: var(--space-3);
flex-wrap: wrap;
transition:
transform 220ms ease-out,
max-height 220ms ease-out,
opacity 220ms ease-out,
padding 220ms ease-out,
margin 220ms ease-out;
max-height: 200px;
overflow: hidden;
opacity 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .reader-nav {
transform: translateY(-100%);
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
opacity: 0;
border-bottom-color: transparent;
pointer-events: none;
}
@@ -763,17 +779,37 @@
color: var(--text-muted);
}
/* Reserve room for the fixed reader-nav above so the first page
image (single mode) and the top of the chapter stack
(continuous mode) aren't hidden behind it. The variable is
written by the ResizeObserver in onMount so the reservation
always matches actual rendered height. Focus mode collapses
the reservation in lockstep with the bar's slide-out. */
.page-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-2);
align-items: center;
padding-top: var(--reader-nav-h);
transition: padding-top 220ms ease-out;
}
.continuous {
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--reader-nav-h);
transition:
padding-top 220ms ease-out,
padding-bottom 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .page-wrap,
:global(html[data-reader-fullscreen='true']) .continuous {
padding-top: 0;
}
:global(html[data-reader-fullscreen='true']) .continuous {
padding-bottom: 0;
}
.page-image {