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

@@ -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' });
});
});