feat: light a chapter when its heading crosses 60% of the viewport

This commit is contained in:
2026-10-11 01:11:09 +03:00
parent 7fa1c08ce8
commit d6045ad2c1
3 changed files with 13 additions and 6 deletions

View File

@@ -32,10 +32,17 @@ export interface ActivationLineInput {
visualViewportHeight: number; visualViewportHeight: number;
} }
/**
* A chapter becomes current when its heading's top crosses this fraction of
* the visual viewport, measured from the top. At 60% the chapter lights up as
* its heading settles into the reading zone, not after it has been read.
*/
export const ACTIVATION_LINE_VH = 0.6;
export const activationLineDocumentY = (input: ActivationLineInput): number => export const activationLineDocumentY = (input: ActivationLineInput): number =>
input.scrollY + input.scrollY +
input.visualViewportOffsetTop + input.visualViewportOffsetTop +
input.visualViewportHeight * 0.35; input.visualViewportHeight * ACTIVATION_LINE_VH;
export interface DocumentEndInput { export interface DocumentEndInput {
scrollY: number; scrollY: number;

View File

@@ -73,9 +73,9 @@ test("passive scrolling replaces a stable fragment without stealing focus", asyn
if (heading === null) throw new Error("Missing experience heading"); if (heading === null) throw new Error("Missing experience heading");
const headingDocumentTop = const headingDocumentTop =
heading.getBoundingClientRect().top + window.scrollY; heading.getBoundingClientRect().top + window.scrollY;
// Place the heading just beyond the 35% activation line. Scroll offsets // Place the heading just beyond the 60% activation line. Scroll offsets
// are integer-rounded in Chromium, so exact boundary equality is brittle. // are integer-rounded in Chromium, so exact boundary equality is brittle.
window.scrollTo(0, headingDocumentTop - window.innerHeight * 0.34); window.scrollTo(0, headingDocumentTop - window.innerHeight * 0.59);
}); });
await expect(page).toHaveURL(`${distUrl}/#experience`, { timeout: 2000 }); await expect(page).toHaveURL(`${distUrl}/#experience`, { timeout: 2000 });

View File

@@ -20,14 +20,14 @@ const headings: readonly SectionHeadingGeometry[] = [
]; ];
describe("active section selection", () => { describe("active section selection", () => {
it("places the activation line at a literal 35 percent of the visual viewport", () => { it("places the activation line at a literal 60 percent of the visual viewport", () => {
expect( expect(
activationLineDocumentY({ activationLineDocumentY({
scrollY: 200, scrollY: 200,
visualViewportOffsetTop: 25, visualViewportOffsetTop: 25,
visualViewportHeight: 1000, visualViewportHeight: 1000,
}), }),
).toBe(575); ).toBe(825);
}); });
it("forces document end within exactly two CSS pixels using current geometry", () => { it("forces document end within exactly two CSS pixels using current geometry", () => {
@@ -60,7 +60,7 @@ describe("active section selection", () => {
).toBe("start"); ).toBe("start");
}); });
it("selects the last heading at or above the literal 35 percent activation line", () => { it("selects the last heading at or above the activation line", () => {
expect( expect(
selectActiveSection({ selectActiveSection({
headings, headings,