fix: restore signs after navigation

This commit is contained in:
2026-09-03 02:59:10 +03:00
parent cce018d3cb
commit 4404c07f93
5 changed files with 71 additions and 8 deletions

View File

@@ -30,7 +30,9 @@ Only forward `passive-scroll` samples with motion enabled are eligible. The open
| `toolkit` | Toolkit | 07 | outer-left | ≤0.55vh or actual arrival | outside the toolkit content corridor | | `toolkit` | Toolkit | 07 | outer-left | ≤0.55vh or actual arrival | outside the toolkit content corridor |
| `contact` | Contact | 08 | right | ≤0.55vh or actual arrival | opposite the Contact destination content | | `contact` | Contact | 08 | right | ≤0.55vh or actual arrival | opposite the Contact destination content |
The controller owns exactly one visible target. A concealed sign can retarget immediately. A visible label is immutable; a newer candidate is coalesced as the pending target. Completed targets are suppressed for the current journey so small oscillations cannot spam them. A stale readable target completes its minimum dwell before departing. Initialization, explicit navigation (including skip links and direct fragments), history restoration, Static mode, and reduced motion remain intentionally suppressed. Visibility loss resets transient lifecycle state. The controller owns exactly one visible target. A concealed sign can retarget immediately. A visible label is immutable; a newer candidate is coalesced as the pending target. Completed targets are suppressed for the current passive traversal so small oscillations cannot spam them. A stale readable target completes its minimum dwell before departing. Initialization, explicit navigation (including skip links and direct fragments), history restoration, Static mode, and reduced motion remain intentionally suppressed. Visibility loss resets transient lifecycle state.
The navigator-recovery follow-up corrected the traversal boundary itself. Suppressed explicit/history/initial samples previously marked whichever active section they observed as completed, including transient sections crossed by a navigator jump. That completion ledger then survived the return to Start and could suppress the correct next sign indefinitely. Non-passive navigation now clears the passive-traversal completion ledger while still rendering no sign. The next real wheel, touch, or scroll-key epoch therefore starts eligible and deterministic; completion remains sticky only inside uninterrupted passive travel, preserving the small-oscillation anti-spam rule. Unit coverage drives a completed Profile lifecycle through Contact and back to Start, while the browser regression clicks Profile, returns through the navigator, and proves the next passive Profile sign renders on both desktop and mobile.
## Lifecycle contract ## Lifecycle contract

View File

@@ -135,6 +135,12 @@ The independent long interaction harness remains invalid on this host and no fra
No new Blender asset was justified. The finite authored car remains Blender-owned and unchanged. Dynamic sign text, route candidacy, lifecycle, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned. Physical-device, Firefox, Safari/WebKit, Edge, assistive-technology, and audience review remain the honest manual limitations below. No new Blender asset was justified. The finite authored car remains Blender-owned and unchanged. Dynamic sign text, route candidacy, lifecycle, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned. Physical-device, Firefox, Safari/WebKit, Edge, assistive-technology, and audience review remain the honest manual limitations below.
### Navigator recovery follow-up
The later navigator failure shared one state-ownership cause: explicit, initial, and history-restoration samples were correctly concealed but incorrectly written into the passive traversal's completed-sign ledger. A navigator jump could therefore disqualify the expected sign, and returning to Start did not re-arm it. Suppressed navigation now clears that traversal ledger without changing the suppression contract. A following wheel, touch, or scroll-key epoch can select the correct next sign again, while completed signs remain suppressed throughout uninterrupted passive travel so small boundary oscillations still cannot spam them.
The focused controller regression failed against the previous implementation because Profile remained `null` after a completed lifecycle and an explicit Contact-to-Start round trip, then passed after the ownership correction. The real-browser regression clicks a section, returns to Start, and verifies that the Profile sign is emitted on the next passive scroll. It passed in both desktop and mobile projects. The final four-worker browser matrix passed 180 checks with 44 deliberate project/capability skips and no failures, including navigation, history, keyboard, accessibility, responsive, Static, reduced-motion, fallback, visual, and flash-safety coverage.
## Direction review checklist ## Direction review checklist
- [x] Information uses near-black opaque panels rather than neon glass. - [x] Information uses near-black opaque panels rather than neon glass.

View File

