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. -->