diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f6c379e8e..2eeea8504 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -106,21 +106,32 @@ body.cockpit-mode #world-timeline { pointer-events: none; } -/* Playing, held while a layer loads the time's data. */ -#world-timeline .world-timeline-time.is-buffering { - animation: world-timeline-buffering 1s ease-in-out infinite alternate; +/* Playing, held while a layer loads the time's data: a spinner by the time. */ +#world-timeline .world-timeline-time.is-waiting { + display: inline-flex; + align-items: center; + gap: 6px; } -@keyframes world-timeline-buffering { +#world-timeline .world-timeline-spinner { + width: 10px; + height: 10px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent); + border-radius: 50%; + animation: world-timeline-spin 0.9s linear infinite; +} + +@keyframes world-timeline-spin { to { - opacity: 0.45; + transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - #world-timeline .world-timeline-time.is-buffering { + #world-timeline .world-timeline-spinner { animation: none; - opacity: 0.6; + border-color: var(--accent); } } diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 06301f3a2..40e7d3ad1 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -411,3 +411,32 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle assert.equal(track.classList.contains('is-zoomed'), false); assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was'); }); + +test('play held for a layer to load shows a spinner once the hold lasts', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); + worldClock.seek(at - 1_800_000); + worldClock.play({ continuous: true }); + await nextTick(); + const bubble = () => bar().querySelector('.world-timeline-time'); + const release = worldClock.wait('test-radar'); + t.after(release); + // A clock tick (a quarter second) tells of the hold; the spinner waits on. + await new Promise((resolve) => setTimeout(resolve, 250)); + await nextTick(); + assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); + // The clock tells of the hold on its next tick, and the bar shows it after. + const until = async (shown) => { + for (let waited = 0; waited < 3_000; waited += 50) { + if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + await nextTick(); + } + return false; + }; + assert.ok(await until(true), 'spinner once the hold lasts'); + assert.match(bubble().textContent, /UTC$/, 'the time stays'); + release(); + assert.ok(await until(false), 'gone once play goes on'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 9588b7e64..40baecaae 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@