Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions docs/development/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions examples/personal-workspace-browser/goal-masthead.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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" });
Expand Down
21 changes: 21 additions & 0 deletions examples/personal-workspace-browser/research-native-reading.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading