From d6045ad2c162fb900dda442d472a3078992e006e Mon Sep 17 00:00:00 2001 From: LucaP Date: Sun, 11 Oct 2026 01:11:09 +0300 Subject: [PATCH] feat: light a chapter when its heading crosses 60% of the viewport --- src/journey/section-controller.ts | 9 ++++++++- tests/browser/navigation.spec.ts | 4 ++-- tests/unit/section-controller.test.ts | 6 +++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/journey/section-controller.ts b/src/journey/section-controller.ts index 8d399fc..a522fa1 100644 --- a/src/journey/section-controller.ts +++ b/src/journey/section-controller.ts @@ -32,10 +32,17 @@ export interface ActivationLineInput { 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 => input.scrollY + input.visualViewportOffsetTop + - input.visualViewportHeight * 0.35; + input.visualViewportHeight * ACTIVATION_LINE_VH; export interface DocumentEndInput { scrollY: number; diff --git a/tests/browser/navigation.spec.ts b/tests/browser/navigation.spec.ts index 0ef3c2f..5267cb2 100644 --- a/tests/browser/navigation.spec.ts +++ b/tests/browser/navigation.spec.ts @@ -73,9 +73,9 @@ test("passive scrolling replaces a stable fragment without stealing focus", asyn if (heading === null) throw new Error("Missing experience heading"); const headingDocumentTop = 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. - window.scrollTo(0, headingDocumentTop - window.innerHeight * 0.34); + window.scrollTo(0, headingDocumentTop - window.innerHeight * 0.59); }); await expect(page).toHaveURL(`${distUrl}/#experience`, { timeout: 2000 }); diff --git a/tests/unit/section-controller.test.ts b/tests/unit/section-controller.test.ts index b0dc5fb..13741c8 100644 --- a/tests/unit/section-controller.test.ts +++ b/tests/unit/section-controller.test.ts @@ -20,14 +20,14 @@ const headings: readonly SectionHeadingGeometry[] = [ ]; 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( activationLineDocumentY({ scrollY: 200, visualViewportOffsetTop: 25, visualViewportHeight: 1000, }), - ).toBe(575); + ).toBe(825); }); it("forces document end within exactly two CSS pixels using current geometry", () => { @@ -60,7 +60,7 @@ describe("active section selection", () => { ).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( selectActiveSection({ headings,