summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/smart-scroll')
-rw-r--r--src/features/smart-scroll/index.ts28
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts156
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts56
-rw-r--r--src/features/smart-scroll/ui/ScrollToBottom.svelte60
-rw-r--r--src/features/smart-scroll/ui/controller.svelte.ts242
-rw-r--r--src/features/smart-scroll/ui/controller.test.ts296
6 files changed, 419 insertions, 419 deletions
diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts
index 0d30257..73b11d6 100644
--- a/src/features/smart-scroll/index.ts
+++ b/src/features/smart-scroll/index.ts
@@ -1,17 +1,17 @@
export type {
- ScrollCommand,
- ScrollGeometry,
- SmartScrollResult,
- SmartScrollState,
+ ScrollCommand,
+ ScrollGeometry,
+ SmartScrollResult,
+ SmartScrollState,
} from "./logic/smart-scroll";
export {
- createSmartScrollState,
- isNearBottom,
- NEAR_BOTTOM_THRESHOLD,
- onContentChange,
- onReset,
- onResume,
- onScroll,
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
} from "./logic/smart-scroll";
export type { SmartScrollController } from "./ui/controller.svelte";
export { createSmartScrollController } from "./ui/controller.svelte";
@@ -19,7 +19,7 @@ export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "smart-scroll",
- description:
- "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up",
+ name: "smart-scroll",
+ description:
+ "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up",
} as const;
diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts
index fc3e3d1..94ba1d7 100644
--- a/src/features/smart-scroll/logic/smart-scroll.test.ts
+++ b/src/features/smart-scroll/logic/smart-scroll.test.ts
@@ -1,103 +1,103 @@
import { describe, expect, it } from "vitest";
import {
- createSmartScrollState,
- isNearBottom,
- NEAR_BOTTOM_THRESHOLD,
- onContentChange,
- onReset,
- onResume,
- onScroll,
- type ScrollGeometry,
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollGeometry,
} from "./smart-scroll";
// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom.
const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 };
const nearBottom: ScrollGeometry = {
- scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
- scrollHeight: 1000,
- clientHeight: 100,
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
+ scrollHeight: 1000,
+ clientHeight: 100,
};
const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 };
describe("isNearBottom", () => {
- it("is true exactly at the bottom", () => {
- expect(isNearBottom(atBottom)).toBe(true);
- });
+ it("is true exactly at the bottom", () => {
+ expect(isNearBottom(atBottom)).toBe(true);
+ });
- it("is true within the threshold of the bottom", () => {
- expect(isNearBottom(nearBottom)).toBe(true);
- });
+ it("is true within the threshold of the bottom", () => {
+ expect(isNearBottom(nearBottom)).toBe(true);
+ });
- it("is false just beyond the threshold", () => {
- expect(
- isNearBottom({
- scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
- scrollHeight: 1000,
- clientHeight: 100,
- }),
- ).toBe(false);
- });
+ it("is false just beyond the threshold", () => {
+ expect(
+ isNearBottom({
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
+ scrollHeight: 1000,
+ clientHeight: 100,
+ }),
+ ).toBe(false);
+ });
- it("is false when scrolled well up", () => {
- expect(isNearBottom(scrolledUp)).toBe(false);
- });
+ it("is false when scrolled well up", () => {
+ expect(isNearBottom(scrolledUp)).toBe(false);
+ });
- it("honours a custom threshold", () => {
- const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
- expect(isNearBottom(geom, 50)).toBe(false);
- expect(isNearBottom(geom, 150)).toBe(true);
- });
+ it("honours a custom threshold", () => {
+ const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
+ expect(isNearBottom(geom, 50)).toBe(false);
+ expect(isNearBottom(geom, 150)).toBe(true);
+ });
});
describe("smart-scroll reducer", () => {
- it("starts stuck and hides the button", () => {
- const s = createSmartScrollState();
- expect(s.stuck).toBe(true);
- });
+ it("starts stuck and hides the button", () => {
+ const s = createSmartScrollState();
+ expect(s.stuck).toBe(true);
+ });
- it("onScroll up unsticks and shows the button, with no command", () => {
- const r = onScroll(createSmartScrollState(), scrolledUp);
- expect(r.state.stuck).toBe(false);
- expect(r.showButton).toBe(true);
- expect(r.command).toBeNull();
- });
+ it("onScroll up unsticks and shows the button, with no command", () => {
+ const r = onScroll(createSmartScrollState(), scrolledUp);
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ expect(r.command).toBeNull();
+ });
- it("onScroll back to the bottom re-sticks and hides the button", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onScroll(up, atBottom);
- expect(r.state.stuck).toBe(true);
- expect(r.showButton).toBe(false);
- expect(r.command).toBeNull();
- });
+ it("onScroll back to the bottom re-sticks and hides the button", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onScroll(up, atBottom);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toBeNull();
+ });
- it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
- const r = onContentChange(createSmartScrollState(), atBottom);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
- expect(r.state.stuck).toBe(true);
- });
+ it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
+ const r = onContentChange(createSmartScrollState(), atBottom);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.state.stuck).toBe(true);
+ });
- it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onContentChange(up, scrolledUp);
- expect(r.command).toBeNull();
- expect(r.state.stuck).toBe(false);
- expect(r.showButton).toBe(true);
- });
+ it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onContentChange(up, scrolledUp);
+ expect(r.command).toBeNull();
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ });
- it("onResume re-sticks and emits an ANIMATED scroll", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onResume(up);
- expect(r.state.stuck).toBe(true);
- expect(r.showButton).toBe(false);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
- });
+ it("onResume re-sticks and emits an ANIMATED scroll", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onResume(up);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
+ });
- it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onReset();
- void up;
- expect(r.state.stuck).toBe(true);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
- expect(r.showButton).toBe(false);
- });
+ it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onReset();
+ void up;
+ expect(r.state.stuck).toBe(true);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.showButton).toBe(false);
+ });
});
diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts
index 021b3fe..4a04812 100644
--- a/src/features/smart-scroll/logic/smart-scroll.ts
+++ b/src/features/smart-scroll/logic/smart-scroll.ts
@@ -6,12 +6,12 @@
/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */
export interface ScrollGeometry {
- /** Current scroll offset from the top. */
- readonly scrollTop: number;
- /** Total scrollable content height. */
- readonly scrollHeight: number;
- /** Visible viewport height. */
- readonly clientHeight: number;
+ /** Current scroll offset from the top. */
+ readonly scrollTop: number;
+ /** Total scrollable content height. */
+ readonly scrollHeight: number;
+ /** Visible viewport height. */
+ readonly clientHeight: number;
}
/** Distance (px) from the bottom within which we still consider the view "at bottom". */
@@ -19,43 +19,43 @@ export const NEAR_BOTTOM_THRESHOLD = 64;
/** True when the viewport is within `threshold` px of the content's bottom edge. */
export function isNearBottom(
- geom: ScrollGeometry,
- threshold: number = NEAR_BOTTOM_THRESHOLD,
+ geom: ScrollGeometry,
+ threshold: number = NEAR_BOTTOM_THRESHOLD,
): boolean {
- return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
+ return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
}
/** A scroll the shell should perform on the real element. */
export interface ScrollCommand {
- readonly kind: "scroll-to-bottom";
- /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
- readonly animate: boolean;
+ readonly kind: "scroll-to-bottom";
+ /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
+ readonly animate: boolean;
}
export interface SmartScrollState {
- /**
- * Whether the view is currently following the bottom. While `stuck`, new
- * content keeps the view pinned to the bottom; once the user scrolls up it
- * goes false and stays false until they return to the bottom (or resume).
- */
- readonly stuck: boolean;
+ /**
+ * Whether the view is currently following the bottom. While `stuck`, new
+ * content keeps the view pinned to the bottom; once the user scrolls up it
+ * goes false and stays false until they return to the bottom (or resume).
+ */
+ readonly stuck: boolean;
}
/** A reducer step's result: the next state, an optional command, and whether to show the button. */
export interface SmartScrollResult {
- readonly state: SmartScrollState;
- readonly command: ScrollCommand | null;
- /** Show the "scroll to bottom" affordance exactly when not stuck. */
- readonly showButton: boolean;
+ readonly state: SmartScrollState;
+ readonly command: ScrollCommand | null;
+ /** Show the "scroll to bottom" affordance exactly when not stuck. */
+ readonly showButton: boolean;
}
/** Initial state — start stuck so the first content snaps to the bottom. */
export function createSmartScrollState(): SmartScrollState {
- return { stuck: true };
+ return { stuck: true };
}
function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult {
- return { state, command, showButton: !state.stuck };
+ return { state, command, showButton: !state.stuck };
}
/**
@@ -64,7 +64,7 @@ function result(state: SmartScrollState, command: ScrollCommand | null): SmartSc
* command — reacting to the user's own scroll with a scroll would fight them.
*/
export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult {
- return result({ stuck: isNearBottom(geom) }, null);
+ return result({ stuck: isNearBottom(geom) }, null);
}
/**
@@ -73,7 +73,7 @@ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartS
* they are. State is unchanged — content growth alone never flips `stuck`.
*/
export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult {
- return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
+ return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
}
/**
@@ -81,7 +81,7 @@ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry):
* emit an animated scroll.
*/
export function onResume(_state: SmartScrollState): SmartScrollResult {
- return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
+ return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
}
/**
@@ -89,5 +89,5 @@ export function onResume(_state: SmartScrollState): SmartScrollResult {
* Reset to stuck and snap (non-animated) to the bottom of the new content.
*/
export function onReset(): SmartScrollResult {
- return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
+ return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
}
diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte
index 6fbd326..38ec1f5 100644
--- a/src/features/smart-scroll/ui/ScrollToBottom.svelte
+++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte
@@ -1,36 +1,36 @@
<script lang="ts">
- // Thin affordance: a floating "scroll to bottom" button shown while the reader
- // has scrolled up. Holds no logic — `show` and `onResume` come from the
- // smart-scroll controller.
- let {
- show,
- onResume,
- }: {
- show: boolean;
- onResume: () => void;
- } = $props();
+ // Thin affordance: a floating "scroll to bottom" button shown while the reader
+ // has scrolled up. Holds no logic — `show` and `onResume` come from the
+ // smart-scroll controller.
+ let {
+ show,
+ onResume,
+ }: {
+ show: boolean;
+ onResume: () => void;
+ } = $props();
</script>
<button
- type="button"
- class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
- class:opacity-0={!show}
- class:pointer-events-none={!show}
- class:opacity-100={show}
- onclick={onResume}
- aria-label="Scroll to bottom"
- aria-hidden={!show}
- tabindex={show ? 0 : -1}
+ type="button"
+ class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
+ class:opacity-0={!show}
+ class:pointer-events-none={!show}
+ class:opacity-100={show}
+ onclick={onResume}
+ aria-label="Scroll to bottom"
+ aria-hidden={!show}
+ tabindex={show ? 0 : -1}
>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- class="size-4"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
- </svg>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
+ </svg>
</button>
diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts
index dbe65d1..fee6561 100644
--- a/src/features/smart-scroll/ui/controller.svelte.ts
+++ b/src/features/smart-scroll/ui/controller.svelte.ts
@@ -7,134 +7,134 @@
// it on unmount.
import {
- createSmartScrollState,
- onContentChange,
- onReset,
- onResume,
- onScroll,
- type ScrollCommand,
- type ScrollGeometry,
- type SmartScrollResult,
- type SmartScrollState,
+ createSmartScrollState,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollCommand,
+ type ScrollGeometry,
+ type SmartScrollResult,
+ type SmartScrollState,
} from "../logic/smart-scroll";
export interface SmartScrollController {
- /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
- readonly showButton: boolean;
- /**
- * Non-reactive point-in-time query: is the view stuck to the bottom right now?
- * For imperative callers (e.g. the chat-limit unload gate) that poll at event
- * time rather than subscribing — reads the reducer state, not a rune.
- */
- isAtBottom(): boolean;
- /**
- * Attach to the scroll container; returns a teardown to call on unmount.
- * Pass the inner CONTENT element to also follow height changes that aren't a
- * transcript update (async markdown/highlight, image loads, a collapse toggling,
- * viewport reflow) via a ResizeObserver.
- */
- attach(el: HTMLElement, content?: HTMLElement): () => void;
- /**
- * Notify that the transcript content changed (a streamed delta / new message).
- * While stuck, keeps the view pinned to the bottom.
- */
- contentChanged(): void;
- /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
- reset(): void;
- /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
- resume(): void;
+ /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
+ readonly showButton: boolean;
+ /**
+ * Non-reactive point-in-time query: is the view stuck to the bottom right now?
+ * For imperative callers (e.g. the chat-limit unload gate) that poll at event
+ * time rather than subscribing — reads the reducer state, not a rune.
+ */
+ isAtBottom(): boolean;
+ /**
+ * Attach to the scroll container; returns a teardown to call on unmount.
+ * Pass the inner CONTENT element to also follow height changes that aren't a
+ * transcript update (async markdown/highlight, image loads, a collapse toggling,
+ * viewport reflow) via a ResizeObserver.
+ */
+ attach(el: HTMLElement, content?: HTMLElement): () => void;
+ /**
+ * Notify that the transcript content changed (a streamed delta / new message).
+ * While stuck, keeps the view pinned to the bottom.
+ */
+ contentChanged(): void;
+ /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
+ reset(): void;
+ /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
+ resume(): void;
}
function geometryOf(el: HTMLElement): ScrollGeometry {
- return {
- scrollTop: el.scrollTop,
- scrollHeight: el.scrollHeight,
- clientHeight: el.clientHeight,
- };
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ };
}
export function createSmartScrollController(): SmartScrollController {
- let state: SmartScrollState = createSmartScrollState();
- let showButton = $state(false);
- let el: HTMLElement | null = null;
- // True while WE drive a programmatic scroll, so the resulting `scroll` event
- // doesn't get misread as the user scrolling up. Cleared on `scrollend`.
- let selfScrolling = false;
-
- function run(command: ScrollCommand | null): void {
- if (!command || !el) return;
- selfScrolling = true;
- el.scrollTo({
- top: el.scrollHeight,
- behavior: command.animate ? "smooth" : "instant",
- });
- }
-
- function apply(r: SmartScrollResult): void {
- state = r.state;
- showButton = r.showButton;
- run(r.command);
- }
-
- function handleScroll(): void {
- if (!el || selfScrolling) return;
- apply(onScroll(state, geometryOf(el)));
- }
-
- function handleScrollEnd(): void {
- selfScrolling = false;
- }
-
- return {
- get showButton(): boolean {
- return showButton;
- },
-
- isAtBottom(): boolean {
- return state.stuck;
- },
-
- attach(node: HTMLElement, content?: HTMLElement): () => void {
- el = node;
- node.addEventListener("scroll", handleScroll, { passive: true });
- node.addEventListener("scrollend", handleScrollEnd);
-
- // A ResizeObserver keeps the view pinned through height changes that are
- // NOT a transcript update — async markdown/syntax-highlight, image loads, a
- // collapse toggling, font swaps, viewport reflow — which a content-count
- // signal can't see. Observe the CONTENT (it grows) and the container (it
- // changes on viewport resize). Routed through `onContentChange`, so it only
- // scrolls while stuck and never fights the reader. The `selfScrolling` guard
- // (and the fact that scrolling doesn't resize content) prevents any loop.
- let ro: ResizeObserver | null = null;
- if (typeof ResizeObserver !== "undefined") {
- ro = new ResizeObserver(() => {
- if (!el || selfScrolling) return;
- apply(onContentChange(state, geometryOf(el)));
- });
- if (content) ro.observe(content);
- ro.observe(node);
- }
-
- return () => {
- node.removeEventListener("scroll", handleScroll);
- node.removeEventListener("scrollend", handleScrollEnd);
- ro?.disconnect();
- if (el === node) el = null;
- };
- },
-
- contentChanged(): void {
- if (!el) return;
- apply(onContentChange(state, geometryOf(el)));
- },
-
- reset(): void {
- apply(onReset());
- },
-
- resume(): void {
- apply(onResume(state));
- },
- };
+ let state: SmartScrollState = createSmartScrollState();
+ let showButton = $state(false);
+ let el: HTMLElement | null = null;
+ // True while WE drive a programmatic scroll, so the resulting `scroll` event
+ // doesn't get misread as the user scrolling up. Cleared on `scrollend`.
+ let selfScrolling = false;
+
+ function run(command: ScrollCommand | null): void {
+ if (!command || !el) return;
+ selfScrolling = true;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: command.animate ? "smooth" : "instant",
+ });
+ }
+
+ function apply(r: SmartScrollResult): void {
+ state = r.state;
+ showButton = r.showButton;
+ run(r.command);
+ }
+
+ function handleScroll(): void {
+ if (!el || selfScrolling) return;
+ apply(onScroll(state, geometryOf(el)));
+ }
+
+ function handleScrollEnd(): void {
+ selfScrolling = false;
+ }
+
+ return {
+ get showButton(): boolean {
+ return showButton;
+ },
+
+ isAtBottom(): boolean {
+ return state.stuck;
+ },
+
+ attach(node: HTMLElement, content?: HTMLElement): () => void {
+ el = node;
+ node.addEventListener("scroll", handleScroll, { passive: true });
+ node.addEventListener("scrollend", handleScrollEnd);
+
+ // A ResizeObserver keeps the view pinned through height changes that are
+ // NOT a transcript update — async markdown/syntax-highlight, image loads, a
+ // collapse toggling, font swaps, viewport reflow — which a content-count
+ // signal can't see. Observe the CONTENT (it grows) and the container (it
+ // changes on viewport resize). Routed through `onContentChange`, so it only
+ // scrolls while stuck and never fights the reader. The `selfScrolling` guard
+ // (and the fact that scrolling doesn't resize content) prevents any loop.
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(() => {
+ if (!el || selfScrolling) return;
+ apply(onContentChange(state, geometryOf(el)));
+ });
+ if (content) ro.observe(content);
+ ro.observe(node);
+ }
+
+ return () => {
+ node.removeEventListener("scroll", handleScroll);
+ node.removeEventListener("scrollend", handleScrollEnd);
+ ro?.disconnect();
+ if (el === node) el = null;
+ };
+ },
+
+ contentChanged(): void {
+ if (!el) return;
+ apply(onContentChange(state, geometryOf(el)));
+ },
+
+ reset(): void {
+ apply(onReset());
+ },
+
+ resume(): void {
+ apply(onResume(state));
+ },
+ };
}
diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts
index 614f4b0..906cc91 100644
--- a/src/features/smart-scroll/ui/controller.test.ts
+++ b/src/features/smart-scroll/ui/controller.test.ts
@@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte";
// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend".
// Faking this outermost edge is the sanctioned mock (no internal modules mocked).
function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) {
- const listeners = new Map<string, Set<EventListener>>();
- const el = {
- scrollTop: 0,
- scrollHeight: opts?.scrollHeight ?? 1000,
- clientHeight: opts?.clientHeight ?? 100,
- scrollTo: vi.fn((arg: ScrollToOptions) => {
- // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
- el.scrollTop = (arg.top ?? 0) - 0;
- if (arg.behavior !== "smooth") {
- fire("scroll");
- fire("scrollend");
- }
- }),
- addEventListener: (type: string, fn: EventListener) => {
- if (!listeners.has(type)) listeners.set(type, new Set());
- listeners.get(type)?.add(fn);
- },
- removeEventListener: (type: string, fn: EventListener) => {
- listeners.get(type)?.delete(fn);
- },
- };
- function fire(type: string): void {
- for (const fn of listeners.get(type) ?? []) fn(new Event(type));
- }
- // Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
- function userScrollTo(top: number): void {
- el.scrollTop = top;
- fire("scroll");
- }
- return {
- el: el as unknown as HTMLElement,
- scrollTo: el.scrollTo,
- fire,
- userScrollTo,
- listenerCount: () => listeners,
- };
+ const listeners = new Map<string, Set<EventListener>>();
+ const el = {
+ scrollTop: 0,
+ scrollHeight: opts?.scrollHeight ?? 1000,
+ clientHeight: opts?.clientHeight ?? 100,
+ scrollTo: vi.fn((arg: ScrollToOptions) => {
+ // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
+ el.scrollTop = (arg.top ?? 0) - 0;
+ if (arg.behavior !== "smooth") {
+ fire("scroll");
+ fire("scrollend");
+ }
+ }),
+ addEventListener: (type: string, fn: EventListener) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type)?.add(fn);
+ },
+ removeEventListener: (type: string, fn: EventListener) => {
+ listeners.get(type)?.delete(fn);
+ },
+ };
+ function fire(type: string): void {
+ for (const fn of listeners.get(type) ?? []) fn(new Event(type));
+ }
+ // Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
+ function userScrollTo(top: number): void {
+ el.scrollTop = top;
+ fire("scroll");
+ }
+ return {
+ el: el as unknown as HTMLElement,
+ scrollTo: el.scrollTo,
+ fire,
+ userScrollTo,
+ listenerCount: () => listeners,
+ };
}
describe("smart-scroll controller", () => {
- it("starts with the button hidden", () => {
- const c = createSmartScrollController();
- expect(c.showButton).toBe(false);
- });
+ it("starts with the button hidden", () => {
+ const c = createSmartScrollController();
+ expect(c.showButton).toBe(false);
+ });
- it("contentChanged while stuck scrolls to the bottom instantly", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- c.contentChanged();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "instant",
- });
- expect(c.showButton).toBe(false);
- });
+ it("contentChanged while stuck scrolls to the bottom instantly", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ c.contentChanged();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("a user scroll up shows the button and stops auto-following", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200); // far from the bottom
- expect(c.showButton).toBe(true);
+ it("a user scroll up shows the button and stops auto-following", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200); // far from the bottom
+ expect(c.showButton).toBe(true);
- const scrollTo = fake.scrollTo;
- scrollTo.mockClear();
- c.contentChanged(); // streaming more content...
- expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
- expect(c.showButton).toBe(true);
- });
+ const scrollTo = fake.scrollTo;
+ scrollTo.mockClear();
+ c.contentChanged(); // streaming more content...
+ expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
+ expect(c.showButton).toBe(true);
+ });
- it("self-driven scrolls are not misread as the user scrolling up", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- // contentChanged drives an instant scrollTo, whose synthetic scroll event
- // must NOT flip us to unstuck (selfScrolling guard).
- c.contentChanged();
- expect(c.showButton).toBe(false);
- });
+ it("self-driven scrolls are not misread as the user scrolling up", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ // contentChanged drives an instant scrollTo, whose synthetic scroll event
+ // must NOT flip us to unstuck (selfScrolling guard).
+ c.contentChanged();
+ expect(c.showButton).toBe(false);
+ });
- it("resume re-sticks and smooth-scrolls to the bottom", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200);
- expect(c.showButton).toBe(true);
+ it("resume re-sticks and smooth-scrolls to the bottom", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
- c.resume();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "smooth",
- });
- expect(c.showButton).toBe(false);
- });
+ c.resume();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "smooth",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("reset snaps to the bottom and hides the button", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200);
- expect(c.showButton).toBe(true);
- c.reset();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "instant",
- });
- expect(c.showButton).toBe(false);
- });
+ it("reset snaps to the bottom and hides the button", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
+ c.reset();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
- const holder: { cb: ResizeObserverCallback | null } = { cb: null };
- const observed: unknown[] = [];
- const disconnect = vi.fn();
- class FakeResizeObserver {
- constructor(cb: ResizeObserverCallback) {
- holder.cb = cb;
- }
- observe(target: Element): void {
- observed.push(target);
- }
- unobserve(): void {}
- disconnect = disconnect;
- }
- vi.stubGlobal("ResizeObserver", FakeResizeObserver);
- try {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- const content = { id: "content" } as unknown as HTMLElement;
- const teardown = c.attach(fake.el, content);
+ it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
+ const holder: { cb: ResizeObserverCallback | null } = { cb: null };
+ const observed: unknown[] = [];
+ const disconnect = vi.fn();
+ class FakeResizeObserver {
+ constructor(cb: ResizeObserverCallback) {
+ holder.cb = cb;
+ }
+ observe(target: Element): void {
+ observed.push(target);
+ }
+ unobserve(): void {}
+ disconnect = disconnect;
+ }
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ try {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const content = { id: "content" } as unknown as HTMLElement;
+ const teardown = c.attach(fake.el, content);
- // Observes both the content (it grows) and the scroll container (viewport resize).
- expect(observed).toContain(content);
- expect(observed).toContain(fake.el);
+ // Observes both the content (it grows) and the scroll container (viewport resize).
+ expect(observed).toContain(content);
+ expect(observed).toContain(fake.el);
- // Stuck → a resize keeps us pinned to the bottom.
- fake.scrollTo.mockClear();
- holder.cb?.([], {} as ResizeObserver);
- expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });
+ // Stuck → a resize keeps us pinned to the bottom.
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });
- // Reader scrolls up → a later resize must NOT yank them down.
- fake.userScrollTo(200);
- fake.scrollTo.mockClear();
- holder.cb?.([], {} as ResizeObserver);
- expect(fake.scrollTo).not.toHaveBeenCalled();
+ // Reader scrolls up → a later resize must NOT yank them down.
+ fake.userScrollTo(200);
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).not.toHaveBeenCalled();
- // Teardown disconnects the observer.
- teardown();
- expect(disconnect).toHaveBeenCalled();
- } finally {
- vi.unstubAllGlobals();
- }
- });
+ // Teardown disconnects the observer.
+ teardown();
+ expect(disconnect).toHaveBeenCalled();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
- it("attach returns a teardown that removes both listeners", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- const teardown = c.attach(fake.el);
- const before = fake.listenerCount();
- expect(before.get("scroll")?.size).toBe(1);
- expect(before.get("scrollend")?.size).toBe(1);
- teardown();
- expect(before.get("scroll")?.size).toBe(0);
- expect(before.get("scrollend")?.size).toBe(0);
- });
+ it("attach returns a teardown that removes both listeners", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const teardown = c.attach(fake.el);
+ const before = fake.listenerCount();
+ expect(before.get("scroll")?.size).toBe(1);
+ expect(before.get("scrollend")?.size).toBe(1);
+ teardown();
+ expect(before.get("scroll")?.size).toBe(0);
+ expect(before.get("scrollend")?.size).toBe(0);
+ });
});