diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css index 0426d099ec..9749cde40f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css @@ -58,8 +58,8 @@ .goal-vital-skeleton i:first-child { width: 64%; height: 18px; } .goal-vital-skeleton i:last-child { width: 40%; } -@keyframes goal-popover-enter { from { opacity: 0; transform: translateY(-4px) scale(.98); } } -@keyframes goal-value-swap { from { opacity: 0; transform: translateY(3px); } } +@keyframes goal-popover-enter { from { transform: translateY(-4px) scale(.98); } } +@keyframes goal-value-swap { from { transform: translateY(3px); } } @keyframes goal-skeleton { to { background-position: -200% 0; } } @media (prefers-reduced-motion: reduce) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css index 5dd0b5d6e1..80e77eae7f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -104,10 +104,10 @@ .personal-workspace-shell[data-pw-theme="loopx"] .personal-composer-pill { border-radius: 6px; } @starting-style { - .goal-overview-criteria li, .goal-overview-attention li { opacity: 0; transform: translateY(4px); } + .goal-overview-criteria li, .goal-overview-attention li { transform: translateY(4px); } } -@keyframes goal-panel-enter { from { opacity: 0; transform: translateY(4px); } } +@keyframes goal-panel-enter { from { transform: translateY(4px); } } @media (prefers-reduced-motion: reduce) { .personal-goal-view-panel:not([hidden]), .personal-composer-pill, .personal-composer-folded .personal-composer-tools-panel { animation: none; } diff --git a/docs/development/assets/research-report-episodes/goal-first-frame-baseline-1440.png b/docs/development/assets/research-report-episodes/goal-first-frame-baseline-1440.png new file mode 100644 index 0000000000..34f93b71b2 Binary files /dev/null and b/docs/development/assets/research-report-episodes/goal-first-frame-baseline-1440.png differ diff --git a/docs/development/assets/research-report-episodes/goal-first-frame-head-1440.png b/docs/development/assets/research-report-episodes/goal-first-frame-head-1440.png new file mode 100644 index 0000000000..647df6e30f Binary files /dev/null and b/docs/development/assets/research-report-episodes/goal-first-frame-head-1440.png differ diff --git a/docs/development/assets/research-report-episodes/research-first-frame-baseline-1280.png b/docs/development/assets/research-report-episodes/research-first-frame-baseline-1280.png new file mode 100644 index 0000000000..58bcdaad07 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-first-frame-baseline-1280.png differ diff --git a/docs/development/assets/research-report-episodes/research-first-frame-baseline-390.png b/docs/development/assets/research-report-episodes/research-first-frame-baseline-390.png new file mode 100644 index 0000000000..b4ecdb19c1 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-first-frame-baseline-390.png differ diff --git a/docs/development/assets/research-report-episodes/research-first-frame-head-1280.png b/docs/development/assets/research-report-episodes/research-first-frame-head-1280.png new file mode 100644 index 0000000000..eab12a68f7 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-first-frame-head-1280.png differ diff --git a/docs/development/assets/research-report-episodes/research-first-frame-head-390.png b/docs/development/assets/research-report-episodes/research-first-frame-head-390.png new file mode 100644 index 0000000000..c0d612c91e Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-first-frame-head-390.png differ diff --git a/docs/development/design.md b/docs/development/design.md index 2b5a1b1146..3faf2bda10 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -651,7 +651,14 @@ Do not mix marketing pills and application squares in the same control group. - Motion explains state transitions; it does not decorate idle content. - Use 120-200ms control transitions and 200-350ms section transitions. -- Prefer opacity and small transforms. +- Prefer small transforms. Reading regions, authoritative values and action + consequences stay fully visible from the first frame. Their availability + must not depend on an entrance animation finishing: a native WebView can + suspend its animation clock while accessible navigation still changes the + page. Check populated views with that clock paused; a screenshot that + fast-forwards animations can conceal a blank first frame. +- 阅读区域、真实读数和操作后果从首帧起保持可见;原生视图暂停动画时,也不能只剩 + 标题或空白。验收包含暂停动画的整页,不能只看自动跳到动画结束的截图。 - Respect `prefers-reduced-motion`. - Do not animate layout continuously, pulse large surfaces, or create background motion that competes with reading. @@ -783,8 +790,8 @@ Tokens live in the Dashboard root: `--motion-fast` (120ms controls), `--motion-base` (200ms state swaps), `--motion-section` (280ms panels), `--ease-out` and `--ease-standard`. -- A changed value swaps with a short fade and 3px rise; a view panel enters - with opacity and 4px. +- A changed value moves once with a 3px rise; a view panel enters with a 4px + movement. Both render their complete current content at full opacity. - Values use the short swap transition while their text changes directly to the new observation. Do not interpolate task counts, decisions, time or money: intermediate text would invent a state. Goal and usage-window changes, unknown diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index 79babbad03..626ca2cdce 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -84,11 +84,23 @@ export const goalMastheadScenario = { const actionRequests = []; page.on("request", (request) => { if (new URL(request.url()).pathname.startsWith("/api/actions/")) actionRequests.push(request.url()); }); try { + const pausedClock = await page.addStyleTag({content: "*, *::before, *::after {animation-play-state: paused !important;}"}); await openGoal(page, title); assert.equal(await page.locator(".personal-channel-title h1").innerText(), title); assert.match(await page.locator(".goal-masthead-breadcrumb").innerText(), /工作台\s*Goals\s*Quarterly cash-flow study/); assert.equal(await page.getByTestId("goal-vitals").locator(".goal-vital").count(), 6, "At most six vitals"); + // A native WebView may suspend its animation clock while an accessible + // navigation commits. The first frame must still contain the real values. + const firstFrame = await page.locator(".goal-vital-value").evaluateAll(values => values.map(value => { + let opacity = 1; + for (let node = value; node; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity); + return opacity; + })); + await page.screenshot({path: resolve(outputDir, "goal-masthead-paused-first-frame.png"), animations: "allow"}); + assert(firstFrame.every(opacity => opacity === 1), "Authoritative Goal readings remain visible before the animation clock advances"); + await pausedClock.evaluate(element => element.remove()); + const state = await vital(page, "状态"); assert.equal(state.value, "等你", "A waiting decision leads the state, as in the Goal list"); assert.match(state.sub, /另有执行进行中/, "The observed turn stays visible beside the decision"); @@ -144,10 +156,14 @@ export const goalMastheadScenario = { await page.locator(".personal-goal-tabs button", { hasText: "概览" }).click(); await page.getByTestId("goal-vitals").waitFor({ state: "visible" }); const stop = page.locator(".goal-lifecycle-trigger"); + const pausedConfirmationClock = await page.addStyleTag({content: "*, *::before, *::after {animation-play-state: paused !important;}"}); await stop.click(); const dialog = page.getByRole("dialog", { name: "停止这个 Goal?" }); await dialog.waitFor(); assert.match(await dialog.innerText(), /已在运行的工具调用会执行完[\s\S]*只有恢复才能重新获得调度资格/); + assert.equal(await dialog.evaluate(element => Number(getComputedStyle(element).opacity)), 1, + "Permission and execution consequences remain readable with a suspended entrance clock"); + await pausedConfirmationClock.evaluate(element => element.remove()); await page.screenshot({ path: resolve(outputDir, "goal-masthead-stop-confirm.png"), animations: "disabled" }); await page.keyboard.press("Escape"); await dialog.waitFor({ state: "detached" }); diff --git a/examples/personal-workspace-browser/research-native-reading.mjs b/examples/personal-workspace-browser/research-native-reading.mjs index 3744d1b002..302bad1e44 100644 --- a/examples/personal-workspace-browser/research-native-reading.mjs +++ b/examples/personal-workspace-browser/research-native-reading.mjs @@ -101,6 +101,27 @@ export const researchNativeReadingScenario = { await page.goto(`${owner.base}/chat/?goalId=${fixture.goal_id}&view=research&statusUrl=%2Fstatus.json`, {waitUntil: "domcontentloaded"}); const native = page.getByRole("region", {name: "原生测量", exact: true}); const selector = page.locator(".research-progress select"); + await native.waitFor(); + const pausedClock = await page.addStyleTag({content: "*, *::before, *::after {animation-play-state: paused !important;}"}); + const tabs = page.getByRole("navigation", {name: "Goal 视图", exact: true}); + await tabs.getByRole("button", {name: "概览", exact: true}).click(); + await tabs.getByRole("button", {name: "研究", exact: true}).click(); + const firstFrames = []; + for (const width of [1280, 390]) { + await page.setViewportSize({width, height: 900}); + // A suspended native WebView clock must not hide a populated document. + // DOM presence alone cannot prove that research is readable. + const opacity = await native.evaluate(element => { + let value = 1; + for (let node = element; node; node = node.parentElement) value *= Number(getComputedStyle(node).opacity); + return value; + }); + firstFrames.push({width, opacity}); + await page.screenshot({path: resolve(outputDir, `research-paused-first-frame-${width}.png`), animations: "allow"}); + } + assert(firstFrames.every(frame => frame.opacity === 1), `Research remains readable with a suspended entrance clock: ${JSON.stringify(firstFrames)}`); + await pausedClock.evaluate(element => element.remove()); + await page.setViewportSize({width: 1280, height: 900}); for (const c of cases) { await selector.selectOption({label: c.id}); const selected = await selector.inputValue();