feat: drive signs from a shared animation clock

This commit is contained in:
2026-09-02 14:14:18 +03:00
parent 24afda8710
commit f2dd3f85dd
9 changed files with 615 additions and 265 deletions

View File

@@ -6,19 +6,19 @@ import {
shallowRef,
watch,
type Ref,
} from "vue";
import type { SectionId } from "@/content/types";
import type { NavigationState } from "@/journey/navigation-controller";
} from 'vue';
import type { SectionId } from '@/content/types';
import type { NavigationState } from '@/journey/navigation-controller';
import {
activationLineDocumentY,
deriveSectionState,
isAtDocumentEnd,
selectActiveSection,
type SectionHeadingGeometry,
} from "@/journey/section-controller";
import { nextSign } from "@/journey/sign-controller";
import type { JourneySnapshot } from "@/journey/types";
import { useScrollDrive } from "./useScrollDrive";
} from '@/journey/section-controller';
import { createSignController } from '@/journey/sign-controller';
import type { JourneySnapshot, ScrollDriveSnapshot } from '@/journey/types';
import { useScrollDrive } from '@/composables/useScrollDrive';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
@@ -39,61 +39,67 @@ export function useJourneySections(
): Readonly<Ref<JourneySnapshot>> {
const firstId = canonicalIds[0];
if (firstId === undefined) {
throw new Error("At least one canonical journey section is required.");
throw new Error('At least one canonical journey section is required.');
}
const drive = useScrollDrive();
const signController = createSignController();
const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
const snapshot = computed<JourneySnapshot>(() => ({
...sectionSnapshot.value,
...drive.value,
routeProgress: sectionSnapshot.value.arrived ? 1 : drive.value.routeProgress,
...drive.snapshot.value,
routeProgress: sectionSnapshot.value.arrived
? 1
: drive.snapshot.value.routeProgress,
}));
let scheduleMeasurement = (): void => {};
let requestJourneyFrame = (): void => {};
let teardown = (): void => {};
const stopNavigationWatch = watch(
() => navigationState.value,
() => scheduleMeasurement(),
() => requestJourneyFrame(),
);
const stopMotionWatch =
motionAllowed === undefined
? (): void => {}
: watch(motionAllowed, () => scheduleMeasurement());
const stopMotionWatch = motionAllowed === undefined
? (): void => {}
: watch(motionAllowed, () => requestJourneyFrame());
onMounted(() => {
const visualViewport = window.visualViewport;
let frameId: number | undefined;
let disposed = false;
let previousScrollY = window.scrollY;
let structureDirty = true;
let previousScrollY = drive.snapshot.value.scrollY;
let previousActiveId = firstId;
let headings: readonly SectionHeadingGeometry[] = [];
const readHeadings = (): readonly SectionHeadingGeometry[] =>
const readHeadings = (scrollY: number): readonly SectionHeadingGeometry[] =>
canonicalIds.flatMap((id) => {
const heading = document.getElementById(`${id}-heading`);
return heading instanceof HTMLElement
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }]
? [{ id, top: heading.getBoundingClientRect().top + scrollY }]
: [];
});
const measure = (): void => {
frameId = undefined;
headings = readHeadings();
if (headings.length === 0) return;
const measure = (
now: number,
driveSnapshot: ScrollDriveSnapshot,
): boolean => {
if (disposed) return false;
if (structureDirty) {
headings = readHeadings(driveSnapshot.scrollY);
structureDirty = false;
}
if (headings.length === 0) return false;
const scrollingElement =
document.scrollingElement ?? document.documentElement;
const viewportHeight =
visualViewport?.height ?? document.documentElement.clientHeight;
const scrollingElement = document.scrollingElement ?? document.documentElement;
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
const scrollY = scrollingElement.scrollTop;
const activationY = activationLineDocumentY({
scrollY,
scrollY: driveSnapshot.scrollY,
visualViewportOffsetTop: viewportOffsetTop,
visualViewportHeight: viewportHeight,
});
const atDocumentEnd = isAtDocumentEnd({
scrollY,
scrollY: driveSnapshot.scrollY,
scrollHeight: scrollingElement.scrollHeight,
visualViewportHeight: viewportHeight,
});
@@ -105,47 +111,46 @@ export function useJourneySections(
const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
const activeIndex = canonicalIds.indexOf(activeSectionId);
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
const deltaY = scrollY - previousScrollY;
const currentSign = sectionSnapshot.value.sign;
const signTargetId =
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
const signTarget = headings.find(
(heading) => heading.id === signTargetId,
);
const sign =
signTargetId === null || signTarget === undefined || viewportHeight <= 0
? null
: nextSign(currentSign, {
source: navigationState.value.source,
deltaY,
viewportHeight,
crossedBoundaries,
candidateId: signTargetId,
distanceVh: (signTarget.top - activationY) / viewportHeight,
motionAllowed: motionAllowed?.value ?? true,
});
const candidateId = canonicalIds[activeIndex + 1] ?? null;
const candidateHeading = headings.find((heading) => heading.id === candidateId);
const candidateDistanceVh = candidateHeading === undefined || viewportHeight <= 0
? null
: (candidateHeading.top - activationY) / viewportHeight;
const sectionState = deriveSectionState(activeSectionId);
const sign = signController.sample({
now,
source: navigationState.value.source,
direction: driveSnapshot.direction,
acceleration: driveSnapshot.acceleration,
deltaY: driveSnapshot.scrollY - previousScrollY,
viewportHeight,
crossedBoundaries,
candidateId,
candidateDistanceVh,
activeSectionId,
motionAllowed: motionAllowed?.value ?? true,
});
sectionSnapshot.value = {
...drive.value,
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
...driveSnapshot,
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
...sectionState,
sign,
};
previousScrollY = scrollY;
previousScrollY = driveSnapshot.scrollY;
previousActiveId = activeSectionId;
return signController.needsFrame();
};
scheduleMeasurement = (): void => {
if (
!disposed &&
frameId === undefined &&
document.visibilityState !== "hidden"
) {
frameId = window.requestAnimationFrame(measure);
}
const removeFrameSubscriber = drive.subscribeFrame(measure);
requestJourneyFrame = drive.requestFrame;
const requestMeasurement = (): void => drive.requestFrame();
const markStructureDirty = (): void => {
structureDirty = true;
requestMeasurement();
};
const resizeObserver = new ResizeObserver(scheduleMeasurement);
const resizeObserver = new ResizeObserver(markStructureDirty);
resizeObserver.observe(document.documentElement);
for (const id of canonicalIds) {
const section = document.getElementById(id);
@@ -153,89 +158,43 @@ export function useJourneySections(
if (section !== null) resizeObserver.observe(section);
if (heading !== null) resizeObserver.observe(heading);
}
for (const media of document.querySelectorAll("img, video")) {
for (const media of document.querySelectorAll('img, video')) {
resizeObserver.observe(media);
}
const handleVisibility = (): void => {
if (document.visibilityState === "hidden") {
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
if (document.visibilityState === 'hidden') {
signController.reset();
return;
}
previousScrollY = window.scrollY;
scheduleMeasurement();
previousScrollY = drive.snapshot.value.scrollY;
markStructureDirty();
};
window.addEventListener("scroll", scheduleMeasurement, PASSIVE_LISTENER);
window.addEventListener("resize", scheduleMeasurement, PASSIVE_LISTENER);
window.addEventListener(
"orientationchange",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.addEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.addEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
document.addEventListener("load", scheduleMeasurement, true);
document.addEventListener("loadedmetadata", scheduleMeasurement, true);
document.addEventListener(
"visibilitychange",
handleVisibility,
PASSIVE_LISTENER,
);
window.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
window.addEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
visualViewport?.addEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
visualViewport?.addEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
document.addEventListener('load', markStructureDirty, true);
document.addEventListener('loadedmetadata', markStructureDirty, true);
document.addEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
scheduleMeasurement();
void document.fonts.ready.then(() => scheduleMeasurement());
markStructureDirty();
void document.fonts?.ready.then(markStructureDirty);
teardown = (): void => {
disposed = true;
window.removeEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
window.removeEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
window.removeEventListener(
"orientationchange",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.removeEventListener(
"resize",
scheduleMeasurement,
PASSIVE_LISTENER,
);
visualViewport?.removeEventListener(
"scroll",
scheduleMeasurement,
PASSIVE_LISTENER,
);
document.removeEventListener("load", scheduleMeasurement, true);
document.removeEventListener("loadedmetadata", scheduleMeasurement, true);
document.removeEventListener(
"visibilitychange",
handleVisibility,
PASSIVE_LISTENER,
);
requestJourneyFrame = (): void => {};
removeFrameSubscriber();
window.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
window.removeEventListener('orientationchange', markStructureDirty, PASSIVE_LISTENER);
visualViewport?.removeEventListener('resize', markStructureDirty, PASSIVE_LISTENER);
visualViewport?.removeEventListener('scroll', requestMeasurement, PASSIVE_LISTENER);
document.removeEventListener('load', markStructureDirty, true);
document.removeEventListener('loadedmetadata', markStructureDirty, true);
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
resizeObserver.disconnect();
if (frameId !== undefined) {
window.cancelAnimationFrame(frameId);
frameId = undefined;
}
signController.reset();
};
});

View File

@@ -12,9 +12,23 @@ const initialSnapshot = (): ScrollDriveSnapshot => ({
driveSpeed: 0.18,
});
export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
export interface ScrollDriveBinding {
snapshot: Readonly<Ref<ScrollDriveSnapshot>>;
requestFrame(): void;
subscribeFrame(
listener: (now: number, snapshot: ScrollDriveSnapshot) => boolean,
): () => void;
}
export function useScrollDrive(): ScrollDriveBinding {
const controller = createScrollDriveController();
const snapshot = shallowRef<ScrollDriveSnapshot>(initialSnapshot());
const subscribers = new Set<(
now: number,
snapshot: ScrollDriveSnapshot,
) => boolean>();
let requestFrame = (): void => {};
let disposed = false;
let teardown = (): void => {};
onMounted(() => {
@@ -32,21 +46,29 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
};
};
let scheduleFrame = (): void => {};
const runFrame = (now: number): void => {
frameId = undefined;
const input = readSample(now);
snapshot.value = resetOnNextFrame
const nextSnapshot = resetOnNextFrame
? controller.reset(input)
: controller.sample(input);
snapshot.value = nextSnapshot;
resetOnNextFrame = false;
if (snapshot.value.acceleration > 0) scheduleFrame();
let keepRunning = false;
for (const subscriber of [...subscribers]) {
keepRunning = subscriber(now, nextSnapshot) || keepRunning;
}
if (nextSnapshot.acceleration > 0 || keepRunning) scheduleFrame();
};
const scheduleFrame = (): void => {
scheduleFrame = (): void => {
if (frameId === undefined && document.visibilityState !== 'hidden') {
frameId = requestAnimationFrame(runFrame);
}
};
requestFrame = scheduleFrame;
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
@@ -70,6 +92,8 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
document.addEventListener('visibilitychange', handleVisibilityChange, PASSIVE_LISTENER);
teardown = (): void => {
disposed = true;
requestFrame = (): void => {};
window.removeEventListener('scroll', scheduleFrame, PASSIVE_LISTENER);
window.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
visualViewport?.removeEventListener('resize', scheduleFrame, PASSIVE_LISTENER);
@@ -79,10 +103,19 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
cancelAnimationFrame(frameId);
frameId = undefined;
}
subscribers.clear();
};
});
onUnmounted(() => teardown());
return readonly(snapshot);
return {
snapshot: readonly(snapshot),
requestFrame: () => requestFrame(),
subscribeFrame(listener) {
if (disposed) return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
},
};
}

View File

@@ -1,53 +1,187 @@
import type { SectionId } from "@/content/types";
import type { JourneySource, SignSnapshot } from "./types";
import type { SectionId } from '@/content/types';
import type {
SignController,
SignControllerSample,
SignSnapshot,
} from '@/journey/types';
export interface SignSample {
source: JourneySource;
deltaY: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;
distanceVh: number;
motionAllowed: boolean;
const ANTICIPATE_MS = 300;
const APPROACH_MS = 900;
const APPROACH_REDUCTION_MS = 250;
const DWELL_MS = 1_400;
const DEPART_MS = 700;
const COOLDOWN_MS = 350;
const ELIGIBLE_DISTANCE_VH = 1.2;
const JUMP_DISTANCE_VH = 0.75;
interface VisibleState {
kind: 'visible';
targetId: SectionId;
latchedAt: number;
approachDuration: number;
departStartedAt: number | null;
}
type State =
| { kind: 'idle' }
| VisibleState
| { kind: 'cooldown'; until: number };
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
export function nextSign(
current: SignSnapshot | null,
input: SignSample,
): SignSnapshot | null {
const eligible =
input.motionAllowed &&
input.source === "passive-scroll" &&
Number.isFinite(input.deltaY) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.distanceVh) &&
input.viewportHeight > 0 &&
input.deltaY > 0 &&
input.deltaY <= input.viewportHeight * 0.75 &&
input.crossedBoundaries <= 1;
const smoothstep = (minimum: number, maximum: number, value: number): number => {
const progress = clamp01((value - minimum) / (maximum - minimum));
return progress * progress * (3 - 2 * progress);
};
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
return null;
}
const isFiniteSample = (input: SignControllerSample): boolean =>
Number.isFinite(input.now) &&
Number.isFinite(input.acceleration) &&
Number.isFinite(input.deltaY) &&
Number.isFinite(input.viewportHeight) &&
Number.isFinite(input.crossedBoundaries) &&
input.viewportHeight > 0 &&
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
const targetId = current?.targetId ?? input.candidateId;
if (targetId === null) return null;
const canLatch = (input: SignControllerSample): input is SignControllerSample & {
candidateId: SectionId;
candidateDistanceVh: number;
} =>
input.motionAllowed &&
input.source === 'passive-scroll' &&
input.direction === 1 &&
input.deltaY > 0 &&
input.deltaY <= input.viewportHeight * JUMP_DISTANCE_VH &&
input.crossedBoundaries <= 1 &&
input.candidateId !== null &&
input.candidateId !== input.activeSectionId &&
input.candidateDistanceVh !== null &&
input.candidateDistanceVh > 0 &&
input.candidateDistanceVh <= ELIGIBLE_DISTANCE_VH;
if (input.distanceVh > 0.55) {
return {
targetId,
phase: "approach",
progress: clamp01((1.2 - input.distanceVh) / 0.65),
};
}
if (input.distanceVh > 0.15) {
return { targetId, phase: "stabilized", progress: 1 };
}
return {
targetId,
phase: "pass",
progress: clamp01((0.15 - input.distanceVh) / 0.4),
export function createSignController(): SignController {
let state: State = { kind: 'idle' };
let lastNow: number | null = null;
const reset = (now?: number): void => {
state = { kind: 'idle' };
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
};
const enterCooldown = (now: number): void => {
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
};
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
if (progress >= 1) {
state = {
kind: 'cooldown',
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
};
return null;
}
return {
targetId: visible.targetId,
phase: 'depart',
progress,
opacity: 1 - smoothstep(0.1, 1, progress),
};
}
const elapsed = Math.max(0, now - visible.latchedAt);
if (elapsed < ANTICIPATE_MS) {
return {
targetId: visible.targetId,
phase: 'anticipate',
progress: clamp01(elapsed / ANTICIPATE_MS),
opacity: 0,
};
}
const approachElapsed = elapsed - ANTICIPATE_MS;
if (approachElapsed < visible.approachDuration) {
const progress = clamp01(approachElapsed / visible.approachDuration);
return {
targetId: visible.targetId,
phase: 'approach',
progress,
opacity: smoothstep(0, 0.42, progress),
};
}
return {
targetId: visible.targetId,
phase: 'dwell',
progress: 1,
opacity: 1,
};
};
const sample = (input: SignControllerSample): SignSnapshot | null => {
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
reset();
return null;
}
lastNow = input.now;
const suppressed =
!input.motionAllowed ||
input.source !== 'passive-scroll' ||
input.crossedBoundaries > 1 ||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
if (suppressed) {
if (!input.motionAllowed) reset(input.now);
else if (state.kind === 'visible') enterCooldown(input.now);
return null;
}
if (state.kind === 'cooldown') {
if (input.now < state.until) return null;
state = { kind: 'idle' };
}
if (state.kind === 'idle') {
if (!canLatch(input)) return null;
state = {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departStartedAt: null,
};
return snapshotVisible(state, input.now);
}
if (input.direction < 0) {
const elapsed = input.now - state.latchedAt;
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
if (elapsed < ANTICIPATE_MS + state.approachDuration) {
state = { kind: 'idle' };
return null;
}
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
state.departStartedAt = input.now;
}
}
if (state.departStartedAt === null) {
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
if (dwellComplete && input.activeSectionId === state.targetId) {
state.departStartedAt = input.now;
}
}
return snapshotVisible(state, input.now);
};
const needsFrame = (): boolean => {
if (state.kind !== 'visible' || lastNow === null) return false;
if (state.departStartedAt !== null) return true;
return lastNow < state.latchedAt + ANTICIPATE_MS + state.approachDuration + DWELL_MS;
};
return { sample, reset, needsFrame };
}

View File

@@ -30,10 +30,33 @@ export type EnvironmentChapter =
| "pre-dawn"
| "destination";
export type SignPhase = 'anticipate' | 'approach' | 'dwell' | 'depart';
export interface SignSnapshot {
targetId: SectionId;
phase: "approach" | "stabilized" | "pass";
phase: SignPhase;
progress: number;
opacity: number;
}
export interface SignControllerSample {
now: number;
source: JourneySource;
direction: -1 | 0 | 1;
acceleration: number;
deltaY: number;
viewportHeight: number;
crossedBoundaries: number;
candidateId: SectionId | null;
candidateDistanceVh: number | null;
activeSectionId: SectionId;
motionAllowed: boolean;
}
export interface SignController {
sample(input: SignControllerSample): SignSnapshot | null;
reset(now?: number): void;
needsFrame(): boolean;
}
export interface JourneySnapshot extends ScrollDriveSnapshot {

View File

@@ -573,6 +573,7 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
targetId: snapshot.sign.targetId,
phase: snapshot.sign.phase,
progress: finite(snapshot.sign.progress),
opacity: finite(snapshot.sign.opacity),
} : null,
arrived: Boolean(snapshot.arrived),
};

View File

@@ -68,11 +68,18 @@ export function createSign(resources: SceneResourceBag) {
return {
group,
update(sign: SignSnapshot | null) {
group.visible = sign !== null;
group.visible = sign !== null && sign.opacity > 0;
if (!sign) return;
updateTexture(sign.targetId);
const phaseOffset = sign.phase === 'approach' ? -26 : sign.phase === 'stabilized' ? -5 : 8;
group.position.set(7.2, 4.1, phaseOffset + sign.progress * 8);
material.opacity = sign.opacity;
const phaseOffset = sign.phase === 'anticipate'
? -30
: sign.phase === 'approach'
? -26 + sign.progress * 21
: sign.phase === 'dwell'
? -5
: -5 + sign.progress * 18;
group.position.set(7.2, 4.1, phaseOffset);
group.rotation.y = -0.12;
},
};

View File

@@ -214,7 +214,10 @@ describe('scene controller lifecycle', () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
const input = journey({ routeProgress: 0.25, sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2 } });
const input = journey({
routeProgress: 0.25,
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
});
controller.update(input);
input.routeProgress = 0.9;

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
import { createSSRApp, defineComponent, h, nextTick } from 'vue';
import { createSSRApp, defineComponent, h, nextTick, shallowRef } from 'vue';
import { renderToString } from '@vue/server-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createScrollDriveController } from '@/journey/scroll-drive-controller';
@@ -170,7 +170,7 @@ describe('useScrollDrive', () => {
const app = createSSRApp(defineComponent({
setup() {
const drive = useScrollDrive();
return () => h('output', drive.value.driveSpeed.toString());
return () => h('output', drive.snapshot.value.driveSpeed.toString());
},
}));
@@ -207,13 +207,14 @@ describe('useScrollDrive', () => {
const documentAdd = vi.spyOn(document, 'addEventListener');
const viewportAdd = vi.spyOn(visualViewport, 'addEventListener');
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
return { drive: useScrollDrive() };
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.routeProgress.toString());
},
template: '<output>{{ drive.routeProgress }}</output>',
}));
const drive = () => wrapper.vm.drive;
const drive = () => binding.snapshot.value;
expect(drive()).toMatchObject({
scrollY: 250,
@@ -306,4 +307,117 @@ describe('useScrollDrive', () => {
document.dispatchEvent(new Event('visibilitychange'));
expect(callbacks).toHaveLength(0);
});
it('keeps the shared frame bus alive only while a subscriber requests frames', async () => {
const { useScrollDrive } = await import('@/composables/useScrollDrive');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 500 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(100);
let binding!: ReturnType<typeof useScrollDrive>;
const wrapper = mount(defineComponent({
setup() {
binding = useScrollDrive();
return () => h('output', binding.snapshot.value.driveSpeed.toString());
},
}));
let subscriberFrames = 0;
const unsubscribe = binding.subscribeFrame((_now, frameSnapshot) => {
expect(frameSnapshot).toEqual(binding.snapshot.value);
subscriberFrames += 1;
return subscriberFrames < 3;
});
binding.requestFrame();
for (const now of [116, 132, 148]) {
expect(callbacks).toHaveLength(1);
const requested = [...callbacks.entries()][0]!;
callbacks.delete(requested[0]);
requested[1](now);
await nextTick();
}
expect(subscriberFrames).toBe(3);
expect(callbacks).toHaveLength(0);
unsubscribe();
binding.requestFrame();
expect(callbacks).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
it('owns the only raw window scroll listener used by the complete journey binding', async () => {
const { useJourneySections } = await import('@/composables/useJourneySections');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
Object.defineProperties(document.documentElement, {
scrollTop: { configurable: true, writable: true, value: 0 },
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 800 },
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: undefined,
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: Promise.resolve() },
});
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
vi.spyOn(performance, 'now').mockReturnValue(0);
const add = vi.spyOn(window, 'addEventListener');
const ids = ['start', 'profile', 'contact'] as const;
const navigation = shallowRef({
epoch: 0,
source: 'passive-scroll' as const,
destinationId: null,
});
const motionAllowed = shallowRef(true);
const wrapper = mount(defineComponent({
setup() {
useJourneySections(ids, navigation, motionAllowed);
return () => h('main', ids.map((id) => h('section', { id }, [
h('h2', { id: `${id}-heading` }, id),
])));
},
}), { attachTo: document.body });
expect(add.mock.calls.filter(([type]) => type === 'scroll')).toHaveLength(1);
wrapper.unmount();
expect(callbacks).toHaveLength(0);
});
});

View File

@@ -1,100 +1,176 @@
import { describe, expect, it } from "vitest";
import { nextSign } from "@/journey/sign-controller";
import type { SignSnapshot } from "@/journey/types";
import { describe, expect, it } from 'vitest';
import { createSignController } from '@/journey/sign-controller';
import type { SignControllerSample } from '@/journey/types';
const passiveSample = (
distanceVh: number,
overrides: Partial<Parameters<typeof nextSign>[1]> = {},
): Parameters<typeof nextSign>[1] => ({
source: "passive-scroll",
deltaY: 10,
viewportHeight: 100,
const sample = (
overrides: Partial<SignControllerSample> = {},
): SignControllerSample => ({
now: 0,
source: 'passive-scroll',
direction: 1,
acceleration: 0,
deltaY: 16,
viewportHeight: 800,
crossedBoundaries: 0,
candidateId: "profile",
distanceVh,
candidateId: 'profile',
candidateDistanceVh: 1,
activeSectionId: 'start',
motionAllowed: true,
...overrides,
});
describe("forward-only sign latching", () => {
it("latches a first eligible candidate at the exact approach boundary", () => {
expect(nextSign(null, passiveSample(1.2))).toEqual({
targetId: "profile",
phase: "approach",
describe('clock-driven sign lifecycle', () => {
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
const controller = createSignController();
expect(controller.sample(sample())).toEqual({
targetId: 'profile',
phase: 'anticipate',
progress: 0,
opacity: 0,
});
expect(controller.needsFrame()).toBe(true);
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
phase: 'anticipate',
progress: expect.closeTo(299 / 300, 10),
});
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: 0,
});
});
it("maps the literal approach and pass ranges independently", () => {
expect(nextSign(null, passiveSample(0.875))).toEqual({
targetId: "profile",
phase: "approach",
progress: expect.closeTo(0.5, 14),
expect(controller.sample(sample({ now: 1_200, direction: 0 }))).toMatchObject({
phase: 'dwell',
progress: 1,
opacity: 1,
});
expect(nextSign(null, passiveSample(-0.05))).toEqual({
targetId: "profile",
phase: "pass",
progress: 0.5,
expect(controller.sample(sample({ now: 2_599, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
phase: 'dwell',
});
});
it("uses exact stabilized, pass, and completion boundaries", () => {
const latched: SignSnapshot = {
targetId: "profile",
phase: "approach",
expect(controller.sample(sample({ now: 2_600, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
phase: 'depart',
progress: 0,
};
opacity: 1,
});
expect(controller.sample(sample({ now: 3_299, direction: 0, activeSectionId: 'profile' }))).toMatchObject({
phase: 'depart',
progress: expect.closeTo(699 / 700, 10),
});
expect(controller.sample(sample({ now: 3_300, direction: 0, activeSectionId: 'profile' }))).toBeNull();
expect(controller.needsFrame()).toBe(false);
expect(controller.sample(sample({
now: 3_649,
candidateId: 'experience',
activeSectionId: 'profile',
}))).toBeNull();
expect(controller.sample(sample({
now: 3_650,
candidateId: 'experience',
activeSectionId: 'profile',
}))).toMatchObject({ targetId: 'experience', phase: 'anticipate' });
});
expect(nextSign(latched, passiveSample(0.55))).toEqual({
targetId: "profile",
phase: "stabilized",
it('shortens approach continuously to 650ms at full acceleration', () => {
const controller = createSignController();
controller.sample(sample({ acceleration: 1 }));
expect(controller.sample(sample({ now: 300, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: 0,
});
expect(controller.sample(sample({ now: 949, direction: 0 }))).toMatchObject({
phase: 'approach',
progress: expect.closeTo(649 / 650, 10),
});
expect(controller.sample(sample({ now: 950, direction: 0 }))).toMatchObject({
phase: 'dwell',
progress: 1,
});
expect(nextSign(latched, passiveSample(0.15))).toEqual({
targetId: "profile",
phase: "pass",
progress: 0,
});
expect(nextSign(latched, passiveSample(-0.25))).toBeNull();
});
it("retains the one latched target while the semantic section advances", () => {
const latched: SignSnapshot = {
targetId: "profile",
phase: "stabilized",
progress: 1,
};
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0 }));
expect(
nextSign(latched, passiveSample(0.1, { candidateId: "experience" })),
).toEqual({
targetId: "profile",
phase: "pass",
progress: expect.closeTo(0.125, 14),
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
expect(controller.needsFrame()).toBe(false);
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
phase: 'depart',
progress: 0,
});
expect(controller.needsFrame()).toBe(true);
});
it('cancels an unreadable sign on reverse and departs a readable sign cleanly', () => {
const early = createSignController();
early.sample(sample());
expect(early.sample(sample({ now: 500, direction: -1, deltaY: -20 }))).toBeNull();
expect(early.needsFrame()).toBe(false);
const readable = createSignController();
readable.sample(sample());
readable.sample(sample({ now: 1_200, direction: 0 }));
expect(readable.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
phase: 'depart',
progress: 0,
});
});
it.each([
["reverse input", { deltaY: -2 }],
["zero-direction input", { deltaY: 0 }],
["explicit navigation", { source: "explicit-navigation" as const }],
["history restoration", { source: "history-restoration" as const }],
["initial positioning", { source: "initial" as const }],
["more than one activation boundary", { crossedBoundaries: 2 }],
["a jump over 0.75 viewport heights", { deltaY: 76 }],
["disabled motion", { motionAllowed: false }],
])("clears or suppresses signs for %s", (_label, overrides) => {
const latched: SignSnapshot = {
targetId: "profile",
phase: "approach",
progress: 0.5,
};
expect(nextSign(latched, passiveSample(0.5, overrides))).toBeNull();
['explicit navigation', { source: 'explicit-navigation' as const }],
['history restoration', { source: 'history-restoration' as const }],
['initial positioning', { source: 'initial' as const }],
['multiple crossed sections', { crossedBoundaries: 2 }],
['a jump over 0.75 viewport heights', { deltaY: 601 }],
['disabled motion', { motionAllowed: false }],
])('suppresses or clears signs for %s', (_label, overrides) => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({ now: 400, ...overrides }))).toBeNull();
expect(controller.needsFrame()).toBe(false);
});
it("does not latch before visibility or after a jump directly past completion", () => {
expect(nextSign(null, passiveSample(1.20001))).toBeNull();
expect(nextSign(null, passiveSample(-0.25))).toBeNull();
expect(nextSign(null, passiveSample(-1))).toBeNull();
it('does not impose cooldown when an idle initial sample precedes real passive input', () => {
const controller = createSignController();
expect(controller.sample(sample({ source: 'initial' }))).toBeNull();
expect(controller.sample(sample({ now: 100 }))).toMatchObject({
targetId: 'profile',
phase: 'anticipate',
});
});
it.each([
['reverse input', { direction: -1 as const, deltaY: -1 }],
['stationary input', { direction: 0 as const, deltaY: 0 }],
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
['candidate already passed', { candidateDistanceVh: 0 }],
['candidate is already active', { activeSectionId: 'profile' as const }],
['missing candidate', { candidateId: null }],
])('does not latch for %s', (_label, overrides) => {
const controller = createSignController();
expect(controller.sample(sample(overrides))).toBeNull();
});
it('keeps one target latched when the next candidate changes', () => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({
now: 600,
direction: 0,
candidateId: 'experience',
}))).toMatchObject({ targetId: 'profile', phase: 'approach' });
});
it('resets malformed samples to a finite idle state', () => {
const controller = createSignController();
controller.sample(sample());
expect(controller.sample(sample({ now: Number.NaN }))).toBeNull();
expect(controller.needsFrame()).toBe(false);
controller.reset();
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
});
});