diff --git a/src/components/SiteNavigation.vue b/src/components/SiteNavigation.vue index 3ede355..ad13a4a 100644 --- a/src/components/SiteNavigation.vue +++ b/src/components/SiteNavigation.vue @@ -26,13 +26,12 @@ const compactLabels: Readonly> = {
  • {{ item.label }}{{ compactLabels[item.id] }}
  • diff --git a/src/composables/useSceneController.ts b/src/composables/useSceneController.ts index 4f6c31c..b5a1fcc 100644 --- a/src/composables/useSceneController.ts +++ b/src/composables/useSceneController.ts @@ -64,9 +64,9 @@ export function useSceneController(sources: SceneControllerSources): SceneContro const reconcile = async (): Promise => { if (!mounted) return; - const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static'; + const requestedQuality = sources.quality.value; const canvas = sources.canvas.value; - if (!allowed) { + if (!sources.policy.value.allowSceneRequest || requestedQuality === 'static') { if (sources.policy.value.reason === 'visitor-pause' && controller !== null) { controller.pause(); publish(); @@ -105,25 +105,19 @@ export function useSceneController(sources: SceneControllerSources): SceneContro error.value = false; state.value = 'loading'; try { - if (TEST_BUILD && isTestFault('F1-scene-chunk')) { - recordTestFaultEvent('scene-chunk-rejected'); - throw new Error('Injected scene chunk failure'); - } - const { createProceduralSceneGeneration } = await import('@/scene/world/environment'); - if ( - abort.signal.aborted - || !mounted - || currentGeneration !== generation - || !sources.policy.value.allowSceneRequest - || sources.quality.value === 'static' - || sources.canvas.value !== canvas - ) return; - let next!: SceneController; next = createSceneController({ canvas, - quality: sources.quality.value, - build: createProceduralSceneGeneration, + quality: requestedQuality, + build: async (context) => { + if (TEST_BUILD && isTestFault('F1-scene-chunk')) { + recordTestFaultEvent('scene-chunk-rejected'); + throw new Error('Injected scene chunk failure'); + } + const { createProceduralSceneGeneration } = await import('@/scene/world/environment'); + if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); + return createProceduralSceneGeneration(context); + }, motionAllowed: () => sources.policy.value.allowSceneRequest, onFrameRendered: sources.sampleFrame, onStateChange: (nextState) => { @@ -145,16 +139,6 @@ export function useSceneController(sources: SceneControllerSources): SceneContro const kind = next.snapshot().state.kind; error.value = kind === 'static'; publish(); - if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) { - window.setTimeout(() => { - if (controller === next && next.snapshot().state.kind === 'running') { - recordTestFaultEvent('first-frame-deadline'); - next.setStatic('first-frame-timeout'); - error.value = true; - publish(); - } - }, 5_000); - } } catch { if (!abort.signal.aborted && currentGeneration === generation) { error.value = true; diff --git a/src/scene/create-scene-controller.ts b/src/scene/create-scene-controller.ts index 141ca60..dd0911c 100644 --- a/src/scene/create-scene-controller.ts +++ b/src/scene/create-scene-controller.ts @@ -16,6 +16,7 @@ import type { } from '@/scene/types'; const RESTORE_CUTOFF_MS = 5_000; +const FIRST_FRAME_CUTOFF_MS = 5_000; const legalTransitions: Readonly> = { idle: ['loading', 'paused', 'static', 'disposed'], @@ -70,6 +71,8 @@ export function createSceneController(options: SceneControllerOptions): SceneCon let contextLossCount = 0; let restorationSucceeded = false; let lostAtForRestore: number | null = null; + let firstFrameTimer: number | null = null; + let firstFrameGeneration: number | null = null; const transition = (next: SceneLifecycleState): void => { machine.transition(next); @@ -78,9 +81,48 @@ export function createSceneController(options: SceneControllerOptions): SceneCon const stopRaf = (): void => rootResources.cancelAnimationFrame(); + const clearFirstFrameWatchdog = (generation?: number): void => { + if ( + firstFrameTimer === null + || (generation !== undefined && firstFrameGeneration !== generation) + ) return; + scheduler.clearTimeout(firstFrameTimer); + rootResources.releaseTimer(firstFrameTimer); + firstFrameTimer = null; + firstFrameGeneration = null; + }; + + const armFirstFrameWatchdog = (generation: number): void => { + clearFirstFrameWatchdog(); + firstFrameGeneration = generation; + const timer = scheduler.setTimeout(() => { + rootResources.releaseTimer(timer); + if (firstFrameTimer !== timer || firstFrameGeneration !== generation) return; + firstFrameTimer = null; + firstFrameGeneration = null; + const lifecycle = machine.snapshot(); + const generationIsCurrent = ( + (lifecycle.kind === 'loading' && lifecycle.generation === generation) + || (lifecycle.kind === 'running' && lifecycle.generation === generation) + || ( + lifecycle.kind === 'paused' + && lifecycle.generation === generation + && !lifecycle.resumeNeedsLoad + ) + ); + if (generationIsCurrent) enterStatic('first-frame-timeout'); + }, FIRST_FRAME_CUTOFF_MS); + firstFrameTimer = rootResources.timer(timer, scheduler.clearTimeout); + }; + + const confirmFirstFrame = (generation: number): void => { + clearFirstFrameWatchdog(generation); + }; + const disposeGeneration = (): void => { stopRaf(); if (current) { + clearFirstFrameWatchdog(current.generation); current.resources.dispose(); current = null; } @@ -91,6 +133,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon const invalidated = loading; loading = null; activeOperation = null; + clearFirstFrameWatchdog(invalidated.generation); invalidated.abort.abort(); invalidated.resources.dispose(); }; @@ -123,9 +166,13 @@ export function createSceneController(options: SceneControllerOptions): SceneCon if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return; try { const frameStartedAt = scheduler.now(); - current.runtime.render(latestJourney, now); + const active = current; + const complete = active.runtime.render(latestJourney, now) !== false; const frameEndedAt = scheduler.now(); - options.onFrameRendered?.(frameStartedAt, frameEndedAt); + if (complete && current === active) { + confirmFirstFrame(active.generation); + options.onFrameRendered?.(frameStartedAt, frameEndedAt); + } } catch { enterStatic('render-failure'); return; @@ -148,6 +195,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon transition(mode === 'restoring' ? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() } : { kind: 'loading', generation }); + if (mode === 'loading') armFirstFrameWatchdog(generation); const context: SceneBuildContext = { canvas: options.canvas, diff --git a/src/scene/types.ts b/src/scene/types.ts index c26acf5..0a1794e 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -17,7 +17,8 @@ export type SceneLifecycleState = export type PageLifecycleOverlay = 'active' | 'frozen' | 'disposed'; export interface SceneGeneration { - render(snapshot: Readonly, now: number): void; + /** Return false only when the update/render attempt did not complete. */ + render(snapshot: Readonly, now: number): void | false; dispose(): void; } diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 0dea51f..9b46b7f 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -121,6 +121,7 @@ export async function createProceduralSceneGeneration( let lastNow: number | null = null; let disposed = false; let renderedFrames = 0; + let firstFrameSuppressionRecorded = false; const resize = (): void => { const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1); @@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration( return { render(snapshot: Readonly, now: number) { if (disposed) return; + if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) { + if (!firstFrameSuppressionRecorded) { + firstFrameSuppressionRecorded = true; + recordTestFaultEvent('first-frame-suppressed'); + } + return false; + } if ( TEST_BUILD && isTestFault('F3-post-first-frame-exception') diff --git a/src/styles/layout.css b/src/styles/layout.css index 9591738..a8584b2 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -73,6 +73,10 @@ outline-offset: -3px; } +.site-navigation__compact-label { + display: none; +} + .portfolio-main { min-width: 0; padding-block: var(--space-4) var(--space-5); @@ -131,8 +135,8 @@ display: none; } - .site-navigation a::after { - content: attr(data-compact-label); + .site-navigation__compact-label { + display: inline; } .portfolio-section { diff --git a/tests/browser/accessibility-keyboard.spec.ts b/tests/browser/accessibility-keyboard.spec.ts index 43893ce..2fe9294 100644 --- a/tests/browser/accessibility-keyboard.spec.ts +++ b/tests/browser/accessibility-keyboard.spec.ts @@ -186,3 +186,28 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur expect(geometry.insideSafeBounds).toBe(true); expect(geometry.overlapArea).toBe(0); }); + +test("responsive navigation exposes the exact visible label as each link's accessible name", async ({ + page, + distUrl, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(distUrl, { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("link", { name: "Experience", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Additional work", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Career", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "More work", exact: true })).toHaveCount(0); + + await page.setViewportSize({ width: 320, height: 800 }); + const career = page.getByRole("link", { name: "Career", exact: true }); + const moreWork = page.getByRole("link", { name: "More work", exact: true }); + await expect(career.locator(".site-navigation__compact-label")).toHaveText("Career"); + await expect(moreWork.locator(".site-navigation__compact-label")).toHaveText("More work"); + await expect(page.getByRole("link", { name: "Experience", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Additional work", exact: true })).toHaveCount(0); + + await career.focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("#experience-heading")).toBeFocused(); +}); diff --git a/tests/browser/fault-matrix.spec.ts b/tests/browser/fault-matrix.spec.ts index 090dbef..dc4011d 100644 --- a/tests/browser/fault-matrix.spec.ts +++ b/tests/browser/fault-matrix.spec.ts @@ -94,6 +94,11 @@ test.describe('scene failure matrix', () => { await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true'); await expect(page.locator('.journey-canvas')).toHaveCount(0); await assertSemanticFaultOracle(page); + const fault = await readFaultSnapshot(page); + expect(fault.events).toContain('first-frame-suppressed'); + expect(fault.events).not.toContain('first-frame-deadline'); + expect(fault.staticFallbackCount).toBe(1); + expect((await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0); await assertNoEscapedErrors(page, errors.pageErrors); }); diff --git a/tests/unit/AppShell.test.ts b/tests/unit/AppShell.test.ts index 74534e3..20f9c47 100644 --- a/tests/unit/AppShell.test.ts +++ b/tests/unit/AppShell.test.ts @@ -57,6 +57,19 @@ describe('semantic portfolio document', () => { expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.'); }); + it('renders separate full and compact navigation labels without overriding their accessible names', async () => { + const document = await renderedDocument(); + const experience = document.querySelector('nav a[href="#experience"]')!; + const additional = document.querySelector('nav a[href="#additional-work"]')!; + + expect(experience.hasAttribute('aria-label')).toBe(false); + expect(experience.querySelector('.site-navigation__full-label')?.textContent).toBe('Experience'); + expect(experience.querySelector('.site-navigation__compact-label')?.textContent).toBe('Career'); + expect(additional.hasAttribute('aria-label')).toBe(false); + expect(additional.querySelector('.site-navigation__full-label')?.textContent).toBe('Additional work'); + expect(additional.querySelector('.site-navigation__compact-label')?.textContent).toBe('More work'); + }); + it('binds navigation and section labels to their canonical IDs when content navigation is reordered', async () => { const content = fixture('portfolio-content.valid.json') as { navigation: Array<{ id: SectionId; label: string }>; @@ -78,7 +91,10 @@ describe('semantic portfolio document', () => { expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([ 'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT', ]); - expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([ + expect([...document.querySelectorAll('nav a')].map((link) => [ + link.getAttribute('href'), + link.querySelector('.site-navigation__full-label')?.textContent, + ])).toEqual([ ['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Experience'], ['#infrastructure', 'Infrastructure'], ['#selected-builds', 'Selected builds'], ['#additional-work', 'Additional work'], ['#toolkit', 'Toolkit'], ['#contact', 'Contact'], ]); diff --git a/tests/unit/scene-lifecycle.test.ts b/tests/unit/scene-lifecycle.test.ts index 3acf365..15cee27 100644 --- a/tests/unit/scene-lifecycle.test.ts +++ b/tests/unit/scene-lifecycle.test.ts @@ -106,6 +106,82 @@ describe('scene controller lifecycle', () => { expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]); }); + it('enters static five seconds after a live-scene request that never completes its first frame', async () => { + const harness = createHarness({ deferredBuild: true }); + const controller = createSceneController(harness.options); + const start = controller.start(); + + harness.scheduler.flushTimers(4_999); + expect(controller.snapshot().state.kind).toBe('loading'); + + harness.scheduler.flushTimers(5_000); + expect(controller.snapshot()).toMatchObject({ + state: { kind: 'static', reason: 'first-frame-timeout' }, + currentGeneration: null, + rafCount: 0, + }); + expect(harness.buildContexts[0]?.signal.aborted).toBe(true); + + harness.resolveBuild(0); + await start; + expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce(); + }); + + it('cancels the first-frame watchdog only after a complete update and render', async () => { + const harness = createHarness(); + const controller = createSceneController(harness.options); + await controller.start(); + + harness.scheduler.flushFrame(16); + harness.scheduler.flushTimers(5_001); + + expect(controller.snapshot()).toMatchObject({ + state: { kind: 'running', generation: 1 }, + currentGeneration: 1, + rafCount: 1, + }); + expect(harness.generations[0]?.dispose).not.toHaveBeenCalled(); + }); + + it('keeps the watchdog armed when a frame explicitly reports incomplete', async () => { + const harness = createHarness(); + const onFrameRendered = vi.fn(); + const controller = createSceneController({ ...harness.options, onFrameRendered }); + await controller.start(); + harness.generations[0]?.render.mockReturnValue(false); + + harness.scheduler.flushFrame(16); + harness.scheduler.flushTimers(5_000); + + expect(controller.snapshot()).toMatchObject({ + state: { kind: 'static', reason: 'first-frame-timeout' }, + currentGeneration: null, + rafCount: 0, + }); + expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce(); + expect(onFrameRendered).not.toHaveBeenCalled(); + }); + + it('replaces a torn-down generation watchdog with one deadline for its restart', async () => { + const harness = createHarness({ deferredBuild: true }); + const controller = createSceneController(harness.options); + const first = controller.start(); + harness.clock.now = 1_000; + controller.pause(); + const second = controller.resume(); + + harness.scheduler.flushTimers(5_000); + expect(controller.snapshot()).toMatchObject({ state: { kind: 'loading', generation: 2 } }); + + harness.scheduler.flushTimers(6_000); + expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'first-frame-timeout' }); + harness.resolveBuild(0); + harness.resolveBuild(1); + await Promise.all([first, second]); + expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce(); + expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce(); + }); + it('retains the same complete generation and frame across a visitor pause', async () => { const harness = createHarness(); const controller = createSceneController(harness.options); @@ -440,7 +516,7 @@ function createHarness(options: { deferredBuild?: boolean } = {}) { class FakeScheduler implements SceneScheduler { private nextId = 1; private frames = new Map(); - private timers = new Map void>(); + private timers = new Map void; dueAt: number }>(); constructor(private readonly clock: { now: number }) {} @@ -451,9 +527,9 @@ class FakeScheduler implements SceneScheduler { return id; }; cancelAnimationFrame = (id: number): void => { this.frames.delete(id); }; - setTimeout = (callback: () => void): number => { + setTimeout = (callback: () => void, delay: number): number => { const id = this.nextId++; - this.timers.set(id, callback); + this.timers.set(id, { callback, dueAt: this.clock.now + delay }); return id; }; clearTimeout = (id: number): void => { this.timers.delete(id); }; @@ -464,6 +540,17 @@ class FakeScheduler implements SceneScheduler { this.frames.clear(); for (const [, callback] of entries) callback(now); } + + flushTimers(now: number): void { + this.clock.now = now; + const due = [...this.timers.entries()] + .filter(([, timer]) => timer.dueAt <= now) + .sort((left, right) => left[1].dueAt - right[1].dueAt); + for (const [id, timer] of due) { + if (!this.timers.delete(id)) continue; + timer.callback(); + } + } } function pageEvent(type: string, persisted: boolean): Event {