@@ -52,7 +52,7 @@ export function createSignController(): SignController {
let lastNow: number | null = null; let lastNow: number | null = null;
let generation = 0; let generation = 0;
let pendingTargetId: SectionId | null = null; let pendingTargetId: SectionId | null = null;
const completedTargets = new Set<SectionId>(); const completedTraversalTargets = new Set<SectionId>();
const reset = (now?: number): void => { const reset = (now?: number): void => {
generation += 1; generation += 1;
@@ -104,7 +104,7 @@ export function createSignController(): SignController {
if (visible.departStartedAt !== null) { if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs); const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
if (progress >= 1) { if (progress >= 1) {
if (visible.dwellStartedAt !== null) completedTargets.add(visible.targetId); if (visible.dwellStartedAt !== null) completedTraversalTargets.add(visible.targetId);
state = { state = {
kind: 'cooldown', kind: 'cooldown',
until: visible.departStartedAt until: visible.departStartedAt
@@ -165,7 +165,7 @@ export function createSignController(): SignController {
if (input.crossedBoundaries > 0) { if (input.crossedBoundaries > 0) {
const route = signRouteFor(input.activeSectionId); const route = signRouteFor(input.activeSectionId);
return route.eligible && !completedTargets.has(route.id) ? route.id : null; return route.eligible && !completedTraversalTargets.has(route.id) ? route.id : null;
} }
if ( if (
input.candidateId === null input.candidateId === null
@@ -176,7 +176,7 @@ export function createSignController(): SignController {
const route = signRouteFor(input.candidateId); const route = signRouteFor(input.candidateId);
return route.eligible return route.eligible
&& input.candidateDistanceVh <= route.triggerMaximumVh && input.candidateDistanceVh <= route.triggerMaximumVh
&& !completedTargets.has(route.id) && !completedTraversalTargets.has(route.id)
? route.id ? route.id
: null; : null;
}; };
@@ -190,8 +190,7 @@ export function createSignController(): SignController {
if (!input.motionAllowed || input.source !== 'passive-scroll') { if (!input.motionAllowed || input.source !== 'passive-scroll') {
if (input.source !== 'passive-scroll') { if (input.source !== 'passive-scroll') {
const route = signRouteFor(input.activeSectionId); completedTraversalTargets.clear();
if (route.eligible) completedTargets.add(route.id);
} }
reset(input.now); reset(input.now);
return null; return null;
@@ -206,7 +205,7 @@ export function createSignController(): SignController {
if (candidate !== null) pendingTargetId = candidate; if (candidate !== null) pendingTargetId = candidate;
if (input.now < state.until) return null; if (input.now < state.until) return null;
state = { kind: 'idle' }; state = { kind: 'idle' };
if (pendingTargetId !== null && !completedTargets.has(pendingTargetId)) { if (pendingTargetId !== null && !completedTraversalTargets.has(pendingTargetId)) {
const targetId = pendingTargetId; const targetId = pendingTargetId;
pendingTargetId = null; pendingTargetId = null;
state = latch( state = latch(

View File

@@ -163,6 +163,36 @@ test('suppresses roadside signs for explicit navigation and history restoration'
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]); expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
}); });
test('restores the next sign after a navigator round trip', async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
await clickNavigationLink(page, '#profile');
await expect(page.locator('#profile-heading')).toBeFocused();
await clickNavigationLink(page, '#start');
await expect(page.locator('#start-heading')).toBeFocused();
const destination = await page.evaluate(() => {
const profile = document.getElementById('profile-heading');
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
return Math.max(
1,
profile.getBoundingClientRect().top + window.scrollY
- viewportHeight * (0.35 + 0.45),
);
});
await page.mouse.wheel(0, 1);
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
await expect.poll(async () => (
await readSceneInstrumentation(page)
).signFrames.some((frame) => frame.targetId === 'profile')).toBe(true);
});
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({ test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
page, page,
distUrl, distUrl,

View File

@@ -458,4 +458,30 @@ describe('clock-driven sign lifecycle', () => {
candidateId: 'experience', candidateId: 'experience',
}))).toBeNull(); }))).toBeNull();
}); });
it('starts a fresh passive sign traversal after explicit navigation', () => {
const controller = createSignController();
controller.sample(sample());
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
expect(controller.sample(sample({
now: 3_400,
source: 'explicit-navigation',
activeSectionId: 'contact',
candidateId: null,
candidateDistanceVh: null,
}))).toBeNull();
expect(controller.sample(sample({
now: 3_500,
source: 'explicit-navigation',
activeSectionId: 'start',
}))).toBeNull();
expect(controller.sample(sample({ now: 3_600 }))).toMatchObject({
targetId: 'profile',
phase: 'anticipate',
});
});
}); });