diff --git a/app/docs/reading-progress-tracker/README.md b/app/docs/reading-progress-tracker/README.md index be3058dd41..b40e25111a 100644 --- a/app/docs/reading-progress-tracker/README.md +++ b/app/docs/reading-progress-tracker/README.md @@ -203,12 +203,15 @@ On first load, legacy `readingProgress` and `mediaProgress` keys are merged into --- -## Return visit — optional continue prompt +## Return visit — resume offer in the reading pill -When the user reopens an in-progress article, a **Continue reading** card slides in from the right on SingleContent (not the notification system). The user chooses: +When the user reopens an in-progress article, the top-bar reading pill (`ArticleOutline.vue`) opens as a **Continue where you left off** offer with the saved percentage on its track. The user chooses: -- **Continue reading** — scrolls to the saved position after **300 ms** -- **Start from top** — dismisses the prompt; saved progress is kept +- **Continue where you left off** — scrolls to the saved position +- **Start from top** (×) — dismisses the offer; saved progress is kept +- Scrolling into the article also dismisses it + +Either way the pill becomes the chapter dropdown, which keeps a *Continue where you left off* entry while a saved position exists. The resume offer's track shows `readingProgressPercent` (live confirmed segments, kept even when saving is disabled for short articles); the chapter dropdown's track follows `scrollProgressPercent`, the viewport's position within the article. During programmatic restore, for **400 ms** (`READING_RESTORE_GUARD_MS`), tracking is suppressed so the scroll jump does not count as reading. @@ -251,12 +254,12 @@ inside `collectSegments()` — if a gate needs a new measurement, precompute it | File | Role | |------|------| -| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker and continue prompt | +| `app/src/pages/SingleContent/SingleContent.vue` | Wires the tracker and the reading pill | | `app/src/composables/useReadingProgressTracker.ts` | Segments, gates, dwell loop, scroll restore | | `app/src/util/readingTime.ts` | WPM, dwell math, words/sec skim cap | | `app/src/contentProgress.ts` | `localStorage` read/write (`contentProgress`) | | `app/src/components/HomePage/ContinueProgress.vue` | Homepage row | -| `app/src/components/content/ContinueReadingPrompt.vue` | In-article resume prompt | +| `app/src/pages/SingleContent/ArticleOutline.vue` | Reading pill: resume offer, chapter dropdown, progress track | --- @@ -280,8 +283,8 @@ inside `collectSegments()` — if a gate needs a new measurement, precompute it - `app/src/composables/useReadingProgressTracker.spec.ts` — segments, gates, dwell, skim, restore - `app/src/util/readingTime.spec.ts` — dwell and words/sec math -- `app/src/components/content/ContinueReadingPrompt.spec.ts` — resume prompt UI +- `app/src/pages/SingleContent/__tests__/ArticleOutline.spec.ts` — reading pill UI ```sh -cd app && npm run test -- src/util/readingTime.spec.ts src/composables/useReadingProgressTracker.spec.ts src/components/content/ContinueReadingPrompt.spec.ts +cd app && npm run test -- src/util/readingTime.spec.ts src/composables/useReadingProgressTracker.spec.ts src/pages/SingleContent/__tests__/ArticleOutline.spec.ts ``` diff --git a/app/src/App.vue b/app/src/App.vue index f3c4140011..dfaa67e957 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -18,6 +18,7 @@ import PrivacyPolicyModal from "@/components/navigation/PrivacyPolicyModal.vue"; import SearchModal from "@/components/navigation/SearchModal.vue"; import AudioPlayer from "@/components/content/AudioPlayer.vue"; import MobileMenu from "@/components/navigation/MobileMenu.vue"; +import { useMobileChromeAutoHide } from "@/composables/useMobileChromeAutoHide"; import AffinityDebugOverlay from "@/components/debug/AffinityDebugOverlay.vue"; import { affinityDebugEnabled, applyAffinityDebugQuery } from "@/recommendation/affinityDebug"; import { useAuthWithPrivacyPolicy } from "@/composables/useAuthWithPrivacyPolicy"; @@ -32,6 +33,7 @@ const LOGO = import.meta.env.VITE_LOGO || defaultLogo; const { t } = useI18n(); const { needRefresh, reload } = usePwaUpdate(); +const mobileChrome = useMobileChromeAutoHide(); const router = useRouter(); @@ -227,9 +229,19 @@ onErrorCaptured((err) => { - + +
+
+ +
+
(); const { onBackClick } = useBackNavigation(); const main = ref(undefined); +// The fade under the pinned chrome only makes sense once body content has scrolled beneath +// it; until the strip's own height has gone by, what sits under it is still the page title. +const TOP_CHROME_H = 56; +const scrolled = ref(false); +// Reading pages let the mobile top bar and bottom menu step aside while scrolling down. +const mobileChrome = useMobileChromeAutoHide(); +const onMainScroll = () => { + const el = main.value; + const scrollTop = el?.scrollTop ?? 0; + scrolled.value = scrollTop >= TOP_CHROME_H; + if (props.desktopTopBar && el) { + mobileChrome.onScroll(scrollTop, el.scrollHeight - el.clientHeight - scrollTop); + } +}; +// Shared so whatever a page puts in the centre slot can reveal itself in step with the fade. +provide("topChromeScrolled", scrolled); +// Rendered as an always-present layer whose opacity animates, so the fade eases in rather +// than snapping on at the threshold. It overshoots the strip's top edge so browsers that +// place the sticky strip a few pixels lower still get a fade that reaches the top bar. +// Backing that lifts the pinned controls off whatever scrolls beneath them (hero images, +// text); fades in with the chrome so nothing changes at rest. +const controlBacking = + "transition-[background-color,box-shadow] duration-500 ease-out rounded-lg ring-1 ring-transparent"; +const controlBackingOn = + "bg-zinc-100 shadow-md !ring-zinc-900/10 dark:bg-slate-700 dark:!ring-white/10"; +// Same treatment applied to each quick control individually, so they read as separate pills. +const quickControlBacking = + "[&>*]:rounded-lg [&>*]:ring-1 [&>*]:ring-transparent [&>*]:transition-[background-color,box-shadow] [&>*]:duration-500 [&>*]:ease-out"; +const quickControlBackingOn = + "[&>*]:bg-zinc-100 [&>*]:shadow-md [&>*]:!ring-zinc-900/10 dark:[&>*]:bg-slate-700 dark:[&>*]:!ring-white/10"; +const topChromeFade = + "pointer-events-none absolute inset-x-0 -top-4 bottom-6 bg-gradient-to-b from-white from-45% via-white/60 via-70% to-transparent transition-opacity duration-500 ease-out dark:from-slate-900 dark:via-slate-900/70"; + // Expose the scrolling
to descendants (e.g. SearchPanel in page mode) so they can drive // infinite scroll off the page's real scroll container instead of an internal one. provide("appMainScrollEl", main); @@ -42,10 +78,13 @@ const handleArrowKeyFocus = (e: KeyboardEvent) => { onMounted(() => { if (isSSG) setTimeout(() => (notificationsReady.value = true), SSG_NOTIFICATION_DELAY_MS); document.addEventListener("keydown", handleArrowKeyFocus); + main.value?.addEventListener("scroll", onMainScroll, { passive: true }); }); onUnmounted(() => { document.removeEventListener("keydown", handleArrowKeyFocus); + main.value?.removeEventListener("scroll", onMainScroll); + mobileChrome.reset(); }); @@ -57,13 +96,25 @@ onUnmounted(() => {
- - +
- - +
+ + + +
+
{
+
drops its top padding on these pages so `top-0` lands on the scrollport edge in + every engine; the strip's 8px of remaining flow height (h-16 minus -mb-14) stands in for + that padding, so page content still originates where it always did, sharing this row. + Negative side margins let it bleed over
's horizontal padding; pointer-events-none + lets clicks fall through the empty centre. + The fade below the controls row lets content dissolve under the chrome. -->