Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
37b8c59
Add reading-progress indicator to SingleContent top bar
MrDirkelz Aug 22, 2026
74f83d9
Add article outline navigation with reading-progress rail
MrDirkelz Aug 22, 2026
05dea6d
feat: update article outline visibility and styling in SingleContent
MrDirkelz Aug 25, 2026
7bb005b
test: stub scrollProgressPercent in SingleContent SSR specs
johan-bell Aug 26, 2026
c475859
feat(app): move article outline into a sticky chapter dropdown in the…
johan-bell Aug 26, 2026
11a2c5e
feat(app): fade content under the sticky top chrome on SingleContent
johan-bell Aug 26, 2026
8cc04b5
fix(app): only fade under the top chrome once the page has scrolled
johan-bell Aug 26, 2026
0663f04
fix(app): delay the top-chrome fade until the title row has scrolled …
johan-bell Aug 26, 2026
accd998
feat(app): ease the top-chrome fade in and lengthen its gradient
johan-bell Aug 26, 2026
b5c50f4
feat(app): bleed the top-chrome fade to the page edges and make the c…
johan-bell Aug 26, 2026
3d0f62e
style(app): shorten the top-chrome fade
johan-bell Aug 26, 2026
ddef7ea
feat(app): show the article title in the top bar when there are no ch…
johan-bell Aug 26, 2026
03b13d5
test(app): make ArticleOutline active-heading assertion jsdom-safe
johan-bell Aug 26, 2026
ba9916c
fix(app): keep the top-bar pill on short articles until the body has …
johan-bell Aug 26, 2026
6d712b7
style(app): centre the top-bar pill on the content column
johan-bell Aug 26, 2026
0148323
fix(app): reveal the top-bar pill in step with the chrome fade
johan-bell Aug 26, 2026
defb0e9
style(app): centre the chapter menu under its pill
johan-bell Aug 26, 2026
5cb487f
fix(app): let the top-chrome fade overshoot its strip so it always me…
johan-bell Aug 26, 2026
9f607cb
feat(app): move the reading-progress bar to the top edge and anchor t…
johan-bell Aug 26, 2026
222ffa2
fix(app): reading-progress bar starts at 0 until the article is scrol…
johan-bell Aug 26, 2026
7ee9fae
style(app): back the pinned top-bar controls with a pill once scrolled
johan-bell Aug 26, 2026
d847855
style(app): give each quick control its own pill in the pinned top bar
johan-bell Aug 26, 2026
9c5517b
feat(app): show reading progress as a track on the top-bar pill inste…
johan-bell Aug 26, 2026
9ddf772
fix(app): measure the pill's reading progress over the whole article …
johan-bell Aug 26, 2026
1807ec2
style(app): card-style progress track on the top-bar pill
johan-bell Aug 26, 2026
04eb660
style(app): size the chapter pill to the longest heading so it stays …
johan-bell Aug 26, 2026
3f25a71
style(app): left-align the chapter pill label
johan-bell Aug 26, 2026
7afecd7
feat(app): merge the resume prompt into the top-bar reading pill
johan-bell Aug 26, 2026
6d62f05
style(app): resume pill matches the old card — centred label, larger …
johan-bell Aug 26, 2026
dea6573
style(app): tighten the resume pill's horizontal padding
johan-bell Aug 26, 2026
9bcd268
feat(app): hide the mobile top bar and bottom menu while scrolling do…
johan-bell Aug 26, 2026
e98925f
feat(app): bring the mobile chrome back when the reader reaches the e…
johan-bell Aug 26, 2026
9c7ab06
fix(app): keep the mobile chrome on pages with too little to scroll
johan-bell Aug 26, 2026
9ead950
feat(app): chapter pill tracks scroll position; resume offer keeps th…
johan-bell Aug 27, 2026
c250bf2
fix(app): drop the menu's continue entry once the reader is past the …
johan-bell Aug 27, 2026
1dd13cd
fix(app): a 99%+ read counts as finished — no resume offer or continu…
johan-bell Aug 27, 2026
d8f941c
style(app): regular-weight, slightly translucent reading pill
johan-bell Aug 27, 2026
d6073e9
fix(app): keep the menu's continue entry until the read is finished
johan-bell Aug 27, 2026
19976a7
style(app): update background colors for reading controls and resume …
johan-bell Aug 28, 2026
36ea9a7
Merge remote-tracking branch 'origin/main' into 1857-app-reading-bar-…
johan-bell Sep 4, 2026
7751a3f
perf(app): drop backdrop blur from the reading pills and listen button
johan-bell Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions app/docs/reading-progress-tracker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

---

