Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions src/components/app/ChapterView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@
function pieceLinked(pieceId: string) {
return linkedCopy?.pieces.find((p) => p.id === pieceId);
}

function returnToShelf(event: MouseEvent) {
if (!chapter || game.chapterKind !== "shelf-chapter") return;
// Keep href for open-in-new-tab / middle-click; primary click stays in-app
// so we can restore the shelf scroll captured when the chapter was opened.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
event.preventDefault();
game.openShelf(chapter.unitId, { focusChapterId: chapter.id });
}
</script>

{#if chapter && linkedCopy}
Expand All @@ -91,6 +102,7 @@
<a
href={learnShelfPath(chapter.unitId)}
class="mb-3 inline-block text-sm text-muted-foreground underline-offset-4 hover:underline"
onclick={returnToShelf}
>
← Back to shelf
</a>
Expand Down Expand Up @@ -290,6 +302,7 @@
variant="outline"
class="w-full sm:h-11 sm:text-base"
href={learnShelfPath(chapter.unitId)}
onclick={returnToShelf}
>
Browse chapters
</Button>
Expand Down
20 changes: 17 additions & 3 deletions src/components/app/ShelfView.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<script lang="ts">
import { tick } from "svelte";
import { tick, untrack } from "svelte";
import CheckIcon from "@lucide/svelte/icons/check";

import { UNITS } from "$lib/far/deck";
import { SHELF_HIDE_READ_KEY } from "$lib/far/constants";
import type { UnitId } from "$lib/far/types";
import { resolveChapterTag } from "$lib/far/glossary";
import { learnChapterPath, learnShelfPath } from "$lib/learn-routes";
import { restoreShelfViewport, saveShelfScroll } from "$lib/shelf-scroll";
import { game } from "$lib/quiz-state.svelte.js";
import { Button } from "$lib/components/ui/button";
import TopicPill from "./TopicPill.svelte";
Expand Down Expand Up @@ -56,6 +57,11 @@
};
}

function rememberScrollBeforeChapter() {
if (!shelf) return;
saveShelfScroll(shelf.unitId);
}

