feat: implement approved restrained motion and state audit (HEL-813)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -4,12 +4,29 @@
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function () {
|
||||
var saved;
|
||||
var saved, motion, legacy;
|
||||
try {
|
||||
saved = localStorage.getItem("aigc-theme");
|
||||
motion = localStorage.getItem("aigc-motion");
|
||||
legacy = localStorage.getItem("aigc-reduce-motion");
|
||||
} catch (_) {}
|
||||
document.documentElement.dataset.theme =
|
||||
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
|
||||
var preference =
|
||||
motion === null || motion === undefined
|
||||
? legacy === "1" || legacy === "true"
|
||||
? "off"
|
||||
: "full"
|
||||
: /^(full|calm|off)$/.test(motion)
|
||||
? motion
|
||||
: "full";
|
||||
var mode = matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
? "off"
|
||||
: preference;
|
||||
document.documentElement.dataset.motion = mode;
|
||||
document.documentElement.dataset.reduce = mode === "off" ? "1" : "0";
|
||||
document.documentElement.classList.toggle("calm", mode === "calm");
|
||||
document.documentElement.classList.toggle("reduce", mode === "off");
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
|
||||
|
||||
@@ -8,6 +8,7 @@ export const spotlight: Action<HTMLElement> = (node) => {
|
||||
event.pointerType === "touch"
|
||||
)
|
||||
return;
|
||||
if (document.documentElement.dataset.motion === "calm") return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
|
||||
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
|
||||
|
||||
@@ -643,9 +643,9 @@
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wft-breathe 1.6s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-workflowtestdialog-wft-breathe) 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wft-breathe {
|
||||
@keyframes -global-motion-workflowtestdialog-wft-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -192,9 +192,9 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--warning);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentartifactcard-pulse) 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@keyframes -global-motion-agentartifactcard-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -664,10 +664,10 @@
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
background: var(--run);
|
||||
animation: blink 1s step-end infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentchat-blink) 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@keyframes blink {
|
||||
@keyframes -global-motion-agentchat-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -681,13 +681,14 @@
|
||||
}
|
||||
/* 进行中指示 = 信号青(§1.3),不占金色。 */
|
||||
.pulse {
|
||||
opacity: var(--thinking-opacity, 1);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--run);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentchat-pulse) 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@keyframes -global-motion-agentchat-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -84,9 +84,9 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.icon-wrap.spinning {
|
||||
animation: spin 1.5s linear infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentplanpanel-spin) 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
@keyframes -global-motion-agentplanpanel-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -155,9 +155,9 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentplanpanel-pulse) 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@keyframes -global-motion-agentplanpanel-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -283,9 +283,9 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rail-dot.run {
|
||||
animation: rail-pulse 1.4s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentsessionsidebar-rail-pulse) 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rail-pulse {
|
||||
@keyframes -global-motion-agentsessionsidebar-rail-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--accent-glow);
|
||||
@@ -399,9 +399,9 @@
|
||||
.dot.run {
|
||||
background: var(--warning);
|
||||
opacity: 1;
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agentsessionsidebar-pulse) 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@keyframes -global-motion-agentsessionsidebar-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -106,9 +106,9 @@
|
||||
}
|
||||
.dot.running {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-agenttoolcard-pulse) 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
@keyframes -global-motion-agenttoolcard-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -312,8 +312,20 @@
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (!particles || activeId || reduceMotion.manual || reduceMotion.system)
|
||||
// Entering another task/home is the next legitimate loop trigger.
|
||||
const canvas = particles;
|
||||
void activeId;
|
||||
if (!canvas) return;
|
||||
delete canvas.dataset.motionRevision;
|
||||
canvas.parentElement?.querySelectorAll('[data-motion-held]').forEach((node) => node.removeAttribute('data-motion-held'));
|
||||
});
|
||||
$effect(() => {
|
||||
const revision = reduceMotion.loopRevision;
|
||||
if (!particles || activeId || reduceMotion.mode !== "full")
|
||||
return;
|
||||
// Switching back to full does not start particles in the current scene.
|
||||
if (particles.dataset.motionRevision && Number(particles.dataset.motionRevision) !== revision) return;
|
||||
particles.dataset.motionRevision = String(revision);
|
||||
const canvas = particles,
|
||||
ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
@@ -336,7 +348,7 @@
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (
|
||||
!activeId &&
|
||||
document.documentElement.dataset.reduce !== "1" &&
|
||||
document.documentElement.dataset.motion === "full" &&
|
||||
!matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
const color =
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.icon-wrap.spinning {
|
||||
animation: spin 1.5s linear infinite;
|
||||
animation: var(--decorative-loop-name, motion-reasoningpanel-spin) 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
@keyframes -global-motion-reasoningpanel-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -118,10 +118,10 @@
|
||||
height: 12px;
|
||||
margin-left: 1px;
|
||||
background: var(--accent);
|
||||
animation: blink 1s step-end infinite;
|
||||
animation: var(--decorative-loop-name, motion-reasoningpanel-blink) 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@keyframes blink {
|
||||
@keyframes -global-motion-reasoningpanel-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -339,6 +339,7 @@
|
||||
filter: blur(70px);
|
||||
opacity: var(--aur-op);
|
||||
animation: drift 18s var(--ease-io) infinite alternate;
|
||||
animation-play-state: var(--loop-play-state);
|
||||
}
|
||||
.aurora i:nth-child(1) {
|
||||
width: 520px;
|
||||
@@ -353,7 +354,7 @@
|
||||
right: 4%;
|
||||
top: 40px;
|
||||
background: var(--aur-2);
|
||||
animation-delay: -6s;
|
||||
animation-delay: var(--loop-delay, -6s);
|
||||
opacity: calc(var(--aur-op) * 0.55);
|
||||
}
|
||||
.aurora i:nth-child(3) {
|
||||
@@ -362,7 +363,7 @@
|
||||
left: 38%;
|
||||
top: 260px;
|
||||
background: var(--aur-3);
|
||||
animation-delay: -12s;
|
||||
animation-delay: var(--loop-delay, -12s);
|
||||
opacity: calc(var(--aur-op) * 0.36);
|
||||
}
|
||||
|
||||
@@ -534,6 +535,7 @@
|
||||
background: var(--orb-core);
|
||||
box-shadow: var(--agent-orb-shadow);
|
||||
animation: breath 4.5s var(--ease-io) infinite;
|
||||
animation-play-state: var(--loop-play-state);
|
||||
}
|
||||
.orb .ring {
|
||||
position: absolute;
|
||||
@@ -542,6 +544,7 @@
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgb(var(--ink) / 0.12);
|
||||
animation: spin 24s linear infinite;
|
||||
animation-play-state: var(--loop-play-state);
|
||||
}
|
||||
.orb .ring::before {
|
||||
content: "";
|
||||
@@ -823,7 +826,7 @@
|
||||
border-color: var(--sc-agent);
|
||||
color: var(--sc-agent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sc-agent) 20%, transparent);
|
||||
animation: breath 2s infinite;
|
||||
animation: var(--stage-loop, breath) 2s infinite;
|
||||
}
|
||||
.pstep.done b {
|
||||
color: var(--tx-2);
|
||||
@@ -873,7 +876,8 @@
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--sc-agent);
|
||||
animation: blink 1.2s infinite;
|
||||
opacity: var(--thinking-opacity, 1);
|
||||
animation: var(--thinking-loop, blink) 1.2s infinite;
|
||||
}
|
||||
.thinking i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
|
||||
@@ -109,7 +109,10 @@
|
||||
let arriving = $state(false);
|
||||
let previous: { id: number; status: string } | null = null;
|
||||
$effect(() => {
|
||||
if (reduceMotion.manual || reduceMotion.system) arriving = false;
|
||||
// A live preference/system change finishes an in-flight reveal.
|
||||
void reduceMotion.mode;
|
||||
void reduceMotion.loopRevision;
|
||||
arriving = false;
|
||||
});
|
||||
$effect(() => {
|
||||
const id = job?.id,
|
||||
@@ -117,11 +120,19 @@
|
||||
if (!id || previous?.id !== id || status !== 'done') {
|
||||
arriving = false;
|
||||
} else if (previous.status === 'running' || previous.status === 'pending') {
|
||||
arriving = !untrack(() => reduceMotion.manual || reduceMotion.system);
|
||||
arriving = !untrack(() => reduceMotion.mode === 'off');
|
||||
}
|
||||
// Repeated done snapshots must not erase the effect before the first paint.
|
||||
previous = id && status ? { id, status } : null;
|
||||
});
|
||||
$effect(() => {
|
||||
if (!arriving) return;
|
||||
const skip = () => (arriving = false);
|
||||
// v3: any explicit pointer action may skip an arrival, including actions
|
||||
// outside the media surface. The listener exists only during this reveal.
|
||||
window.addEventListener('pointerdown', skip, true);
|
||||
return () => window.removeEventListener('pointerdown', skip, true);
|
||||
});
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
transform: translateY(var(--parameter-pop-enter-y)) scale(var(--pop-enter-scale));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
|
||||
@@ -606,9 +606,9 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: take-sheen 1.4s linear infinite;
|
||||
animation: var(--decorative-loop-name, motion-takecard-take-sheen) 1.4s linear infinite;
|
||||
}
|
||||
@keyframes take-sheen {
|
||||
@keyframes -global-motion-takecard-take-sheen {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
--button-font-size: var(--manage-control-size);
|
||||
--button-radius: var(--manage-control-radius);
|
||||
--button-secondary-bg: var(--bg-3);
|
||||
animation: management-enter var(--t-slow) var(--ease-out);
|
||||
animation: management-enter var(--page-enter-duration) var(--ease-out);
|
||||
--button-sm-height: var(--manage-button-height);
|
||||
--button-sm-padding: var(--manage-button-padding);
|
||||
--button-sm-radius: var(--manage-button-radius);
|
||||
@@ -717,7 +717,7 @@ textarea.f-input {
|
||||
@keyframes management-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(var(--page-enter-y));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import { page } from "$app/state";
|
||||
import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Switch from "$lib/components/ui/Switch.svelte";
|
||||
import MotionChoices from "./MotionChoices.svelte";
|
||||
import { settings, jobsApi, comfy } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte";
|
||||
import { reduceMotion } from "$lib/reduceMotion.svelte";
|
||||
import CommandPalette from "./CommandPalette.svelte";
|
||||
import ProjectScope from "./ProjectScope.svelte";
|
||||
import QueueTray from "./QueueTray.svelte";
|
||||
@@ -230,7 +230,7 @@
|
||||
aria-label="查看生成任务"
|
||||
>
|
||||
<Icon name="nav-queue" />
|
||||
{#if genJobs.some((j) => j.status === "running") && !reduceMotion.manual && !reduceMotion.system}<span
|
||||
{#if genJobs.some((j) => j.status === "running") && reduceMotion.mode !== "off"}<span
|
||||
class="queue-ring"
|
||||
aria-hidden="true"
|
||||
></span>{/if}
|
||||
@@ -324,14 +324,7 @@
|
||||
><Icon name="search" size={16} /><span>搜索</span><kbd>Ctrl K</kbd
|
||||
></button
|
||||
>
|
||||
<label class="motion"
|
||||
><Switch
|
||||
checked={reduceMotion.system || reduceMotion.manual}
|
||||
disabled={reduceMotion.system}
|
||||
label={t("shell.reduceMotion")}
|
||||
onchange={setReduceMotion}
|
||||
/><span>减少动效</span></label
|
||||
>
|
||||
<MotionChoices />
|
||||
<LanguageSwitcher /><QueueTray jobs={genJobs} />
|
||||
</header>
|
||||
{/if}
|
||||
@@ -343,11 +336,7 @@
|
||||
{#snippet headerActions()}<span class="theme-subtitle">同一布局与功能,只换灯光</span>{/snippet}
|
||||
<ThemeChoices compact />
|
||||
{#snippet footer()}
|
||||
<div class="theme-motion"><span class="theme-foot-note" role="status"><Icon name={$themeStorageUnavailable ? "close" : "check"} size={18} />{$themeStorageUnavailable ? "浏览器禁止本地存储:本次有效,其他操作不受影响" : "已记住,下次打开沿用 · 正在编辑的内容不受影响"}</span><button class="a-btn secondary sm"
|
||||
disabled={reduceMotion.system}
|
||||
onclick={() => setReduceMotion(!reduceMotion.manual)}
|
||||
aria-pressed={!reduceMotion.system && !reduceMotion.manual}
|
||||
><Icon name="motion" size={14} />{reduceMotion.system || reduceMotion.manual ? "动效关" : "动效开"}</button></div>
|
||||
<div class="theme-motion"><span class="theme-foot-note" role="status"><Icon name={$themeStorageUnavailable ? "close" : "check"} size={18} />{$themeStorageUnavailable ? "浏览器禁止本地存储:本次有效,其他操作不受影响" : "已记住,下次打开沿用 · 正在编辑的内容不受影响"}</span><MotionChoices /></div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -367,6 +356,7 @@
|
||||
.theme-subtitle { font-size: var(--theme-pop-note-size); color: var(--tx-3); }
|
||||
.theme-foot-note { display: flex; align-items: center; gap: var(--theme-pop-footer-gap); }
|
||||
.theme-motion {
|
||||
flex-wrap: wrap;
|
||||
--button-sm-height: var(--theme-pop-button-height);
|
||||
--button-sm-padding: var(--theme-pop-button-padding);
|
||||
--button-sm-font-size: var(--theme-pop-button-size);
|
||||
@@ -540,9 +530,9 @@
|
||||
border-radius: var(--rail-queue-ring-radius);
|
||||
border: var(--rail-queue-ring-border) solid transparent;
|
||||
border-top-color: var(--sc-video);
|
||||
animation: rail-spin var(--rail-queue-ring-duration) linear infinite;
|
||||
animation: var(--queue-loop, rail-spin) var(--rail-queue-ring-duration) linear infinite;
|
||||
}
|
||||
@keyframes rail-spin {
|
||||
@keyframes -global-rail-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -571,7 +561,7 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-body {
|
||||
animation: a-page-enter var(--t-slow) var(--ease-out);
|
||||
animation: a-page-enter var(--page-enter-duration) var(--ease-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -622,13 +612,6 @@
|
||||
color: var(--tx-3);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.motion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { MOTION_MODES, reduceMotion, setMotionMode } from "$lib/reduceMotion.svelte";
|
||||
import "$lib/components/manage/management.css";
|
||||
</script>
|
||||
|
||||
<div class="mg-options" role="group" aria-label="动效强度">
|
||||
{#each MOTION_MODES as mode (mode.id)}
|
||||
<button class="mg-option" class:on={reduceMotion.mode === mode.id}
|
||||
aria-pressed={reduceMotion.mode === mode.id} disabled={reduceMotion.system}
|
||||
onclick={() => setMotionMode(mode.id)}>{mode.label}</button>
|
||||
{/each}
|
||||
{#if reduceMotion.system}<span role="status">关闭 · 跟随系统</span>{/if}
|
||||
{#if reduceMotion.storageUnavailable}<span role="status">浏览器禁止本地存储:本次有效,其他操作不受影响</span>{/if}
|
||||
</div>
|
||||
@@ -162,7 +162,7 @@
|
||||
border-radius: var(--r-lg) 0 0 var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: drawer-slide var(--d-drawer) var(--ease-drawer) both;
|
||||
animation: var(--drawer-animation, drawer-slide) var(--d-drawer) var(--ease-drawer) both;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
@@ -229,7 +229,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes drawer-slide {
|
||||
@keyframes -global-drawer-calm {
|
||||
from { opacity: 0; transform: translateY(var(--pop-enter-y)) scale(var(--pop-enter-scale)); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes -global-drawer-slide {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@
|
||||
);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
animation: progress-slide 1.2s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-progressbar-progress-slide) 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress-slide {
|
||||
@keyframes -global-motion-progressbar-progress-slide {
|
||||
0% {
|
||||
left: -40%;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
.skeleton {
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--r-sm);
|
||||
animation: skeleton-breath 1.4s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-skeleton-skeleton-breath) 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
@keyframes skeleton-breath {
|
||||
@keyframes -global-motion-skeleton-skeleton-breath {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
border-style: solid;
|
||||
border-color: var(--spinner-track, var(--bg-elevated));
|
||||
border-top-color: var(--spinner-arc, var(--accent));
|
||||
animation: spin 0.7s linear infinite;
|
||||
animation: var(--decorative-loop-name, motion-spinner-spin) 0.7s linear infinite;
|
||||
}
|
||||
.spinner-sm {
|
||||
width: 14px;
|
||||
@@ -48,7 +48,7 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
@keyframes -global-motion-spinner-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.dot.pulsing {
|
||||
animation: chip-pulse 1.4s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-statuschip-chip-pulse) 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
@keyframes -global-motion-statuschip-chip-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -84,9 +84,9 @@
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
animation: var(--decorative-loop-name, motion-jobchip-wb-chip-breath) 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes wb-chip-breath {
|
||||
@keyframes -global-motion-jobchip-wb-chip-breath {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -408,11 +408,15 @@
|
||||
border-color: color-mix(in srgb, var(--gold) 50%, transparent);
|
||||
color: var(--gold);
|
||||
}
|
||||
.project-workspace [data-state="running"] {
|
||||
--project-stage-shadow: var(--running-stage-shadow);
|
||||
}
|
||||
.project-workspace [data-state="running"] .avatar,
|
||||
.project-workspace [data-state="draft"] .avatar,
|
||||
.project-workspace [data-state="edited"] .avatar {
|
||||
border-color: var(--sc-agent);
|
||||
color: var(--sc-agent);
|
||||
box-shadow: var(--project-draft-shadow);
|
||||
box-shadow: var(--project-stage-shadow, var(--project-draft-shadow));
|
||||
}
|
||||
.project-workspace .step-btn.active::after {
|
||||
content: "";
|
||||
|
||||
@@ -1,33 +1,90 @@
|
||||
import { storageGet, storageSet } from "./storage";
|
||||
import { storageGet, storageTrySet } from "./storage";
|
||||
|
||||
/**
|
||||
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
|
||||
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
|
||||
* otherwise the top-bar switch decides. The effective value is mirrored to
|
||||
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
|
||||
*/
|
||||
const STORAGE_KEY = "aigc-reduce-motion";
|
||||
export type MotionMode = "full" | "calm" | "off";
|
||||
export const MOTION_MODES = [
|
||||
{ id: "full", label: "完整" },
|
||||
{ id: "calm", label: "克制" },
|
||||
{ id: "off", label: "关闭" },
|
||||
] as const;
|
||||
const STORAGE_KEY = "aigc-motion";
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
export const reduceMotion = $state({ system: false, manual: false });
|
||||
|
||||
export const reduceMotion = $state({
|
||||
system: false,
|
||||
manual: false,
|
||||
preference: "full" as MotionMode,
|
||||
mode: "full" as MotionMode,
|
||||
loopRevision: 0,
|
||||
storageUnavailable: false,
|
||||
});
|
||||
let stop: (() => void) | null = null;
|
||||
const isMode = (value: string | null): value is MotionMode =>
|
||||
value === "full" || value === "calm" || value === "off";
|
||||
|
||||
function apply() {
|
||||
document.documentElement.dataset.reduce =
|
||||
reduceMotion.system || reduceMotion.manual ? "1" : "0";
|
||||
function apply(initial = false) {
|
||||
const root = document.documentElement;
|
||||
const mode = reduceMotion.system ? "off" : reduceMotion.preference;
|
||||
if (!initial && mode !== reduceMotion.mode) {
|
||||
// Returning to full must wait for the next page/task trigger. New nodes
|
||||
// have no marker; mounted loops stay at their static initial appearance.
|
||||
root
|
||||
.querySelectorAll(
|
||||
".aurora i, .orb .core, .orb .ring, .thinking i, .pstep.run .s, .queue-ring, .a-scan-beam, .a-tile-ring > i",
|
||||
)
|
||||
.forEach((node) => node.setAttribute("data-motion-held", ""));
|
||||
for (const animation of document.getAnimations()) {
|
||||
if (animation.effect?.getTiming().iterations === Infinity) {
|
||||
const target = (animation.effect as KeyframeEffect).target;
|
||||
if (target instanceof Element)
|
||||
target.setAttribute("data-motion-held", "");
|
||||
} else {
|
||||
try {
|
||||
animation.finish();
|
||||
} catch {
|
||||
animation.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
reduceMotion.loopRevision++;
|
||||
}
|
||||
reduceMotion.mode = mode;
|
||||
reduceMotion.manual = reduceMotion.preference === "off";
|
||||
root.dataset.motion = mode;
|
||||
root.dataset.reduce = mode === "off" ? "1" : "0";
|
||||
root.classList.toggle("calm", mode === "calm");
|
||||
root.classList.toggle("reduce", mode === "off");
|
||||
if (!initial && mode === "full") {
|
||||
// Loops suppressed by calm/off have no Animation until the tokens change.
|
||||
// Hold those too, in the same task and before the next paint.
|
||||
for (const animation of document.getAnimations()) {
|
||||
if (animation.effect?.getTiming().iterations !== Infinity) continue;
|
||||
const target = (animation.effect as KeyframeEffect).target;
|
||||
if (target instanceof Element)
|
||||
target.setAttribute("data-motion-held", "");
|
||||
animation.cancel();
|
||||
}
|
||||
}
|
||||
if (!initial) window.dispatchEvent(new Event("aigc-motion-change"));
|
||||
}
|
||||
|
||||
/** Call once on the client (root layout). Returns the teardown. */
|
||||
/** Root-layout lifecycle; OS override never overwrites the saved preference. */
|
||||
export function initReduceMotion(): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
stop?.();
|
||||
const mq = window.matchMedia(QUERY);
|
||||
const saved = storageGet(STORAGE_KEY);
|
||||
const legacy = storageGet("aigc-reduce-motion");
|
||||
reduceMotion.preference =
|
||||
saved === null
|
||||
? legacy === "1" || legacy === "true"
|
||||
? "off"
|
||||
: "full"
|
||||
: isMode(saved)
|
||||
? saved
|
||||
: "full";
|
||||
reduceMotion.system = mq.matches;
|
||||
reduceMotion.manual = storageGet(STORAGE_KEY) === "1";
|
||||
apply();
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
reduceMotion.system = e.matches;
|
||||
apply(true);
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
reduceMotion.system = event.matches;
|
||||
apply();
|
||||
};
|
||||
mq.addEventListener("change", onChange);
|
||||
@@ -38,8 +95,14 @@ export function initReduceMotion(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean) {
|
||||
reduceMotion.manual = on;
|
||||
storageSet(STORAGE_KEY, on ? "1" : "0");
|
||||
export function setMotionMode(mode: MotionMode) {
|
||||
if (reduceMotion.system || !isMode(mode)) return;
|
||||
reduceMotion.preference = mode;
|
||||
reduceMotion.storageUnavailable = !storageTrySet(STORAGE_KEY, mode);
|
||||
apply();
|
||||
}
|
||||
|
||||
/** Compatibility for existing boolean controls. */
|
||||
export function setReduceMotion(on: boolean) {
|
||||
setMotionMode(on ? "off" : "full");
|
||||
}
|
||||
|
||||
@@ -200,9 +200,9 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rail-dot.run {
|
||||
animation: rail-pulse 1.4s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-shotsessionrail-rail-pulse) 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rail-pulse {
|
||||
@keyframes -global-motion-shotsessionrail-rail-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--accent-glow);
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
.dot.run {
|
||||
background: var(--run);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
animation: var(--decorative-loop-name, motion-shotsessionrail-dot-pulse) 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
@keyframes -global-motion-shotsessionrail-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -42,6 +42,7 @@ let orbit: OrbitControls;
|
||||
let gizmo: TransformControls;
|
||||
let gizmoHelper: THREE.Object3D;
|
||||
let raf = 0;
|
||||
let cleanupVisibility: (() => void) | null = null;
|
||||
let resizeObserver: ResizeObserver;
|
||||
let cleanupPointerUp: (() => void) | null = null;
|
||||
|
||||
@@ -473,6 +474,7 @@ let exporting = false;
|
||||
// ── loop ────────────────────────────────────────────────────────
|
||||
|
||||
function tick() {
|
||||
if (document.hidden) { raf = 0; return; }
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (exporting) return; // export drives the scene on its own fixed clock
|
||||
const now = performance.now() / 1000;
|
||||
@@ -635,10 +637,18 @@ let exporting = false;
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
const visibility = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
if (!document.hidden) tick();
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
cleanupVisibility = () => document.removeEventListener('visibilitychange', visibility);
|
||||
visibility();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupVisibility?.();
|
||||
cancelAnimationFrame(raf);
|
||||
resizeObserver?.disconnect();
|
||||
cleanupPointerUp?.();
|
||||
|
||||
@@ -29,10 +29,19 @@ export function storageGet(key: string): string | null {
|
||||
}
|
||||
|
||||
export function storageSet(key: string, value: string): void {
|
||||
storageTrySet(key, value);
|
||||
}
|
||||
|
||||
/** Also reports failure when a preference UI needs to explain persistence. */
|
||||
export function storageTrySet(key: string, value: string): boolean {
|
||||
try {
|
||||
webStorage()?.setItem(key, value);
|
||||
const storage = webStorage();
|
||||
if (!storage) return false;
|
||||
storage.setItem(key, value);
|
||||
return true;
|
||||
} catch {
|
||||
// Private mode / quota exceeded — persistence is best-effort.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function setTheme(id: ThemeId, event?: MouseEvent) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
if (document.startViewTransition) {
|
||||
if (document.startViewTransition && root.dataset.motion !== "calm") {
|
||||
if (event && event.detail !== 0) {
|
||||
root.style.setProperty("--theme-x", event.clientX + "px");
|
||||
root.style.setProperty("--theme-y", event.clientY + "px");
|
||||
@@ -108,6 +108,16 @@ export function initTheme() {
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
};
|
||||
const finishMotion = () => {
|
||||
transition?.skipTransition();
|
||||
clearTimeout(fallbackTimer);
|
||||
document.documentElement.classList.remove("theme-anim");
|
||||
};
|
||||
window.addEventListener("aigc-motion-change", finishMotion);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
return () => {
|
||||
finishMotion();
|
||||
window.removeEventListener("aigc-motion-change", finishMotion);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -583,7 +583,7 @@ button.a-tile:disabled {
|
||||
background: conic-gradient(var(--accent) 0.3turn, var(--line-2) 0);
|
||||
-webkit-mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
|
||||
mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
|
||||
animation: a-tile-spin 900ms linear infinite;
|
||||
animation: var(--tile-loop, a-tile-spin) 900ms linear infinite;
|
||||
}
|
||||
@keyframes a-tile-spin {
|
||||
to {
|
||||
@@ -1020,15 +1020,16 @@ select:focus-visible {
|
||||
z-index: -1;
|
||||
background-image:
|
||||
radial-gradient(
|
||||
var(--lattice-light-radius) circle at var(--pointer-x, 50%)
|
||||
var(--pointer-y, 50%),
|
||||
var(--lattice-light-radius) circle at
|
||||
var(--lattice-x, var(--pointer-x, 50%))
|
||||
var(--lattice-y, var(--pointer-y, 50%)),
|
||||
color-mix(in srgb, var(--scene) var(--lattice-scene), transparent),
|
||||
transparent 70%
|
||||
),
|
||||
var(--lattice);
|
||||
mask-image: radial-gradient(
|
||||
var(--lattice-mask-radius) circle at var(--pointer-x, 50%)
|
||||
var(--pointer-y, 50%),
|
||||
var(--lattice-mask-radius) circle at var(--lattice-x, var(--pointer-x, 50%))
|
||||
var(--lattice-y, var(--pointer-y, 50%)),
|
||||
#000 0,
|
||||
transparent var(--lattice-mask-end)
|
||||
);
|
||||
@@ -1075,6 +1076,7 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
.a-btn.primary::after {
|
||||
display: var(--effect-display);
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1094,6 +1096,20 @@ select:focus-visible {
|
||||
.a-result-develop > video {
|
||||
animation: var(--result-develop) var(--t-reveal) var(--ease-out);
|
||||
}
|
||||
@keyframes a-develop-calm {
|
||||
from {
|
||||
filter: var(--result-calm-filter);
|
||||
opacity: var(--result-calm-opacity);
|
||||
}
|
||||
to {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
/* Finishing a one-shot or changing mode must not restart a mounted loop. */
|
||||
[data-motion-held] {
|
||||
animation: none !important;
|
||||
}
|
||||
@keyframes a-develop {
|
||||
0% {
|
||||
filter: blur(18px) brightness(1.8) saturate(0);
|
||||
@@ -1135,6 +1151,7 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
.a-scan-beam {
|
||||
display: var(--effect-display);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -1169,7 +1186,7 @@ select:focus-visible {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--t-base),
|
||||
inset var(--t-slow) var(--ease-out);
|
||||
inset var(--viewfinder-position-duration, var(--t-slow)) var(--ease-out);
|
||||
background:
|
||||
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) top
|
||||
left/var(--viewfinder-length) var(--viewfinder-stroke) no-repeat,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
} from "@tanstack/svelte-query";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte";
|
||||
import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte";
|
||||
import MotionChoices from "$lib/components/shell/MotionChoices.svelte";
|
||||
import ThemeChoices from "$lib/components/shell/ThemeChoices.svelte";
|
||||
import MemoryPanel from "$lib/components/settings/MemoryPanel.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
@@ -431,25 +431,7 @@
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<h3>动效强度</h3>
|
||||
<div class="mg-options">
|
||||
<button
|
||||
class="mg-option"
|
||||
class:on={!reduceMotion.system && !reduceMotion.manual}
|
||||
aria-pressed={!reduceMotion.system && !reduceMotion.manual}
|
||||
disabled={reduceMotion.system}
|
||||
onclick={() => setReduceMotion(false)}>完整</button
|
||||
><button
|
||||
class="mg-option"
|
||||
disabled
|
||||
title="当前动效偏好只提供完整和减少动效,克制档尚未实现"
|
||||
>克制</button
|
||||
><button
|
||||
class="mg-option"
|
||||
class:on={reduceMotion.system || reduceMotion.manual}
|
||||
aria-pressed={reduceMotion.system || reduceMotion.manual}
|
||||
onclick={() => setReduceMotion(true)}>关闭(减少动效)</button
|
||||
>
|
||||
</div>
|
||||
<MotionChoices />
|
||||
<p class="mg-dim">
|
||||
系统开启“减少动态效果”时自动关闭;主题切换保留当前编辑内容。
|
||||
</p>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 600 B |
@@ -332,7 +332,14 @@
|
||||
--t-reveal: 700ms;
|
||||
--scan-duration: 1.6s;
|
||||
--lattice-duration: 320ms;
|
||||
--running-stage-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--sc-agent) 20%, transparent);
|
||||
--page-enter-duration: 320ms;
|
||||
--page-enter-y: 6px;
|
||||
--effect-display: block;
|
||||
--loop-play-state: running;
|
||||
--result-calm-filter: blur(10px);
|
||||
--result-calm-opacity: 0.4;
|
||||
--primary-sweep-width: 40%;
|
||||
--primary-sweep-from: -60%;
|
||||
--primary-sweep-to: 130%;
|
||||
@@ -426,6 +433,7 @@
|
||||
[data-theme="day"] {
|
||||
--viewfinder-color: color-mix(in srgb, var(--scene) 85%, black);
|
||||
--result-develop: a-develop-day;
|
||||
--result-calm-filter: blur(8px) sepia(0.25);
|
||||
--project-button-disabled-filter: none;
|
||||
--project-button-disabled-bg: #e3ddd2;
|
||||
--project-button-disabled-color: #8d877d;
|
||||
@@ -516,6 +524,8 @@
|
||||
[data-theme="grade"] {
|
||||
--desk-media-radius: 4px;
|
||||
--result-develop: a-develop-grade;
|
||||
--result-calm-filter: blur(5px) saturate(0.2);
|
||||
--result-calm-opacity: 0.5;
|
||||
--project-card-radius: 4px;
|
||||
--project-hero-radius: 4px;
|
||||
--project-eyebrow-radius: 3px;
|
||||
@@ -851,6 +861,7 @@
|
||||
--d-state: 160ms;
|
||||
--d-pop: var(--t-base);
|
||||
--pop-enter-y: 6px;
|
||||
--parameter-pop-enter-y: 4px;
|
||||
--pop-enter-scale: 0.98;
|
||||
--d-leave: 200ms;
|
||||
--d-enter: 220ms;
|
||||
@@ -1105,3 +1116,39 @@ html.theme-anim *::after {
|
||||
.b4 .art-f .stage-select {
|
||||
--agent-select-weight: 400;
|
||||
}
|
||||
|
||||
/* HEL-813 / approved v3: each behavior has its own value, no duration factor. */
|
||||
:root.calm {
|
||||
--page-enter-duration: 200ms;
|
||||
--page-enter-y: 2px;
|
||||
--d-pop: 150ms;
|
||||
--d-drawer: 150ms;
|
||||
--drawer-animation: drawer-calm;
|
||||
--pop-enter-y: 2px;
|
||||
--parameter-pop-enter-y: 2px;
|
||||
--ease-drawer: var(--ease-out);
|
||||
--pop-enter-scale: 0.995;
|
||||
--t-reveal: 350ms;
|
||||
--result-develop: a-develop-calm;
|
||||
--effect-display: none;
|
||||
--queue-loop: none;
|
||||
--tile-loop: none;
|
||||
--loop-delay: 0s;
|
||||
--decorative-loop-name: none;
|
||||
--loop-play-state: paused;
|
||||
--thinking-opacity: 0.65;
|
||||
--thinking-loop: none;
|
||||
--stage-loop: none;
|
||||
--toast-enter: 200ms;
|
||||
--toast-enter-y: 4px;
|
||||
--toast-enter-scale: 1;
|
||||
--toast-ease: var(--ease-out);
|
||||
--lattice-x: 50%;
|
||||
--lattice-y: 50%;
|
||||
--viewfinder-inset: var(--viewfinder-selected-inset);
|
||||
--viewfinder-position-duration: 0ms;
|
||||
}
|
||||
:root[data-reduce="1"] {
|
||||
--stage-loop: none;
|
||||
--viewfinder-inset: var(--viewfinder-selected-inset);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
> 授权记录:HEL-801 确认线程 `01a167c6-b1dd-7a15-b304-c6d1dbd50dca`,2026-10-23 用户“1-8项按建议”。下文待确认字样保留原始材料,施工按该次批准解释:四主题350ms滤镜推荐方案、关闭档静态阶段微调、首版不自动降级;200ms纯淡入和硬件降级备选未选。
|
||||
|
||||
# 夜片场 · 动效三档规范 v3(原始美术师材料存档)
|
||||
|
||||
> 美术师出具,补 HEL-733 报告的规范缺口。v1 §6 与 v2 §4 继续有效;本文只定义「克制」档行为和三档优先规则。**凡本文新增、原样本与 v1/v2 中没有依据的规则,一律标【待确认】,经白栖知看样确认后才授权施工。**
|
||||
|
||||
## 0. 依据核对(哪些已有依据,哪些是本次新建议)
|
||||
|
||||
已有依据,可直接施工,不需再请批:
|
||||
|
||||
- 原 HTML 1137 行只有「完整 / 克制 / 关闭(减少动效)」三个选项和一句“系统开启减少动态效果时自动切到关闭”;1532 行 `motionBtn` 与 1605 行系统检测都只切换 `html.reduce`。**原样本没有克制档的任何行为定义。**
|
||||
- v1 §6(样本「规范 → 06 动效」表)定义了完整档各效果的触发/时长/曲线/可打断,以及“减少动效时”的行为;v2 §4 沿用 v1 并补充主题切换 520/280ms。**v1/v2 同样没有克制档行为表。**
|
||||
- 关闭档行为已有依据:`html.reduce` 全局 `animation-duration:1ms; animation-iteration-count:1; transition-duration:1ms`,`.fx-only`(队列环、扫描光、极光、粒子、光球、思考点)`display:none`,主题切换立即替换,显影淡入 1ms,蜂窝仅背景变亮,取景框保留角标无位移。
|
||||
|
||||
本次新建议(全部【待确认】):克制档每一项行为、三档优先与偏好恢复规则、动态切换规则、低性能降级策略。下列表格中「克制」列即新建议,施工员不得自行乘系数或类推。
|
||||
|
||||
## 1. 三档定义与优先级
|
||||
|
||||
| 档 | 类名 | 含义 |
|
||||
|---|---|---|
|
||||
| 完整 | (默认,无类) | 现有全部动效,v1 §6 + v2 §4 已批准 |
|
||||
| 克制 | `html.calm` | 【待确认】保留状态反馈与方向感:一次性过渡缩短减幅,装饰性循环改静态标识,真实数值反馈(进度条)不动 |
|
||||
| 关闭 | `html.reduce` | 既有行为:去掉光束、粒子与位移,只保留颜色和状态变化 |
|
||||
|
||||
优先级(【待确认】规则 1–5):
|
||||
|
||||
1. **系统优先**:`prefers-reduced-motion: reduce` 生效时强制关闭档,覆盖用户任何选择;界面上三档开关显示「关闭」并标注“跟随系统”。
|
||||
2. **偏好记忆与恢复**:用户选择存 `localStorage['aigc-motion']`(`full`/`calm`/`off`,非法值回退 `full`)。系统减少动效只覆盖表现、**不改写已存偏好**;用户关闭系统设置后,恢复其先前选择的档位。
|
||||
3. **动态切换即时停止**:运行中切到克制/关闭,所有循环立即停止(粒子取消 rAF 并清空画布、CSS 循环类移除),进行中的入场/显影/弹层动画跳到终态;切回完整时,循环从下一个触发事件起恢复,不向进行中的画面中途注入循环。
|
||||
4. **后台与卸载**:标签页隐藏(`visibilitychange`)时粒子 rAF 停止(既有行为,扩展到一切未来 rAF 循环);CSS 循环依赖浏览器自动节流,不另做计时器。`pagehide`/卸载不做除既有偏好持久化外的任何操作。
|
||||
5. **低性能降级**:首版不做自动降级——三档 + 系统档已覆盖,自动跳档会造成状态不一致。若总工评估后认为需要,备选方案是仅在首次访问、用户从未选择过时,按 `hardwareConcurrency ≤ 4` 或 `deviceMemory ≤ 4` 默认建议克制档并一次性提示,绝不覆盖已有选择。**此备选技术可行性待总工确认后再定。**
|
||||
|
||||
## 2. 克制档循环保留总决策【待确认】
|
||||
|
||||
| 循环 | 克制档处置 | 理由 |
|
||||
|---|---|---|
|
||||
| 扫描光束(生成中) | 停止扫动,保留生成底色 + 进度条 | 进度条是真实数值反馈;光束是装饰 |
|
||||
| 队列环(侧栏队列点) | 停止旋转,保留静态弧形描边 | 静态弧仍有“进行中”含义 |
|
||||
| 智能体极光 | 暂停漂移,保留静态氛围色块 | 极光是页面气质底色,静态不丢氛围 |
|
||||
| 46 粒子(canvas) | 停止 rAF 并清空 | 静态粒子无意义且 canvas 常驻成本高 |
|
||||
| 光球呼吸 + 双环旋转 | 停止呼吸与旋转,保留静态光球与环 | 光球本体是视觉锚点 |
|
||||
| 思考点(三点闪烁) | 停止闪烁,保留静态三点 + 文字 | 文字本身已传达状态 |
|
||||
| 运行阶段呼吸(项目步骤) | 停止呼吸,保留静态高亮描边与底色 | 当前步骤必须可辨识 |
|
||||
| 视频播放 pan | 不变 | 只在用户手动播放时运行,非装饰循环 |
|
||||
|
||||
## 3. 逐类行为表
|
||||
|
||||
「完整」列是已批准的既有行为(原样本实测值);「克制」列全部为【待确认】新建议;「关闭」列为既有 `html.reduce` 行为。
|
||||
|
||||
### 3.1 扫描光束(生成中)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 任务进入 running | 同左 | 同左 |
|
||||
| 循环与停止 | 1.6s `ease-io` 无限循环;任务停止/取消即卸载 | 不循环:光束层不渲染,仅生成底色 `--gen-bg` + 颗粒保留 | 光束隐藏(`.fx-only`) |
|
||||
| 动画属性及幅度 | `left: -45% → 100%`,宽 45% | 无位移动画 | 无 |
|
||||
| 时长曲线 | 1.6s · `--ease-io` | — | — |
|
||||
| 可否打断 | 取消即停 | 无循环可打断 | — |
|
||||
| 终态 | 任务结束卸载生成层 | 同左 | 同左 |
|
||||
| 配套保留 | — | 进度条 `width .25s linear` 照常(真实数值,三档均不动) | 同左 |
|
||||
|
||||
### 3.2 队列环(侧栏队列点)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 队列有进行中任务 | 同左 | 同左 |
|
||||
| 循环与停止 | `spin` 1.6s linear 无限 | 不旋转,静态显示 1.5px 弧形描边(border-top-color 场景色) | 隐藏(`.fx-only`),仅保留计数数字 |
|
||||
| 动画属性及幅度 | `rotate 0→360deg` | 无 | 无 |
|
||||
| 可否打断 | 队列清空即移除 | 同左 | 同左 |
|
||||
| 终态 | 无进行中任务时移除 | 同左 | 同左 |
|
||||
|
||||
### 3.3 智能体极光
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 进入智能体首页/任务页 | 同左 | 进入页面 |
|
||||
| 循环与停止 | `drift` 18s `ease-io` 无限交替;离开页面即停 | 暂停漂移(`animation-play-state:paused` 于初始帧),保留三色静态色块与 `blur(70px)`、浓度 `--aur-op` 不变 | 隐藏(`.fx-only`) |
|
||||
| 动画属性及幅度 | `translate(60px,40px) scale(1.12)` | 无位移 | 无 |
|
||||
| 可否打断 | 离开页面即停 | 同左 | — |
|
||||
| 终态 | 离页卸载 | 同左 | 同左 |
|
||||
|
||||
### 3.4 粒子(46 粒 canvas)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 进入智能体页 `startParticles()` | 不启动 | 不启动 |
|
||||
| 循环与停止 | rAF 循环;离页/隐藏标签页/减少动效时停止并清空 | 不启动 rAF,canvas 保持空 | 不启动(既有判断) |
|
||||
| 动画属性及幅度 | 上升 `.15–.5 px/f`、正弦横漂 `.2`、半径 `.4–2`、透明度 `.15–.65` | 无 | 无 |
|
||||
| 可否打断 | 离页即停 | — | — |
|
||||
| 终态 | 清空画布 | 始终为空 | 同左 |
|
||||
|
||||
### 3.5 光球呼吸与双环
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 进入智能体首页 hero | 同左 | 隐藏 |
|
||||
| 循环与停止 | 核心 `breath` 4.5s `ease-io` 无限;环 `spin` 24s、外环 40s 反向虚线 | 全部暂停:核心保留 `--orb-core` 渐变与基础光晕(`0 0 80px`),双环静态 | 整体隐藏(`.fx-only`) |
|
||||
| 动画属性及幅度 | `scale 1→1.05`,光晕 80→120px | 无 | 无 |
|
||||
| 可否打断 | 离页即停 | — | — |
|
||||
|
||||
### 3.6 思考点
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 智能体任务进行中 | 同左 | 隐藏 |
|
||||
| 循环与停止 | `blink` 1.2s 无限,三点依次延迟 0/.2/.4s | 静态三点(透明度 .65 固定),说明文字照常 | 隐藏(`.fx-only`),文字保留 |
|
||||
| 动画属性及幅度 | `opacity→.2`、`translateY(-2px)` | 无 | 无 |
|
||||
|
||||
### 3.7 运行阶段呼吸(项目步骤 `.pstep.run`)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 项目阶段进入 running | 同左 | 同左 |
|
||||
| 循环与停止 | `breath` 2s 无限(光晕脉冲) | 不脉冲;保留场景色描边 + 静态 `0 0 0 3px 20%` 光晕 + 场景色图标 | 全局 1ms 规则使脉冲仅一闪;建议克制同款静态样式应用于关闭档以保持可辨识【待确认,属对关闭档的微调建议】 |
|
||||
| 可否打断 | 阶段结束即换样式 | 同左 | 同左 |
|
||||
|
||||
### 3.8 页面进入
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 切换一级页 | 同左 | 同左 |
|
||||
| 动画属性及幅度 | `opacity 0→1` + `translateY(6px)→0` | `opacity 0→1` + `translateY(2px)→0`(幅度减到 2px,不乘时长系数之外的属性) | 直接出现(1ms) |
|
||||
| 时长曲线 | 320ms · `--ease-out` | 200ms · `--ease-out` | 1ms |
|
||||
| 可否打断 | 是(再次切换接管) | 是 | — |
|
||||
| 终态 | 移除动画、稳定布局 | 同左 | 同左 |
|
||||
|
||||
### 3.9 弹层(参数弹层 / 主题弹层 / 灯箱 / 底部 sheet)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 打开弹层 | 同左 | 同左 |
|
||||
| 动画属性及幅度 | `opacity` + `translateY(6px)` + `scale(.98)` | `opacity` + `translateY(2px)` + `scale(.995)` | 直接出现 |
|
||||
| 时长曲线 | 200ms · `--ease-out` | 150ms · `--ease-out` | 1ms |
|
||||
| 可否打断 | Esc / 点外部立即关 | 同左 | 同左 |
|
||||
| 终态 | 稳定显示,焦点圈定不变 | 同左 | 同左 |
|
||||
|
||||
### 3.10 主题切换「开灯」
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 选择新主题 | 同左 | 同左 |
|
||||
| 形式 | View Transitions 圆形展开;不支持时 280ms 颜色淡入淡出 | 不用圆形展开:统一走 280ms 颜色淡入淡出(`html.theme-anim`,340ms 后移除类) | 立即替换 |
|
||||
| 时长曲线 | 520ms · `--ease-out`(回退 280ms · `ease-io`) | 280ms · `--ease-io` | 0ms |
|
||||
| 可否打断 | 是,再次选择接管 | 是 | — |
|
||||
| 终态 | `data-theme` 已换、内容不动 | 同左 | 同左 |
|
||||
|
||||
### 3.11 显影(结果到达)——四主题分别规定,不乘统一系数
|
||||
|
||||
触发均为:仅当前同一任务从 pending/running 到 done;历史与主题切换不重播;点击任意处跳过;完成后移除类。三档通用。
|
||||
|
||||
| 主题 | 完整(既有,700ms · `--ease-out`) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 夜片场 night | `develop`:blur(18px) brightness(1.8) saturate(0) opacity .2 scale(1.015) → 60% blur(2px) → 清晰 | 保留模糊显影个性但去掉过曝与缩放:blur(10px) opacity .4 → 清晰,350ms · `--ease-out` | 淡入 1ms |
|
||||
| 蓝调时刻 bluehour | 同 night | 同 night 的克制规则(两主题完整档本就共用 `develop`) | 淡入 1ms |
|
||||
| 日光棚 day | `developDay`:blur(16px) brightness(1.35) sepia(.6) saturate(.2) → 清晰(相纸显影) | 保留棕褐相纸感、减幅度:blur(8px) sepia(.25) opacity .4 → 清晰,350ms | 淡入 1ms |
|
||||
| 调色间 grade | `developGrade`:blur(10px) saturate(0) opacity .25 → 清晰 | 保留去饱和个性:blur(5px) saturate(.2) opacity .5 → 清晰,350ms | 淡入 1ms |
|
||||
|
||||
备选方案(供用户二选一,默认推荐上行方案):克制档四主题统一改为 200ms 纯淡入,不做滤镜。推荐保留各主题滤镜个性的方案,否则克制档下四主题显影无差别,违背“同一个片场换一种灯光”的主题原则。
|
||||
|
||||
### 3.12 主按钮扫光
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 悬停主操作 | 悬停主操作 | — |
|
||||
| 动画属性及幅度 | 40% 宽高光 `left -60%→130%`、`skewX(-20deg)` | 无扫光;保留既有背景/阴影 hover 变化(`--t-fast/--t-base` 颜色过渡不动) | 无扫光(1ms 等同无) |
|
||||
| 时长曲线 | 700ms · `--ease-out`,一次 | — | — |
|
||||
| 可否打断 | 是(移出即复位) | — | — |
|
||||
| 说明 | disabled/aria-disabled 不扫光,三档通用 | 同左 | 同左 |
|
||||
|
||||
### 3.13 蜂窝 / 光栅悬停(`.lg`)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 指针进入列表/卡片 | 同左 | 同左 |
|
||||
| 行为 | 160px 径向场景色光 + 140px mask 蜂窝纹,跟随指针 `--mx/--my` | 不跟随指针:进入时蜂窝光斑静止于卡片中心渐显,离开渐隐 | 仅背景变亮,无蜂窝纹 |
|
||||
| 时长曲线 | 320ms 渐显 · `--ease-out` | 320ms 渐显渐隐 · `--ease-out`(不变) | 1ms |
|
||||
| 可否打断 | 是 | 是 | — |
|
||||
|
||||
### 3.14 取景框(`.vf` 角标)
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 选中工作流/作品 | 同左 | 同左 |
|
||||
| 动画属性及幅度 | 角标透明度 0→1 + inset −1→−3(外扩 2px) | 角标透明度 0→1,无 inset 位移,直接出现在 −3 | 保留角标,无位移(既有表已定) |
|
||||
| 时长曲线 | 透明度 200ms;位置 320ms · `--ease-out` | 透明度 200ms 不变 | 1ms |
|
||||
| 可否打断 | 是 | 是 | — |
|
||||
|
||||
### 3.15 提示 Toast
|
||||
|
||||
| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) |
|
||||
|---|---|---|---|
|
||||
| 触发 | 操作完成 | 同左 | 同左 |
|
||||
| 动画属性及幅度 | `opacity` + `translateY(10px)` + `scale(.96)`,`--ease-spring` | `opacity` + `translateY(4px)`,去弹性用 `--ease-out` | 淡入(1ms) |
|
||||
| 时长曲线 | 320ms · spring;3.2s 后消失 | 200ms · `--ease-out`;3.2s 消失不变 | 1ms |
|
||||
| 可否打断 | 是 | 是 | — |
|
||||
|
||||
### 3.16 微交互(三档均保留,非装饰)
|
||||
|
||||
悬停/聚焦的颜色、背景、边框过渡(`--t-fast 120ms` / `--t-base 200ms`)、开关弹簧位移、卡片 hover 位移、进度条 width 过渡、选中态颜色变化:**三档全部保留**,属于可用性反馈而非动效装饰;关闭档下由全局 1ms 规则即时完成,不删除属性本身。
|
||||
|
||||
## 4. 施工提示(确认后执行)
|
||||
|
||||
1. 克制档实现为 `html.calm` 一个类:CSS 侧用 `html.calm` 覆盖各动画属性(停用 animation 或替换关键帧引用),JS 侧三处接入点——`startParticles` 增加 calm 判断、主题切换 `setTheme` 在 calm 时走 `theme-anim` 回退路径、显影按主题选择 `develop*Calm` 关键帧。
|
||||
2. 全部视觉值继续引用语义 token;克制档不新增颜色变量,只新增(【待确认】命名):`--t-calm:150–350ms` 各值写入上表、`--ease-out` 复用。
|
||||
3. 存储键 `aigc-motion` 复用既有安全存储封装(存储不可用时当次有效 + 提示,与主题一致);旧版只有 reduce 布尔时的迁移:`reduce=true → off`,无记录 → `full`。【待确认】
|
||||
4. 三档开关 UI:主题弹层底部「动效开」按钮改为三态分段(完整/克制/关闭),系统减少动效时显示“关闭 · 跟随系统”且禁用切换。【待确认】
|
||||
5. 不得借通用“减少动效”建议改回已定完整/关闭档行为;emil-skills 等通用参考与已批准样本冲突时,以样本为准。
|
||||
|
||||
## 5. 待确认清单(汇总,请白栖知逐条过目)
|
||||
|
||||
1. §1 优先级规则 1–5(系统优先、偏好恢复、动态切换、后台卸载、低性能不自动降级 + 备选)。
|
||||
2. §2 八类循环的克制处置(静态化 or 停止)。
|
||||
3. §3 各一次性效果的克制幅度/时长(逐项数值,非系数)。
|
||||
4. §3.11 四主题克制显影:推荐方案(保留个性滤镜 350ms)vs 备选(统一 200ms 淡入)。
|
||||
5. §3.7 附带建议:关闭档的运行阶段样式与克制档静态样式对齐(属对既有关闭档的唯一微调建议)。
|
||||
6. §4 存储键命名、迁移规则与三态开关 UI 形态。
|
||||
@@ -0,0 +1,28 @@
|
||||
# HEL-813 · 已批准克制档与取舍落实
|
||||
|
||||
继续原草稿 PR #12 / `agent/agent/8ec6e532b3ac`,审核起点 `e2f139806fd5d49640b5e75f6d9215b62d2b8e40`,base `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`。保留 HEL-733/806/809/810 及 B4。依据是 HEL-801 确认线程 `01a167c6-b1dd-7a15-b304-c6d1dbd50dca` 中“1-8项按建议”、看图单第8组和已批准的美术师 v3 推荐方案;画布整页仍另等样图确认。
|
||||
|
||||
## 实现与边界
|
||||
|
||||
- `reduceMotion.svelte.ts` 保存 `aigc-motion=full|calm|off`,系统减少动态强制表现为 off,保留用户偏好,恢复系统后恢复选择。兼容旧 `aigc-reduce-motion=1/true`;非法新值回 full。`app.html` 首绘执行相同优先规则。
|
||||
- 共用 `MotionChoices` 复用既有 `mg-options/mg-option`,主题弹层、设置及原顶栏入口消费同一组件。系统优先时禁用三档并显示“关闭 · 跟随系统”;安全存储失败时本次仍有效并给说明。`storageSet` 原 void 契约保持,新增可报告成功的 `storageTrySet`。
|
||||
- `themes.css` 是各项视觉值唯一来源:页面 200ms/2px,弹层150ms/2px/.995,Toast200ms/4px/非弹簧,蜂窝固定中心,取景框无位移。完整档数值保留。管理页原本有独立的320ms/6px规则,现消费同一页面入场token。原参数弹层完整档4px保持,克制档2px。
|
||||
- 克制档隐藏扫描光及按钮扫光、保留生成底色/颗粒/真实进度;队列弧保留静态描边;极光及光球保留静态初始外观;粒子取消rAF并清空。真实任务/计划/侧栏/占位等现有循环也消费同一停用token,保留静态状态及文字,未造第二套状态控件。项目运行阶段复用既有草稿高亮边框/光晕。未创建演示助手或模型身份。
|
||||
- 显影只响应同一当前任务 pending/running→done:克制档夜片场/蓝调 blur(10px)、日光 blur(8px) sepia(.25)、调色 blur(5px) saturate(.2),均350ms。保持历史/主题切换不重播、重复done不抹掉首次显影、点击可跳过。切档使当前一次性动画到终态,既有循环保持静态,下一次进入页面/任务才恢复完整档循环。
|
||||
- 克制主题切换走280ms颜色过渡,340ms清理;完整档仍圆形520ms/280ms回退,关闭档立即替换。只改root token和store,编辑器、上传、项目和会话不重挂。切档与卸载清理主题过渡。
|
||||
- 智能体粒子隐藏标签/离页清理;搭景预览rAF在隐藏标签停调度、显示恢复、卸载清理。手动视频播放/时间线功能保留。
|
||||
- 搭景缺图标来自既有依赖 `mannequin-js/src/scene.js` 导入副作用,向head追加 `../assets/logo/logo.png`。新增该兼容地址,字节复用 `static/favicon.png`,无新设计/依赖/第三方源码修改。
|
||||
|
||||
Toast关闭/撤销、18px普通页及项目底栏上方24px避让、真实项目来源、导航末槽用途、搭景现有结构、未知模型资料,以及看图单第8组19种真实状态沿用已批准现状。本轮不删真实功能、不改参数声明、不加进度硬偏移、不更换字体源、不改原HTML、不动数据库/业务API或线上配置。具体逐状态无需改/已改/待确认清单在本卡证据报告中。
|
||||
|
||||
## 复现
|
||||
|
||||
先安装锁文件中的既有依赖,运行前端 lint / check / test / build,再运行冻结preview测试;不要在preview回归进行中重建文件。
|
||||
|
||||
`e2e/hel813.cjs`:三档×四主题×三窗口截图、前后同态空态几何、批准样本、350ms逐帧、系统优先/恢复、旧偏好、存储禁用、粒子清理、切档终态、显影跳过与主题编辑器保留。
|
||||
|
||||
`e2e/hel813-fonts.cjs`:明确标注的隔离假服务快照与归属进度事件,按HEL-807六行夹具;原样本只在运行DOM映射真实文案并加载与应用同一字体CSS。联网/离线分开,每组记录实际FontFace加载状态、computed font、文字宽度及外框/填充几何,不改产品字体或偏移。
|
||||
|
||||
环境沿用运行机已有 Python3.12 虚拟环境与 Playwright,无项目依赖增加。用 `PLAYWRIGHT_MODULE_PATH`、`LD_LIBRARY_PATH`、`HEL732_PYTHON` 指向现有运行工具。测试进程由 `e2e/run-hel733.py` 前台管理,退出先比对multica daemon PID,再停止自己的进程并删除隔离库。只有一次性本机API、假ComfyUI/LLM,无真实GPU/收费调用。
|
||||
|
||||
所有最终结果、精确版本、数表、录屏和清理记录交本卡附件。后端零修改;HEL-812独立确认的五项全量隔离失败保持原结论,不新增跳过或忽略。最终独立审核/部署由总管另派,总站与画布未在此签验收。
|
||||
+1
-1
@@ -61,7 +61,7 @@ const wait=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
ok('theme keeps session and unsaved result',(await p.locator('.shot-copy textarea').first().inputValue())==='HEL733 unsaved session draft'&&p.url()===sessionUrl);
|
||||
await p.goto(WEB+'/agents');await p.locator('canvas.particles').waitFor();await wait(150);
|
||||
const full=await p.evaluate(()=>window.particleFrames);await wait(150);ok('particles advance in full mode',(await p.evaluate(()=>window.particleFrames))>full);
|
||||
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion button').click();await wait(100);const reduced=await p.evaluate(()=>window.particleFrames);await wait(200);
|
||||
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion').getByRole('button',{name:'关闭',exact:true}).click();await wait(100);const reduced=await p.evaluate(()=>window.particleFrames);await wait(200);
|
||||
ok('manual reduce stops particle rAF',(await p.evaluate(()=>window.particleFrames))===reduced);await p.keyboard.press('Escape');
|
||||
await p.emulateMedia({reducedMotion:'reduce'});ok('system reduce is mirrored on root',await p.locator('html').getAttribute('data-reduce')==='1');
|
||||
await p.goto(WEB+'/settings');await p.locator('.management-header').waitFor();ok('system reduce disables motion override',await p.locator('html').getAttribute('data-reduce')==='1');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Explicit fake-service fixture; same as HEL-807, isolated API files/workflows only.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const OUT='../evidence/hel813/fonts';fs.mkdirSync(OUT,{recursive:true});
|
||||
const api=async(p,b)=>{const r=await fetch('http://127.0.0.1:8247'+p,{method:b?'POST':'GET',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()};
|
||||
(async()=>{
|
||||
const result={rows:[],failures:[],conditions:'Same origin-bound Google font CSS on both documents; no production font changes. Offline is a separate recorded condition.'};
|
||||
const names=['H3 首尾帧生视频','Krea2 · 4 宫格故事板','Qwen 2.1 · 局部重绘','H3 参考生视频','Krea2 · 文生图'];
|
||||
const tmpl=(await api('/api/workflows'))[0],wf=[];
|
||||
for(const name of names)wf.push(await api('/api/workflows',{name,kind:name.startsWith('H3')?'video':'image',workflow_json:tmpl.workflow_json}));
|
||||
const project=await api('/api/projects',{title:'雾港来信',idea:'HEL813 font fixture only'});
|
||||
const real=await api('/api/jobs');const png=real.find(j=>j.kind==='image'&&j.output_paths?.length)?.output_paths||[];const mp4=real.find(j=>j.kind==='video'&&j.output_paths?.length)?.output_paths||[];
|
||||
const tasks=[['镜头 07 · 首尾帧','workbench','video',0,'running'],['分镜草图 8/12','agent','image',1,'running'],['Qwen 局部重绘 #62','studio-image','image',2,'pending'],['参考生视频 #21','studio-video','video',3,'failed'],['Krea2 文生图 #55','studio-image','image',4,'done'],['镜头 06 · 首尾帧','workbench','video',0,'done']].map(([title,source,kind,w,status],i)=>({id:900001+i,project_id:project.id,workflow_id:wf[w].id,scene_id:null,clip_id:null,kind,status,payload:{title,source,endpoint:{id:'hel813-fake',name:'5090D'}},output_paths:status==='done'?(kind==='image'?png:mp4):[],error:status==='failed'?'OOM':null,retry_count:0,phase:status==='running'?'comfy_running':null,created_at:`2026-10-${21-i}T13:0${i}:00Z`,updated_at:'2026-10-23T00:00:00Z'}));
|
||||
fs.writeFileSync(OUT+'/fixture.json',JSON.stringify({kind:'explicit fake-service snapshots; progress SSE 62/35; no product state changed',tasks},null,2));
|
||||
const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const b=await chromium.launch({args:['--no-sandbox']});
|
||||
const fontCSS='https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap';
|
||||
try{for(const condition of ['online','offline']){
|
||||
const c=await b.newContext({timezoneId:'Asia/Shanghai',ignoreHTTPSErrors:false});
|
||||
if(condition==='offline')await c.route(/^https:\/\/(fonts.googleapis.com|fonts.gstatic.com)\//,r=>r.abort());
|
||||
await c.addInitScript(()=>{localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-lang','zh');window.sources=[];window.EventSource=class {constructor(){window.sources.push(this);setTimeout(()=>this.onopen?.(),0)}addEventListener(t,f){(this.handlers??={})[t]=f}close(){}}});
|
||||
const a=await c.newPage(),s=await c.newPage();a.on('pageerror',e=>result.failures.push(e.message));
|
||||
await a.route('**/api/jobs',r=>r.fulfill({json:tasks}));await a.goto('http://127.0.0.1:5173/tasks');await a.locator('.task-table tbody tr').first().waitFor();
|
||||
await a.evaluate(()=>{for(const source of window.sources)for(const [job_id,progress]of [[900001,62],[900002,35]])source.handlers?.['job.progress']?.({data:JSON.stringify({type:'job.progress',data:{job_id,progress},ts:new Date().toISOString()})})});await a.locator('.task-progress').first().waitFor();
|
||||
await s.goto('http://127.0.0.1:'+server.address().port);await s.evaluate(css=>{go('tasks');renderTasks();document.documentElement.classList.add('reduce');const link=document.createElement('link');link.rel='stylesheet';link.href=css;document.head.append(link)},fontCSS);
|
||||
for(const p of [a,s])await p.evaluate(async()=>{await Promise.race([document.fonts.load('400 11px "JetBrains Mono"'),new Promise(r=>setTimeout(r,15000))]);await document.fonts.ready});
|
||||
// Map only the legitimate source-label difference; preserve the raw original.
|
||||
const cells=await a.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText.trim())));
|
||||
await s.evaluate(cells=>{const rows=document.querySelectorAll('#taskTable tr');for(const [i,row]of rows.entries())for(let k=1;k<8;k++){const e=row.cells[k],text=cells[i][k];if(k===1)e.querySelector('b').textContent=text;else if(k<5||k===6)e.textContent=text;else if(k===5)e.querySelector('.tag').textContent=text.split('\n')[0];else {const buttons=e.querySelectorAll('button');const desired=text.split('\n');buttons.forEach((b,j)=>b.textContent=desired[j]||'')}}},cells);
|
||||
for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
for(const p of [a,s]){await p.setViewportSize({width,height});await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme)}
|
||||
for(const page of [a,s])await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await new Promise(r=>setTimeout(r,150));
|
||||
const measure=async(p,kind)=>p.evaluate(kind=>{const body=document.querySelector(kind==='app'?'.task-table tbody':'#taskTable'),bar=body.querySelector(kind==='app'?'.task-progress':'.bar'),time=body.rows[0].cells[6],r=e=>{const x=e.getBoundingClientRect();return {x:x.x,y:x.y,width:x.width,height:x.height}};const cv=document.createElement('canvas').getContext('2d');cv.font=getComputedStyle(time).font;return {bar:r(bar),fill:r(bar.querySelector('i')),time:r(time),computedFont:getComputedStyle(time).font,computedFamily:getComputedStyle(time).fontFamily,text:time.textContent,canvasTextWidth:cv.measureText(time.textContent).width,fontCheck:document.fonts.check('400 11px "JetBrains Mono"'),loadedFonts:[...document.fonts].filter(f=>f.status==='loaded').map(f=>({family:f.family,weight:f.weight,status:f.status})),allFonts:[...document.fonts].map(f=>({family:f.family,weight:f.weight,status:f.status})),fontRequests:performance.getEntriesByType('resource').filter(r=>r.name.includes('fonts.')).map(r=>({url:r.name,duration:r.duration,transfer:r.transferSize}))}},kind);
|
||||
const app=await measure(a,'app'),sample=await measure(s,'sample');result.rows.push({condition,theme,width,height,app,sample,delta:{x:app.bar.x-sample.bar.x,y:app.bar.y-sample.bar.y,width:app.bar.width-sample.bar.width,height:app.bar.height-sample.bar.height}});
|
||||
console.log('FONT',condition,theme,width,'dx',app.bar.x-sample.bar.x,'loaded',app.loadedFonts.length,sample.loadedFonts.length);
|
||||
await a.screenshot({path:`${OUT}/${condition}-${theme}-${width}-app.png`});await s.screenshot({path:`${OUT}/${condition}-${theme}-${width}-sample.png`});
|
||||
}
|
||||
await c.close();
|
||||
}}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2));await b.close();server.closeAllConnections();await new Promise(r=>server.close(r))}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,25 @@
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict'),path=require('node:path');
|
||||
const OUT='../evidence/hel813/supplement';fs.mkdirSync(OUT,{recursive:true});
|
||||
const checks=[],errors=[],rows=[];const ok=(n,v)=>{checks.push({name:n,pass:!!v});assert(v,n);console.log('PASS',n)};
|
||||
const api=async(p,b)=>{const r=await fetch('http://127.0.0.1:8247'+p,{method:b?'POST':'GET',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok);return r.json()};
|
||||
(async()=>{const b=await chromium.launch({args:['--no-sandbox']});try{
|
||||
const c=await b.newContext({viewport:{width:1440,height:900}});await c.addInitScript(()=>{localStorage.setItem('aigc-motion','calm');localStorage.setItem('aigc-lang','zh');window.viewportFrames=0;for(const Type of [window.WebGLRenderingContext,window.WebGL2RenderingContext]){if(!Type)continue;const clear=Type.prototype.clear;Type.prototype.clear=function(...args){window.viewportFrames++;return clear.apply(this,args)}}});const p=await c.newPage();p.on('pageerror',e=>errors.push(e.stack));p.setDefaultTimeout(15000);
|
||||
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));await p.goto('http://127.0.0.1:5173/create?wf='+f.dual);await p.locator('#desk-prompt').fill('HEL813 outside reveal skip');
|
||||
const row=p.locator('.workflow-row.reference-option').first();await row.hover({position:{x:8,y:8}});const a=await row.evaluate(e=>getComputedStyle(e,'::after').backgroundImage);await row.hover({position:{x:80,y:25}});const z=await row.evaluate(e=>getComputedStyle(e,'::after').backgroundImage);rows.push({honeycombBefore:a,honeycombAfter:z});ok('calm honeycomb stays centered when pointer moves',a===z&&await row.evaluate(e=>!e.style.getPropertyValue('--pointer-x')&&getComputedStyle(document.documentElement).getPropertyValue('--lattice-x').trim()==='50%'));
|
||||
const sweep=await p.locator('.gen').evaluate(e=>getComputedStyle(e,'::after').display);ok('calm primary sweep hidden',sweep==='none');
|
||||
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));await p.locator('.gen').click();await p.waitForFunction(()=>document.querySelector('.a-result-develop'));await p.locator('#desk-prompt').dispatchEvent('pointerdown');await p.waitForFunction(()=>!document.querySelector('.a-result-develop'));ok('pointer outside media skips current reveal',await p.locator('.a-result-develop').count()===0);
|
||||
const frame=await p.locator('.print').evaluate(e=>{const s=getComputedStyle(e,'::after');return {inset:s.inset,transition:s.transitionDuration}});ok('calm viewfinder has no position transition',frame.transition.split(',').some(x=>parseFloat(x)===0));rows.push({viewfinder:frame});
|
||||
// A real isolated project, only its read response marks an ongoing stage.
|
||||
const project=await api('/api/projects',{title:'HEL813 running-state fixture'});const snapshot=await api('/api/workbench/projects/'+project.id);snapshot.stages[0].status='running';
|
||||
await p.route('**/api/workbench/projects/'+project.id,r=>r.fulfill({json:snapshot}));
|
||||
for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await p.goto('http://127.0.0.1:5173/workbench/'+project.id+'?stage=script');await p.locator('[data-state="running"] .avatar').waitFor();await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme);
|
||||
const style=await p.locator('[data-state="running"] .avatar').evaluate(e=>{const s=getComputedStyle(e);return {border:s.borderColor,color:s.color,shadow:s.boxShadow,animation:s.animationName}});rows.push({theme,width,height,style});ok(`running stage keeps static halo ${theme} ${width}`,style.shadow!=='none'&&style.animation==='none');await p.screenshot({path:`${OUT}/running-${theme}-${width}.png`,animations:'disabled'});
|
||||
}
|
||||
await p.goto('http://127.0.0.1:5173/build-scene');await p.waitForTimeout(1000);const frames=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(150);ok('manual scene viewport renders in calm',await p.evaluate(()=>window.viewportFrames)>frames);
|
||||
await p.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))});const hidden=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(180);ok('background scene RAF stops',await p.evaluate(()=>window.viewportFrames)===hidden);
|
||||
await p.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'))});await p.waitForTimeout(120);ok('visible scene RAF resumes',await p.evaluate(()=>window.viewportFrames)>hidden);
|
||||
await p.goto('http://127.0.0.1:5173/settings');const left=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(120);ok('scene unload cleans RAF',await p.evaluate(()=>window.viewportFrames)===left);
|
||||
const icon=await p.request.get('http://127.0.0.1:5173/assets/logo/logo.png');ok('dependency favicon uses existing image bytes',icon.status()===200&&(await icon.body()).equals(fs.readFileSync('calliope-web/static/favicon.png')));ok('no supplemental pageerrors',errors.length===0);await c.close();
|
||||
}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify({checks,errors,rows},null,2));await b.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Approved v3 behavior on the frozen production build + disposable real API.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),path=require('node:path'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const OUT='../evidence/hel813',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247';
|
||||
fs.mkdirSync(OUT+'/shots',{recursive:true});
|
||||
const report={checks:[],errors:[],motion:[],geometry:[],reveal:[],fonts:[]};
|
||||
const ok=(name,value)=>{report.checks.push({name,pass:!!value});assert(value,name);console.log('PASS',name)};
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const api=async(p,b,method)=>{const r=await fetch(API+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()};
|
||||
const sampleServer=http.createServer((req,res)=>{
|
||||
const u=new URL(req.url,'http://localhost');
|
||||
if(u.pathname.startsWith('/api/')){const q=http.request(API+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});q.on('error',()=>res.end());res.on('close',()=>q.destroy());req.pipe(q);return}
|
||||
if(u.pathname==='/sample'||u.pathname==='/calm-sample'){res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/'+(u.pathname==='/sample'?'夜片场-多主题交互样本.html':'motion-calm-sample.html')));return}
|
||||
const build=path.resolve('../evidence/hel813-before/calliope-web/build');let file=path.join(build,decodeURIComponent(u.pathname));if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');
|
||||
res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
const sockets=new Set();sampleServer.on('connection',s=>{sockets.add(s);s.on('close',()=>sockets.delete(s))});
|
||||
const geom=loc=>loc.evaluate(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:r.x,y:r.y,w:r.width,h:r.height,font:s.font,fontFamily:s.fontFamily,bg:s.backgroundColor,radius:s.borderRadius,animation:s.animation,transform:s.transform}});
|
||||
const mode=async(p,label)=>{await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion').getByRole('button',{name:label,exact:true}).click();await p.keyboard.press('Escape');await wait(40)};
|
||||
const theme=async(p,id)=>p.evaluate(id=>{document.documentElement.dataset.theme=id;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:id}))},id);
|
||||
(async()=>{
|
||||
await new Promise(r=>sampleServer.listen(0,'127.0.0.1',r));const OLD='http://127.0.0.1:'+sampleServer.address().port;
|
||||
const b=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
try{
|
||||
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
|
||||
const ctx=await b.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}});
|
||||
await ctx.addInitScript(()=>{
|
||||
localStorage.setItem('aigc-lang','zh');
|
||||
window.arrivals=[];window.particleFrames=0;window.vtCalls=0;
|
||||
document.addEventListener('animationstart',e=>{if(e.animationName.startsWith('a-develop'))window.arrivals.push({name:e.animationName,time:performance.now()})},true);
|
||||
const clear=CanvasRenderingContext2D.prototype.clearRect;CanvasRenderingContext2D.prototype.clearRect=function(...args){if(this.canvas.classList.contains('particles'))window.particleFrames++;return clear.apply(this,args)};
|
||||
const vt=document.startViewTransition?.bind(document);if(vt)document.startViewTransition=(...args)=>{window.vtCalls++;return vt(...args)};
|
||||
});
|
||||
const p=await ctx.newPage();p.on('pageerror',e=>report.errors.push(e.stack));p.setDefaultTimeout(12000);
|
||||
await p.goto(WEB+'/agents');await p.locator('.orb').waitFor();await wait(250);
|
||||
const frames=await p.evaluate(()=>window.particleFrames);await wait(150);ok('full particles advance',await p.evaluate(()=>window.particleFrames)>frames);
|
||||
await mode(p,'克制');ok('calm is effective and persisted',await p.evaluate(()=>document.documentElement.dataset.motion==='calm'&&localStorage.getItem('aigc-motion')==='calm'));
|
||||
const stopped=await p.evaluate(()=>window.particleFrames);await wait(180);ok('calm cancels and clears particle rAF',await p.evaluate(()=>window.particleFrames)===stopped);
|
||||
ok('calm keeps static aurora and orb',await p.locator('.orb').evaluate(e=>getComputedStyle(e).display!=='none'));
|
||||
ok('switching mode stops existing loops',await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length===0));
|
||||
await p.emulateMedia({reducedMotion:'reduce'});await wait(50);ok('OS override displays off without changing calm preference',await p.evaluate(()=>document.documentElement.dataset.motion==='off'&&localStorage.getItem('aigc-motion')==='calm'));
|
||||
await p.locator('.sidebar-bottom button[title="主题"]').click();ok('OS locks all three controls',await p.locator('.theme-motion .mg-option:disabled').count()===3);ok('OS explanation visible',await p.getByText('关闭 · 跟随系统',{exact:true}).isVisible());await p.keyboard.press('Escape');
|
||||
await p.emulateMedia({reducedMotion:'no-preference'});await wait(50);ok('OS restoration returns to calm',await p.locator('html').getAttribute('data-motion')==='calm');
|
||||
await p.reload();ok('calm survives refresh',await p.locator('html').getAttribute('data-motion')==='calm');
|
||||
await mode(p,'完整');const held=await p.evaluate(()=>window.particleFrames);await wait(160);ok('returning full does not inject particles mid-scene',await p.evaluate(()=>window.particleFrames)===held);
|
||||
ok('returning full leaves current CSS loops static',await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length===0));
|
||||
await p.goto(WEB+'/settings');await p.goto(WEB+'/agents');await wait(150);const resumed=await p.evaluate(()=>window.particleFrames);await wait(150);ok('next page entry restarts full particles',await p.evaluate(()=>window.particleFrames)>resumed);
|
||||
// Real visibility handlers, not timers: hidden clears the canvas and cancels RAF.
|
||||
await p.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))});const hidden=await p.evaluate(()=>window.particleFrames);await wait(150);ok('hidden tab stops particle RAF',await p.evaluate(()=>window.particleFrames)===hidden);
|
||||
await p.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'))});await wait(100);ok('visible tab resumes full particle RAF',await p.evaluate(()=>window.particleFrames)>hidden);
|
||||
await p.goto(WEB+'/settings');const left=await p.evaluate(()=>window.particleFrames);await wait(100);ok('leaving agent page cleans particle RAF',await p.evaluate(()=>window.particleFrames)===left);
|
||||
// Old preferences: absence, both historical true encodings, invalid new key.
|
||||
for(const [saved,legacy,expected] of [[null,null,'full'],[null,'1','off'],[null,'true','off'],['invalid','1','full']]){
|
||||
const c=await b.newContext();await c.addInitScript(({saved,legacy})=>{if(saved!==null)localStorage.setItem('aigc-motion',saved);if(legacy!==null)localStorage.setItem('aigc-reduce-motion',legacy)},{saved,legacy});const q=await c.newPage();await q.goto(WEB+'/settings');await q.locator('.management-header').waitFor();ok('preference migration '+JSON.stringify([saved,legacy]),await q.locator('html').getAttribute('data-motion')===expected);await c.close();
|
||||
}
|
||||
const blocked=await b.newContext();await blocked.addInitScript(()=>{Storage.prototype.getItem=()=>{throw new DOMException('blocked','SecurityError')};Storage.prototype.setItem=()=>{throw new DOMException('blocked','SecurityError')}});const q=await blocked.newPage();q.on('pageerror',e=>report.errors.push(e.message));await q.goto(WEB+'/settings');await q.getByRole('group',{name:'动效强度'}).last().getByRole('button',{name:'克制',exact:true}).click();ok('blocked storage still allows calm',await q.locator('html').getAttribute('data-motion')==='calm');ok('blocked storage explains in-session preference',await q.getByText('浏览器禁止本地存储:本次有效,其他操作不受影响',{exact:true}).count()>0);await blocked.close();
|
||||
const oldCtx=await b.newContext();await oldCtx.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','1')});const before=await oldCtx.newPage();
|
||||
const candidate=await ctx.newPage();await candidate.goto(OLD+'/calm-sample');await candidate.locator('#motionSeg [data-m="calm"]').click();
|
||||
await p.route('**/api/playground/jobs*',r=>r.fulfill({json:[]}));await before.route('**/api/playground/jobs*',r=>r.fulfill({json:[]}));
|
||||
// Geometry and static/motion values for all four themes and three windows.
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const id of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await before.setViewportSize({width,height});await candidate.setViewportSize({width,height});
|
||||
await before.goto(OLD+'/settings');await theme(before,id);await before.screenshot({path:`${OUT}/shots/settings-${id}-${width}-before.png`,animations:'disabled'});
|
||||
await p.goto(WEB+'/settings');await mode(p,'克制');await theme(p,id);await wait(200);await p.screenshot({path:`${OUT}/shots/settings-${id}-${width}-after.png`,animations:'disabled'});
|
||||
await candidate.evaluate(id=>setTheme(id),id);await candidate.screenshot({path:`${OUT}/shots/approved-${id}-${width}.png`,animations:'disabled'});
|
||||
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.screenshot({path:`${OUT}/shots/theme-${id}-${width}-calm.png`,animations:'disabled'});
|
||||
const panel=await geom(p.locator('.modal-panel'));ok(`theme controls fit ${id} ${width}`,panel.x>=0&&panel.x+panel.w<=width&&panel.y>=0&&panel.y+panel.h<=height);
|
||||
const values=await p.evaluate(()=>{const s=getComputedStyle(document.documentElement);return Object.fromEntries(['--page-enter-duration','--page-enter-y','--d-pop','--pop-enter-y','--pop-enter-scale','--t-reveal','--result-calm-filter','--toast-enter','--toast-enter-y','--toast-enter-scale','--lattice-x','--lattice-y','--viewfinder-inset'].map(k=>[k,s.getPropertyValue(k).trim()]))});
|
||||
ok(`actual management entry duration ${id} ${width}`,await p.locator('.management-page').evaluate(e=>getComputedStyle(e).animationDuration==='0.2s'));
|
||||
report.motion.push({id,width,height,panel,values});ok(`per-effect calm tokens ${id} ${width}`,values['--d-pop']==='150ms'&&values['--t-reveal']==='350ms'&&values['--toast-enter']==='200ms');await p.keyboard.press('Escape');
|
||||
for(const [value,label] of [['full','完整'],['calm','克制'],['off','关闭']]) {
|
||||
await mode(p,label);await p.goto(WEB+'/agents');await theme(p,id);await wait(120);
|
||||
const loops=await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length);
|
||||
ok(`agent ${value} loops ${id} ${width}`,value==='full'?loops>0:loops===0);
|
||||
await p.screenshot({path:`${OUT}/shots/agent-${id}-${width}-${value}.png`});
|
||||
}
|
||||
await mode(p,'克制');
|
||||
// Same actual workflow + prompt before/after; no demonstration brand data.
|
||||
const route='/create?wf='+f.dual;await before.goto(OLD+route);await theme(before,id);await before.locator('#desk-prompt').fill('HEL813 同状态输入');await before.screenshot({path:`${OUT}/shots/create-${id}-${width}-before.png`,animations:'disabled'});
|
||||
await p.goto(WEB+route);await theme(p,id);await p.locator('#desk-prompt').fill('HEL813 同状态输入');await p.screenshot({path:`${OUT}/shots/create-${id}-${width}-after.png`,animations:'disabled'});
|
||||
const a=await geom(p.locator('.desk-emblem')),z=await geom(before.locator('.desk-emblem'));report.geometry.push({id,width,height,before:z,after:a});ok(`unchanged empty-ring geometry ${id} ${width}`,['x','y','w','h'].every(k=>a[k]===z[k]));
|
||||
}
|
||||
await p.unroute('**/api/playground/jobs*');
|
||||
await before.close();await oldCtx.close();await candidate.close();
|
||||
// Live reveals through pending/running→done from fake ComfyUI, per theme.
|
||||
await p.setViewportSize({width:1440,height:900});
|
||||
for(const id of ['night','day','grade','bluehour']){
|
||||
await p.goto(WEB+'/create?wf='+f.dual);await theme(p,id);await p.locator('#desk-prompt').fill('HEL813 '+id+' live reveal');
|
||||
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));
|
||||
await p.locator('.gen').click();await p.locator('.a-scan-beam').waitFor({state:'attached'});ok(id+' calm scan hidden',await p.locator('.a-scan-beam').evaluate(e=>getComputedStyle(e).display==='none'));
|
||||
await p.waitForFunction(()=>window.arrivals.length>0);
|
||||
const reveal=await p.evaluate(()=>{const e=document.querySelector('.a-result-develop > img');if(!e)return null;const a=e.getAnimations()[0];a.pause();a.currentTime=0;return {duration:a.effect.getTiming().duration,frames:a.effect.getKeyframes()}});ok(id+' reveal exists at 350ms',reveal?.duration===350);report.reveal.push({id,...reveal});
|
||||
await p.screenshot({path:`${OUT}/shots/reveal-${id}-000.png`});await p.evaluate(()=>{const a=document.querySelector('.a-result-develop > img').getAnimations()[0];a.currentTime=175});await p.screenshot({path:`${OUT}/shots/reveal-${id}-175.png`});
|
||||
// Repeated snapshots must keep this first arrival; mode switch finishes it.
|
||||
await p.evaluate(()=>window.dispatchEvent(new Event('focus')));ok(id+' repeated done does not erase paused reveal',await p.locator('.a-result-develop').count()===1);
|
||||
await mode(p,'关闭');ok(id+' running reveal jumps to final when changing mode',await p.locator('.a-result-develop').count()===0);await mode(p,'克制');
|
||||
await p.reload();ok(id+' history does not replay reveal',await p.locator('.a-result-develop').count()===0);
|
||||
}
|
||||
// Theme changes use fallback, preserve the actual editor and input values.
|
||||
await p.goto(WEB+'/create?wf='+f.dual);await p.locator('#desk-prompt').fill('HEL813 未保存输入');await p.locator('#desk-prompt').evaluate(e=>window.savedEditor=e);
|
||||
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.evaluate(()=>{window.themeDiagnostics={before:{theme:document.documentElement.dataset.theme,mode:document.documentElement.dataset.motion,classes:document.documentElement.className},mutations:[]};new MutationObserver(()=>window.themeDiagnostics.mutations.push({time:performance.now(),theme:document.documentElement.dataset.theme,mode:document.documentElement.dataset.motion,classes:document.documentElement.className})).observe(document.documentElement,{attributes:true})});const vt=await p.evaluate(()=>window.vtCalls);const nextTheme=(await p.locator('html').getAttribute('data-theme'))==='day'?'夜片场':'日光棚';await p.locator('.choices.compact .choice').filter({hasText:nextTheme}).click();await wait(30);fs.writeFileSync(OUT+'/theme-diagnostics.json',JSON.stringify(await p.evaluate(()=>window.themeDiagnostics),null,2));ok('calm theme uses color fallback',await p.evaluate(()=>window.themeDiagnostics.mutations.some(x=>x.classes.includes('theme-anim'))));ok('calm skips circular View Transition',await p.evaluate(()=>window.vtCalls)===vt);await wait(350);ok('fallback timer clears class',!(await p.locator('html').evaluate(e=>e.classList.contains('theme-anim'))));await p.keyboard.press('Escape');ok('theme preserves editor identity and prompt',await p.locator('#desk-prompt').evaluate(e=>e===window.savedEditor&&e.value==='HEL813 未保存输入'));
|
||||
// Genuine skip: generate again, pause at arrival, click the media surface.
|
||||
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true}));await p.locator('.gen').click();await p.waitForFunction(()=>document.querySelector('.a-result-develop'));await p.locator('.a-result-develop').first().dispatchEvent('pointerdown');await p.waitForFunction(()=>!document.querySelector('.a-result-develop'));ok('arrival can be skipped explicitly',await p.locator('.a-result-develop').count()===0);
|
||||
await ctx.close();ok('no browser page errors',report.errors.length===0);
|
||||
}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(report,null,2));await b.close();for(const s of sockets)s.destroy();await new Promise(r=>sampleServer.close(r))}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
Reference in New Issue
Block a user