Expand All @@ -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
```
18 changes: 15 additions & 3 deletions app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();

Expand Down Expand Up @@ -227,9 +229,19 @@ onErrorCaptured((err) => {

<!-- Mobile Navigation (mobile only) -->
<!-- <MobileMenu class="w-full lg:hidden z-10" /> -->
<MobileMenu
class="z-50 w-full border-t-2 border-t-zinc-100/25 dark:border-t-slate-700/50 lg:hidden"
/>
<!-- Collapses via grid rows while a reading page scrolls down; see useMobileChromeAutoHide. -->
<div
class="z-50 grid w-full transition-[grid-template-rows] duration-300 ease-out lg:hidden"
:class="
mobileChrome.hidden.value ? '[grid-template-rows:0fr]' : '[grid-template-rows:1fr]'
"
>
<div class="min-h-0 overflow-hidden">
<MobileMenu
class="w-full border-t-2 border-t-zinc-100/25 dark:border-t-slate-700/50"
/>
</div>
</div>

<!-- Privacy Policy Modal for authentication flow -->
<PrivacyPolicyModal
Expand Down
154 changes: 127 additions & 27 deletions app/src/components/BasePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { queryParams } from "@/globalConfig";
import type { ContentDto } from "luminary-shared";
import { ChevronLeftIcon } from "@heroicons/vue/24/outline";
import { useBackNavigation } from "@/composables/useBackNavigation";
import { useMobileChromeAutoHide } from "@/composables/useMobileChromeAutoHide";

const showNotifications = !queryParams.has("supress-notifications");

Expand All @@ -19,16 +20,51 @@ const isSSG = import.meta.env.VITE_BUILD_TARGET === "web";
const SSG_NOTIFICATION_DELAY_MS = 3000;
const notificationsReady = ref(!isSSG);

defineProps<{
const props = defineProps<{
content?: ContentDto;
showBackButton?: boolean;
desktopTopBar?: boolean;
/** The centre slot is occupied before any scrolling; keep the row below the chrome clear for it. */
reserveTopBarCenter?: boolean;
}>();

const { onBackClick } = useBackNavigation();

const main = ref<HTMLElement | undefined>(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 <main> 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);
Expand All @@ -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();
});
</script>

Expand All @@ -57,13 +96,25 @@ onUnmounted(() => {

<!-- Content column -->
<div class="flex min-w-0 flex-1 flex-col scrollbar-hide">
<!-- Mobile top bar -->
<TopBar
:showBackButton="showBackButton"
class="border-b-2 border-b-zinc-200/50 dark:border-b-slate-950/50 lg:hidden"
<!-- Mobile top bar. Collapses via grid rows (no height to measure) while the
reader scrolls down, and returns on the first scroll up. -->
<div
class="grid transition-[grid-template-rows] duration-300 ease-out lg:hidden"
:class="
mobileChrome.hidden.value
? '[grid-template-rows:0fr]'
: '[grid-template-rows:1fr]'
"
>
<template #quickControls><slot name="quickControls" /></template>
</TopBar>
<div class="min-h-0 overflow-hidden">
<TopBar
:showBackButton="showBackButton"
class="border-b-2 border-b-zinc-200/50 dark:border-b-slate-950/50"
>
<template #quickControls><slot name="quickControls" /></template>
</TopBar>
</div>
</div>

<Teleport
v-if="notificationsReady"
Expand All @@ -73,37 +124,86 @@ onUnmounted(() => {
</Teleport>

<main
class="flex-1 overflow-y-scroll px-2 py-2 scrollbar-hide focus:outline-none dark:bg-slate-900 md:max-lg:px-4"
class="flex-1 overflow-y-scroll px-2 pb-2 scrollbar-hide focus:outline-none dark:bg-slate-900 md:max-lg:px-4"
:class="desktopTopBar ? 'pt-0' : 'pt-2'"
ref="main"
>
<!-- Desktop pinned chrome: back (left) + quick controls (right) stay fixed while scrolling.
Direct child of the scrolling <main> so `sticky` keeps it pinned the whole way.
-mb-9 collapses its flow height so page content originates at the top of the page,
sharing this row; pointer-events-none lets clicks fall through the empty centre. -->
<main> 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 <main>'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. -->
<div
v-if="desktopTopBar"
class="pointer-events-none sticky top-0 z-20 -mb-9 hidden h-9 items-center lg:flex"
class="pointer-events-none sticky top-0 z-20 -mx-2 -mb-14 hidden h-16 items-start px-2 pt-2 lg:flex"
>
<RouterLink
v-if="showBackButton"
:to="{ name: 'home' }"
v-slot="{ href }"
custom
>
<a
:href="href"
class="pointer-events-auto flex-shrink-0 rounded-md p-1 text-zinc-600 hover:bg-zinc-200 dark:text-slate-100 dark:hover:bg-slate-700"
@click="onBackClick($event)"
aria-label="Go back"
<div
:class="[topChromeFade, scrolled ? 'opacity-100' : 'opacity-0']"
aria-hidden="true"
/>
<div class="relative flex h-9 w-full items-center">
<!-- Centred on the full row (= the content column's axis) rather than
on the space left between the two asymmetric control groups.
top-0.5 matches the 2px inset the centred back/quick controls get
from being shorter than the row, so the pill's top edge lines up
with theirs instead of sitting flush against the row top. -->
<div class="pointer-events-none absolute inset-x-0 top-0.5 flex justify-center">
<div class="pointer-events-auto flex min-w-0 max-w-[calc(100%-16rem)]">
<slot name="topBarCenter" />
</div>
</div>
<RouterLink
v-if="showBackButton"
:to="{ name: 'home' }"
v-slot="{ href }"
custom
>
<ChevronLeftIcon class="h-5 w-5" />
</a>
</RouterLink>
<div class="pointer-events-auto ml-auto flex items-center gap-2 pr-2">
<slot name="quickControls" />
<a
:href="href"
class="pointer-events-auto relative z-10 flex-shrink-0 p-1.5 text-zinc-600 hover:bg-zinc-200 dark:text-slate-100 dark:hover:bg-slate-600"
:class="[controlBacking, { [controlBackingOn]: scrolled }]"
@click="onBackClick($event)"
aria-label="Go back"
>
<ChevronLeftIcon class="h-5 w-5" />
</a>
</RouterLink>
<div
class="pointer-events-auto relative z-10 ml-auto flex items-center gap-2 pr-2"
:class="[quickControlBacking, { [quickControlBackingOn]: scrolled }]"
>
<slot name="quickControls" />
</div>
</div>
</div>

<!-- Mobile counterpart of the centre slot: pinned at the top of the scrolling area with
the same collapsed flow height, so it floats over the content. -->
<div
v-if="desktopTopBar && $slots.topBarCenter"
class="pointer-events-none sticky top-0 z-20 -mx-2 -mb-14 flex h-16 items-start justify-center px-2 pt-2 md:-mx-4 md:px-4 lg:hidden"
>
<div
:class="[topChromeFade, scrolled ? 'opacity-100' : 'opacity-0']"
aria-hidden="true"
/>
<div
class="pointer-events-auto relative flex h-9 min-w-0 max-w-full items-center justify-center"
>
<slot name="topBarCenter" />
</div>
</div>

<!-- Spacer that keeps in-flow content (banners, the page title) out from under a
centre-slot control that is showing before the page has scrolled. -->
<div
v-if="desktopTopBar && reserveTopBarCenter"
class="h-11"
/>

<!-- Desktop notification: normal flow below the pinned chrome; pushes article down when present.
[&>div]:mb-2 trims the banner's default mb-4 so the gap above the title matches the page-top gap. -->
<div
Expand Down
14 changes: 8 additions & 6 deletions app/src/components/common/DropdownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defineOptions({ inheritAttrs: false });

const props = withDefaults(
defineProps<{
placement?: "bottom-end" | "bottom-start" | "top-start" | "top-end";
placement?: "bottom-end" | "bottom-start" | "bottom-center" | "top-start" | "top-end";
panelClass?: string;
}>(),
{ placement: "bottom-end" },
Expand Down Expand Up @@ -53,11 +53,13 @@ onUnmounted(() => {
const placementClasses =
props.placement === "bottom-start"
? "left-0 origin-top-left"
: props.placement === "top-start"
? "bottom-full mb-2 left-0 origin-bottom-left"
: props.placement === "top-end"
? "bottom-full mb-2 right-0 origin-bottom-right"
: "right-0 origin-top-right";
: props.placement === "bottom-center"
? "left-1/2 -translate-x-1/2 origin-top"
: props.placement === "top-start"
? "bottom-full mb-2 left-0 origin-bottom-left"
: props.placement === "top-end"
? "bottom-full mb-2 right-0 origin-bottom-right"
: "right-0 origin-top-right";
</script>

<template>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/content/ContentTile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ describe("ContentTile", () => {
});

expect(wrapper.html()).toContain('style="width: 45%');
// Reading progress uses the ContinueReadingPrompt bar style (yellow fill).
// Reading progress uses the shared progress-bar style (yellow fill).
expect(wrapper.html()).toContain("bg-yellow-500");
});

Expand Down
2 changes: 1 addition & 1 deletion app/src/components/content/ContentTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ const displayProgress = computed(() => Math.max(mediaProgress.value, readingProg
</p>
</div>

<!-- Progress (playback or reading): same bar design as ContinueReadingPrompt, on the image's bottom edge. -->
<!-- Progress (playback or reading): same bar design as the reading pill, on the image's bottom edge. -->
<div
v-if="showProgress && displayProgress > 0"
class="absolute inset-x-0 bottom-0 z-20 h-1 overflow-hidden rounded-b-lg bg-zinc-200 dark:bg-slate-600"
Expand Down
Loading
Loading