function setHideRead(next: boolean) {
hideRead = next;
persistHideReadPref(next);
Expand All @@ -68,9 +74,15 @@
$effect(() => {
const current = shelf;
if (!current) return;
void tick().then(() => {
const unitId = current.unitId;
// One-shot focus id from Back to chapters / Browse chapters.
const focusChapterId = untrack(() => game.pendingShelfFocusChapterId);
void tick().then(async () => {
if (game.shelf !== current) return;
headingEl?.focus();
// Keep a11y focus without jumping the viewport to the heading.
headingEl?.focus({ preventScroll: true });
game.pendingShelfFocusChapterId = null;
await restoreShelfViewport(unitId, { focusChapterId });
});
});

Expand Down Expand Up @@ -137,11 +149,13 @@
{@const read = game.isChapterRead(ch.id)}
{@const pills = loadBearingTags(ch.tags)}
<li
id={`shelf-chapter-${ch.id}`}
class="rounded-2xl border-2 border-border bg-card transition-colors hover:border-primary/50 hover:bg-muted/30"
>
<a
href={learnChapterPath(shelf.unitId, ch.id)}
class="flex w-full flex-col gap-2 p-4 text-left sm:p-5"
onclick={rememberScrollBeforeChapter}
>
<div class="flex items-center justify-between gap-2">
<span class="text-xs tabular-nums text-muted-foreground sm:text-sm">
Expand Down
9 changes: 8 additions & 1 deletion src/components/app/SummaryView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,14 @@

<div class="flex w-full flex-col gap-2">
{#if s.mode === "chapter" && s.unit}
<Button size="lg" onclick={() => s?.unit && game.openShelf(s.unit.id)}>
<Button
size="lg"
onclick={() =>
s?.unit &&
game.openShelf(s.unit.id, {
focusChapterId: s.chapterId,
})}
>
Back to chapters
</Button>
{:else if s.mode === "unit" && s.unit && unitNotPrime}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/far/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const TIER_VERDICT: Record<OptionTier, { label: string; tone: Tone }> = {
export const PROGRESS_KEY = "learn-the-far:quiz-progress:v2";
/** Separate from quiz mastery — reading never clears the pie. */
export const READING_PROGRESS_KEY = "learn-the-far:reading-progress:v1";
/** sessionStorage: shelf scrollY before entering a chapter (per unit). */
export const SHELF_SCROLL_KEY_PREFIX = "learn-the-far:shelf-scroll:v1:";
/** Shelf preference: hide chapters already marked read. */
export const SHELF_HIDE_READ_KEY = "learn-the-far:shelf-hide-read:v1";

Expand Down
8 changes: 7 additions & 1 deletion src/lib/quiz-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ export class QuizGame {
/** Unit reference shelf (multi-chapter hub). */
shelf = $state<ChapterShelf | null>(null);
reading = $state<ReadingProgress>(emptyReadingProgress());
/**
* One-shot: after Back to chapters, ShelfView scrolls this chapter into view
* when no saved window scroll position is available.
*/
pendingShelfFocusChapterId = $state<string | null>(null);

// current question interaction
answeredOptionId = $state<string | null>(null);
Expand Down Expand Up @@ -472,12 +477,13 @@ export class QuizGame {
}
}

openShelf(unitId: UnitId) {
openShelf(unitId: UnitId, opts?: { focusChapterId?: string }) {
this.shelf = shelfForUnit(unitId);
this.chapter = null;
this.chapterKind = null;
this.summary = null;
this.activeChapterId = null;
this.pendingShelfFocusChapterId = opts?.focusChapterId ?? null;
this.view = "shelf";
this.reading = { ...this.reading, lastUnitId: unitId };
saveReadingProgress(this.reading);
Expand Down
85 changes: 85 additions & 0 deletions src/lib/shelf-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { SHELF_SCROLL_KEY_PREFIX } from "$lib/far/constants";
import type { UnitId } from "$lib/far/types";

function canUseSessionStorage() {
return typeof window !== "undefined" && "sessionStorage" in window;
}

function keyFor(unitId: UnitId): string {
return `${SHELF_SCROLL_KEY_PREFIX}${unitId}`;
}

/** Remember where the learner was on a unit shelf before opening a chapter. */
export function saveShelfScroll(unitId: UnitId, scrollY = window.scrollY) {
if (!canUseSessionStorage()) return;
try {
const y = Math.max(0, Math.round(scrollY));
window.sessionStorage.setItem(keyFor(unitId), String(y));
} catch {
// Ignore quota / private-mode failures.
}
}

/** Peek at a saved shelf scroll without clearing it. */
export function peekShelfScroll(unitId: UnitId): number | null {
if (!canUseSessionStorage()) return null;
try {
const raw = window.sessionStorage.getItem(keyFor(unitId));
if (raw == null) return null;
const y = Number(raw);
return Number.isFinite(y) && y >= 0 ? y : null;
} catch {
return null;
}
}

/** Clear a saved shelf scroll after a successful restore (or intentional discard). */
export function clearShelfScroll(unitId: UnitId) {
if (!canUseSessionStorage()) return;
try {
window.sessionStorage.removeItem(keyFor(unitId));
} catch {
// Ignore.
}
}

function frames(count: number): Promise<void> {
return new Promise((resolve) => {
const step = (n: number) => {
if (n <= 0) {
resolve();
return;
}
requestAnimationFrame(() => step(n - 1));
};
step(count);
});
}

/**
* Restore the shelf viewport after returning from a chapter.
* Prefers the saved window scroll; falls back to centering the chapter card.
* Clears the saved scroll only after applying it so a premature layout pass
* cannot wipe the value before the list has height.
*/
export async function restoreShelfViewport(
unitId: UnitId,
opts?: { focusChapterId?: string | null },
): Promise<void> {
const savedY = peekShelfScroll(unitId);
await frames(2);

if (savedY != null) {
window.scrollTo({ top: savedY, left: 0, behavior: "auto" });
// Second pass after late layout (fonts, wrapping) can still shift height.
await frames(1);
window.scrollTo({ top: savedY, left: 0, behavior: "auto" });
clearShelfScroll(unitId);
return;
}

const chapterId = opts?.focusChapterId;
if (!chapterId) return;
const card = document.getElementById(`shelf-chapter-${chapterId}`);
card?.scrollIntoView({ block: "center", behavior: "auto" });
}