diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 6ae2425e41..15fa2a0988 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -65,39 +65,58 @@ it('shows completed paragraph first lines across blank lines with a right-edge f const reasoning = page.locator('[data-variant="think"][data-state="running"]') await expandOwningTurnProcess(page, reasoning) await reasoning.waitFor() - const whale = page.locator('[data-chat-running] svg') - const animatedWhale = whale.locator('path:has(animate)') - const restingWhale = whale.locator('path:not(:has(animate))') - await animatedWhale.locator('animate').waitFor({ state: 'attached' }) - const positions = await animatedWhale.evaluate(async (element) => { - const path = element as SVGPathElement - const svg = path.ownerSVGElement! - const time = svg.getCurrentTime() - svg.pauseAnimations() - try { - const positions: number[][] = [] - for (const at of [0.3, 0.9, 3.3]) { - svg.setCurrentTime(at) - await new Promise(resolve => requestAnimationFrame(() => { resolve() })) - const point = path.getPointAtLength(path.getTotalLength() * 0.4) - positions.push([point.x, point.y]) - } - return positions - } finally { - svg.setCurrentTime(time) - svg.unpauseAnimations() - } - }) - expect(positions[1]).not.toEqual(positions[0]) - expect(positions[2]).toEqual(positions[0]) - expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none') - expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none') - await page.emulateMedia({ reducedMotion: 'reduce' }) - await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none') - await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none') - await page.emulateMedia({ reducedMotion: 'no-preference' }) - await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none') - await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none') + const whale = page.locator('[data-chat-running] span[aria-hidden="true"]:has(> svg)') + const animatedWhale = whale.locator(':scope > span') + const restingWhale = whale.locator(':scope > svg') + await whale.waitFor() + expect(await whale.locator('animate').count()).toBe(0) + const expectStaticWhale = async () => { + await expect.poll(() => animatedWhale.isVisible()).toBe(false) + await expect.poll(() => restingWhale.isVisible()).toBe(true) + expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskImage)).toBe('none') + const colors = await restingWhale.locator('path').evaluate((path) => { + const probe = document.createElement('span') + probe.style.cssText = 'color: Canvas; forced-color-adjust: none' + document.body.append(probe) + try { + const style = getComputedStyle(path) + return { + stroke: style.stroke.toLowerCase() === 'currentcolor' ? style.color : style.stroke, + canvas: getComputedStyle(probe).color, + length: (path as SVGPathElement).getTotalLength(), + } + } finally { probe.remove() } + }) + expect(colors.stroke).not.toBe('none') + expect(colors.stroke).not.toBe('transparent') + expect(colors.stroke).not.toBe(colors.canvas) + expect(colors.length).toBeGreaterThan(0) + } + try { + await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' }) + await expect.poll(() => animatedWhale.isVisible()).toBe(true) + expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha') + const imageSize = await animatedWhale.evaluate(async (element) => { + const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1] + if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL') + const image = new Image() + image.src = source + await image.decode() + return [image.naturalWidth, image.naturalHeight] + }) + expect(imageSize).toEqual([28, 28]) + expect(await restingWhale.isVisible()).toBe(false) + const firstWhaleFrame = await whale.screenshot({ animations: 'allow' }) + await expect.poll(async () => !(await whale.screenshot({ animations: 'allow' })).equals(firstWhaleFrame), { + timeout: 5000, + }).toBe(true) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expectStaticWhale() + await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'active' }) + await expectStaticWhale() + } finally { + await page.emulateMedia({ reducedMotion: null, forcedColors: null }) + } expect(await reasoning.getAttribute('data-preview')).toBeNull() first.proceed.resolve(undefined) @@ -120,6 +139,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f third.proceed.resolve(undefined) await settled await page.getByText('Done', { exact: true }).waitFor() + await whale.waitFor({ state: 'detached' }) expect(console.pageErrors).toEqual([]) expect(console.warnings).toEqual([]) } finally { diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 71c13916b4..ff121998a6 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -160,32 +160,35 @@ flex: none; width: calc(14px + var(--dsh-content-font-delta, 0px)); height: calc(14px + var(--dsh-content-font-delta, 0px)); + /* The fixed footprint contains the animation's layout and paint. */ contain: strict; overflow: hidden; } .runningWhaleAnimated { + display: none; position: absolute; inset: 0; - background: currentColor; - mask: url('./running-whale@2x.png') center / 100% 100% no-repeat; - mask-mode: alpha; - will-change: transform; } .runningWhaleStill { - display: none; + display: initial; } -@media (prefers-reduced-motion: reduce) { - .runningWhaleAnimated { - display: none; - mask-image: none; - will-change: auto; - } +@supports (mask-mode: alpha) and (mask-image: url('')) { + @media (prefers-reduced-motion: no-preference) and (forced-colors: none) { + .runningWhaleAnimated { + display: block; + background: currentColor; + mask: url('./running-whale@2x.png') center / 100% 100% no-repeat; + mask-mode: alpha; + /* Keep animated-mask repaints on an icon-sized composited layer. */ + will-change: transform; + } - .runningWhaleStill { - display: initial; + .runningWhaleStill { + display: none; + } } } diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 932dd53060..ba6d2e0b47 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -4131,8 +4131,12 @@ describe('ChatView', () => { expect(column.lastElementChild).toBe(status) expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中') expect(status?.lastElementChild?.textContent).toBe('深度求索中') - expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true') - expect(status?.querySelector('animate')).not.toBeNull() + const icon = status?.querySelector('svg')?.parentElement + expect(icon?.getAttribute('aria-hidden')).toBe('true') + expect(icon?.firstElementChild?.tagName).toBe('SPAN') + expect(icon?.querySelectorAll('path')).toHaveLength(1) + expect(icon?.getAttribute('style')).toBeNull() + expect(status?.querySelector('animate')).toBeNull() act(() => { h.setSession({ running: false }) }) expect(column.querySelector('[data-chat-running]')).toBeNull() }) diff --git a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx index f2a06bd9a2..5d1bbc0c99 100644 --- a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx +++ b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx' @@ -7,26 +9,55 @@ import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx' afterEach(cleanup) describe('RunningWhaleTail', () => { - it('renders matching animated and resting contours for the stylesheet to switch', () => { + it('renders a decorative mask seat and static SVG without inline styles', () => { const view = render() - expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true') - expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16') - const contours = view.container.querySelectorAll('path') - expect(contours).toHaveLength(2) - expect(contours[1]?.getAttribute('d')).toBe(contours[0]?.getAttribute('d')) - const animation = view.container.querySelector('animate') - expect(animation?.getAttribute('attributeName')).toBe('d') - expect(animation?.getAttribute('begin')).toBe('0.3s') - expect(animation?.getAttribute('dur')).toBe('3s') - expect(animation?.getAttribute('repeatCount')).toBe('indefinite') - const paths = animation!.getAttribute('values')!.split(';') - const times = animation!.getAttribute('keyTimes')!.split(';').map(Number) - expect(paths.length).toBeGreaterThan(1) - expect(paths).toHaveLength(times.length) - expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d')) - expect(paths.at(-1)).toBe(paths[0]) - expect(times[0]).toBe(0) - expect(times.at(-1)).toBe(1) - expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true) + const icon = view.container.firstElementChild! + expect(icon.tagName).toBe('SPAN') + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(icon.children).toHaveLength(2) + expect(icon.firstElementChild?.tagName).toBe('SPAN') + expect(icon.firstElementChild?.childElementCount).toBe(0) + const svg = icon.querySelector('svg')! + expect(icon.lastElementChild).toBe(svg) + expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') + expect(svg.querySelectorAll('path')).toHaveLength(1) + expect(svg.querySelector('path')?.getAttribute('d')).toMatch(/^M/) + expect(svg.querySelector('path')?.getAttribute('stroke')).toBe('currentColor') + expect(view.container.querySelector('animate')).toBeNull() + expect(view.container.querySelector('[style]')).toBeNull() + }) + + it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => { + const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/running-whale@2x.png')) + expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) + const chunks: { type: string; data: Buffer }[] = [] + for (let offset = 8; offset < png.length;) { + const length = png.readUInt32BE(offset) + const end = offset + 12 + length + expect(end).toBeLessThanOrEqual(png.length) + chunks.push({ type: png.toString('ascii', offset + 4, offset + 8), data: png.subarray(offset + 8, end - 4) }) + offset = end + } + expect(chunks.at(-1)?.type).toBe('IEND') + const headers = chunks.filter(chunk => chunk.type === 'IHDR') + expect(headers).toHaveLength(1) + const header = headers[0]!.data + expect([header.readUInt32BE(0), header.readUInt32BE(4), header[8], header[9]]).toEqual([28, 28, 8, 4]) + const animations = chunks.filter(chunk => chunk.type === 'acTL') + expect(animations).toHaveLength(1) + expect([animations[0]!.data.readUInt32BE(0), animations[0]!.data.readUInt32BE(4)]).toEqual([60, 0]) + const frames = chunks.filter(chunk => chunk.type === 'fcTL') + expect(frames).toHaveLength(60) + let duration = 0 + for (const { data } of frames) { + expect(data).toHaveLength(26) + expect(data.readUInt32BE(4)).toBeGreaterThan(0) + expect(data.readUInt32BE(8)).toBeGreaterThan(0) + expect(data.readUInt32BE(4) + data.readUInt32BE(12)).toBeLessThanOrEqual(28) + expect(data.readUInt32BE(8) + data.readUInt32BE(16)).toBeLessThanOrEqual(28) + expect([data.readUInt16BE(20), data.readUInt16BE(22)]).toEqual([1, 20]) + duration += data.readUInt16BE(20) / data.readUInt16BE(22) + } + expect(duration).toBeCloseTo(3) }) })