feat: drive signs from a shared animation clock
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user