HEL-733/813/818 既定动效与已批准四主题画布 (#12)

Merge PR #12: HEL-733/806/809/810/813/818 final wrap-up (reviewed head 37bd8d85, HEL-819 independent review passed)
This commit was merged in pull request #12.
This commit is contained in:
2026-10-24 00:55:36 +08:00
85 changed files with 3395 additions and 837 deletions
@@ -124,6 +124,19 @@ def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | Non
return None
def media_io_from_schema(
inputs: list[dict[str, Any]], outputs: list[dict[str, Any]]
) -> dict[str, list[str]]:
"""Media declared by the final input schema and real graph outputs."""
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
return {
"in": sorted(
{e["kind"].replace("_list", "") for e in inputs if e.get("kind") in media_kinds}
),
"out": sorted({e["kind"] for e in outputs if e.get("kind") in ("image", "video", "audio")}),
}
def suggested_metadata(
converted: dict[str, Any],
source_path: str | None = None,
@@ -131,21 +144,10 @@ def suggested_metadata(
) -> dict[str, Any]:
"""The import suggestion bundle for one converted workflow."""
judgment = classify_usage(converted)
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
media_in = sorted(
{
entry["kind"].replace("_list", "")
for entry in parse_dynamic_inputs(converted)
if entry.get("kind") in media_kinds
}
)
media_out = sorted(
{
str(entry.get("kind"))
for entry in parse_dynamic_outputs(converted)
if entry.get("kind") in ("image", "video", "audio")
}
media_io = media_io_from_schema(
parse_dynamic_inputs(converted), parse_dynamic_outputs(converted)
)
media_in, media_out = media_io["in"], media_io["out"]
category, category_label = category_from_source_path(source_path)
return {
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.metadata import suggested_metadata
from calliope.comfyui.metadata import media_io_from_schema, suggested_metadata
from calliope.comfyui.output_map import (
auto_output_map,
load_output_map,
@@ -76,7 +76,9 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
)
# HEL-681: structured library metadata (raw JSON columns → values).
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["media_io"] = _resolved_media_io(
data.get("media_io"), data["input_schema"], data["output_schema"]
)
data["tags"] = _load_json_col(data.get("tags"), [])
data["favorite"] = bool(data.get("favorite"))
return data
@@ -90,6 +92,20 @@ def _load_json_col(raw: Any, default: Any) -> Any:
return value if value is not None else default
def _resolved_media_io(
raw: Any, inputs: list[dict[str, Any]], outputs: list[dict[str, Any]]
) -> dict[str, list[str]]:
"""Fill missing declarations only; an explicit empty list is meaningful."""
stored = _load_json_col(raw, {})
derived = media_io_from_schema(inputs, outputs)
if not isinstance(stored, dict):
return derived
return {
key: stored[key] if isinstance(stored.get(key), list) else derived[key]
for key in ("in", "out")
}
async def _connection_check(
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
) -> dict[str, Any]:
@@ -197,13 +213,15 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
output_map, output_map_source = None, None
else:
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
media_io = _resolved_media_io(data["media_io"], schema, outputs)
media_io["in"] = media_io_from_schema(schema, outputs)["in"]
is_enabled = data["is_enabled"]
if output_map_source is None or usage is None:
is_enabled = 0
conn.execute(
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
" is_enabled = ? WHERE id = ?",
" is_enabled = ?, media_io = ? WHERE id = ?",
(
json.dumps(schema),
json.dumps(outputs),
@@ -212,6 +230,7 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
output_map,
output_map_source,
is_enabled,
json.dumps(media_io),
workflow_id,
),
)
@@ -247,7 +266,8 @@ async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
try:
if view == "meta":
# HEL-681: metadata-only list for 150+ scale browsing — no
# workflow_json, no per-row re-parse. `needs_mapping` here is the
# workflow_json in the response. Only legacy/missing media
# declarations need graph parsing. `needs_mapping` here is the
# cheap pending check (usage/output_map not yet settled); rows
# that never generated keep showing as pending exactly like the
# full view reports them.
@@ -260,11 +280,27 @@ async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
data = row_to_dict(row)
data["is_enabled"] = bool(data.get("is_enabled"))
data["favorite"] = bool(data.get("favorite"))
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
media = _load_json_col(data.get("media_io"), {})
if isinstance(media, dict) and all(
isinstance(media.get(key), list) for key in ("in", "out")
):
data["media_io"] = {key: media[key] for key in ("in", "out")}
else:
legacy = conn.execute(
"SELECT workflow_json, input_schema FROM workflows WHERE id = ?",
(data["id"],),
).fetchone()
graph = json.loads(legacy["workflow_json"])
schema = effective_input_schema(
_load_json_col(legacy["input_schema"], []), graph
)
data["media_io"] = _resolved_media_io(
data.get("media_io"), schema, parse_dynamic_outputs(graph)
)
data["tags"] = _load_json_col(data.get("tags"), [])
data["needs_mapping"] = data.get("usage") is None or data.get(
"output_map_source"
) is None
data["needs_mapping"] = (
data.get("usage") is None or data.get("output_map_source") is None
)
out.append(data)
return out
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
@@ -369,6 +405,9 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
output_map = json.dumps(auto_map) if auto_map else None
output_map_source = "auto" if auto_map else None
mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map)
schema = _stored_schema(payload.input_schema, payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
media_io = media_io_from_schema(json.loads(schema), outputs)
conn = get_db(settings.db_path)
try:
# Byte-identical re-imports are the main way the library fills up with
@@ -395,15 +434,15 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
INSERT INTO workflows (name, kind, workflow_json, input_schema,
output_schema, description, prompt_profile,
endpoint_id, is_enabled, usage, usage_source,
output_map, output_map_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
output_map, output_map_source, media_io)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.name,
kind,
json.dumps(payload.workflow_json),
_stored_schema(payload.input_schema, payload.workflow_json),
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
schema,
json.dumps(outputs),
payload.description,
profile,
payload.endpoint_id,
@@ -412,6 +451,7 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
usage_source,
output_map,
output_map_source,
json.dumps(media_io),
),
)
conn.commit()
@@ -474,7 +514,20 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
if "input_schema" in data and data["input_schema"] is not None:
if not all(isinstance(e, dict) for e in data["input_schema"]):
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
media = _resolved_media_io(
existing["media_io"], [], parse_dynamic_outputs(workflow_json)
)
schema = effective_input_schema(data["input_schema"], workflow_json)
media["in"] = media_io_from_schema(schema, [])["in"]
data["media_io"] = json.dumps(media)
data["input_schema"] = json.dumps(data["input_schema"])
elif existing["media_io"] is None:
schema = effective_input_schema(
_load_json_col(existing["input_schema"], []), workflow_json
)
data["media_io"] = json.dumps(
media_io_from_schema(schema, parse_dynamic_outputs(workflow_json))
)
# endpoint_id: explicit null clears the binding; other Nones are skips
# (an output_map reset keeps its explicit NULLs).
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
@@ -792,6 +845,8 @@ def _insert_converted(
"existing_id": existing["id"],
}
is_enabled = 1 if (usage and mapping_ok) else 0
schema = _stored_schema(item.input_schema, converted)
outputs = parse_dynamic_outputs(converted)
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
@@ -805,10 +860,8 @@ def _insert_converted(
report["name"],
kind,
workflow_text,
json.dumps(
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
),
json.dumps(parse_dynamic_outputs(converted)),
schema,
json.dumps(outputs),
item.description if item.description is not None else suggested["description"],
profile,
is_enabled,
@@ -818,7 +871,7 @@ def _insert_converted(
output_map_source,
suggested["category"],
suggested["model_family"],
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
json.dumps(media_io_from_schema(json.loads(schema), outputs)),
json.dumps(item.tags or []),
item.source_path,
),
@@ -339,7 +339,7 @@ def test_meta_view_has_metadata_but_no_full_json(client):
def test_legacy_list_shape_and_old_records_unchanged(client):
"""Old imports (API format via POST /api/workflows) keep working; the
legacy list still carries workflow_json and the new columns default."""
legacy list still carries workflow_json and truthful media declarations."""
created = client.post(
"/api/workflows",
json={"name": "旧导入", "workflow_json": _api_workflow()},
@@ -352,7 +352,7 @@ def test_legacy_list_shape_and_old_records_unchanged(client):
assert "usage" in row and "output_map" in row
assert row["favorite"] is False
assert row["tags"] == []
assert row["media_io"] == {"in": [], "out": []}
assert row["media_io"] == {"in": ["image"], "out": ["video"]}
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
assert meta[0]["source"] is None
@@ -0,0 +1,205 @@
"""HEL-809: import declarations, persistence and both library views agree."""
from __future__ import annotations
import json
import pytest
from calliope.config import settings
from calliope.db import get_db
from tests.test_workflow_import_batch import _ui_graph
def graph(*classes):
nodes = {str(i): {"class_type": cls, "inputs": {}} for i, cls in enumerate(classes, 1)}
nodes["90"] = {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": "HEL809 isolated prompt"},
"_meta": {"title": "Prompt (Input:prompt)"},
}
for i in range(1, len(classes) + 1):
role = "mask" if classes[i - 1] == "LoadImageMask" else "image"
nodes[str(i)]["_meta"] = {"title": f"Reference (Input:{role})"}
return nodes
def create(client, workflow, schema=None):
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": workflow})
assert analyzed.status_code == 200
response = client.post(
"/api/workflows",
json={
"name": "HEL809 input contract",
"workflow_json": workflow,
"input_schema": analyzed.json()["inputs"] if schema is None else schema,
},
)
assert response.status_code == 200, response.text
return response.json()
def assert_views(client, wid, expected):
detail = client.get(f"/api/workflows/{wid}").json()
assert detail["media_io"] == expected
for query in ("", "?view=meta"):
rows = client.get("/api/workflows" + query).json()
row = next(r for r in rows if r["id"] == wid)
assert row["media_io"] == expected
# This is the library's exact media predicate, fed by the meta API.
for media in ("image", "video", "audio"):
hits = [
r["id"]
for r in rows
if media in r["media_io"]["in"] or media in r["media_io"]["out"]
]
assert (wid in hits) == (media in expected["in"] or media in expected["out"])
if query:
assert not {"workflow_json", "input_schema", "output_schema"} & row.keys()
def stored_media(wid):
with get_db(settings.db_path) as conn:
return json.loads(
conn.execute("SELECT media_io FROM workflows WHERE id = ?", (wid,)).fetchone()[
"media_io"
]
)
@pytest.mark.parametrize(
"classes", [("LoadImage",), ("LoadImageMask",), ("LoadImage", "LoadImageMask"), ()]
)
def test_analyze_create_persists_media_and_agrees_with_library(client, classes):
row = create(client, graph(*classes))
expected = {"in": ["image"] if classes else [], "out": []}
if "LoadImageMask" in classes:
assert any(e["role"] == "mask" and e["kind"] == "image" for e in row["input_schema"])
assert stored_media(row["id"]) == expected
assert row["model_family"] is None # never infer a model or output from kind
assert_views(client, row["id"], expected)
def test_create_uses_confirmed_schema_not_original_graph(client):
row = create(client, graph(), [{"nodeId": "90", "kind": "audio_list", "manual": True}])
assert stored_media(row["id"]) == {"in": ["audio"], "out": []}
assert_views(client, row["id"], {"in": ["audio"], "out": []})
def test_update_changes_inputs_preserving_explicit_outputs_and_other_metadata(client):
row = create(client, graph("LoadImage"))
with get_db(settings.db_path) as conn:
conn.execute(
"UPDATE workflows SET media_io = ?, model_family = ?, tags = ? WHERE id = ?",
(json.dumps({"in": ["image"], "out": []}), "existing", '["tag"]', row["id"]),
)
for kind, expected in (("audio_list", ["audio"]), ("text", []), ("video_list", ["video"])):
response = client.patch(
f"/api/workflows/{row['id']}",
json={
"input_schema": [{"nodeId": "90", "kind": kind, "manual": True}],
},
)
assert response.status_code == 200, response.text
assert response.json()["model_family"] == "existing"
assert response.json()["tags"] == ["tag"]
assert stored_media(row["id"]) == {"in": expected, "out": []}
assert_views(client, row["id"], {"in": expected, "out": []})
@pytest.mark.parametrize(
"raw, expected",
[
(None, {"in": ["image"], "out": ["image"]}),
('{"in": [], "out": []}', {"in": [], "out": []}),
('{"out": []}', {"in": ["image"], "out": []}),
('{"in": []}', {"in": [], "out": ["image"]}),
],
)
def test_legacy_null_and_missing_keys_are_distinct_from_empty_lists(client, raw, expected):
workflow = graph("LoadImageMask")
workflow["99"] = {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}
row = create(client, workflow)
with get_db(settings.db_path) as conn:
conn.execute("UPDATE workflows SET media_io = ? WHERE id = ?", (raw, row["id"]))
assert_views(client, row["id"], expected)
with get_db(settings.db_path) as conn:
assert (
conn.execute("SELECT media_io FROM workflows WHERE id = ?", (row["id"],)).fetchone()[
"media_io"
]
== raw
) # read only fallback
if raw is None:
response = client.patch(f"/api/workflows/{row['id']}", json={"favorite": True})
assert response.status_code == 200
assert stored_media(row["id"]) == expected
def test_legacy_prompt_only_has_no_invented_media(client):
row = create(client, graph())
with get_db(settings.db_path) as conn:
conn.execute("UPDATE workflows SET media_io = NULL WHERE id = ?", (row["id"],))
assert_views(client, row["id"], {"in": [], "out": []})
def test_batch_import_metadata_survives_unrelated_update(client):
response = client.post(
"/api/workflows/import-batch",
json={
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/demo.json",
"workflow_json": _ui_graph(),
"tags": ["existing batch"],
}
]
},
)
assert response.status_code == 200, response.text
wid = response.json()["imported_ids"][0]
before = client.get(f"/api/workflows/{wid}").json()
response = client.patch(f"/api/workflows/{wid}", json={"favorite": True})
after = response.json()
for key in ("media_io", "source", "source_path", "category", "model_family", "tags"):
assert before[key] == after[key]
assert_views(client, wid, {"in": [], "out": ["image"]})
def test_reanalyze_refreshes_inputs_and_preserves_empty_output_declaration(client):
row = create(client, graph("LoadImageMask"))
client.patch(
f"/api/workflows/{row['id']}",
json={
"input_schema": [{"nodeId": "90", "kind": "text"}],
},
)
# Legacy auto schema: read/reanalyze must rederive it before media classification.
with get_db(settings.db_path) as conn:
conn.execute(
"UPDATE workflows SET input_schema = ? WHERE id = ?",
('[{"nodeId": "90", "kind": "text", "auto": true}]', row["id"]),
)
response = client.post(f"/api/workflows/{row['id']}/reanalyze")
assert response.status_code == 200
expected = {"in": ["image"], "out": []}
assert stored_media(row["id"]) == expected
assert_views(client, row["id"], expected)
def test_batch_import_uses_final_confirmed_input_schema(client):
response = client.post(
"/api/workflows/import-batch",
json={
"files": [
{
"workflow_json": _ui_graph(),
"input_schema": [{"nodeId": "2", "kind": "image_list", "manual": True}],
}
]
},
)
assert response.status_code == 200, response.text
wid = response.json()["imported_ids"][0]
assert stored_media(wid) == {"in": ["image"], "out": ["image"]}
assert_views(client, wid, {"in": ["image"], "out": ["image"]})
+18 -1
View File
@@ -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" />
@@ -0,0 +1,18 @@
import type { Action } from "svelte/action";
/** HEL-716: interacting with an arriving result skips its development effect. */
export const finishAnimations: Action<HTMLElement> = (node) => {
const finish = () => {
for (const animation of node.getAnimations({ subtree: true })) {
if (animation.playState === "running") animation.finish();
}
};
node.addEventListener("pointerdown", finish);
node.addEventListener("keydown", finish);
return {
destroy() {
node.removeEventListener("pointerdown", finish);
node.removeEventListener("keydown", finish);
},
};
};
@@ -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`);
+51 -138
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { type Node, type NodeProps } from '@xyflow/svelte';
import { untrack } from 'svelte';
import { reduceMotion } from '$lib/reduceMotion.svelte';
import { finishAnimations } from '$lib/actions/finishAnimations';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
interface ArtifactNodeData extends Record<string, unknown> {
canvasNodeId: number;
jobId: number | null;
title: string;
/** Generated media file (image or video) — absolute path served via /assets. */
artifactPath: string | null;
@@ -17,39 +21,66 @@
type ArtifactNode = Node<ArtifactNodeData, 'image' | 'video'>;
let { data }: NodeProps<ArtifactNode> = $props();
let { data, selected }: NodeProps<ArtifactNode> = $props();
const mediaSrc = $derived(data.artifactPath ? assetUrl(data.artifactPath) : null);
const mediaSrc = $derived(
data.artifactPath ? assetUrl(data.artifactPath) : null,
);
// '#t=0.1' fragment paints the first frame (same trick as ClipMonitor).
const videoSrc = $derived(
data.kind === 'video' && mediaSrc ? mediaSrc + '#t=0.1' : null,
);
const running = $derived(data.status === 'running' || data.status === 'pending');
const running = $derived(
data.status === 'running' || data.status === 'pending',
);
let arriving = $state(false);
let previous: { id: number | null; status: string } | null = null;
$effect(() => {
void reduceMotion.mode;
void reduceMotion.loopRevision;
arriving = false;
});
$effect(() => {
const id = data.jobId,
status = data.status;
if (!id || previous?.id !== id || status !== 'done') arriving = false;
else if (previous.status === 'pending' || previous.status === 'running')
arriving = !untrack(() => reduceMotion.mode === 'off');
previous = { id, status };
});
$effect(() => {
if (!arriving) return;
const skip = () => (arriving = false);
window.addEventListener('pointerdown', skip, true);
return () => window.removeEventListener('pointerdown', skip, true);
});
function openPreview() {
if (!mediaSrc) return;
data.onOpenMedia?.(data.kind);
}
</script>
<div class="artifact-node" class:running>
<div
class="canvas-card artifact-node a-viewfinder"
class:a-in-frame={selected}
data-kind={data.kind}
>
<header>
<span class="type-chip">{data.kind}</span>
<span class="title" title={data.title}>{data.title}</span>
{#if running}
<span class="status running-badge">{t('canvas.running')}</span>
{:else if data.status === 'failed'}
<span class="status failed-badge">{t('canvas.failed')}</span>
{:else if videoSrc}
<span class="play-badge" aria-hidden="true">▶</span>
{/if}
</header>
<div class="media">
<div
class="media"
use:finishAnimations
onanimationend={() => (arriving = false)}
>
{#if videoSrc}
<button
type="button"
class="media-btn"
class:a-result-develop={arriving}
onclick={openPreview}
title={t('canvas.openPreview')}
>
@@ -65,6 +96,7 @@
<button
type="button"
class="media-btn"
class:a-result-develop={arriving}
onclick={openPreview}
title={t('canvas.openPreview')}
>
@@ -81,131 +113,12 @@
{:else}
<span class="placeholder">{t('canvas.noMedia')}</span>
{/if}
<span class="out-kind">{data.kind}</span>
</div>
<span class="out-kind">{data.kind}</span>
<footer>
{#if running}<span class="status running-badge">{t('canvas.running')}</span
>{:else if data.status === 'failed'}<span class="status failed-badge"
>{t('canvas.failed')}</span
>{/if}<span class="node-id">#{data.canvasNodeId}</span>
</footer>
</div>
<style>
.artifact-node {
width: 220px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.artifact-node.running {
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 30px var(--accent-glow);
}
header {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 10px;
}
.type-chip {
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
font-size: 9.5px;
padding: 2px 6px;
border-radius: 999px;
flex-shrink: 0;
}
.running-badge {
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 50%;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.media {
height: 124px;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* SafeMedia renders the <img> inside a child component, so the scoped
* `.media-el` below never lands on it — the :global() reach is what
* makes it fill the frame (same pattern as EntityNode). Without it the
* image renders at natural size and only a slice is visible. */
.media :global(.media-el),
.media-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-btn {
appearance: none;
border: none;
background: transparent;
padding: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: block;
}
.placeholder {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
/* Identical to EntityNode's kind badge: transparent-black pill, muted
* white text — one style across project and non-project cards. */
.out-kind {
position: absolute;
right: 16px;
bottom: 10px;
font-size: 8.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--on-media);
pointer-events: none;
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
</style>
+32 -98
View File
@@ -32,13 +32,15 @@
}
}
let { data }: NodeProps<EntityNode> = $props();
let { data, selected }: NodeProps<EntityNode> = $props();
const posterSrc = $derived(assetUrl(data.imagePath));
// '#t=0.1' media fragment: with preload="metadata" browsers paint nothing
// until playback, so the thumbnail sat black. The fragment seeks+paints
// the first frame without playing (same trick as ClipMonitor).
const videoSrc = $derived(data.videoPath ? assetUrl(data.videoPath) + '#t=0.1' : null);
const videoSrc = $derived(
data.videoPath ? assetUrl(data.videoPath) + '#t=0.1' : null,
);
function activate() {
// One rule for every card (artifact and entity alike): click opens the
@@ -52,7 +54,9 @@
</script>
<div
class="entity-node"
class="canvas-card entity-node a-viewfinder"
class:a-in-frame={selected}
data-kind={data.entityType}
role="button"
tabindex="0"
onclick={activate}
@@ -61,7 +65,9 @@
}}
>
<header>
<Icon name={iconFor(data.entityType)} size={14} />
<span class="kind-icon"
><Icon name={iconFor(data.entityType)} size={14} /></span
>
<span class="title" title={data.title}>{data.title}</span>
{#if videoSrc}
<span class="play-badge" aria-hidden="true">
@@ -73,7 +79,13 @@
{#if videoSrc && posterSrc}
<!-- Idle video card: show the poster (cheap static file, never a
mounted <video>). Click opens the modal player. -->
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
<SafeMedia
class="media-el"
src={posterSrc}
alt={data.title}
kind="image"
controls={false}
/>
{:else if videoSrc}
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
<video
@@ -84,101 +96,23 @@
preload="metadata"
></video>
{:else if posterSrc}
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
<SafeMedia
class="media-el"
src={posterSrc}
alt={data.title}
kind="image"
controls={false}
/>
{:else}
<span class="placeholder">{data.entityType}</span>
{/if}
{#if videoSrc}<span class="out-kind">video</span>{:else if posterSrc}<span
class="out-kind">image</span
>{/if}
</div>
{#if videoSrc}
<span class="out-kind">video</span>
{:else if posterSrc}
<span class="out-kind">image</span>
{/if}
<footer>
<span>{data.entityType}</span><span class="node-id"
>#{data.canvasNodeId}</span
>
</footer>
</div>
<style>
.entity-node {
width: 240px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: visible;
cursor: pointer;
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 40px var(--accent-glow);
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
color: var(--text-secondary);
}
.title {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.play-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--text-primary);
flex-shrink: 0;
}
.media {
height: 132px;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0 0 14px 14px;
}
.media :global(.media-el),
.media-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.placeholder {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.out-kind {
position: absolute;
right: 16px;
bottom: 10px;
font-size: 8.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--on-media);
pointer-events: none;
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
/* Scope border-box to the canvas; shared legacy chat uses content-box elsewhere. */
.canvas-root,
.canvas-root *,
.canvas-root *::before,
.canvas-root *::after {
box-sizing: border-box;
}
/* Canvas surfaces consume the approved shared theme and motion tokens. */
.canvas-root .svelte-flow {
--xy-background-color-default: var(--board-bg);
--xy-background-pattern-color-default: var(--board-dot);
--xy-controls-button-background-color-default: transparent;
--xy-controls-button-background-color-hover-default: rgb(var(--ink) / 0.06);
--xy-controls-button-color-default: var(--tx-2);
--xy-controls-button-color-hover-default: var(--tx-1);
--xy-controls-button-border-color-default: transparent;
}
.canvas-root .svelte-flow__panel {
margin: 14px;
}
.canvas-root .svelte-flow__controls,
.canvas-root .svelte-flow__minimap {
padding: 4px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--glass);
backdrop-filter: blur(14px);
box-shadow: var(--sh-1);
overflow: hidden;
}
.canvas-root .svelte-flow__controls {
gap: 2px;
height: 160px;
}
.canvas-root .svelte-flow__controls-button {
width: 30px;
height: 30px;
padding: 7px;
border-radius: 7px;
}
.canvas-root .svelte-flow__minimap {
padding: 10px;
}
.canvas-root .svelte-flow__minimap svg {
display: block;
background: rgb(var(--ink) / 0.04);
border-radius: 5px;
}
.canvas-root .svelte-flow__minimap-mask {
fill: rgb(var(--ink) / 0.04);
stroke: color-mix(in srgb, var(--gold) 80%, var(--hi));
}
.canvas-card {
position: relative;
width: 240px;
border-radius: var(--r-lg);
background: var(--bg-2);
border: 1px solid var(--line-2);
box-shadow: var(--sh-1);
cursor: grab;
transition:
box-shadow var(--t-base),
border-color var(--t-base);
--scene: var(--sc-image);
}
.canvas-card.artifact-node {
width: 220px;
border-radius: var(--r-md);
}
.canvas-card:hover {
box-shadow: var(--sh-2);
border-color: var(--line-3);
}
.canvas-card.a-in-frame,
.canvas-root .svelte-flow__node:focus-visible .canvas-card {
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
}
.canvas-card:focus-visible {
outline: none;
}
.canvas-card:focus-visible::after,
.canvas-root .svelte-flow__node:focus-visible .canvas-card::after {
opacity: 1;
inset: var(--viewfinder-selected-inset);
}
.canvas-card header {
display: flex;
align-items: center;
gap: 7px;
padding: 9px 10px 8px;
}
.canvas-card .title {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-weight: 600;
color: var(--tx-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-card .kind-icon {
width: 22px;
height: 22px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: 6px;
color: var(--kind-color);
background: color-mix(in srgb, var(--kind-color) 12%, transparent);
}
.canvas-card[data-kind="character"] {
--kind-color: var(--sc-agent);
}
.canvas-card[data-kind="location"],
.canvas-card[data-kind="scene"],
.canvas-card[data-kind="video"] {
--kind-color: var(--sc-video);
}
.canvas-card[data-kind="item"] {
--kind-color: var(--sc-project);
}
.canvas-card[data-kind="image"] {
--kind-color: var(--sc-image);
}
.canvas-card .play-badge {
display: grid;
place-items: center;
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgb(var(--ink) / 0.06);
color: var(--tx-2);
}
.canvas-card .media {
position: relative;
height: 132px;
margin: 0 8px 8px;
border-radius: calc(var(--r-md) - 2px);
background: var(--desk-media-bg);
box-shadow: var(--media-shadow);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.canvas-card.artifact-node .media {
height: 124px;
}
.canvas-card .media-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.canvas-card .media-btn {
appearance: none;
border: 0;
background: transparent;
padding: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: block;
}
.canvas-card .placeholder {
font-size: 10px;
letter-spacing: 0.08em;
color: var(--tx-3);
}
.canvas-card .out-kind {
position: absolute;
right: 6px;
bottom: 6px;
height: 18px;
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 4px;
font-size: 10.5px;
color: var(--on-media);
background: var(--media-scrim);
border: 1px solid var(--line-2);
pointer-events: none;
}
.canvas-card footer {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px 9px;
font-size: 11px;
color: var(--tx-3);
}
.canvas-card footer .node-id {
margin-left: auto;
font-family: var(--mono);
}
.canvas-card .type-chip {
font-size: 10.5px;
padding: 2px 6px;
border-radius: 4px;
color: var(--kind-color);
background: color-mix(in srgb, var(--kind-color) 12%, transparent);
height: 18px;
display: inline-flex;
align-items: center;
}
.canvas-card .status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
}
.canvas-card .running-badge {
color: var(--info);
}
.canvas-card .running-badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--info);
}
.canvas-card .failed-badge {
color: var(--err);
}
@@ -8,18 +8,11 @@
type Toast,
} from '$lib/toast';
import Icon from '$lib/components/ui/Icon.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { t } from '$lib/i18n.svelte';
let items = $state<Toast[]>([]);
onMount(() => subscribeToasts((next) => (items = next)));
const kindIcon: Record<Toast['kind'], IconName> = {
success: 'check',
error: 'alert',
info: 'info',
};
</script>
{#if items.length}
@@ -27,6 +20,7 @@
{#each items as toast (toast.id)}
<div
class="toast {toast.kind}"
class:leaving={toast.leaving}
role="status"
onmouseenter={() => pauseToast(toast.id)}
onmouseleave={() => resumeToast(toast.id)}
@@ -35,15 +29,18 @@
>
<span class="badge" aria-hidden="true">
<span class="dot"></span>
<Icon name={kindIcon[toast.kind]} size={15} />
</span>
<span class="msg">{toast.message}</span>
{#if toast.action}
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
{toast.action.label}
</button>
{/if}
<button
<span class="msg">{toast.message}</span>
{#if toast.action}
<button
type="button"
class="action"
onclick={() => (toast.action?.run(), dismissToast(toast.id))}
>
{toast.action.label}
</button>
{/if}
<button
type="button"
class="dismiss"
aria-label={t('toast.dismiss')}
@@ -57,35 +54,45 @@
{/if}
<style>
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
/* Shared host; visual values come from the approved sample tokens. */
.toast-host {
position: fixed;
right: 24px;
bottom: var(--toast-bottom, 24px);
right: var(--toast-right);
bottom: var(--toast-bottom);
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
z-index: 10001;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(400px, calc(100vw - 32px));
max-width: min(var(--toast-max-width), calc(100vw - 32px));
pointer-events: none;
}
.toast {
box-sizing: border-box;
font-size: var(--toast-font);
line-height: var(--toast-line-height);
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--r-lg);
background: var(--float-bg);
box-shadow: var(--shadow-float);
animation: toast-in var(--d-enter) var(--ease-out);
min-width: var(--toast-min-width);
max-width: var(--toast-max-width);
padding: var(--toast-padding);
border-radius: var(--toast-radius);
background: var(--toast-bg);
border: 1px solid var(--toast-border);
box-shadow: var(--toast-shadow);
animation: toast-in var(--toast-enter) var(--toast-ease);
transition: opacity var(--toast-exit);
}
.toast.leaving {
opacity: 0;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 1px;
margin-top: var(--toast-dot-top);
flex-shrink: 0;
}
.dot {
@@ -93,6 +100,7 @@
height: 7px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.toast.success .badge {
color: var(--ok);
@@ -105,10 +113,10 @@
}
.msg {
flex: 1;
font-size: var(--fs-body);
line-height: 1.4;
font-size: var(--toast-font);
line-height: var(--toast-line-height);
color: var(--text-primary);
font-weight: 500;
font-weight: 400;
}
/* §四.5: Toast 动作(如撤销)为金色文字 */
.action {
@@ -158,7 +166,8 @@
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(var(--toast-enter-y))
scale(var(--toast-enter-scale));
}
to {
opacity: 1;
@@ -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;
@@ -488,38 +488,38 @@
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-mini);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: var(--chat-tag-size, var(--fs-mini));
font-weight: var(--chat-tag-weight, 700);
letter-spacing: var(--chat-tag-spacing, 0.06em);
text-transform: var(--chat-tag-transform, uppercase);
color: var(--agent, var(--text-2));
margin-bottom: 4px;
padding-left: 2px;
}
.agent-dot {
width: 8px;
height: 8px;
width: var(--chat-dot-size, 8px);
height: var(--chat-dot-size, 8px);
border-radius: 999px;
flex-shrink: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
box-shadow: var(--chat-identity-shadow, 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent));
}
.reasoning-wrap {
margin-bottom: 4px;
max-width: 86%;
}
.bubble {
max-width: 86%;
border-radius: var(--r-lg);
padding: 10px 14px;
font-size: 13.5px;
max-width: var(--chat-bubble-width, 86%);
border-radius: var(--chat-bubble-radius, var(--r-lg));
padding: var(--chat-bubble-padding, 10px 14px);
font-size: var(--chat-bubble-font, 13.5px);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
.user-bubble {
background: var(--bg-3);
border: 1px solid var(--line);
background: var(--chat-user-bg, var(--bg-3));
border: 1px solid var(--chat-user-border, var(--line));
color: var(--text);
}
.user-bubble.steering {
@@ -574,7 +574,7 @@
.assistant-bubble {
background: var(--bg-2);
border: 1px solid var(--line);
border-left: 3px solid var(--agent, var(--text-2));
border-left: var(--chat-agent-border-width, 3px) solid var(--chat-agent-border, var(--agent, var(--text-2)));
color: var(--text);
}
.question-card {
@@ -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;
@@ -21,6 +21,7 @@
interface Props {
studio?: boolean;
primaryAction?: boolean;
running: boolean;
onSend: (payload: AgentComposerPayload) => void;
onCancel: () => void;
@@ -36,6 +37,7 @@ interface Props {
let {
studio = false,
primaryAction = false,
running,
onSend,
onCancel,
@@ -578,7 +580,7 @@ let {
{/if}
<button
type="button"
class={studio ? "a-btn sm primary" : "send"}
class={studio ? "a-btn sm primary" : primaryAction ? "a-btn primary" : "send"}
class:steer={running}
onclick={submit}
disabled={!sendable}
@@ -622,7 +624,7 @@ let {
gap: 8px;
width: 100%;
min-width: 0;
padding-top: var(--composer-top-padding, 10px);
padding: var(--composer-padding, var(--composer-top-padding, 10px) 0 0);
border-top: var(--composer-top-border, 1px) solid var(--border);
}
.composer.drag-over .box {
@@ -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;
@@ -201,8 +201,8 @@
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--bg-surface);
border-right: 1px solid var(--line);
background: var(--bg-1);
display: flex;
flex-direction: column;
gap: 4px;
@@ -243,8 +243,8 @@
justify-content: center;
width: 32px;
height: 32px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
@@ -267,32 +267,34 @@
min-height: 0;
}
.rail-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-elevated);
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
padding: 0;
}
.rail-dot::after {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tx-3);
}
.rail-dot:hover {
border-color: var(--accent);
background: rgb(var(--ink) / 0.05);
}
.rail-dot.active {
background: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--sc-agent) 12%, transparent);
}
.rail-dot.run {
animation: rail-pulse 1.4s ease-in-out infinite;
.rail-dot.active::after {
background: var(--sc-agent);
}
@keyframes rail-pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--accent-glow);
}
50% {
box-shadow: 0 0 0 5px transparent;
}
.rail-dot.run::after {
background: var(--ok);
}
.new-chat {
display: flex;
@@ -399,9 +401,10 @@
.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;
@@ -1,5 +1,6 @@
<script lang="ts">
import "./workspace.css";
import { reduceMotion } from "$lib/reduceMotion.svelte";
import { onMount, untrack } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
@@ -310,8 +311,21 @@
if (!editing && results.length) openResult(results[0]);
});
});
onMount(() => {
if (!particles) return;
$effect(() => {
// 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;
@@ -334,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 =
@@ -364,9 +378,17 @@
}
raf = requestAnimationFrame(frame);
};
frame();
const visibility = () => {
cancelAnimationFrame(raf);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!document.hidden) frame();
};
document.addEventListener("visibilitychange", visibility);
visibility();
return () => {
cancelAnimationFrame(raf);
ctx.clearRect(0, 0, canvas.width, canvas.height);
document.removeEventListener("visibilitychange", visibility);
observer.disconnect();
};
});
@@ -1,4 +1,5 @@
<script lang="ts">
import { storageGet, storageSet, storageRemove } from '$lib/storage';
import { resolve } from '$app/paths';
import { onMount, untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
@@ -120,7 +121,7 @@
let activeKey = $state<string>(
(() => {
const stored = localStorage.getItem('calliope.agents.activeKey');
const stored = storageGet('calliope.agents.activeKey');
return stored && (ALL_KEYS as readonly string[]).includes(stored)
? stored
: 'script';
@@ -160,7 +161,7 @@
return;
}
const stored = Number(
localStorage.getItem(`calliope.agents.session.${key}`),
storageGet(`calliope.agents.session.${key}`),
);
const found = list.find((s) => s.id === stored) ?? list[0];
activeId = found.id;
@@ -168,14 +169,14 @@
function rememberSession(key: string, id: number | null) {
if (id != null)
localStorage.setItem(`calliope.agents.session.${key}`, String(id));
else localStorage.removeItem(`calliope.agents.session.${key}`);
storageSet(`calliope.agents.session.${key}`, String(id));
else storageRemove(`calliope.agents.session.${key}`);
}
function selectAgent(key: string) {
if (sendPending || uploadPending || projectPending) return;
localStorage.setItem('calliope.agents.activeKey', key);
storageSet('calliope.agents.activeKey', key);
activeKey = key;
resetStreaming();
}
@@ -927,7 +928,7 @@
...agentPreferences,
[activeKey]: { ...currentPreference, ...patch },
};
localStorage.setItem(
storageSet(
'calliope.reference.agentPreferences',
JSON.stringify(agentPreferences),
);
@@ -935,10 +936,10 @@
onMount(() => {
try {
pinnedAgents = JSON.parse(
localStorage.getItem('calliope.reference.agentPins') || '[]',
storageGet('calliope.reference.agentPins') || '[]',
);
agentPreferences = JSON.parse(
localStorage.getItem('calliope.reference.agentPreferences') || '{}',
storageGet('calliope.reference.agentPreferences') || '{}',
);
} catch {
// Unavailable or malformed local storage keeps the default agent preferences.
@@ -948,7 +949,7 @@
pinnedAgents = pinnedAgents.includes(key)
? pinnedAgents.filter((p) => p !== key)
: [...pinnedAgents, key];
localStorage.setItem(
storageSet(
'calliope.reference.agentPins',
JSON.stringify(pinnedAgents),
);
@@ -1049,7 +1050,7 @@
project: id,
},
};
localStorage.setItem(
storageSet(
'calliope.reference.agentPreferences',
JSON.stringify(agentPreferences),
);
@@ -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;
@@ -1,5 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { finishAnimations } from '$lib/actions/finishAnimations';
import { reduceMotion } from '$lib/reduceMotion.svelte';
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
@@ -102,6 +104,35 @@
outIndex = 0;
});
// Only a live transition to a completed result develops. Opening old history
// or changing the theme never replays an arrival effect.
let arriving = $state(false);
let previous: { id: number; status: string } | null = null;
$effect(() => {
// A live preference/system change finishes an in-flight reveal.
void reduceMotion.mode;
void reduceMotion.loopRevision;
arriving = false;
});
$effect(() => {
const id = job?.id,
status = job?.status;
if (!id || previous?.id !== id || status !== 'done') {
arriving = false;
} else if (previous.status === 'running' || previous.status === 'pending') {
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.
@@ -208,9 +239,12 @@
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
class="print a-photo a-viewfinder a-in-frame"
class:a-result-develop={arriving}
use:finishAnimations
onanimationend={() => (arriving = false)}
style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'}
style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
@@ -248,9 +282,12 @@
</div>
{:else if output}
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
class="print a-photo a-viewfinder a-in-frame"
class:a-result-develop={arriving}
use:finishAnimations
onanimationend={() => (arriving = false)}
style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'}
style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
@@ -275,6 +312,24 @@
style:left={`${compareAt}%`}
></div>{/if}
</div>
{:else if job.status === 'running' || job.status === 'pending'}
<div class="generation-card" aria-label={st.label}>
<div class="generation-grain" aria-hidden="true"></div>
{#if job.status === 'running'}<div
class="a-scan-beam"
aria-hidden="true"
></div>{/if}
<div class="generation-info">
<b>{st.label}</b>
{#if progress > 0}<div
class="a-progress"
style:--p={progress / 100}
>
<i></i>
</div>{/if}
<span>{st.detail}</span>
</div>
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
@@ -535,7 +590,7 @@
.artboard {
position: relative;
display: grid;
grid-template-rows: 0 minmax(0, 1fr) 40px;
grid-template-rows: 0 minmax(0, 1fr) var(--desk-action-height);
height: 100%;
min-height: 0;
border: 0;
@@ -644,6 +699,7 @@
min-height: 0;
}
.print {
overflow: hidden;
position: relative;
display: grid;
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
@@ -653,14 +709,15 @@
min-width: 0;
border: 0;
padding: 0;
background: none;
box-shadow: none;
background: var(--desk-media-bg);
box-shadow: var(--media-shadow);
border-radius: var(--desk-media-radius);
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
translate: var(--desk-player-offset) var(--desk-player-offset);
width: 64px;
height: 64px;
border-radius: 50%;
@@ -684,6 +741,37 @@
.print :global(.a-fix) {
display: none;
}
.generation-card {
width: var(--generation-width);
aspect-ratio: var(--ar, 1);
max-height: var(--generation-max-height);
border-radius: var(--generation-radius);
position: relative;
overflow: hidden;
background: var(--gen-bg);
border: 1px solid var(--line-2);
}
.generation-grain {
position: absolute;
inset: 0;
opacity: var(--generation-grain-opacity);
background-image: repeating-radial-gradient(
circle at 17% 32%,
var(--grain) 0 1px,
transparent 1px 3px
);
}
.generation-info {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: var(--generation-info-padding);
background: var(--generation-info-bg);
display: flex;
flex-direction: column;
gap: var(--generation-info-gap);
}
.stage-empty {
display: flex;
flex-direction: column;
@@ -26,13 +26,14 @@
{#each sorted as job (job.id)}
{@const status = jobStatusInfo(job)}
<button
class="history-take"
class="history-take a-viewfinder"
class:a-in-frame={selectedId === job.id}
class:selected={selectedId === job.id}
onclick={() => onSelect(job)}
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb">
<div class="thumb" class:placeholder={!job.output_paths[0] || status.tone !== 'ok'}>
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
@@ -96,52 +97,63 @@
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 6px;
padding: var(--desk-history-padding);
display: flex;
flex-direction: column;
gap: var(--desk-history-gap);
align-items: center;
}
.history-take {
display: block;
position: relative;
width: 100%;
padding: 3px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
}
.history-take.selected {
border-color: var(--scene);
background: color-mix(in srgb, var(--scene) 10%, transparent);
padding: 0;
border: 1px solid var(--line);
border-radius: var(--desk-media-radius);
background: var(--desk-media-bg);
flex: none;
}
.history-take.selected { border-color: transparent; }
.thumb {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
background: var(--bg-0);
border-radius: var(--r-xs);
background: var(--desk-media-bg);
border-radius: inherit;
overflow: hidden;
font-size: 10px;
}
img,
video {
.thumb.placeholder { aspect-ratio: 1; display: grid; place-items: center; }
img, video {
width: 100%;
height: 100%;
height: auto;
display: block;
object-fit: contain;
position: absolute;
inset: 0;
}
.video-mark {
position: absolute;
bottom: 3px;
right: 3px;
width: 16px;
height: 16px;
border-radius: 4px;
display: grid;
place-items: center;
z-index: 1;
color: var(--on-media);
font-size: 24px;
text-shadow: 0 1px 6px var(--media-scrim);
font-size: 10px;
background: var(--desk-history-label-bg);
}
.failed {
color: var(--err);
}
small {
font-family: var(--mono);
font-size: 10px;
color: var(--tx-3);
position: absolute;
left: 3px;
top: 3px;
font: 600 9.5px var(--mono);
padding: 1px 4px;
border-radius: 3px;
background: var(--desk-history-label-bg);
color: var(--desk-history-label-color);
}
p {
text-align: center;
@@ -10,7 +10,7 @@
import { SvelteMap, SvelteSet, SvelteURL } from 'svelte/reactivity';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import '$lib/components/ui/Icon.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Composer from '$lib/components/generate/Composer.svelte';
@@ -1152,7 +1152,7 @@
/>
{/snippet}
<AppShell active={kind} title="创作台" workspace>
<AppShell active={kind} title="创作台" workspace hideHeader>
<div
class="reference-desk"
class:focused={focusMode}
@@ -1221,7 +1221,7 @@
>
</div>{/if}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果">
<section class="viewer" aria-label="查看生成结果" style:background="var(--desk-stage-background)">
<div class="preview-area">
{#if selectedJob}<Artboard
main
@@ -1252,22 +1252,9 @@
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>{:else}<div class="desk-welcome">
<span class="eyebrow">● AI 创作引擎</span>
<h1>让灵感,成为作品</h1>
<div class="desk-emblem">{kind === 'image' ? '◈' : '▷'}</div>
<div class="workflow-description">
<strong
>{currentWorkflow?.name ??
currentMeta?.name ??
'选择工作流,开始创作'}</strong
>
<p>
{currentWorkflow?.description ??
currentMeta?.description ??
'选择左侧工作流,填写下方提示词与参数。'}
</p>
<small>生成结果在这里显示,视频由你手动播放。</small>
</div>
<div class="desk-emblem"><Icon name={kind === 'image' ? 'image' : 'film'} size={22} /></div>
<div class="empty-title">作品会在这里完整显示</div>
<div class="empty-hint">选工作流 → 填素材与提示词 → 开始生成</div>
</div>{/if}
</div>
</section>
@@ -1620,7 +1607,7 @@
.studio-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
gap: var(--desk-layout-gap);
flex: 1;
min-height: 0;
min-width: 0;
@@ -1632,49 +1619,38 @@
min-width: 0;
overflow: hidden;
}
.viewer {
padding: var(--desk-stage-padding);
box-sizing: border-box;
}
.studio-form {
min-height: 0;
min-width: 0;
}
.desk-welcome {
box-sizing: border-box;
padding-bottom: var(--desk-action-height);
display: flex;
height: 100%;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--bg-1);
}
.desk-welcome h1 {
font-size: 24px;
font-weight: 550;
margin: 0;
gap: var(--desk-empty-gap);
color: var(--tx-3);
}
.desk-emblem {
color: var(--accent);
font-size: 56px;
line-height: 1.2;
}
.eyebrow {
color: var(--accent);
font-size: 10px;
}
.workflow-description {
max-width: 400px;
padding: 0 20px;
}
.workflow-description strong {
font-size: 13px;
color: var(--text-2);
}
.workflow-description p,
.workflow-description small {
font-size: 11px;
color: var(--text-3);
box-sizing: border-box;
width: var(--desk-empty-ring);
height: var(--desk-empty-ring);
border-radius: 50%;
border: 1px dashed var(--line-3);
display: grid;
place-items: center;
color: color-mix(in srgb, var(--scene) 70%, var(--hi));
}
.empty-title { font-size: var(--desk-empty-title-size); color: var(--tx-2); }
.empty-hint { font-size: var(--desk-empty-hint-size); }
.history-full {
height: min(680px, 68vh);
}
@@ -1724,12 +1700,6 @@
.picker-empty {
color: var(--text-3);
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
}
@media (max-width: 1100px) {
.refract-studio {
padding-left: 12px;
@@ -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";
@@ -17,11 +17,14 @@
import { storageGet, storageSet } from "$lib/storage";
import Modal from "$lib/components/ui/Modal.svelte";
import ThemeChoices from "./ThemeChoices.svelte";
import { themeStorageUnavailable } from "$lib/theme";
/** Desktop studio shell: six named sections, project scope, search,
* real queue and connection status. Individual tools keep their routes. */
interface Props {
workspace?: boolean;
/** The workspace supplies its own full-height header (creative desk). */
hideHeader?: boolean;
pageHeader?: import("svelte").Snippet<[{ openSearch: () => void }]>;
active: NavId;
title: string;
@@ -37,6 +40,7 @@
children,
pageHeader,
workspace = false,
hideHeader = false,
}: Props = $props();
let paletteOpen = $state(false);
@@ -226,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}
@@ -282,7 +286,9 @@
class="app-body"
style:background={pageHeader ? "var(--manage-page-background)" : undefined}
>
{#if pageHeader}{@render pageHeader({ openSearch: openPalette })}{:else}
{#if pageHeader}{@render pageHeader({
openSearch: openPalette,
})}{:else if !hideHeader}
<header class="topbar">
<div class="breadcrumb">
{currentNav?.label ?? title}<span>/</span><span class="page-name"
@@ -318,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}
@@ -333,16 +332,12 @@
</div>
</div>
<CommandPalette bind:open={paletteOpen} />
<Modal bind:open={themeOpen} title="外观主题">
<Modal bind:open={themeOpen} title="界面主题" rail>
{#snippet headerActions()}<span class="theme-subtitle">同一布局与功能,只换灯光</span>{/snippet}
<ThemeChoices compact />
<label class="theme-motion"
><Switch
checked={reduceMotion.system || reduceMotion.manual}
disabled={reduceMotion.system}
label={t("shell.reduceMotion")}
onchange={setReduceMotion}
/><span>减少动效</span></label
>
{#snippet footer()}
<div class="theme-motion"><span class="theme-foot-note" role="status"><Icon name={$themeStorageUnavailable ? "close" : "check"} size={18} />{$themeStorageUnavailable ? "浏览器禁止本地存储:本次有效,其他操作不受影响" : "已记住,下次打开沿用 · 正在编辑的内容不受影响"}</span><MotionChoices /></div>
{/snippet}
</Modal>
<style>
@@ -358,15 +353,21 @@
);
box-shadow: 0 0 0 1px var(--line-3);
}
.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);
--button-sm-radius: var(--theme-pop-button-radius);
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
font-size: 12px;
color: var(--tx-2);
justify-content: space-between;
width: 100%;
gap: var(--theme-pop-footer-gap);
font-size: var(--theme-pop-note-size);
color: var(--tx-3);
}
.nav-item[aria-current="page"] :global(svg) {
color: var(--nav-c, var(--gold));
@@ -385,7 +386,7 @@
grid-template-columns: var(--rail-expanded-width) minmax(0, 1fr);
}
.sidebar {
background: var(--bg-1);
background: var(--rail-background);
border-right: 1px solid var(--line);
--icon-stroke: var(--manage-icon-stroke);
padding: var(--rail-padding);
@@ -529,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);
}
@@ -560,6 +561,7 @@
text-decoration: none;
}
.app-body {
animation: a-page-enter var(--page-enter-duration) var(--ease-out);
display: flex;
flex-direction: column;
min-height: 0;
@@ -610,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>
@@ -1,4 +1,5 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
let {
compact = false,
@@ -62,17 +63,17 @@
: item.name}{#if tiles && item.id === "night"}<span
class="mg-tag theme-default">默认</span
>{/if}</strong
><small>{tiles ? item.tone : item.english}</small>{#if !tiles}<span
><small>{tiles ? item.tone : item.english + " · " + item.tone}</small>{#if !tiles}<span
>{item.description}</span
>{/if}</span
>
<span class="check" class:tile-check={tiles} aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
>{#if $theme === item.id}<Icon name="check" size={14} />{/if}</span
>
</button>
{/each}
</div>
{#if $themeStorageUnavailable}<p class="notice" role="status">
{#if $themeStorageUnavailable && !compact}<p class="notice" role="status">
浏览器禁止本地存储:本次有效,其他操作不受影响
</p>{/if}
@@ -116,6 +117,26 @@
flex-direction: column;
}
.choices.compact {
--choice-box-sizing: border-box;
--choice-description-size: var(--theme-pop-note-size);
--choice-description-line: var(--theme-choice-description-line);
--choice-gap: var(--theme-choice-gap);
--choice-radius: var(--theme-choice-radius);
--choice-border: transparent;
--choice-hover-background: rgb(var(--ink) / .04);
--choice-hover-border: var(--line-2);
--choice-selected: color-mix(in srgb, var(--gold) 55%, transparent);
--choice-selected-bg: color-mix(in srgb, var(--gold) 8%, transparent);
--choice-tone-font: var(--theme-choice-english-font);
--choice-preview-radius: var(--theme-choice-preview-radius);
--choice-preview-art: var(--theme-preview-art);
--choice-art-shadow: 0 0 0 1px var(--line-2);
--choice-art-border: 0;
--choice-dock-radius: 3px;
--choice-dock-width: 100%;
--choice-dock-border: 1px solid var(--line-2);
--choice-dock-background: var(--bg-3);
gap: var(--theme-choice-list-gap);
grid-template-columns: 1fr;
}
.choice {
@@ -125,7 +146,7 @@
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
gap: var(--choice-gap, 8px);
padding: 8px;
border: 1px solid var(--line-2);
border: 1px solid var(--choice-border, var(--line-2));
border-radius: var(--choice-radius, var(--r-md));
background: var(--bg-2);
color: var(--tx-1);
@@ -133,18 +154,20 @@
cursor: pointer;
}
.choice:hover {
border-color: var(--line-3);
border-color: var(--choice-hover-border, var(--line-3));
background: var(--choice-hover-background, var(--bg-3));
}
.choice.selected {
border-color: var(--choice-selected, var(--gold));
box-shadow: var(--choice-shadow, none);
background: var(--choice-selected-bg, var(--bg-2));
}
.choice:focus-visible {
outline: 2px solid var(--info);
outline-offset: 2px;
}
.compact .choice {
font: var(--theme-choice-font);
grid-template-columns: 76px minmax(0, 1fr) 18px;
align-items: center;
}
@@ -250,7 +273,8 @@
}
.copy > span {
width: 100%;
font-size: 11px;
font-size: var(--choice-description-size, 11px);
line-height: var(--choice-description-line, inherit);
color: var(--tx-2);
}
.check {
@@ -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%);
}
+41 -13
View File
@@ -14,6 +14,8 @@
children: Snippet;
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
anchor?: HTMLElement | null;
/** HEL-726 theme picker uses the same dialog behavior beside the rail. */
rail?: boolean;
headerActions?: Snippet;
}
@@ -26,6 +28,7 @@
footer,
children,
anchor = null,
rail = false,
headerActions,
}: Props = $props();
@@ -129,6 +132,24 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="modal-backdrop"
style:--modal-overlay={rail ? 'transparent' : undefined}
style:--modal-box-sizing={rail ? 'border-box' : undefined}
style:--modal-width={rail ? 'var(--theme-pop-width)' : undefined}
style:--modal-position={rail ? 'fixed' : undefined}
style:--modal-left={rail ? 'var(--theme-pop-left)' : undefined}
style:--modal-bottom={rail ? 'var(--theme-pop-bottom)' : undefined}
style:--modal-border={rail ? '1px solid var(--line-2)' : undefined}
style:--modal-bg={rail ? 'var(--bg-2)' : undefined}
style:--modal-shadow={rail ? 'var(--theme-pop-shadow)' : undefined}
style:--modal-origin={rail ? 'bottom left' : undefined}
style:--modal-header-padding={rail ? 'var(--theme-pop-header-padding)' : undefined}
style:--modal-title-size={rail ? 'var(--theme-pop-title-size)' : undefined}
style:--modal-title-weight={rail ? '700' : undefined}
style:--modal-header-gap={rail ? 'var(--theme-pop-header-gap)' : undefined}
style:--modal-close-size={rail ? 'var(--theme-pop-button-height)' : undefined}
style:--modal-body-padding={rail ? 'var(--theme-pop-body-padding)' : undefined}
style:--modal-footer-padding={rail ? 'var(--theme-pop-footer-padding)' : undefined}
style:--modal-footer-border={rail ? '1px solid var(--line)' : undefined}
class:anchored={!!anchor}
onclick={handleBackdropClick}
>
@@ -186,17 +207,23 @@
align-items: center;
justify-content: center;
padding: 24px;
background: var(--overlay);
background: var(--modal-overlay, var(--overlay));
animation: modal-fade var(--d-state) ease-out;
}
.modal-panel {
width: min(520px, 100%);
width: var(--modal-width, min(520px, 100%));
box-sizing: var(--modal-box-sizing, content-box);
position: var(--modal-position, static);
left: var(--modal-left, auto);
bottom: var(--modal-bottom, auto);
border: var(--modal-border, 0);
transform-origin: var(--modal-origin, center);
max-height: calc(100vh - 96px);
display: flex;
flex-direction: column;
background: var(--float-bg);
background: var(--modal-bg, var(--float-bg));
border-radius: var(--r-lg);
box-shadow: var(--shadow-float);
box-shadow: var(--modal-shadow, var(--shadow-float));
outline: none;
animation: modal-rise var(--d-pop) var(--ease-out);
}
@@ -242,22 +269,22 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px 0;
gap: var(--modal-header-gap, 12px);
padding: var(--modal-header-padding, 18px 20px 0);
}
.modal-title {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-title);
font-weight: 600;
font-size: var(--modal-title-size, var(--fs-title));
font-weight: var(--modal-title-weight, 600);
color: var(--text-primary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: var(--modal-close-size, 28px);
height: var(--modal-close-size, 28px);
margin-left: auto;
border: none;
border-radius: var(--radius-sm);
@@ -279,7 +306,7 @@
outline-offset: 2px;
}
.modal-body {
padding: 16px 20px 20px;
padding: var(--modal-body-padding, 16px 20px 20px);
overflow-y: auto;
font-size: 14px;
line-height: 1.5;
@@ -289,7 +316,8 @@
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 20px 20px;
padding: var(--modal-footer-padding, 0 20px 20px);
border-top: var(--modal-footer-border, 0);
}
@keyframes modal-fade {
from {
@@ -302,7 +330,7 @@
@keyframes modal-rise {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(var(--pop-enter-y)) scale(var(--pop-enter-scale));
}
to {
opacity: 1;
@@ -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;
@@ -105,6 +105,7 @@ export const icons = {
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
motion: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
@@ -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: "";
+4
View File
@@ -599,6 +599,10 @@ export const de: Dict = {
// --- Build scene ---
"buildScene.title": "Szene bauen",
"buildScene.builderBadge": "3D-Builder",
"buildScene.suggestionTwoPerson": "Komposition mit zwei Personen",
"buildScene.suggestionHeroPose": "Komposition mit Heldenpose",
"buildScene.suggestionBlockout": "Einfachen Raum anlegen",
"buildScene.suggestionCapture": "Referenzbild aufnehmen",
"buildScene.preparing": "Szene wird vorbereitet…",
"buildScene.stillLoading": "Szene lädt noch…",
"buildScene.expandChat": "Chat ausklappen",
+4
View File
@@ -579,6 +579,10 @@ export const en = {
// --- Build scene ---
"buildScene.title": "Build Scene",
"buildScene.builderBadge": "3D Builder",
"buildScene.suggestionTwoPerson": "Two-person composition",
"buildScene.suggestionHeroPose": "Hero pose composition",
"buildScene.suggestionBlockout": "Block out a simple room",
"buildScene.suggestionCapture": "Capture a reference image",
"buildScene.preparing": "Preparing scene…",
"buildScene.stillLoading": "Scene still loading…",
"buildScene.expandChat": "Expand chat",
+4
View File
@@ -599,6 +599,10 @@ export const es: Dict = {
// --- Build scene ---
"buildScene.title": "Componer escena",
"buildScene.builderBadge": "Editor 3D",
"buildScene.suggestionTwoPerson": "Composición de dos personas",
"buildScene.suggestionHeroPose": "Composición con pose heroica",
"buildScene.suggestionBlockout": "Crear una habitación sencilla",
"buildScene.suggestionCapture": "Capturar una imagen de referencia",
"buildScene.preparing": "Preparando la escena…",
"buildScene.stillLoading": "La escena aún se está cargando…",
"buildScene.expandChat": "Expandir chat",
+4
View File
@@ -603,6 +603,10 @@ export const fr: Dict = {
// --- Build scene ---
"buildScene.title": "Composer la scène",
"buildScene.builderBadge": "Éditeur 3D",
"buildScene.suggestionTwoPerson": "Composition à deux personnages",
"buildScene.suggestionHeroPose": "Composition en pose héroïque",
"buildScene.suggestionBlockout": "Créer une pièce simple",
"buildScene.suggestionCapture": "Capturer une image de référence",
"buildScene.preparing": "Préparation de la scène…",
"buildScene.stillLoading": "Scène en cours de chargement…",
"buildScene.expandChat": "Développer la discussion",
+4
View File
@@ -592,6 +592,10 @@ export const ja: Dict = {
// --- Build scene ---
"buildScene.title": "シーン構築",
"buildScene.builderBadge": "3D ビルダー",
"buildScene.suggestionTwoPerson": "二人の会話の構図",
"buildScene.suggestionHeroPose": "ヒーローポーズの構図",
"buildScene.suggestionBlockout": "シンプルな部屋を作成",
"buildScene.suggestionCapture": "参考画像をキャプチャ",
"buildScene.preparing": "シーンを準備中…",
"buildScene.stillLoading": "シーンをまだ読み込み中…",
"buildScene.expandChat": "チャットを展開",
+4
View File
@@ -582,6 +582,10 @@ export const ko: Dict = {
// --- Build scene ---
"buildScene.title": "장면 구성",
"buildScene.builderBadge": "3D 빌더",
"buildScene.suggestionTwoPerson": "두 사람 대화 구도",
"buildScene.suggestionHeroPose": "영웅 자세 구도",
"buildScene.suggestionBlockout": "간단한 방 만들기",
"buildScene.suggestionCapture": "참조 이미지 캡처",
"buildScene.preparing": "장면 준비 중…",
"buildScene.stillLoading": "장면을 아직 불러오는 중…",
"buildScene.expandChat": "채팅 펼치기",
+4
View File
@@ -566,6 +566,10 @@ export const zh: Dict = {
// --- Build scene ---
"buildScene.title": "搭建场景",
"buildScene.builderBadge": "3D 搭建器",
"buildScene.suggestionTwoPerson": "双人对话构图",
"buildScene.suggestionHeroPose": "英雄姿态构图",
"buildScene.suggestionBlockout": "搭建简单房间",
"buildScene.suggestionCapture": "截取参考画面",
"buildScene.preparing": "正在准备场景……",
"buildScene.stillLoading": "场景仍在加载……",
"buildScene.expandChat": "展开对话",
+85 -22
View File
@@ -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?.();
+10 -1
View File
@@ -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;
}
}
+19 -6
View File
@@ -8,28 +8,31 @@ export const THEMES = [
name: "夜片场",
english: "Nocturne",
tone: "深色 · 默认",
description: "墨黑与钨丝金 · 沉浸式创作",
description:
"墨黑棚里一盏钨丝灯:作品最亮,金色只给“去做”。夜间与沉浸创作。",
},
{
id: "day",
name: "日光棚",
english: "Daylight",
tone: "浅色",
description: "暖白纸面 · 白天办公与长时间审稿",
description:
"开窗的白天摄影棚:暖白纸面、墨色文字、琥珀主键。白天办公与长时间审稿。",
},
{
id: "grade",
name: "调色间",
english: "Grading",
tone: "中性深灰",
description: "中性深灰 · 判断作品色彩",
description:
"调色室的中性灰:不给作品染色,场景色降饱和、主键为白。判断色彩、久盯不累。",
},
{
id: "bluehour",
name: "蓝调时刻",
english: "Blue Hour",
tone: "深蓝",
description: "靛蓝与珊瑚 · 提案与灵感发散",
description: "日落后的靛蓝天色配珊瑚主光,氛围最浓。提案、展示与灵感发散。",
},
] as const;
export type ThemeId = (typeof THEMES)[number]["id"];
@@ -73,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");
@@ -105,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);
};
}
+21 -4
View File
@@ -13,11 +13,13 @@ export type Toast = {
/** Auto-dismiss delay in ms. 0 = sticky until dismissed. */
duration: number;
action?: ToastAction;
leaving?: boolean;
};
type Listener = (toasts: Toast[]) => void;
const DEFAULT_DURATION = 3200;
const EXIT_DURATION = 300;
let seq = 0;
let toasts: Toast[] = [];
@@ -27,6 +29,7 @@ type TimerState = {
remaining: number;
startedAt: number;
handle: ReturnType<typeof setTimeout>;
paused?: boolean;
};
const timers = new Map<number, TimerState>();
@@ -56,7 +59,18 @@ function schedule(id: number, delay: number) {
timers.set(id, {
remaining: delay,
startedAt: Date.now(),
handle: setTimeout(() => dismissToast(id), delay),
handle: setTimeout(() => expireToast(id), delay),
});
}
function expireToast(id: number) {
timers.delete(id);
toasts = toasts.map((t) => (t.id === id ? { ...t, leaving: true } : t));
emit();
timers.set(id, {
remaining: 0,
startedAt: Date.now(),
handle: setTimeout(() => dismissToast(id), EXIT_DURATION),
});
}
@@ -76,24 +90,27 @@ export function showToast(
/** Pause auto-dismiss (e.g. while the pointer or focus is on the toast). */
export function pauseToast(id: number) {
const timer = timers.get(id);
if (!timer) return;
if (!timer || timer.paused || toasts.find((t) => t.id === id)?.leaving)
return;
clearTimeout(timer.handle);
timer.remaining = Math.max(
0,
timer.remaining - (Date.now() - timer.startedAt),
);
timer.startedAt = Date.now();
timer.paused = true;
}
/** Resume auto-dismiss after a pause. */
export function resumeToast(id: number) {
const timer = timers.get(id);
if (!timer) return;
if (!timer?.paused) return;
clearTimeout(timer.handle);
const delay = Math.max(0, timer.remaining);
timer.remaining = delay;
timer.startedAt = Date.now();
timer.handle = setTimeout(() => dismissToast(id), delay);
timer.paused = false;
timer.handle = setTimeout(() => expireToast(id), delay);
}
export const toast = {
+164 -27
View File
@@ -111,6 +111,8 @@ code,
width: 100%;
}
.a-btn.primary {
position: relative;
overflow: hidden;
border: var(--button-primary-border-width, 1px) solid transparent;
border-bottom-width: 0;
min-height: var(--button-primary-height, 40px);
@@ -581,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 {
@@ -936,27 +938,6 @@ button.a-tile:disabled {
border-radius: var(--r-sm);
}
/* Reduced-motion baseline for components that still carry their own
durations. Components that need a styled static fallback (Skeleton,
ProgressBar indeterminate, StatusChip pulse) also handle this in their
scoped styles. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
:root[data-reduce="1"] *,
:root[data-reduce="1"] *::before,
:root[data-reduce="1"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Refract shared behavior: desktop composition, paper surfaces, direct feedback. */
[hidden] {
display: none !important;
@@ -1036,13 +1017,23 @@ select:focus-visible {
border-radius: inherit;
pointer-events: none;
opacity: 0;
background-image: var(--lattice);
z-index: -1;
background-image:
radial-gradient(
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(
70px circle at var(--pointer-x, -100px) var(--pointer-y, -100px),
#000,
transparent
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)
);
transition: opacity 180ms ease;
transition: opacity var(--lattice-duration) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.reference-option:hover::after {
@@ -1072,3 +1063,149 @@ select:focus-visible {
color: color-mix(in srgb, var(--sc-agent) 70%, var(--hi));
border-color: color-mix(in srgb, var(--sc-agent) 35%, transparent);
}
/* HEL-716 / HEL-726 effects: one shared source, theme values in themes.css. */
@keyframes a-page-enter {
from {
opacity: 0;
transform: translateY(var(--page-enter-y));
}
to {
opacity: 1;
transform: none;
}
}
.a-btn.primary::after {
display: var(--effect-display);
content: "";
position: absolute;
top: 0;
bottom: 0;
width: var(--primary-sweep-width);
left: var(--primary-sweep-from);
background: var(--primary-sweep);
transform: skewX(var(--primary-sweep-skew));
transition: left 0s;
pointer-events: none;
}
.a-btn.primary:hover:not(:disabled):not([aria-disabled="true"])::after {
left: var(--primary-sweep-to);
transition: left var(--t-reveal) var(--ease-out);
}
.a-result-develop > img,
.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);
opacity: 0.2;
transform: scale(1.015);
}
60% {
filter: blur(2px) brightness(1.1) saturate(0.8);
opacity: 1;
}
100% {
filter: none;
transform: none;
}
}
@keyframes a-develop-day {
0% {
filter: blur(16px) brightness(1.35) sepia(0.6) saturate(0.2);
opacity: 0.15;
transform: scale(1.015);
}
60% {
filter: blur(2px) sepia(0.12);
opacity: 1;
}
100% {
filter: none;
transform: none;
}
}
@keyframes a-develop-grade {
0% {
filter: blur(10px) saturate(0);
opacity: 0.25;
}
100% {
filter: none;
opacity: 1;
}
}
.a-scan-beam {
display: var(--effect-display);
position: absolute;
top: 0;
bottom: 0;
width: var(--scan-width);
left: calc(0% - var(--scan-width));
background: var(--beam);
animation: a-scan var(--scan-duration) var(--ease-io) infinite;
pointer-events: none;
}
@keyframes a-scan {
to {
left: 100%;
}
}
:root[data-reduce="1"] .a-scan-beam,
:root[data-reduce="1"] .a-btn.primary::after {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.a-scan-beam,
.a-btn.primary::after {
display: none;
}
}
.a-viewfinder::after {
content: "";
position: absolute;
inset: var(--viewfinder-inset);
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition:
opacity var(--t-base),
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,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) top
left/var(--viewfinder-stroke) var(--viewfinder-length) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) top
right/var(--viewfinder-length) var(--viewfinder-stroke) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) top
right/var(--viewfinder-stroke) var(--viewfinder-length) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) bottom
left/var(--viewfinder-length) var(--viewfinder-stroke) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) bottom
left/var(--viewfinder-stroke) var(--viewfinder-length) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) bottom
right/var(--viewfinder-length) var(--viewfinder-stroke) no-repeat,
linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) bottom
right/var(--viewfinder-stroke) var(--viewfinder-length) no-repeat;
}
.a-viewfinder.a-in-frame::after {
opacity: 1;
inset: var(--viewfinder-selected-inset);
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { storageGet, storageSet, storageRemove } from '$lib/storage';
import { onMount } from 'svelte';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -47,18 +48,18 @@
let chatCollapsed = $state(false);
$effect(() => {
railCollapsed = localStorage.getItem(RAIL_KEY) === '1';
chatCollapsed = localStorage.getItem(CHAT_KEY) === '1';
railCollapsed = storageGet(RAIL_KEY) === '1';
chatCollapsed = storageGet(CHAT_KEY) === '1';
});
function toggleRail() {
railCollapsed = !railCollapsed;
localStorage.setItem(RAIL_KEY, railCollapsed ? '1' : '0');
storageSet(RAIL_KEY, railCollapsed ? '1' : '0');
}
function toggleChat() {
chatCollapsed = !chatCollapsed;
localStorage.setItem(CHAT_KEY, chatCollapsed ? '1' : '0');
storageSet(CHAT_KEY, chatCollapsed ? '1' : '0');
}
function isSceneSession(s: AgentSession): boolean {
@@ -77,7 +78,7 @@
if (shotStore.compositionId != null) await shotStore.flushSave();
sessionId = id;
compositionReady = false;
if (persist) localStorage.setItem(ACTIVE_SESSION_KEY, String(id));
if (persist) storageSet(ACTIVE_SESSION_KEY, String(id));
const comp = await shotStore.loadComposition(id);
if (comp && comp.title === 'Untitled composition') {
const title = `Scene · ${id}`;
@@ -106,7 +107,7 @@
// (AI Canvas) excludes them, so the two surfaces never leak
// into each other.
const sessions = await agentApi.listSceneSessions();
const stored = Number(localStorage.getItem(ACTIVE_SESSION_KEY) ?? '');
const stored = Number(storageGet(ACTIVE_SESSION_KEY) ?? '');
const sceneSessions = sessions.filter(isSceneSession);
const initial = sceneSessions.find((s) => s.id === stored) ?? sceneSessions[0] ?? null;
if (initial) {
@@ -143,8 +144,8 @@
const mine = comps.find((c) => c.agent_session_id === id);
if (mine) await shotApi.deleteComposition(mine.id);
await agentApi.deleteSession(id);
if (Number(localStorage.getItem(ACTIVE_SESSION_KEY)) === id) {
localStorage.removeItem(ACTIVE_SESSION_KEY);
if (Number(storageGet(ACTIVE_SESSION_KEY)) === id) {
storageRemove(ACTIVE_SESSION_KEY);
}
const sessions = await agentApi.listSceneSessions();
const next = sessions.find(isSceneSession);
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { storageGet } from '$lib/storage';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { agentApi, canvasApi } from '$lib/api';
@@ -10,7 +11,7 @@
onMount(async () => {
try {
const sessions = await agentApi.listSessions();
const stored = Number(localStorage.getItem('calliope.agents.activeSession'));
const stored = Number(storageGet('calliope.agents.activeSession'));
const found =
sessions.find((s) => s.id === stored) ?? sessions[0] ?? (await agentApi.createSession({}));
const graph = found.project_id
+349 -198
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import {
createMutation,
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
import {
SvelteFlow,
Controls,
@@ -16,6 +20,7 @@
type Viewport,
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import '$lib/canvas/canvas.css';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AgentChat from '$lib/components/agent/AgentChat.svelte';
import AgentComposer from '$lib/components/agent/AgentComposer.svelte';
@@ -44,7 +49,11 @@
type Location,
type Scene,
} from '$lib/api';
import type { AgentComposerPayload, SkillOption, WorkflowOption } from '$lib/agentComposer';
import type {
AgentComposerPayload,
SkillOption,
WorkflowOption,
} from '$lib/agentComposer';
import { connectEvents } from '$lib/events';
import { toast } from '$lib/toast';
@@ -145,7 +154,11 @@
caption: string;
} | null>(null);
function openMediaPreview(kind: 'image' | 'video', path: string | null, caption: string) {
function openMediaPreview(
kind: 'image' | 'video',
path: string | null,
caption: string,
) {
const src = assetUrl(path);
if (!src) return;
preview = { src, kind, caption };
@@ -184,10 +197,13 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Effect-local job lookup is built and synchronously consumed into nodes, then discarded; graph/jobs already drive the effect.
const nodeRunning = new Map<number, boolean>();
for (const n of graph.nodes) {
const tracked = n.job_id != null ? jobs.find((j) => j.id === n.job_id) : undefined;
const tracked =
n.job_id != null ? jobs.find((j) => j.id === n.job_id) : undefined;
nodeRunning.set(
n.id,
tracked ? tracked.status === 'running' || tracked.status === 'pending' : false,
tracked
? tracked.status === 'running' || tracked.status === 'pending'
: false,
);
}
nodes = graph.nodes
@@ -204,7 +220,10 @@
title: n.title ?? 'Artifact',
artifactPath: n.artifact_path,
kind: n.type,
status: nodeRunning.get(n.id) ? 'running' : n.status,
status: nodeRunning.get(n.id)
? 'running'
: (jobs.find((j) => j.id === n.job_id)?.status ?? n.status),
jobId: n.job_id,
onOpenMedia: (kind: 'image' | 'video') =>
openMediaPreview(kind, n.artifact_path, n.title ?? 'Artifact'),
},
@@ -216,14 +235,23 @@
position: { x: n.x, y: n.y },
data: {
canvasNodeId: n.id,
entityType: n.entity_type as 'character' | 'location' | 'item' | 'scene',
entityType: n.entity_type as
'character' | 'location' | 'item' | 'scene',
title: n.title ?? 'Untitled',
imagePath: media.imagePath,
videoPath: media.videoPath,
onOpenMedia: (kind: 'image' | 'video') =>
kind === 'video'
? openMediaPreview('video', media.videoPath, n.title ?? 'Untitled')
: openMediaPreview('image', media.imagePath, n.title ?? 'Untitled'),
? openMediaPreview(
'video',
media.videoPath,
n.title ?? 'Untitled',
)
: openMediaPreview(
'image',
media.imagePath,
n.title ?? 'Untitled',
),
},
};
});
@@ -238,7 +266,9 @@
y: node.position.y,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.savePosFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.savePosFailed'),
);
}
}
@@ -258,7 +288,11 @@
// Group drags: targetNode is null, `nodes` is the whole selection.
// Single drags: nodes[0] is the dragged node. Persist EVERYTHING —
// persisting only the last node dropped the rest of a group's moves.
const moved = e.nodes?.length ? e.nodes : e.targetNode ? [e.targetNode] : [];
const moved = e.nodes?.length
? e.nodes
: e.targetNode
? [e.targetNode]
: [];
if (moved.length > 0) void persistNodePositions(moved);
// A refetch landed during the drag: rebuild now that positions are
// settled (and re-ordered by the just-saved values on the next fetch).
@@ -277,7 +311,11 @@
return typeof id === 'number' ? id : null;
}
async function onBeforeDeleteCanvas({ nodes }: { nodes: Node[] }): Promise<boolean> {
async function onBeforeDeleteCanvas({
nodes,
}: {
nodes: Node[];
}): Promise<boolean> {
const blocked = nodes.filter(
(n) =>
n.type === 'entity' ||
@@ -292,11 +330,15 @@
}
async function onDeleteCanvas({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(nodeServerId).filter((id): id is number => id != null);
const ids = nodes
.map(nodeServerId)
.filter((id): id is number => id != null);
if (ids.length === 0) return;
let fileDeletedCount = 0;
let keptCount = 0;
const results = await Promise.allSettled(ids.map((id) => canvasApi.deleteNode(canvasId, id)));
const results = await Promise.allSettled(
ids.map((id) => canvasApi.deleteNode(canvasId, id)),
);
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value.file_deleted) fileDeletedCount++;
@@ -310,7 +352,9 @@
return;
}
if (fileDeletedCount > 0 && keptCount > 0) {
toast.success(t('canvas.deletedMixed', { files: fileDeletedCount, kept: keptCount }));
toast.success(
t('canvas.deletedMixed', { files: fileDeletedCount, kept: keptCount }),
);
} else if (fileDeletedCount > 0) {
toast.success(t('canvas.deletedWithFile', { count: fileDeletedCount }));
} else {
@@ -365,7 +409,9 @@
// localStorage first (synchronous, survives navigation + reloads
// even if the request never lands), backend per canvas second.
storageSet(viewportLocalStorageKey(canvasId), json);
void canvasApi.patchCanvas(canvasId, { viewport_json: json }).catch(() => undefined);
void canvasApi
.patchCanvas(canvasId, { viewport_json: json })
.catch(() => undefined);
}, 500);
}
@@ -433,7 +479,9 @@
const activeSession = $derived(
activeId != null ? (sessions.find((s) => s.id === activeId) ?? null) : null,
);
const running = $derived(Boolean(activeSession?.running || activeSession?.status === 'running'));
const running = $derived(
Boolean(activeSession?.running || activeSession?.status === 'running'),
);
const sessionQuery = createQuery(
toStore(() => ({
@@ -448,7 +496,8 @@
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['agent-project-jobs', projectId ?? 'sandbox'],
queryFn: () => (projectId != null ? jobsApi.list(projectId) : playgroundApi.jobs()),
queryFn: () =>
projectId != null ? jobsApi.list(projectId) : playgroundApi.jobs(),
enabled: activeId != null,
})),
);
@@ -491,10 +540,16 @@
// gone session just 404s in the session query, which is recoverable.
activeId = pid;
// Deep-link handoff (/agents?project=&task=) pre-fills the composer once.
const prefill = sessionStorage.getItem('calliope.canvas.composerPrefill');
if (prefill) {
sessionStorage.removeItem('calliope.canvas.composerPrefill');
setComposerDraft(prefill);
try {
const prefill = sessionStorage.getItem(
'calliope.canvas.composerPrefill',
);
if (prefill) {
sessionStorage.removeItem('calliope.canvas.composerPrefill');
setComposerDraft(prefill);
}
} catch {
// A blocked handoff cache must not interrupt opening the real session.
}
replaceUrlParam('session');
return;
@@ -546,7 +601,9 @@
: await canvasApi.ensureForSession(s.id);
goto(resolve(`/canvas/${graph.canvas.id}?session=${s.id}`));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.openThatFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.openThatFailed'),
);
}
}
@@ -582,7 +639,9 @@
const graph = await canvasApi.ensureForSession(s.id);
goto(resolve(`/canvas/${graph.canvas.id}?session=${s.id}`));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.sessionCreateFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.sessionCreateFailed'),
);
}
}
@@ -599,7 +658,9 @@
goto(resolve(`/canvas/${graph.canvas.id}?session=${s.id}`));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.sessionCreateFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.sessionCreateFailed'),
);
}
}
@@ -611,7 +672,9 @@
await client.invalidateQueries({ queryKey: ['agent-sessions'] });
toast.success(t('canvas.sessionDeleted'));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.sessionDeleteFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.sessionDeleteFailed'),
);
}
}
@@ -633,12 +696,15 @@
goto(resolve(`/canvas/${graph.canvas.id}?session=${sid}`));
toast.success(t('canvas.chatUnlinked'));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.unlinkFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.unlinkFailed'),
);
}
}
const sendMutation = createMutation({
mutationFn: (payload: AgentComposerPayload) => agentApi.postMessage(activeId!, payload),
mutationFn: (payload: AgentComposerPayload) =>
agentApi.postMessage(activeId!, payload),
onMutate: () => {
if (activeId != null) {
const s = sessions.find((x) => x.id === activeId);
@@ -649,9 +715,10 @@
thinkingAgent = null;
liveTools = [];
livePlan = null;
client.setQueryData<AgentSession & { messages: AgentMessage[]; plan?: AgentPlan | null }>(
['agent-session', activeId],
(old) => (old ? { ...old, plan: null } : old),
client.setQueryData<
AgentSession & { messages: AgentMessage[]; plan?: AgentPlan | null }
>(['agent-session', activeId], (old) =>
old ? { ...old, plan: null } : old,
);
}
}
@@ -676,7 +743,9 @@
await client.invalidateQueries({ queryKey: ['agent-session', activeId] });
toast.info(t('canvas.runCancelled'));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.cancelFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.cancelFailed'),
);
}
}
@@ -693,13 +762,19 @@
activeId = s.id;
$sendMutation.mutate(payload);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.sessionCreateFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.sessionCreateFailed'),
);
}
}
/** Question-card click: the option text is the reply, stamped with the
* question seq so the backend records a structured approval. */
function handleQuestionAnswer(option: string, _scope: string, questionSeq: number) {
function handleQuestionAnswer(
option: string,
_scope: string,
questionSeq: number,
) {
if (running || activeId == null) return;
handleSend({
content: option,
@@ -800,7 +875,9 @@
...liveTools[real],
result: ev.data?.result,
phase:
(ev.data?.result as { ok?: boolean } | undefined)?.ok === false ? 'error' : 'done',
(ev.data?.result as { ok?: boolean } | undefined)?.ok === false
? 'error'
: 'done',
};
}
}
@@ -819,7 +896,9 @@
livePlan = livePlan
? {
...livePlan,
tasks: livePlan.tasks.map((t, i) => (i === index ? { ...t, status } : t)),
tasks: livePlan.tasks.map((t, i) =>
i === index ? { ...t, status } : t,
),
}
: livePlan;
}
@@ -851,7 +930,9 @@
// ---- artifacts from job events -------------------------------------------
async function materializeJobArtifact(data: Record<string, unknown> | undefined) {
async function materializeJobArtifact(
data: Record<string, unknown> | undefined,
) {
const jobId = Number(data?.job_id ?? NaN);
if (!Number.isFinite(jobId)) return;
// Scope: project canvases take their own project's jobs; sandbox
@@ -958,7 +1039,7 @@
// Stored in px; capped against the live window so a resized window can
// never restore an oversize panel (max 30% of window width).
let chatWidth = $state(300);
let chatWidth = $state(320);
let dragging = $state(false);
function clampChatWidth(px: number): number {
@@ -968,9 +1049,15 @@
$effect(() => {
const raw = Number(storageGet(CHAT_W_KEY));
if (Number.isFinite(raw) && raw > 0) chatWidth = clampChatWidth(raw);
chatWidth = clampChatWidth(
Number.isFinite(raw) && raw > 0
? raw
: window.innerWidth <= 1280
? 300
: 320,
);
// Keep the panel within the cap when the window shrinks.
const onResize = () => (chatWidth = clampChatWidth(chatWidth));
const onResize = () => (chatWidth = clampChatWidth(storageGet(CHAT_W_KEY) ? chatWidth : window.innerWidth <= 1280 ? 300 : 320));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
@@ -1003,6 +1090,8 @@
let titleDraft = $state('');
let titleEditing = $state(false);
let savedFlash = $state(false);
let savedTimer: ReturnType<typeof setTimeout> | undefined;
onDestroy(() => clearTimeout(savedTimer));
$effect(() => {
if (!titleEditing) titleDraft = canvas?.title ?? '';
@@ -1016,20 +1105,25 @@
await canvasApi.patchCanvas(canvasId, { title: next });
await client.invalidateQueries({ queryKey: ['canvas', canvasId] });
savedFlash = true;
setTimeout(() => (savedFlash = false), 1400);
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (savedFlash = false), 2400);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('canvas.renameFailed'));
toast.error(
err instanceof Error ? err.message : t('canvas.renameFailed'),
);
}
}
const SUGGESTIONS: { label: string; prompt: string }[] = [
{
label: t('canvas.suggestFilm'),
prompt: 'Create a new film project and guide me through drafting the story.',
prompt:
'Create a new film project and guide me through drafting the story.',
},
{
label: t('canvas.suggestStory'),
prompt: 'Draft a storyline — beats, characters, environments, and misc. items.',
prompt:
'Draft a storyline — beats, characters, environments, and misc. items.',
},
];
</script>
@@ -1076,14 +1170,19 @@
title={t('canvas.rename')}
>
{canvas.title}
<span class="rename-icon"><Icon name="edit" size={14} /></span>
</button>
{/if}
{#if savedFlash}
<span class="saved">saved</span>
{/if}
<span class="title-separator"></span>
{#if canvas.project}
<a class="project-chip" href={resolve(`/workbench/${canvas.project.id}`)}>
<Icon name="folder" size={12} />
<a
class="project-chip"
href={resolve(`/workbench/${canvas.project.id}`)}
>
<Icon name="folder" size={14} />
{canvas.project.title}
</a>
<button
@@ -1100,12 +1199,12 @@
title: canvas.project.title,
})}
>
<Icon name="link-off" size={12} />
<Icon name="link-off" size={14} />
{t('canvas.unlinkButton')}
</button>
{:else}
<span class="sandbox-chip">
<Icon name="sparkle" size={12} />
<Icon name="sparkle" size={14} />
Sandbox
</span>
{/if}
@@ -1115,7 +1214,7 @@
bind:nodes
bind:viewport
{nodeTypes}
colorMode="light"
colorMode="system"
minZoom={0.05}
maxZoom={4}
{initialViewport}
@@ -1131,9 +1230,24 @@
onmoveend={(_event, vp) => scheduleViewportSave(vp)}
oninit={assertViewportOnInit}
>
<Background variant={BackgroundVariant.Dots} gap={26} size={1} />
<Controls />
<MiniMap />
<Background
variant={BackgroundVariant.Dots}
gap={26 / viewport.zoom}
size={2 / viewport.zoom}
patternColor="var(--board-dot)"
/>
<Controls
><span class="zoom-value">{Math.round(viewport.zoom * 100)}%</span
></Controls
>
<MiniMap
width={150}
height={88}
nodeColor={(node) => node.type === 'video' ? 'color-mix(in srgb, var(--sc-video) 60%, var(--tx-3))' : 'color-mix(in srgb, var(--sc-image) 55%, var(--tx-3))'}
maskColor="rgb(var(--ink) / 0.04)"
maskStrokeColor="var(--gold)"
maskStrokeWidth={1.5}
/>
<Panel position="bottom-center">
<button
type="button"
@@ -1183,8 +1297,15 @@
<div class="chat-title-block">
{#if activeSession}
<span class="chat-title">{activeSession.title}</span>
<span class="chat-subtitle"
>{activeSession.project_id
? (canvas?.project?.title ?? 'Sandbox')
: 'Sandbox'}</span
>
{:else}
<span class="chat-title muted">{t('canvas.noChatSelected')}</span>
<span class="chat-title muted"
>{t('canvas.noChatSelected')}</span
>
{/if}
</div>
<button
@@ -1213,7 +1334,7 @@
<AgentPlanPanel {plan} />
{/if}
{#key activeId}
<AgentComposer
<AgentComposer primaryAction
{running}
draft={composerDraft}
draftNonce={composerNonce}
@@ -1261,57 +1382,28 @@
flex: 1;
display: flex;
min-height: 0;
position: relative;
background: var(--bg-1);
--scene: var(--sc-image);
}
.flow-panel {
flex: 1;
min-width: 0;
position: relative;
background: var(--board-bg);
}
.load-error {
padding: 24px;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin: 24px;
color: var(--tx-2);
}
.tidy-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.tidy-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--accent);
}
.tidy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.canvas-empty-hint {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 340px;
padding: 10px 14px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
font-size: 12px;
color: var(--text-secondary);
}
.canvas-empty-hint strong {
color: var(--text-primary);
font-size: 12.5px;
.titlecard,
.tidy-btn,
.canvas-empty-hint,
.chat-expand {
background: var(--glass);
backdrop-filter: blur(14px);
border: 1px solid var(--line-2);
box-shadow: var(--sh-1);
}
.titlecard {
position: absolute;
@@ -1320,139 +1412,224 @@
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
gap: 8px;
height: 42px;
max-width: calc(100% - 28px);
padding: 0 8px 0 14px;
border-radius: var(--r-lg);
}
.title-btn {
border: none;
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-primary);
color: var(--tx-1);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
cursor: text;
padding: 2px 4px;
border-radius: var(--radius-sm);
max-width: 280px;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-btn:hover {
background: var(--bg-elevated);
.rename-icon {
color: var(--tx-3);
opacity: 0;
}
.title-btn:hover .rename-icon,
.title-btn:focus-visible .rename-icon {
opacity: 1;
}
.title-input {
border: 1px solid var(--accent);
background: var(--bg-elevated);
color: var(--text-primary);
min-width: 0;
width: 180px;
border: 1px solid var(--gold);
background: var(--well);
color: var(--tx-1);
font-size: 14px;
font-weight: 600;
padding: 2px 6px;
border-radius: var(--radius-sm);
max-width: 280px;
outline: none;
border-radius: var(--r-sm);
}
.title-separator {
flex-shrink: 0;
width: 1px;
height: 18px;
background: var(--line-2);
}
.saved {
font-size: 10.5px;
color: var(--text-muted);
letter-spacing: 0.05em;
font-size: 11px;
color: var(--ok);
}
.project-chip,
.sandbox-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--text-secondary);
text-decoration: none;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 999px;
height: 28px;
padding: 0 10px;
border-radius: var(--r-pill);
border: 1px solid var(--line-2);
font-size: 12px;
color: var(--tx-2);
white-space: nowrap;
text-decoration: none;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
}
.project-chip {
background: color-mix(in srgb, var(--sc-project) 12%, transparent);
border-color: color-mix(in srgb, var(--sc-project) 38%, transparent);
color: color-mix(in srgb, var(--sc-project) 82%, var(--hi));
}
.project-chip:hover {
border-color: var(--accent);
color: var(--text-primary);
background: color-mix(in srgb, var(--sc-project) 18%, transparent);
}
.unlink-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: var(--text-secondary);
padding: 3px 9px;
border: 1px solid var(--border);
border-radius: 999px;
height: 28px;
padding: 0 9px;
border: 0;
background: transparent;
border-radius: var(--r-sm);
color: var(--tx-3);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition:
border-color 0.15s,
color 0.15s;
}
.unlink-btn:hover:not(:disabled) {
border-color: var(--err-line);
color: var(--err);
background: color-mix(in srgb, var(--err) 8%, transparent);
}
.unlink-btn:disabled {
.unlink-btn:disabled,
.tidy-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tidy-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 16px;
border-radius: var(--r-pill);
color: var(--tx-1);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition:
box-shadow var(--t-base),
border-color var(--t-base);
}
.tidy-btn:hover:not(:disabled) {
border-color: var(--line-3);
box-shadow: var(--sh-2);
}
.canvas-empty-hint {
display: flex;
flex-direction: column;
gap: 3px;
width: 380px;
max-width: 100%;
padding: 14px 18px;
border-radius: var(--r-lg);
text-align: center;
color: var(--tx-2);
font-size: 12px;
line-height: 1.6;
}
.canvas-empty-hint strong {
font-size: 14px;
font-weight: 600;
color: var(--tx-1);
}
/* Empty canvas title and instruction share the top edge: reserve a second row. */
.flow-panel :global(.svelte-flow__panel.top.center) {
margin-top: 70px;
max-width: calc(100% - 28px);
}
.zoom-value {
padding: 3px 0;
margin-top:2px;
border-top:1px solid var(--line);
font: 10px var(--mono);
color: var(--tx-3);
text-align: center;
}
.chat-panel {
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg-surface);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border-left: 1px solid var(--line);
background: var(--bg-1);
--chat-padding: 14px 12px;
--chat-bubble-width:100%; --chat-bubble-font:13px; --chat-bubble-radius:12px;
--chat-bubble-padding:9px 12px; --chat-agent-border-width:1px; --chat-agent-border:var(--line);
--chat-tag-size:11px; --chat-tag-weight:400; --chat-tag-transform:none; --chat-tag-spacing:.4px;
--chat-identity-shadow:none; --chat-dot-size:7px;
--chat-user-bg:color-mix(in srgb,var(--gold) 10%,transparent);
--chat-user-border:color-mix(in srgb,var(--gold) 25%,transparent);
--id-1:var(--tx-1); --id-2:var(--tx-2); --id-3:var(--tx-3); --id-4:var(--tx-2); --id-5:var(--tx-3);
--composer-send-bg: var(--grad-gold);
--composer-box-bg: var(--well);
--composer-padding: 10px 12px 12px;
--composer-send-height: 32px;
}
.chat-splitter {
width: 7px;
flex-shrink: 0;
padding: 0;
border: none;
border: 0;
background: transparent;
cursor: col-resize;
margin-left: -3px;
margin-right: -3px;
margin: 0 -3px;
z-index: 6;
position: relative;
transition: background 0.12s;
}
.chat-splitter:hover,
.chat-splitter.dragging,
.chat-splitter:focus-visible {
background: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--gold) 45%, transparent);
outline: none;
}
.chat-splitter.dragging {
/* Svelte Flow's pane captures pointer events while dragging near the
graph edge — suppress text selection app-wide during the drag. */
cursor: col-resize;
}
.chat-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 10px 14px;
border-bottom: 1px solid var(--border);
gap: 8px;
height: 46px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.chat-title-block {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.chat-title {
font-size: 13px;
font-size: 13.5px;
font-weight: 600;
color: var(--text-primary);
color: var(--tx-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-subtitle {
font-size: 11px;
color: var(--tx-3);
white-space: nowrap;
}
.chat-title.muted {
color: var(--text-muted);
color: var(--tx-3);
font-weight: 400;
}
.chat-toggle,
@@ -1460,30 +1637,34 @@
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
border-radius: var(--radius-sm);
flex-shrink: 0;
color: var(--tx-2);
border-radius: var(--r-md);
cursor: pointer;
}
.chat-toggle {
width: 26px;
height: 26px;
border: 0;
background: transparent;
}
.chat-toggle:hover,
.chat-expand:hover {
color: var(--text-primary);
border-color: var(--accent);
color: var(--tx-1);
}
.chat-expand {
position: absolute;
right: 12px;
top: 12px;
width: 34px;
height: 34px;
position: absolute;
right: 14px;
top: 14px;
z-index: 5;
}
/* ── ≤760px 可用级适配(F 批):解除 100vh 固定布局,画布与对话纵向堆叠。 ── */
@media (max-width: 1280px) {
.titlecard {
height: 38px;
}
}
@media (max-width: 760px) {
.shell {
height: auto;
@@ -1498,7 +1679,7 @@
}
.chat-panel {
width: 100% !important;
border-left: none;
border-left: 0;
border-top: 1px solid var(--line);
min-height: 50vh;
}
@@ -1506,34 +1687,4 @@
display: none;
}
}
.flow-panel {
background: #eae9e3;
border: 1px solid var(--line);
border-radius: 17px;
margin: 20px 0 20px 20px;
overflow: hidden;
}
.workspace {
background: var(--bg);
}
.titlecard {
background: var(--surface);
border-color: var(--line);
box-shadow: 0 4px 12px #292a2d0a;
}
.chat-panel {
margin: 20px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
}
:global(.svelte-flow) {
--xy-background-color-default: #eae9e3;
--xy-background-pattern-color-default: #c8c9bc;
--xy-edge-stroke-default: #a7b0e4;
}
.canvas-empty-hint {
color: var(--text-3);
}
</style>
+2 -20
View File
@@ -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

+173 -4
View File
@@ -35,7 +35,7 @@
--agent-task-aurora-opacity: 0.6;
--agent-duration-width: auto;
--agent-duration-gap: 0px;
--agent-footer-font: 11.5px;
--agent-footer-font: 13px;
--agent-art-meta-font: 11px;
--agent-duration-bg: transparent;
--agent-duration-font: var(--mono);
@@ -51,6 +51,8 @@
color-scheme: dark;
/* surfaces: ink black with a faint blue cast, never pure #000 */
--board-bg: #0a0c12;
--board-dot: rgb(var(--ink) / 0.11);
--bg-0: #07080c;
--bg-1: #0c0e14;
--bg-2: #12151d;
@@ -128,6 +130,28 @@
--desk-dock-focus:
var(--sh-2), 0 0 0 3px color-mix(in srgb, var(--gold) 8%, transparent),
0 0 40px color-mix(in srgb, var(--gold) 8%, transparent);
--theme-pop-button-height: 26px;
--theme-pop-button-padding: 9px;
--theme-pop-button-size: 12px;
--theme-pop-button-radius: 5px;
--theme-pop-width: 380px;
--theme-pop-left: calc(var(--rail) + 8px);
--theme-pop-bottom: 12px;
--theme-pop-shadow: var(--sh-2), 0 24px 60px rgba(0, 0, 0, 0.35);
--theme-pop-header-gap: 8px;
--theme-pop-header-padding: 12px 14px 8px;
--theme-pop-body-padding: 4px 10px 10px;
--theme-pop-footer-padding: 8px 10px 8px 14px;
--theme-pop-title-size: 13.5px;
--theme-pop-note-size: 11.5px;
--theme-pop-footer-gap: 6px;
--theme-choice-font: 400 13px/1.55 var(--font);
--theme-choice-gap: 12px;
--theme-choice-list-gap: 6px;
--theme-choice-radius: 10px;
--theme-choice-preview-radius: 8px;
--theme-choice-english-font: 500 10.5px var(--mono);
--theme-choice-description-line: 1.45;
--theme-reveal-duration: 520ms;
--theme-fallback-duration: 280ms;
--theme-preview-art: linear-gradient(160deg, #2f5d7c, #f2b36b);
@@ -163,6 +187,7 @@
--manage-card-min: 280px;
--manage-card-padding: 14px;
/* Shared sample rail and management typography, not per-theme offsets. */
--rail-background: var(--bg-1);
--rail-padding: 12px 0 10px;
--rail-brand-size: 40px;
--rail-brand-margin: 10px;
@@ -214,6 +239,35 @@
--task-tabs-offset: 12px;
--task-search-width: 240px;
--task-preview-height: 300px;
--viewfinder-length: 12px;
--viewfinder-stroke: 1.5px;
--viewfinder-inset: -1px;
--viewfinder-selected-inset: -3px;
--desk-player-offset: -50%;
--desk-media-bg: #000;
--desk-media-radius: 8px;
--desk-stage-padding: 12px 16px 8px;
--desk-layout-gap: 0px;
--desk-action-height: 40px;
--desk-history-padding: 8px;
--desk-history-gap: 8px;
--desk-history-label-bg: rgba(0, 0, 0, 0.6);
--desk-history-label-color: #ccc;
--desk-empty-ring: 84px;
--desk-empty-gap: 12px;
--desk-empty-title-size: 14px;
--desk-empty-hint-size: 12px;
--desk-stage-background:
radial-gradient(
60% 70% at 50% 45%,
color-mix(in srgb, var(--scene) var(--stage-tint), transparent),
transparent 70%
),
repeating-linear-gradient(0deg, var(--scanline) 0 1px, transparent 1px 3px);
--lattice-light-radius: 160px;
--lattice-mask-end: 75%;
--lattice-mask-radius: 140px;
--lattice-scene: 30%;
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
@@ -277,6 +331,35 @@
--t-base: 200ms;
--t-slow: 320ms;
--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%;
--primary-sweep-skew: -20deg;
--primary-sweep: linear-gradient(
100deg,
transparent,
rgba(255, 255, 255, 0.55),
transparent
);
--result-develop: a-develop;
--scan-width: 45%;
--generation-width: min(60%, 520px);
--generation-max-height: 92%;
--generation-radius: 8px;
--generation-grain-opacity: 0.25;
--generation-info-padding: 12px 14px;
--generation-info-gap: 8px;
--generation-info-bg: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
/* layout */
--rail: 68px;
--list: 272px;
@@ -349,6 +432,8 @@
}
[data-theme="day"] {
--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;
@@ -356,6 +441,8 @@
--project-draft-shadow: none;
--rail-active-shadow: none;
color-scheme: light;
--board-bg: #e6e0d3;
--board-dot: rgb(var(--ink) / 0.16);
--bg-0: #e9e4da;
--bg-1: #f4f1ea;
--bg-2: #fbf9f5;
@@ -437,6 +524,10 @@
}
[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;
@@ -451,6 +542,8 @@
--desk-filter-radius: 3px;
--desk-connection-radius: 3px;
color-scheme: dark;
--board-bg: #1e1e21;
--board-dot: rgb(var(--ink) / 0.12);
--bg-0: #1b1b1c;
--bg-1: #222223;
--bg-2: #29292b;
@@ -545,6 +638,8 @@
}
[data-theme="bluehour"] {
--desk-media-radius: 14px;
--rail-background: linear-gradient(180deg, #101a40, #1a1440);
--project-shot-radius: 14px;
--project-button-radius: 10px;
--project-button-sm-radius: 8px;
@@ -553,6 +648,8 @@
--rail-nav-radius: 14px;
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
color-scheme: dark;
--board-bg: #0c1330;
--board-dot: rgb(var(--ink) / 0.13);
--bg-0: #0a1029;
--bg-1: #0f1838;
--bg-2: #162148;
@@ -668,6 +765,11 @@
--hist: 84px;
}
}
@media (max-height: 800px) {
:root {
--desk-stage-padding: 8px 16px 8px;
}
}
@media (max-width: 1280px) {
:root {
--rail: 60px;
@@ -686,7 +788,23 @@
--composer-bg: var(--desk-dock-bg);
--composer-focus: var(--bg-2);
--float-bg: var(--bg-2);
--toast-bottom: 24px;
--toast-right: 18px;
--toast-bottom: 18px;
--toast-min-width: 260px;
--toast-max-width: 380px;
--toast-padding: 10px 14px;
--toast-radius: 10px;
--toast-bg: color-mix(in srgb, var(--bg-3) 97%, transparent);
--toast-border: var(--line-2);
--toast-shadow: var(--sh-2);
--toast-font: 12.5px;
--toast-line-height: 1.55;
--toast-enter: 320ms;
--toast-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
--toast-enter-y: 10px;
--toast-enter-scale: 0.96;
--toast-exit: 300ms;
--toast-dot-top: 6px;
--on-media: #fff;
--on-media-line: #ffffff66;
--sel-shadow: 0 0 0 1px var(--gold);
@@ -747,7 +865,10 @@
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--d-press: 160ms;
--d-state: 160ms;
--d-pop: 180ms;
--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;
--d-drawer: 250ms;
@@ -842,7 +963,7 @@ html.theme-anim *::after {
}
}
/* Keep transient feedback clear of the project’s fixed primary actions. */
/* Preserve the existing clearance for the fixed project actions. */
:root:has(.project-workspace) {
--toast-bottom: calc(var(--project-footer-height) + 24px);
}
@@ -935,6 +1056,7 @@ html.theme-anim *::after {
--agent-tool-radius: 4px;
}
[data-theme="bluehour"] {
--rail-background: linear-gradient(180deg, #101a40, #1a1440);
--agent-header-bg: color-mix(in srgb, #0f1838 55%, transparent);
--agent-shot-radius: 14px;
--agent-bubble-radius: 14px;
@@ -1000,3 +1122,50 @@ 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);
}
/* Resolve the existing corner token against each canvas card's local scene.
* Other consumers retain the original inherited root value. */
:root,
.canvas-card.a-viewfinder {
--viewfinder-color: color-mix(in srgb, var(--scene) 90%, var(--hi));
}
[data-theme="day"],
[data-theme="day"] .canvas-card.a-viewfinder {
--viewfinder-color: color-mix(in srgb, var(--scene) 85%, black);
}
+216
View File
@@ -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,47 @@
# HEL-733 · 全站既定动效修复与首轮差异核查
本卡尚未达到整站验收门槛。原样本的“克制”档缺少行为规则,需总管补充规范;不能用禁用该档宣布完成。当前交付是可审核的阶段代码、隔离复测和缺口清单,不合并、不部署,不请求用户整体验收。
基底为已集成B4的 `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`,已核实远端与原PR #11合并状态。原HTML SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。HEL-801批准的6项差异和hero小窗口例外保留。
## 实现
- `static/themes.css` 为新增/修正的视觉值统一来源;共享效果写在原 `app.css`,既有组件消费token,未引入样式覆盖层或项目依赖。
- `AppShell` / `StudioDesk`:创作台使用自身页头,删除重复的48/44px全局顶栏;保留Ctrl+K、主题与真实队列入口。蓝调rail恢复原渐变;空态恢复样本84px环、标题与提示,舞台使用原场景光晕与扫描底纹,不放演示作品。
- 统一页面320ms进入、蜂窝320ms径向跟随、取景框角标、扫描光1.6s、主按钮700ms扫光。既有减少动效规则归并为单一来源,系统或手动减少动效下关闭位移/循环/装饰。
- `Artboard`:仅当前任务从pending/running到done时进行700ms主题显影;重复done快照不提前清除效果,历史/切主题不重播,点击可结束。生成层绑定真实running/pending状态;不修改任务来源、数值或业务逻辑。修复播放键按压时全局transform覆盖居中导致点击落空的问题,改用独立translate保持中心;视频默认暂停且只由用户操作播放。
- `HistoryRail`:图片/视频按自身比例显示,编号覆盖在媒体上,复用统一取景框,移除强制方形缩略图;缺失/未完成保持明确占位。
- 主题弹层复用`Modal`焦点、关闭和层级机制,原组件参数化定位与尺寸,恢复rail旁8px/底部12px/380px宽、四行预览与原文案。`ThemeChoices`仍复用设置页同一主题注册表,设置tiles形态保留。共用弹层修正为200ms、上移6px+缩放.98。
- 智能体粒子在减少动效、任务页或隐藏标签页停止rAF,退出清理;B4成果底栏的存量11.5px容器字号改为样本13px,真实选择控件保留。
- 画布入口、画布会话预填、搭景与原会话管理在浏览器local/sessionStorage禁止时使用既有安全封装或容错,避免页面抛错;缓存不可用不改变真实数据与会话归属。
## 进度条约4.6px核对
测量对象是第一行running任务的外框`.task-progress`和填充`i`,不是只看填充百分比。1440/1366/1280下原样本与实际x差分别为-4.9375/-4.640625/-4.296875,外框均156×4。
原样本第二行来源为“智能体 · 分镜助手”,隔离真实任务按契约显示“项目 · 雾港来信”,auto表格列宽随文本重新分配。只在参考DOM对应来源文字、不动CSS后,四主题三窗口外框与填充x/y/宽/高差均0。保留真实来源,未添加CSS偏移,不以base同样存在豁免。原HEL-772附件只有base-results,没有head-results;本轮提供当前基底重新实测、输入和DOM,不声称读到了缺失文件。
## 复现与验证边界
先`npm --prefix calliope-web ci`,执行lint/check/test/build。浏览器验证需要运行环境已有的Playwright和Chromium;不加入项目依赖。`e2e/hel733-audit.mjs`读取冻结build、原HTML与明确隔离输入,阻止外部请求和产品写操作;样本从任务附件下载到仓库父目录`materials`。完整输入与产物在本卡证据附件中。
`e2e/run-hel733.py`使用真实隔离后端、假ComfyUI、假智能体模型和冻结build的preview服务。测试与构建顺序执行,避免开发服务器被check/build生成文件热重载而污染交互记录。退出核对daemon PID,关闭所有自建进程并删除隔离数据库;保留截图、结果与录屏。可用`HEL733_TESTS`指定脚本列表。
初审96组覆盖8页×四主题×三窗口;真实隔离数据截图另覆盖12类页面。大部分原样本是演示内容,应用截图是真实隔离API状态/空态,不能把这些自动称为全站同状态像素通过。B4的24组已有内容映射由`hel732-visual.cjs`另行核对。具体数表、命令、结果、录屏和未覆盖项见本卡附件。
## 未完成门槛
1. “克制”需要各类循环、位移/滤镜、时长/曲线、打断及系统优先规则;原HTML1137行只有选项,1532/1605行脚本只支持reduce,v1/v2也未补规则,不自行创作第三套动效。
2. 全站各真实/空态与交互态的完整同状态映射、逐页目检、所有关键数值表尚未全部收齐,初审图不能代替差异清零。
3. HEL-767既有末槽“导航”与原样本“规范”用途不同,需总管核定,不把几何一致视为新增授权。画布/搭景原样本仅组织说明,缺精确画面处不自创。
4. 未验证真实GPU/收费模型质量、线上真实用户数据和独立部署;由总管后续安排总工审核与部署。
回退本轮代码可revert本卡提交,保留2952af4与全部B4修复;零后端/数据库结构改动,无部署/迁移需回退。
## 本轮最终自测结果
- 全仓`npm run lint`、`npm run check`(0 errors / 0 warnings)、`npm test`、`npm run build`通过;保留现有构建chunk/第三方未使用导入提示,无新增检查例外。
- 冻结构建+真实隔离API:新增浏览器187项通过、0 pageerror;涵盖四主题显影/扫描、真实鼠标播放、首尾帧上传与编辑器实例保留、会话草稿、粒子停止、local/sessionStorage禁止。
- B4功能31项通过;同状态24组,156对关键DOM值0差异,另4项交互通过。原验证脚本中开发时动态导入源模块改为点击实际主题入口,现可审核冻结产物。
- 96组逐页初审图、144张真实隔离页面图、12组主题弹层;弹层外框四主题三窗口x/y/宽/高差均0。全动效下12组蜂窝背景/遮罩/透明度/时序/层级均与原件一致。
- 以上不替代未完成门槛;没有把真实数据差异、缺样本或克制禁用归类为整站通过。
@@ -0,0 +1,17 @@
HEL-806 continues draft PR #12 on `agent/agent/8ec6e532b3ac`, from `1f6c2d2a6b8da29ef29a874709d90da138123216`; target remains `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`. All B4 and HEL-733 changes are retained. No merge or deployment.
The approved HTML remains read-only, SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`. The architectural boundaries follow HEL-727 and the existing B1–B4 components. No dependency, backend, schema, API, cloud generation integration or production configuration is changed.
- `themes.css`, `ToastHost.svelte`, `toast.ts`: use the sample's 18px placement, surface, border, 10px radius, 12.5px text, dot, 320ms spring entry and 3200ms display followed by 300ms fade. Repeated pause no longer consumes remaining time twice. Existing undo/manual dismissal/sticky feedback and the project's fixed-action clearance remain. Those real controls and clearance are recorded as specific sample conflicts, not silently removed or presented as approved.
- `StudioDesk.svelte`, `Artboard.svelte`: replace the extra 12px gap with the original stage padding (12px 16px 8px, top 8px when height <=800), keep the 40px action row for empty as well as populated states, and clip media to its theme radius (8px night/day, 4px grade, 14px bluehour). Values have one source in tokens; no overlay stylesheet or duplicate component.
- `e2e/hel806.cjs`: real disposable HTTP data, fake ComfyUI/model only, four themes × three viewports. Original reference, runtime content map and actual screenshots are distinct. Reference changes use existing templates/content/state; no reference CSS/layout changes. Missing empty states, non-storyboard stage content, real edit/compare/validation dialogs and organization-only pages are explicitly marked. The sample's static parameter list is not substituted for actual workflow declarations.
- `e2e/hel806-toast.mjs`, `hel806-toast-browser.cjs`: deterministic expiry/pause/sticky/cleanup regression plus full/reduced motion, project footer clearance and real explicit confirmation.
- `scripts/report-hel806.py`: measured values, self-contained themed galleries and parallel images. Screenshot delivery uses lossless WebP; decoded RGB equality is checked against source PNGs. Manifests retain both hashes. Identical bitmap bytes are deduplicated inside self-contained upload parts; media-files.json preserves the logical-name mapping, and every state stays with its three viewport sizes. Screenshot counts and DOM measurements are evidence, never automatic visual approval.
Reproduce from a frozen build: install the repository's existing dependencies, run frontend lint/check/test/build, then set existing `PLAYWRIGHT_MODULE_PATH`, browser library path and `HEL732_PYTHON` for this machine. `HEL733_TESTS=e2e/hel732.cjs,e2e/hel729.cjs,e2e/hel742.cjs,e2e/hel733-live.cjs,e2e/hel732-visual.cjs,e2e/hel806.cjs,e2e/hel806-toast-browser.cjs HEL733_SKIP_SHOTS=1 python3 e2e/run-hel733.py`. The original HTML is downloaded through the CLI to `../materials` and the B4 reference copy to `../evidence/reference`. All required results are collected before exit; the runner closes its exact child processes and removes the disposable database. Run `node e2e/hel806-toast.mjs` separately. The report script reads `../evidence/hel806-final/results.json`.
The attached coverage report owns the per-page findings and exact run results. The six approved business differences, small-window hero exception, text-to-image without image slots, honest unknown connection metrics and manual video playback remain. Restrained motion, final navigation slot purpose and canvas/scene visual samples still await the design decision already routed by the coordinator. No claim that HEL-733 or the whole site is complete; final independent review and deployment belong to the responsible reviewer.
Rollback only these commits, keeping `1f6c2d2` and B4. No database rollback is needed.
Validation completed: full frontend lint/check (0 errors / 0 warnings), guard scripts and production build pass. Relevant backend regressions: 24 pass, one existing Starlette deprecation warning. B1 browser checks: 42; no-image guards: 7; B4 functional: 31; shared motion/storage checks: 43, all pass with no page errors. B4 same-state 24 groups / 156 measured selector pairs have no differences, plus four interactions. The audit delivers 34 states × 12 viewports/themes = 408 groups and 12 dedicated Toast style/clearance checks. Image frame geometry/radius and empty-ring geometry each match the reference in their 12 measured groups; these local findings do not imply whole-page approval.
@@ -0,0 +1,10 @@
HEL-809 fixes single-workflow imports being shown as prompt-only and excluded from media filters. It continues draft PR #12 from `cb410fbdeaa87528aaaab1f0dbffeb0240a9a32e`, retaining all existing fixes; target is `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`.
- `comfyui/metadata.py`: extracts the existing input-kind normalization and real output-kind rules into `media_io_from_schema`, shared by import suggestions and persistence. Mask inputs already have kind=image; lists normalize to their media kind. Model family inference is unchanged.
- `routers/workflows.py`: single create and schema updates persist media declarations from the final schema. Reanalyze also refreshes inputs. Batch import uses the same helper with the confirmed schema while retaining category, model family, tags and source. Existing output declarations, including empty arrays, survive schema edits; missing outputs derive only from actual parsed graph declarations, never workflow kind/usage.
- Full and metadata-only serialization derive absent media declarations for legacy NULL rows without database writes. Present arrays, including valid empty arrays, are preserved independently per key. The lightweight list fetches/parses graph data only for records missing declarations; its response still excludes heavy schemas/graphs. The existing frontend row and filter both consume this result; no frontend product file or visual rule changes.
- `tests/test_workflow_media_io.py`: 14 targeted regressions cover real analyze→create image/mask/prompt paths, confirmed schema overrides, update classification, NULL and partial legacy metadata versus empty arrays, no invented outputs/families, batch metadata retention, reanalyze and agreement among detail/full/meta library views. The previous batch test expecting missing declarations now expects the graph's actual image→video declaration.
Validation: Python 3.12 relevant backend suites: 93 passed, one existing Starlette deprecation warning. Ruff on changed product/new regression files and git diff whitespace check pass. Unchanged frontend production build passes. `e2e/hel809.cjs` and `e2e/run-hel809.py` use the same frontend build against frozen pre-fix and current backend, capture actual drawer analyze/create requests and responses plus meta/detail reads, and assert old prompt-only/filter miss versus corrected image/filter hit. Pure prompt row retains its label and is excluded from the image filter. Screenshots/responses/logs and exact final head/base are delivered on HEL-809.
Reproduce: use an existing backend Python environment with repository dependencies and build `calliope-web`; set `PLAYWRIGHT_MODULE_PATH` and browser library path for the machine, then run `python e2e/run-hel809.py`. The runner uses loopback-only ports 18249/18250, checks availability first, pauses generation, points ComfyUI/LLM to a closed loopback port, starts no real model service, terminates its exact backend children and removes its temporary databases in finally. Evidence remains in the caller's workdir. No new dependency, migration, production data/config, deployment or merge. Rollback this task's commit only; existing NULL records remain compatible with the preceding version.
@@ -0,0 +1,20 @@
## HEL-810 — translate scene suggestion buttons
The four Build Scene welcome suggestions exposed internal translation keys.
Added the approved Chinese labels and matching English labels in the existing
dictionaries. The other five dictionaries also receive these four entries to
preserve their existing exhaustive `Dict` contract. No prompts, interactions,
layout, theme, dependencies, or scene data changed.
Validation: frontend lint, Svelte/TypeScript check, existing npm guards, and build.
An isolated browser run at 1440×900 captured full-page before/after Chinese night
views and the English view; all four buttons still fill the original prompts,
and switching the existing language selector to English updates all four labels.
API responses were in-memory fixtures; mutations and external requests were
blocked. No backend, database, GPU, paid generation, or production service was
used. Browser and local Vite server closed in finally; evidence is attached to
HEL-810. No new test duplicates the dictionary implementation.
Continues PR #12 from HEL-809 head b6ded3942b7f18a522343dff39a1e48bf924e177,
targeting leefer/hel-731-studio-project at
2952af42b6232483640f1f27803980367cd62dd0. No merge or deployment.
@@ -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独立确认的五项全量隔离失败保持原结论,不新增跳过或忽略。最终独立审核/部署由总管另派,总站与画布未在此签验收。
@@ -0,0 +1,21 @@
# HEL-818 · 已批准四主题画布外观
继续原草稿 PR #12(远端 `agent/agent/8ec6e532b3ac`),本轮起点 `fe4759b788ea82747e1085f94cfe749756b08fb3`,base `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`。HEL-801 的画布线程已明确批准;静态外观以 HEL-814 HTML/规范为依据,动效冲突按总管补充与 HEL-805 v3,不复制演示脚本。
- `static/themes.css` 新增四主题 `--board-bg` 与 `--board-dot` 八行;既有 `--viewfinder-color` 的两条取值集中到 root/画布卡片分组规则,在画布卡片局部重新求值,避免继承根节点金色,数值不变,其它消费者仍沿 root 值;不改共享主题/三档动效状态机、滤镜和时序。
- `canvas/[id]/+page.svelte` 合并重复样式,移除固定浅灰和 light 锁定。浮条、项目胶囊、空态、整理按钮、助手头和收展钮消费已有主题 token;默认助手 320px,小窗口 300px,保留手动宽度、视口、真实数据与已有操作。
- `lib/canvas/canvas.css` 是两类卡片与 xyflow 画布控件的共同样式来源。实体 240、产物 220;媒体 132/124;统一玻璃缩放控件与 172×110 小地图;复用 `a-viewfinder`,选中及键盘焦点采用既有 −1→−3px 规则。
- 两类卡片保留真实文件、视频首帧及预览回调,不放演示卡和示例标记。产物只在同一个 job 的 pending/running→done 时使用既有 `a-result-develop`(700/350ms),切档终态、历史不播、重复 done 不重播、点击跳过;运行状态静态显示。
- `AgentChat` 通过有原值 fallback 的样式参数复用原有聊天组件;仅画布传入批准外观的尺寸/中性身份色。`AgentComposer` 增加 opt-in `primaryAction` 复用现成 `a-btn primary`,其它调用方保持原路径;会话轨在原组件中统一 48/240px 与 32px 按钮/6px 状态点。没有第二套对话、偏好或动效控件。
验证脚本 `e2e/run-hel818.py` 前台启动本 checkout 的一次性 SQLite 与假 ComfyUI/LLM,使用独立端口 18247/15173/19188/19189,不接触其它任务的服务。浏览器脚本交四主题×三窗口原图、运行时真实内容映射图、应用图、几何数值、显影逐帧和录屏。映射文件独立保存,原 HTML/批准图不改;不声称整页像素一致。退出前比对 multica daemon PID,只停自己的进程,并删除隔离库。
需要明确的差异:外壳顶栏保持既有 1px 边框几何;真实内容、项目/节点编号和默认视口不是演示排布。空态提示下移到画布内 70px,避开真实标题/项目操作(原样本两者同处 14px,长标题会相叠);此位置附图交最终视觉审核。输入提示误写技能、saved/Sandbox/建议和确认框硬编码英文继续列为已有问题,未顺手改文案。源码的既有删除快捷键是 Backspace,演示规范写成 Delete;本轮保留原键位,未增加交互。
前端 lint/check/test/build、画布相关后端隔离测试及浏览器最终记录见 HEL-818 附件。本轮后端代码零修改;HEL-812/817 的五项全量隔离失败仍按原版本基线保留,不新增忽略或跳过。原 HEL-813 审核证据仅属于旧头,最终集成审核需核本轮新头。未合并、部署或改线上配置/数据。
同状态画布比较明确采用离线字体条件:浏览器同时阻止应用和映射样本的 Google Fonts 请求,两者使用同机同字体栈的 fallback,数表保留 computed font;这不是产品字体来源调整,也不是联网字体一致性证明。原样图(未映射)单独保留,真实内容/节点位置/视口/视频首帧和会话文本只在测试运行 DOM 映射。
最终验证:前端 lint/check(0错误0警告)、test/build 通过;浏览器185/185通过、pageerror=0;画布相关后端隔离测试43通过(保留一项依赖弃用提示)。完整/克制四主题显影与三档主题切换共11组动画记录,四主题×三窗口12组比较。标题、实体/产物、缩放控件40×160、小地图172×110的同状态尺寸吻合,助手320/300;顶栏保留既有1px差异。点阵按样图保持屏幕26px网格/1px半径,抵消图形视口缩放;小地图保留10px内衬和按媒体种类的真实色块。
本轮未单独回归计划/推理面板的所有分支、完整档不支持ViewTransition浏览器的回退路径、联网字体及后端全量套件;相关实现保留既有版本,旧证据不冒充新头实测。浏览器操作覆盖框选、多选保护、Backspace删除、命名、项目解绑、缩放持久化、拖动、整理、助手收展/键盘调宽、图片/视频预览、上传保留、工作流/技能菜单、工具参数展开、引导与停止。
+1 -1
View File
@@ -53,7 +53,7 @@ const style=loc=>loc.evaluate(e=>{const s=getComputedStyle(e),r=e.getBoundingCli
await a.screenshot({path:`${out}/task-${theme}-${width}-actual.png`,animations:'disabled'});await s.screenshot({path:`${out}/task-${theme}-${width}-reference-content.png`,animations:'disabled'});
for(const [name,actual,ref] of [['columns','.atask','.atask'],['artifact','.artifact','.artifact'],['header','.art-h','.art-h'],['footer','.art-f','.art-f'],['shot','.shot','.shot'],['save','.art-f .secondary','.art-f .btn:not(.primary)'],['primary','.art-f .primary','.art-f .primary']])rows.push({theme,width,height,page:'task',name,actual:await style(a.locator(actual).first()),sample:await style(s.locator(ref).first())});
if(width===1440&&theme==='night'){
await a.getByLabel('镜头 b4-shot-1 描述').fill('主题切换保持未保存编辑');await a.evaluate(async()=>{const {setTheme}=await import('/src/lib/theme.ts');setTheme('day')});await a.waitForFunction(()=>document.documentElement.dataset.theme==='day');assert.equal(await a.getByLabel('镜头 b4-shot-1 描述').inputValue(),'主题切换保持未保存编辑');operations.push('切换主题保持未保存成果文本');await a.screenshot({path:out+'/operation-theme-edit-preserved.png'});
await a.getByLabel('镜头 b4-shot-1 描述').fill('主题切换保持未保存编辑');await a.locator('.sidebar-bottom button[title="主题"]').click();await a.locator('.choices.compact .choice').filter({hasText:'日光棚'}).click();await a.keyboard.press('Escape');await a.waitForTimeout(600);await a.waitForFunction(()=>document.documentElement.dataset.theme==='day');assert.equal(await a.getByLabel('镜头 b4-shot-1 描述').inputValue(),'主题切换保持未保存编辑');operations.push('切换主题保持未保存成果文本');await a.screenshot({path:out+'/operation-theme-edit-preserved.png'});
await a.getByRole('button',{name:'原文',exact:true}).click();await a.waitForFunction(()=>document.querySelector('[aria-label=成果草稿]')?.value.includes('主题切换保持未保存编辑'));assert((await a.getByLabel('成果草稿').inputValue()).includes('主题切换保持未保存编辑'));operations.push('逐镜编辑与原文视图同步');await a.screenshot({path:out+'/operation-raw-edit.png'});
await a.reload();await a.locator('.shot-copy textarea').first().waitFor();await a.getByRole('button',{name:'对比上一版',exact:true}).click();await a.getByRole('dialog').waitFor();await a.screenshot({path:out+'/operation-version-comparison.png'});await a.keyboard.press('Escape');
const tool=a.locator('.tool-card .head').first();await tool.click();await a.locator('.tool-card .body').first().waitFor();operations.push('工具详情真实参数与返回值可展开');await a.screenshot({path:out+'/operation-tool-expanded.png'});
+114
View File
@@ -0,0 +1,114 @@
// Foreground static-build audit. All API reads use labelled, isolated inputs;
// all non-local requests and all product mutations are blocked.
import fs from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
const out = process.env.HEL733_OUT || '../evidence/hel733/before';
await fs.mkdir(out, { recursive: true });
const fixture = JSON.parse(await fs.readFile('../evidence/hel733/fixtures.json'));
const sample = await fs.readFile('../materials/夜片场-多主题交互样本.html');
const log = { errors: [], blocked: [], rows: [], requests: [] };
const server = http.createServer(async (req, res) => {
try {
const p = decodeURIComponent(new URL(req.url, 'http://local').pathname);
if (p === '/sample') { res.setHeader('Content-Type','text/html;charset=utf-8'); return res.end(sample); }
let file = path.join('calliope-web/build', p);
if (!path.extname(file)) file = 'calliope-web/build/index.html';
res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html');
res.end(await fs.readFile(file));
} catch { res.writeHead(404); res.end(); }
});
await new Promise(r => server.listen(0,'127.0.0.1',r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({args:['--no-sandbox']});
const measure = (p, selectors) => p.evaluate(selectors => Object.fromEntries(Object.entries(selectors).map(([key,sel]) => {
const e=document.querySelector(sel); if(!e) return [key,null];
const r=e.getBoundingClientRect(),c=getComputedStyle(e);
return [key,{x:r.x,y:r.y,width:r.width,height:r.height,...Object.fromEntries(['fontSize','fontWeight','lineHeight','letterSpacing','backgroundColor','backgroundImage','borderRadius','color','padding','boxSizing','animationName','animationDuration'].map(k=>[k,c[k]]))}];
})),selectors);
try {
const c=await browser.newContext();
await c.addInitScript(()=>{
localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','1');
window.__sse=[];window.EventSource=class{constructor(){window.__sse.push(this);setTimeout(()=>this.onopen?.(),0)}addEventListener(t,f){(this.handlers??={})[t]=f}close(){}};
});
await c.route('**/*',async route=>{
const req=route.request(),u=new URL(req.url());
if(['data:','blob:'].includes(u.protocol))return route.continue();
if(u.origin!==base){log.blocked.push(req.url());return route.abort()}
if(!u.pathname.startsWith('/api/'))return route.continue();
log.requests.push([req.method(),u.pathname]);
if(req.method()!=='GET')return route.fulfill({status:403,body:'Isolated audit blocks mutations'});
const p=u.pathname;let body=[];
if(p==='/api/settings')body={comfyui_endpoints:[],llm_profiles:[],queue_concurrency:2,queue_poll_timeout_sec:1800,queue_poll_interval_sec:1,queue_max_retries:1,data_dir:'HEL733 isolated audit',dry_run:false};
if(p==='/api/jobs')body=u.searchParams.get('empty')?[]:fixture.jobs;
if(p==='/api/workflows')body=fixture.workflows;
if(p==='/api/projects')body=[{id:1,title:'雾港来信'}];
if(p==='/api/jobs/queue-status')body={paused:false};
if(p==='/api/comfyui/endpoints')body={endpoints:[],active_id:null};
if(p==='/api/agent/sessions')body=[];
if(p==='/api/agent/agents')body=[];
if(/^\/api\/workflows\/\d+$/.test(p))body={...fixture.workflows.find(x=>x.id===+p.split('/').pop()),workflow_json:{}};
if(p==='/api/file')return route.fulfill({status:404,body:'No audit media'});
return route.fulfill({json:body});
});
const a=await c.newPage(),s=await c.newPage();a.on('pageerror',e=>log.errors.push(e.message));
await s.goto(base+'/sample');await s.evaluate(()=>document.documentElement.classList.add('capture','reduce'));
const pages=process.env.HEL733_CREATE_ONLY ? [['create','create']] : process.env.HEL733_POP_ONLY ? [['settings','settings']] : process.env.HEL733_TASKS_ONLY ? [['tasks','tasks']] : [['','studio'],['create','create'],['agents','agents'],['workflows','workflows'],['connections','models'],['tasks','tasks'],['library','library'],['settings','settings']];
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour'])for(const [route,ref] of pages){
await a.setViewportSize({width,height});await s.setViewportSize({width,height});
await a.goto(base+'/'+route);await a.locator('aside.sidebar').waitFor();
await a.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}));for(const es of window.__sse)for(const [id,progress] of [[1,62],[2,35]])es.handlers?.['job.progress']?.({data:JSON.stringify({type:'job.progress',data:{job_id:id,progress}})})},theme);
await s.evaluate(({ref,theme})=>{go(ref);renderTasks();document.documentElement.dataset.theme=theme;document.querySelector('#motionBtn span').textContent='动效关'},{ref,theme});
await a.waitForTimeout(400);
const as={rail:'aside.sidebar',brand:'aside.sidebar .brand',nav:'aside.sidebar nav a',divider:'aside.sidebar hr',queue:'.queue-dot',theme:'.sidebar-bottom button',settings:'.sidebar-bottom a[href="/settings"]',header:'.management-header, .studio-header, .studio-heading, .desk-head, .topbar',progress:'.task-progress',fill:'.task-progress i'};
const ss={rail:'.rail',brand:'.rail .brand',nav:'.rail>.nav',divider:'.rail hr',queue:'.rail .queue-dot',theme:'#themeBtn',settings:'.rail [data-go="settings"]',header:`#p-${ref} .ph`,progress:'#taskTable .bar',fill:'#taskTable .bar i'};
if(route==='create'){
Object.assign(as,{title:'.model-selector',primary:'.dock .gen',prompt:'#desk-prompt',workspace:'.reference-desk'});
Object.assign(ss,{title:'#hdName',primary:'#genBtn',prompt:'#posTa',workspace:'#p-create .create'});
}else if(route==='agents'){
Object.assign(as,{title:'.ahero h2',primary:'.big-input .primary',prompt:'.big-input textarea'});
Object.assign(ss,{title:'#p-agents .ahero h2',primary:'#p-agents .big-input .primary',prompt:'#p-agents .big-input textarea'});
}else if(['workflows','connections','tasks','library','settings'].includes(route)){
Object.assign(as,{title:'.management-header > b',primary:'.management-header .a-btn, .a-btn.primary',search:'.mg-search',input:'.mg-input, .field-input'});
Object.assign(ss,{title:`#p-${ref} .ph b`,primary:`#p-${ref} .ph .btn, #p-${ref} .btn.primary`,search:`#p-${ref} .ph .input`,input:`#p-${ref} .input`});
}
const name=`${route||'studio'}-${theme}-${width}`;
const row={route:route||'studio',ref,theme,width,height,actual:await measure(a,as),sample:await measure(s,ss)};
if(route==='tasks'){
row.actualTable=await a.locator('.task-table table').evaluate(e=>({columns:[...e.rows[1].cells].map(c=>({text:c.innerText,width:c.getBoundingClientRect().width,x:c.getBoundingClientRect().x,style:getComputedStyle(c).cssText})),html:e.outerHTML}));
row.sampleTable=await s.locator('#p-tasks table').evaluate(e=>({columns:[...e.rows[1].cells].map(c=>({text:c.innerText,width:c.getBoundingClientRect().width,x:c.getBoundingClientRect().x})),html:e.outerHTML}));
}
if(route==='tasks'){
const sources=await a.locator('.task-table tbody tr td:nth-child(3)').allTextContents();
await s.evaluate(sources=>{document.querySelectorAll('#taskTable tr').forEach((r,i)=>r.cells[2].textContent=sources[i])},sources);
row.sameSourceSample=await measure(s,{progress:'#taskTable .bar',fill:'#taskTable .bar i'});
}
log.rows.push(row);
if(route==='create') {
await a.evaluate(()=>document.documentElement.dataset.reduce='0');await s.evaluate(()=>document.documentElement.classList.remove('reduce'));
await a.locator('.workflow-row').first().hover({position:{x:40,y:20}});
await s.locator('#wfList .wfi').first().hover({position:{x:40,y:20}});
const pointer=await a.locator('.workflow-row').first().evaluate(e=>({x:e.style.getPropertyValue('--pointer-x'),y:e.style.getPropertyValue('--pointer-y')}));
await s.locator('#wfList .wfi').first().evaluate((e,p)=>{e.style.setProperty('--mx',p.x);e.style.setProperty('--my',p.y)},pointer);
row.hoverInput={mode:'full',pointer};
await a.waitForTimeout(350);
const pseudo=(p,sel,which)=>p.locator(sel).first().evaluate((e,which)=>{const c=getComputedStyle(e,which);return Object.fromEntries(['backgroundImage','maskImage','opacity','transitionDuration','transitionTimingFunction','zIndex'].map(k=>[k,c[k]]))},which);
row.hoverActual=await pseudo(a,'.workflow-row','::after');row.hoverSample=await pseudo(s,'#wfList .wfi','::before');
await a.mouse.move(0,0);await s.mouse.move(0,0);await a.waitForTimeout(350);await a.evaluate(()=>document.documentElement.dataset.reduce='1');await s.evaluate(()=>document.documentElement.classList.add('reduce'));
}
if(route==='settings') {
await a.locator('.sidebar-bottom button[title="主题"]').click();
await s.evaluate(()=>document.querySelector('#themePop').classList.add('on'));
await a.waitForTimeout(400);
row.themePopActual=await measure(a,{panel:'.modal-panel',row:'.choices.compact .choice',preview:'.choices.compact .preview'});
row.themePopSample=await measure(s,{panel:'#themePop',row:'.topt',preview:'.topt .tprev'});
await a.screenshot({path:`${out}/${name}-theme-pop-actual.png`});await s.screenshot({path:`${out}/${name}-theme-pop-sample.png`});
await a.keyboard.press('Escape');await s.evaluate(()=>document.querySelector('#themePop').classList.remove('on'));
}
await a.screenshot({path:`${out}/${name}-actual.png`,animations:'disabled'});await s.screenshot({path:`${out}/${name}-sample.png`,animations:'disabled'});
console.log(name);
}
await c.close();
}finally{await fs.writeFile(out+'/results.json',JSON.stringify(log,null,2));await browser.close();await new Promise(r=>server.close(r));}
+86
View File
@@ -0,0 +1,86 @@
// Real isolated API and fake ComfyUI; never connects to production.
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/hel733/live',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247';
fs.mkdirSync(OUT,{recursive:true});
const checks=[],errors=[],ok=(name,value)=>{checks.push({name,pass:!!value});assert(value,name);console.log('PASS',name)};
const api=async p=>(await fetch(API+p)).json();
const wait=ms=>new Promise(r=>setTimeout(r,ms));
(async()=>{
const b=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH,args:['--no-sandbox']});
try{
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
const state=JSON.parse(fs.readFileSync('e2e/shots/hel732/fixture-state.json'));
const c=await b.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}});
await c.addInitScript(()=>{
window.mediaCalls=[];
for(const method of ['play','pause']){const original=HTMLMediaElement.prototype[method];HTMLMediaElement.prototype[method]=function(...args){window.mediaCalls.push({method,src:this.src,stack:new Error().stack});const result=original.apply(this,args);result?.catch?.(e=>window.mediaCalls.push({method,error:e.message}));return result}}
window.arrivalEvents=[];document.addEventListener('animationstart',e=>window.arrivalEvents.push({name:e.animationName,time:performance.now()}),true);
const clear=CanvasRenderingContext2D.prototype.clearRect;
window.particleFrames=0;CanvasRenderingContext2D.prototype.clearRect=function(...args){if(this.canvas.classList.contains('particles'))window.particleFrames++;return clear.apply(this,args)};
});
const p=await c.newPage();p.setDefaultTimeout(15000);p.on('pageerror',e=>errors.push(e.message));
for(const theme of (process.env.HEL733_SKIP_EFFECTS?[]:['night','day','grade','bluehour'])){
await p.goto(WEB+'/create?wf='+f.dual);
await p.locator('#desk-prompt').fill('HEL733 '+theme+' 状态保留与显影');
await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}));localStorage.setItem('aigc-reduce-motion','0')},theme);
await wait(400);
ok(theme+' creative header starts at y=0 without duplicate global bar',await p.locator('.studio-heading').evaluate(e=>e.getBoundingClientRect().y===0));
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:4,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();
const scan=await p.locator('.a-scan-beam').evaluate(e=>{const c=getComputedStyle(e);return [c.animationName,c.animationDuration]});
ok(theme+' scan 1.6s',scan[0]==='a-scan'&&scan[1]==='1.6s');await p.screenshot({path:OUT+'/'+theme+'-generating.png'});
try{await p.waitForFunction(()=>window.arrivalEvents.some(x=>x.name.startsWith('a-develop')),null,{timeout:30000})}catch(e){fs.writeFileSync(OUT+'/arrival-failure.json',JSON.stringify({theme,events:await p.evaluate(()=>window.arrivalEvents),jobs:await api('/api/playground/jobs'),dom:await p.locator('.preview-area').innerHTML()},null,2));throw e}
const events=await p.evaluate(()=>window.arrivalEvents);ok(theme+' correct development variant',events.some(x=>x.name===({day:'a-develop-day',grade:'a-develop-grade'}[theme]||'a-develop')));
await p.locator('.a-result-develop').first().dispatchEvent('pointerdown').catch(()=>{});await p.waitForTimeout(800);ok(theme+' development class clears after completion',await p.locator('.a-result-develop').count()===0);
const before=await p.locator('#desk-prompt').inputValue();
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.choices .choice').filter({hasText:'日光棚'}).first().click();await wait(650);
ok(theme+' theme keeps prompt, workflow and result',(await p.locator('#desk-prompt').inputValue())===before&&(await p.locator('.print img').count())===1&&new URL(p.url()).searchParams.get('wf')===String(f.dual));
ok(theme+' theme does not replay result arrival',await p.locator('.a-result-develop').count()===0);await p.keyboard.press('Escape');
}
await p.goto(WEB+'/create?kind=video&wf='+f.h3);
await p.locator('#desk-prompt').fill('HEL733 video upload state');
await p.getByRole('button',{name:'管理素材',exact:true}).click();
for(const [label,file] of [['首帧','coast.jpg'],['尾帧','arch.jpg']]){
const slot=p.locator('.a-slot').filter({has:p.getByText(label,{exact:true})});
await slot.locator('input[type=file]').setInputFiles('calliope-web/static/refract/'+file);await slot.locator('img').waitFor();
}
await p.getByRole('button',{name:'完成',exact:true}).click();
await p.locator('#desk-prompt').evaluate(e=>window.hel733Editor=e);
const uploads=await p.locator('.media-token img').evaluateAll(es=>es.map(e=>e.src));
for(const name of ['夜片场','日光棚','调色间','蓝调时刻']){
await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.choices.compact .choice').filter({hasText:name}).click();await wait(600);await p.keyboard.press('Escape');
ok(name+' theme retains live editor, uploads and workflow',await p.locator('#desk-prompt').evaluate(e=>e===window.hel733Editor)&&JSON.stringify(await p.locator('.media-token img').evaluateAll(es=>es.map(e=>e.src)))===JSON.stringify(uploads)&&new URL(p.url()).searchParams.get('wf')===String(f.h3));
}
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',videos:true,delay_sec:1,unique_filename:true}));
await p.locator('.gen').click();const video=p.locator('.print video');await video.waitFor();await p.waitForFunction(()=>document.querySelector('.print video')?.readyState>=1);
ok('new video remains paused',await video.evaluate(e=>e.paused&&!e.autoplay));await video.evaluate(e=>{window.videoEvents=[];for(const name of ['play','playing','pause','error','ended'])e.addEventListener(name,()=>window.videoEvents.push({name,time:e.currentTime,error:e.error?.message}));});await p.locator('.big-play').click();await wait(500);fs.writeFileSync(OUT+'/video-diagnostics.json',JSON.stringify(await video.evaluate(e=>({events:window.videoEvents,calls:window.mediaCalls,src:e.src,paused:e.paused,ready:e.readyState,network:e.networkState,error:e.error?.message,codec:e.canPlayType('video/mp4; codecs="avc1.42E01E"')})),null,2));await p.waitForFunction(()=>!document.querySelector('.print video').paused);ok('video plays only on explicit action',await video.evaluate(e=>!e.paused));await video.evaluate(e=>e.pause());
if(process.env.HEL733_VIDEO_ONLY){await c.close();return}
await p.goto(WEB+'/agents?session='+state.board);await p.locator('.shot-copy textarea').first().fill('HEL733 unsaved session draft');
const sessionUrl=p.url();await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.choices.compact .choice').filter({hasText:'日光棚'}).click();await wait(600);await p.keyboard.press('Escape');
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').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');
await p.emulateMedia({reducedMotion:'no-preference'});
// All real API data / actual empty states. Reference equivalence is a separate audit.
for(const [width,height] of (process.env.HEL733_SKIP_SHOTS ? [] : [[1440,900],[1366,768],[1280,720]]))for(const theme of ['night','day','grade','bluehour'])for(const [name,route] of [['studio','/'],['image','/create?wf='+f.dual],['video','/create?kind=video'],['agents','/agents'],['agent-task','/agents?session='+state.board],['project','/workbench/'+state.project.id+'?stage=storyboard'],['workflows','/workflows'],['models','/model-connections'],['tasks','/tasks'],['library','/library'],['settings','/settings'],['canvas','/canvas']]){
await p.setViewportSize({width,height});await p.goto(WEB+route);await p.waitForTimeout(name==='canvas'?800:200);
await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme);
await p.screenshot({path:`${OUT}/${name}-${theme}-${width}.png`});
ok(`${name} ${theme} ${width} body renders`,(await p.locator('body').innerText()).trim().length>0);
}
await c.close();
const blocked=await b.newContext({viewport:{width:1440,height:900}});
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=>errors.push(q.url()+' '+e.stack));
for(const route of ['/create?wf='+f.dual,'/agents','/settings','/canvas','/build-scene','/agents?legacy=1']){
await q.goto(WEB+route);await q.waitForTimeout(800);ok('storage blocked '+route+' remains usable',await q.locator('.refract-shell, .canvas-layout, .flow-canvas').count()>0||!(await q.locator('.redirect-error').count()));
}
await q.goto(WEB+'/settings');await q.locator('.choices.tiles .choice').filter({hasText:'日光棚'}).click();await wait(650);ok('storage blocked theme changes',await q.locator('html').getAttribute('data-theme')==='day');
await q.screenshot({path:OUT+'/storage-blocked.png'});await blocked.close();ok('no browser page errors',errors.length===0);
}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify({checks,errors},null,2));await b.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
+17
View File
@@ -0,0 +1,17 @@
// Shared feedback on the real fixed project footer, using only disposable state.
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
(async()=>{const out='../evidence/hel806-toast';fs.mkdirSync(out,{recursive:true});const api=async(p,b,method)=>{const r=await fetch('http://127.0.0.1:8247'+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 settings=await api('/api/settings');assert(settings.data_dir.includes('e2e/shots/reference'));
const project=await api('/api/projects',{title:'HEL806 Toast clearance'});await api(`/api/workbench/projects/${project.id}/stages/storyboard`,{content:{shots:[{id:'s1',scene:'1',shot_size:'远景',description:'隔离的海岸镜头,纸船驶向灯塔。',duration_sec:5}]}},'PATCH');
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 browser=await chromium.launch({args:['--no-sandbox']});const rows=[],errors=[];
try{const c=await browser.newContext();await c.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','0')});const p=await c.newPage(),s=await c.newPage();p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:5173/workbench/'+project.id+'?stage=storyboard');await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('HEL806 Toast 验证:在隔离海岸镜头加入新的纸船描述');await p.locator('[data-testid=stage-save-btn]').click();await p.locator('.toast').last().hover();await p.mouse.move(0,0);await p.locator('.toast .dismiss').last().focus();
const message=await p.locator('.toast .msg').last().innerText();
const style=loc=>loc.evaluate(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:r.x,y:r.y,width:r.width,height:r.height,...Object.fromEntries(['padding','borderRadius','backgroundColor','borderTopColor','boxShadow','fontSize','lineHeight','animationDuration','animationTimingFunction','transitionDuration'].map(k=>[k,s[k]]))}});
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 s.setViewportSize({width,height});await p.evaluate(t=>document.documentElement.dataset.theme=t,theme);await s.goto('http://127.0.0.1:'+server.address().port);await s.evaluate(({theme,message})=>{go('project');document.documentElement.dataset.theme=theme;toast(message)}, {theme,message});await p.waitForTimeout(350);
const actual=await style(p.locator('.toast').last()),reference=await style(s.locator('.toast').last()),footer=await p.locator('.project-footer').boundingBox();assert(actual.y+actual.height<=footer.y,'toast clears fixed actions');assert.equal(actual.animationDuration,reference.animationDuration);assert.equal(actual.animationTimingFunction,reference.animationTimingFunction);
await p.screenshot({path:`${out}/project-${theme}-${width}.png`});await p.locator('.toast').last().screenshot({path:`${out}/toast-${theme}-${width}-actual.png`});await s.locator('.toast').last().screenshot({path:`${out}/toast-${theme}-${width}-reference.png`});
await p.evaluate(()=>document.documentElement.dataset.reduce='1');assert.equal(await p.locator('.toast').last().evaluate(e=>getComputedStyle(e).animationName),'none');await p.evaluate(()=>document.documentElement.dataset.reduce='0');rows.push({theme,width,height,message,actual,reference,footer,gap:footer.y-actual.y-actual.height});}
await p.locator('.toast .dismiss').last().evaluate(e=>e.blur());await p.mouse.move(0,0);await p.locator('.toast').last().waitFor({state:'detached',timeout:5000});await p.locator('[data-testid=stage-confirm-btn]').click();assert.equal((await api(`/api/workbench/projects/${project.id}`)).stages.find(x=>x.stage_key==='storyboard').status,'confirmed');assert.equal(errors.length,0);console.log('PASS 12 toast style/timing pairs, reduce, footer clearance and real confirmation');
}finally{fs.writeFileSync(out+'/results.json',JSON.stringify({rows,errors},null,2));await browser.close();await new Promise(r=>server.close(r))}})().catch(e=>{console.error(e);process.exitCode=1});
+16
View File
@@ -0,0 +1,16 @@
// Deterministic timing regression: expiry, fade, repeated pause, resume, sticky and dismissal.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
const ts=createRequire(import.meta.url)('../calliope-web/node_modules/typescript');
let time=0,seq=0;const pending=new Map();
const saved={now:Date.now,setTimeout:globalThis.setTimeout,clearTimeout:globalThis.clearTimeout};
Date.now=()=>time;globalThis.setTimeout=(fn,delay)=>{pending.set(++seq,{at:time+delay,fn});return seq};globalThis.clearTimeout=id=>pending.delete(id);
const tick=ms=>{const end=time+ms;for(;;){const next=[...pending].sort((a,b)=>a[1].at-b[1].at)[0];if(!next||next[1].at>end)break;time=next[1].at;pending.delete(next[0]);next[1].fn()}time=end};
try{
const {showToast,subscribeToasts,pauseToast,resumeToast,dismissToast}=await import('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(fs.readFileSync(new URL('../calliope-web/src/lib/toast.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64'));let items=[];const unsub=subscribeToasts(x=>items=x);
const first=showToast('one');tick(3199);assert.equal(items[0].leaving,undefined);tick(1);assert.equal(items[0].leaving,true);tick(299);assert.equal(items.length,1);tick(1);assert.equal(items.length,0);
const paused=showToast('paused');tick(1000);pauseToast(paused);tick(1000);pauseToast(paused);tick(5000);assert.equal(items[0].leaving,undefined);resumeToast(paused);tick(2199);assert.equal(items[0].leaving,undefined);tick(1);assert.equal(items[0].leaving,true);pauseToast(paused);resumeToast(paused);tick(300);assert.equal(items.length,0);
const sticky=showToast('sticky','info',0);tick(9000);assert.equal(items.length,1);dismissToast(sticky);assert.equal(items.length,0);
const closed=showToast('manual');dismissToast(closed);tick(4000);assert.equal(items.length,0);assert.equal(pending.size,0);unsub();console.log('PASS toast: 3200ms visible + 300ms fade, idempotent pause, remaining time, sticky/manual cleanup');
}finally{Date.now=saved.now;globalThis.setTimeout=saved.setTimeout;globalThis.clearTimeout=saved.clearTimeout}
+138
View File
@@ -0,0 +1,138 @@
// Foreground real isolated API audit. Reference DOM changes only content, never CSS.
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
const fs=require('node:fs'),http=require('node:http'),path=require('node:path'),assert=require('node:assert/strict');
const OUT=process.env.HEL806_OUT||'../evidence/hel806-final',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247';
const result={rows:[],checks:[],errors:[],blocked:[],requests:[]};
const api=async(p,body,method)=>{const r=await fetch(API+p,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error(p+' '+await r.text());return r.json()};
const ok=(name,value)=>{assert(value,name);result.checks.push(name);console.log('PASS',name)};
const wait=ms=>new Promise(r=>setTimeout(r,ms));
const metrics=(p,sels)=>p.evaluate(sels=>Object.fromEntries(Object.entries(sels).map(([key,sel])=>{const e=document.querySelector(sel);if(!e)return[key,null];const r=e.getBoundingClientRect(),s=getComputedStyle(e);return[key,{x:r.x,y:r.y,width:r.width,height:r.height,...Object.fromEntries(['fontSize','fontWeight','lineHeight','borderRadius','borderTopWidth','borderTopColor','backgroundColor','backgroundImage','color','padding','boxShadow','animationDuration','animationTimingFunction'].map(k=>[k,s[k]]))}]})),sels);
(async()=>{
fs.mkdirSync(OUT+'/shots',{recursive:true});
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
const settings=await api('/api/settings');ok('database is disposable',settings.data_dir.includes('e2e/shots/reference'));
const base=(await api('/api/workflows/'+f.dual)).workflow_json;
const img={'class_type':'LoadImage',inputs:{image:''},_meta:{title:'(Input:image) 输入图'}};
const wf=async(name,kind,extra)=>api('/api/workflows',{name,kind,workflow_json:{...base,...extra}});
const edit=await wf('HEL806 · 图生图真实输入','image',{'5':img});
const mask=await wf('HEL806 · 遮罩真实输入','image',{'5':img,'6':{class_type:'LoadImageMask',inputs:{image:'',channel:'red'},_meta:{title:'(Input:mask) 遮罩'}}});
const ref=await wf('HEL806 · 视频参考真实输入','video',{'5':img,'9':{class_type:'VHS_VideoCombine',inputs:{},_meta:{title:'(Output:video) 视频输出'}}});
const long=await wf('HEL806 · 真实长文件名 · Qwen_Image_2_1_局部重绘_批量蒙版_参数映射_最终审核版本_20261023.json','image',{});
await api('/api/workflows/'+long.id,{is_enabled:false},'PATCH');await api('/api/workflows/'+ref.id,{usage:'ref2video',usage_source:'user',is_enabled:true},'PATCH');
await new Promise((resolve,reject)=>{const socket=require('node:net').connect(18187,'127.0.0.1');socket.on('connect',()=>{socket.destroy();reject(Error('Failure fixture port must be unused'))});socket.on('error',e=>e.code==='ECONNREFUSED'?resolve():reject(e));socket.setTimeout(1000,()=>{socket.destroy();reject(Error('Failure fixture probe timeout'))})});
await api('/api/comfyui/endpoints',{name:'HEL806 · 故意不可达的隔离连接',base_url:'http://127.0.0.1:18187',auth_type:'none'});
const sourceFile='4-视频生成/Minimax-h3/MiniMax_H3_音频MV_多参长视频分段_Selflift双采潜放大_Qwen3.8官方Skills自动提示词.json';
const sourceBytes=fs.readFileSync(path.join('example_ComfyUI_workflows',sourceFile));
const imported=await api('/api/workflows/import-batch',{files:[{source_path:sourceFile,workflow_json:JSON.parse(sourceBytes.toString('utf8').replace(/^\uFEFF/,''))}]});
ok('real long repository filename imported without generation',imported.imported===1);
await api('/api/workflows/'+imported.imported_ids[0],{name:'HEL806 · 真文件 · '+path.basename(sourceFile)},'PATCH');
fs.writeFileSync(OUT+'/import-report.json',JSON.stringify({sourceFile,source_sha256:require('node:crypto').createHash('sha256').update(sourceBytes).digest('hex'),imported,workflow:await api('/api/workflows/'+imported.imported_ids[0])},null,2));
const project=await api('/api/projects',{title:'HEL806 · 海岸来信',idea:'纸船带回一片树叶'});
const stages={script:{text:'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},style:{directions:[{id:'s1',name:'晨光',description:'柔和暖光',prompt:'warm'},{id:'s2',name:'蓝调',description:'冷光',prompt:'blue'}]},storyboard:{shots:Array.from({length:12},(_,i)=>({id:'b'+i,scene:'1',shot_size:'远景',camera:'固定',description:'女孩沿着海岸走向灯塔,纸船随海风起伏。',duration_sec:5,dialog:''}))},assets:{assets:[{id:'a1',kind:'character',name:'海边女孩',description:'隔离角色',prompt:'girl'}]},shot:{shots:[{id:'s1',shot_no:'S1',description:'女孩走向灯塔',prompt:'girl on coast',duration_sec:5}]}};
const stagePath=s=>`/api/workbench/projects/${project.id}/stages/${s}`;
for(const [s,content]of Object.entries(stages))await api(stagePath(s),{content},'PATCH');
await api(stagePath('storyboard')+'/confirm',{});
await api(stagePath('storyboard'),{content:{shots:stages.storyboard.shots.map((x,i)=>i?x:{...x,description:'等待确认的新草稿'})}},'PATCH');
fs.writeFileSync(OUT+'/inputs.json',JSON.stringify({f,edit:await api('/api/workflows/'+edit.id),mask:await api('/api/workflows/'+mask.id),ref:await api('/api/workflows/'+ref.id),long:await api('/api/workflows/'+long.id),project,stages},null,2));
if(process.env.HEL806_SEED_TASKS==='1'){
const seeded=[];for(const mode of ['ok','fail']){fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode,delay_sec:0,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));const created=await api('/api/playground/generate',{workflow_id:f.dual,source:'studio-image',input_values:{'1':'HEL806 isolated '+mode,'2':'模糊','3':42}});let job;for(let i=0;i<200;i++){job=await api('/api/jobs/'+created.job.id);if(!['running','pending'].includes(job.status))break;await wait(150)}assert.equal(job.status,mode==='ok'?'done':'failed');seeded.push(job)}fs.writeFileSync(OUT+'/seed-jobs.json',JSON.stringify(seeded,null,2));
}
if(process.env.HEL806_REAL_EMPTY==='1'){
assert.equal((await api('/api/jobs')).length,0,'empty-state run must start without jobs');const owned=await api('/api/projects');for(const project of owned){const r=await fetch(API+'/api/projects/'+project.id,{method:'DELETE'});assert(r.ok,'delete only disposable fixture project')};const after={projects:await api('/api/projects'),jobs:await api('/api/jobs')};assert.equal(after.projects.length,0);assert.equal(after.jobs.length,0);fs.writeFileSync(OUT+'/real-empty.json',JSON.stringify({removedFixtureIds:owned.map(p=>p.id),after},null,2));
}
const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r));
const browser=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});
try{
const c=await browser.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}});
await c.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','1')});
await c.route('**/*',route=>{const u=new URL(route.request().url());if(['data:','blob:'].includes(u.protocol)||['127.0.0.1','localhost'].includes(u.hostname)){if(u.pathname.startsWith('/api/'))result.requests.push([route.request().method(),u.pathname]);return route.continue()}result.blocked.push(u.origin);return route.abort()});
const p=await c.newPage(),s=await c.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>result.errors.push(e.message));p.on('dialog',d=>d.accept());
const goto=async route=>{await p.goto(WEB+route);await p.locator('aside.sidebar').first().waitFor();await wait(220)};
const upload=async()=>{await p.getByRole('button',{name:'管理素材',exact:true}).click();const inputs=p.locator('.a-slot input[type=file]');for(let i=0;i<await inputs.count();i++){await inputs.nth(i).setInputFiles('calliope-web/static/refract/coast.jpg');await wait(150)}await p.getByRole('button',{name:'完成',exact:true}).click()};
const creative=async(id,kind='image')=>{await goto('/create?kind='+kind+'&wf='+id);await p.locator('#desk-prompt').waitFor();const w=await api('/api/workflows/'+id);await p.locator('.rail-search input').fill(w.name);await wait(150)};
const emptyRoute=async(route,pattern,json)=>{await p.route(pattern,r=>r.fulfill({json}));await goto(route)};
const generate=async(id,kind)=>{fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:0,unique_filename:true,videos:kind==='video',image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));await creative(id,kind);await p.locator('#desk-prompt').fill('HEL806 successful isolated output');const before=Math.max(0,...(await api('/api/playground/jobs')).map(j=>j.id));await p.locator('.gen').click();let job;for(let i=0;i<200;i++){job=(await api('/api/playground/jobs')).find(j=>j.id>before&&j.workflow_id===id&&j.status==='done');if(job)break;await wait(150)}assert(job,'new successful isolated job');await p.locator('.result-title').filter({hasText:'第 '+job.id+' 次生成'}).waitFor();await p.locator(kind==='video'?'.print video':'.print img').waitFor({timeout:45000});await wait(800)};
const videoWf=(await api('/api/workflows')).find(x=>x.name==='文生视频·回归');
const cases=[
{name:'image-output',ref:'create',run:()=>generate(f.dual,'image'),creative:true},
{name:'video-output',ref:'create',run:()=>generate(videoWf.id,'video'),creative:true},
{name:'image-failed',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('HEL806 fake failure');fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'fail',delay_sec:0}));await p.locator('.gen').click();await p.locator('.stage-empty').filter({hasText:'失败'}).waitFor({timeout:45000})},creative:true},
{name:'studio-real',ref:'studio',run:()=>goto('/'),basis:'approved-cover-exception'},
{name:'studio-empty',ref:'studio',run:async()=>{if(process.env.HEL806_REAL_EMPTY==='1')await goto('/');else{await p.route('**/api/jobs',r=>r.fulfill({json:[]}));await emptyRoute('/','**/api/projects',[])}},missing:'原件没有无项目空态'},
{name:'image-text-empty',ref:'create',run:async()=>{await p.route('**/api/playground/jobs',r=>r.fulfill({json:[]}));await creative(f.dual)},creative:true},
{name:'image-long-prompt',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('海边女孩、晨光、纸船与灯塔,保持人物和空间连续。'.repeat(70));const btn=p.getByRole('button',{name:'展开提示词',exact:true});if(await btn.count())await btn.click()},creative:true},
{name:'image-edit-upload',ref:'create',run:async()=>{await creative(edit.id);await upload()},creative:true},
{name:'image-mask-upload',ref:'create',run:async()=>{await creative(mask.id);await upload()},creative:true},
{name:'video-first-last',ref:'create',run:async()=>{await creative(f.h3,'video');await upload()},creative:true},
{name:'video-reference',ref:'create',run:async()=>{await creative(ref.id,'video');await upload()},creative:true},
{name:'video-parameters',ref:'create',run:async()=>{await creative(f.h3,'video');await p.getByRole('button',{name:'创作设置⌄',exact:true}).first().click()},missing:'真实声明参数与原件固定参数内容不同;弹层公共框可比较'},
...Object.keys(stages).map(stage=>({name:'project-'+stage,ref:'project',run:async()=>{await goto('/workbench/'+project.id+'?stage='+stage);await p.locator('[data-testid=workbench-page]').waitFor()},projectStage:stage,...(stage==='storyboard'?{}:{missing:'原件renderProject切阶段后仍渲染同一分镜网格,无此阶段内容样本'})})),
{name:'project-compare',ref:'project',run:async()=>{await goto('/workbench/'+project.id+'?stage=storyboard');await p.getByRole('button',{name:'对比已确认版',exact:true}).click()},missing:'原件比较按钮没有比较弹窗实现'},
{name:'project-confirmed',ref:'project',run:async()=>{await api(stagePath('storyboard')+'/confirm',{});await goto('/workbench/'+project.id+'?stage=storyboard')},projectStage:'storyboard'},
{name:'workflows-long-disabled',ref:'workflows',run:async()=>{await goto('/workflows');await p.getByRole('textbox',{name:'搜索名称、文件名、模型…',exact:true}).fill('HEL806');await wait(100)},basis:'content-mapped-table'},
{name:'connections-probed',ref:'models',run:async()=>{await goto('/connections');await p.getByText('连接测试通过',{exact:false}).first().waitFor()},basis:'content-mapped-cards'},
{name:'connections-empty',ref:'models',run:()=>emptyRoute('/connections','**/api/settings',{...settings,comfyui_endpoints:[],comfyui_active_id:null}),missing:'原件没有无本地连接状态'},
{name:'connections-edit',ref:'models',run:async()=>{await goto('/connections');await p.getByRole('button',{name:'编辑',exact:true}).first().click()},missing:'原件编辑按钮没有编辑表单实现'},
{name:'models-llm',ref:'models',run:()=>goto('/connections?tab=llm'),missing:'原件大语言模型页签没有内容实现'},
{name:'models-cloud-image',ref:'models',run:()=>goto('/connections?tab=image'),basis:'cloud-card-subset'},
{name:'models-cloud-video',ref:'models',run:()=>goto('/connections?tab=video'),basis:'cloud-card-subset'},
{name:'tasks-real',ref:'tasks',run:()=>goto('/tasks'),basis:'content-mapped-table'},
{name:'tasks-empty',ref:'tasks',run:()=>emptyRoute('/tasks','**/api/jobs',[]),missing:'原件没有零任务空态'},
{name:'library-real',ref:'library',run:()=>goto('/library'),basis:'content-mapped-media'},
{name:'library-empty',ref:'library',run:()=>emptyRoute('/library','**/api/library/media',[]),missing:'原件没有零素材空态'},
{name:'settings',ref:'settings',run:()=>goto('/settings'),basis:'approved-real-settings'},
{name:'settings-invalid',ref:'settings',run:async()=>{await goto('/settings');await p.locator('input[type=number]').first().fill('0')},missing:'原件无数值校验提示的实现,保留真实字段校验界面'},
{name:'canvas-current',ref:'canvas',run:()=>goto('/canvas'),missing:'原件仅组织说明页,待总管另补样本'},
{name:'scene-current',ref:'canvas',run:async()=>{await goto('/build-scene');await p.locator('canvas').first().waitFor();await wait(1500)},missing:'原件仅组织说明页,待总管另补样本'}
];
for(const item of cases.filter(x=>!process.env.HEL806_CASES||process.env.HEL806_CASES==='*'||process.env.HEL806_CASES.split(',').includes(x.name))){
await p.unrouteAll({behavior:'wait'});await item.run();
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 s.setViewportSize({width,height});
await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme);
await s.goto('http://127.0.0.1:'+sample.address().port);await s.evaluate(({ref,theme})=>{go(ref);document.documentElement.dataset.theme=theme;document.documentElement.classList.add('reduce');document.querySelector('#toasts').replaceChildren()}, {ref:item.ref,theme});
const prefix=OUT+'/shots/'+item.name+'-'+theme+'-'+width;
await s.screenshot({path:prefix+'-original.png',animations:'disabled'});
let mapping={};
if(item.name==='studio-real'){
const data=await p.evaluate(()=>({eye:document.querySelector('.studio-eyebrow').textContent,title:document.querySelector('.studio-display').innerText,em:document.querySelector('.studio-display em').textContent,description:document.querySelector('.hero-description').textContent,stages:[...document.querySelectorAll('.studio-stages>div')].map(e=>({text:e.textContent,done:e.classList.contains('done'),cur:e.classList.contains('cur')})),cards:[...document.querySelectorAll('.a-card')].map(e=>({title:e.querySelector('.title')?.textContent,status:e.querySelector('.status-tag')?.textContent,meta:e.querySelector('.meta')?.innerText,cover:e.querySelector('img')?.src})),confirm:[...document.querySelectorAll('.studio-side .confirm-item')].map(e=>e.innerText)}));mapping=data;
await s.evaluate(data=>{const hero=document.querySelector('#p-studio .hero');hero.querySelector('.eyebrow').textContent=data.eye;const d=hero.querySelector('.display'),em=document.createElement('em');em.textContent=data.em;d.replaceChildren(data.title.split('\n')[0],document.createElement('br'),em,' '+data.title.split('\n').slice(1).join(' ').replace(data.em,''));hero.querySelector('.copy p').textContent=data.description;hero.querySelector('.art').style.backgroundImage='none';hero.querySelector('.copy .row .btn:last-child').textContent='查看阶段草稿';hero.querySelectorAll('.stagebar>div').forEach((e,i)=>{e.classList.toggle('done',data.stages[i].done);e.classList.toggle('cur',data.stages[i].cur)});document.querySelectorAll('#p-studio .quick p')[1].textContent='粘贴剧本文本';const list=document.querySelector('#projGrid'),template=list.firstElementChild.cloneNode(true),add=list.lastElementChild.cloneNode(true);list.replaceChildren();for(const card of data.cards){const el=template.cloneNode(true);el.querySelector('.cv').style.backgroundImage=card.cover?`url("${card.cover}")`:'none';el.querySelector('.pb b').textContent=card.title;el.querySelector('.tag').textContent=card.status;el.querySelector('.pb>.row').textContent=card.meta;list.append(el)}list.append(add);document.querySelector('#p-studio .sec-h .tag').textContent=data.cards.length;},data);
}
if(item.creative){
const id=+new URL(p.url()).searchParams.get('wf'),w=await api('/api/workflows/'+id);
const data=await p.evaluate(()=>({prompt:document.querySelector('#desk-prompt')?.value||'',negative:document.querySelector('#desk-negative')?.value||'',uploads:[...document.querySelectorAll('.media-token img')].map(e=>e.src),file:document.querySelector('.workflow-file')?.textContent,connection:document.querySelector('.studio-heading .conn')?.textContent,media:document.querySelector('.print img,.print video')?.src,ratio:(()=>{const e=document.querySelector('.print img,.print video');return e?((e.naturalWidth||e.videoWidth)/(e.naturalHeight||e.videoHeight)):null})(),failed:document.querySelector('.stage-empty')?.innerText?.includes('失败')}));
mapping={workflow:w.name,...data};
await s.evaluate(({w,data,kind})=>{st.kind=kind;const slots=w.input_schema.filter(x=>['image','mask'].includes(x.kind)).map(x=>[x.role==='mask'?'mask':x.role==='first_frame'?'first':x.role==='last_frame'?'last':'input',1,1,true]);const mapped={...WF.find(x=>x.k===kind),id:w.id,k:kind,name:w.name,file:data.file||w.name,slots,neg:!!data.negative||w.input_schema.some(x=>x.role==='negative'),ready:true,pending:false};WF.splice(0,WF.length,mapped);st.sel[kind]=w.id;st.q=w.name;document.querySelector('#wfSearch').value=w.name;st.hist[kind]=data.media?[{n:1,k:kind,wf:w.id,ratio:data.ratio||1,seed:42,img:data.media}]:data.failed?[{n:1,k:kind,wf:w.id,fail:true}]:[];st.fills[w.id]={};slots.forEach((x,i)=>{if(data.uploads[i])st.fills[w.id][x[0]+i]=data.uploads[i]});renderCreate();document.querySelector('#posTa').value=data.prompt;document.querySelector('#negTa').value=data.negative;count();if(data.prompt.length>1000)document.querySelector('#dock').classList.add('tall')},{w,data,kind:new URL(p.url()).searchParams.get('kind')||'image'});
}
if(item.projectStage==='storyboard'){
const texts=await p.locator('.storyboard-card').allTextContents();mapping={project:project.title,shots:stages.storyboard.shots,texts};
await s.evaluate(({title,shots,texts})=>{document.querySelector('#p-project .crumb b').textContent=title;document.querySelectorAll('#projShots .scard').forEach((e,i)=>{e.querySelector('.im').style.backgroundImage='none';e.querySelector('.bd p').textContent=i?'女孩沿着海岸走向灯塔,纸船随海风起伏。':(texts[0]?.includes('等待确认')?'等待确认的新草稿':'女孩沿着海岸走向灯塔,纸船随海风起伏。');e.querySelector('.bd b').textContent='远景';e.querySelector('.tc').textContent=String(i+1).padStart(2,'0')+' · 5s';e.querySelector('.st .tag').textContent='待画'});document.querySelector('#projTasks').replaceChildren();document.querySelector('#draftbar').classList.toggle('hide',!texts[0]?.includes('等待确认'))},{title:project.title,shots:stages.storyboard.shots,texts});
}
if(item.name==='tasks-real'){
const cells=await p.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText)));
const thumbnails=await p.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>e.cells[0].querySelector('img')?.src||''));mapping={cells,thumbnails};
await s.evaluate(({cells,thumbnails})=>{const tbody=document.querySelector('#taskTable'),template=tbody.firstElementChild.cloneNode(true),button=template.querySelector('button').cloneNode(true);tbody.replaceChildren();for(const [idx,cellsRow] of cells.entries()){const row=template.cloneNode(true);[...row.cells].forEach((e,i)=>{if(i===0)e.firstElementChild.style.backgroundImage=thumbnails[idx]?`url("${thumbnails[idx]}")`:'none';else if(i===1)e.querySelector('b').textContent=cellsRow[i];else if(i===5){const tag=e.querySelector('.tag');tag.textContent=cellsRow[i];tag.className='tag'+(cellsRow[i].includes('失败')?' err':cellsRow[i].includes('完成')?' ok':'');e.querySelector('.bar')?.remove()}else if(i===7){const b=button.cloneNode(true);b.textContent=cellsRow[i];b.className='btn sm'+(cellsRow[i].includes('重试')?'':' ghost');e.querySelector('.row').replaceChildren(b)}else e.textContent=cellsRow[i]||''});tbody.append(row)}},{cells,thumbnails});
}
if(item.name==='workflows-long-disabled'){
const cells=await p.locator('.mg-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText)));mapping={cells};
await s.evaluate(cells=>{const body=document.querySelector('#wfTable'),template=body.firstElementChild.cloneNode(true);body.replaceChildren();for(const vals of cells){const row=template.cloneNode(true);[...row.cells].forEach((e,i)=>{if(i===0){e.querySelector('b').textContent=vals[i].split('\n')[0];e.querySelector('small').textContent=vals[i].split('\n').slice(1).join(' ')}else if(i<5){const child=e.querySelector('.tag,.dim');if(child)child.textContent=vals[i];else e.textContent=vals[i]}});body.append(row)}},cells);
}
if(item.name==='library-real'){
const media=await p.locator('.asset-preview img,.asset-preview video').evaluateAll(es=>es.map(e=>({src:e.src,kind:e.tagName})));for(const [i,m]of media.entries())if(m.kind==='VIDEO'){const video=p.locator('.asset-preview video').nth(media.slice(0,i).filter(x=>x.kind==='VIDEO').length);m.src='data:image/png;base64,'+(await video.screenshot()).toString('base64')};mapping={media};
await s.evaluate(media=>{const grid=document.querySelector('#libGrid'),template=grid.firstElementChild.cloneNode(true);grid.replaceChildren();for(const m of media){const el=template.cloneNode(true);el.querySelector('img').src=m.src;el.querySelector('.tag')?.remove();grid.append(el)}},media);
}
if(item.name.startsWith('models-cloud'))await s.evaluate(kind=>{const cards=document.querySelector('#modelCards');[...cards.children].forEach((e,i)=>{if(kind==='image'?![2].includes(i):![3,4].includes(i))e.remove()});document.querySelectorAll('#p-models .seg button').forEach((e,i)=>e.classList.toggle('on',i===(kind==='image'?2:3)))},item.name.endsWith('image')?'image':'video');
if(item.name==='connections-probed'){
const cards=await p.locator('.mg-card').evaluateAll(es=>es.map(e=>e.innerText));mapping={cards};await s.evaluate(cards=>{const c=document.querySelector('#modelCards');const template=c.firstElementChild.cloneNode(true);const add=c.lastElementChild.cloneNode(true);const clouds=[...c.children].slice(2,5);c.replaceChildren();for(const text of cards.filter(x=>x.includes('http://'))){const e=template.cloneNode(true),lines=text.split('\n').filter(Boolean);e.querySelector('b').textContent=lines[0];e.querySelector('.mono').textContent=lines.find(x=>x.includes('http://'));e.querySelector('.muted').textContent=lines.find(x=>x.includes('队列未知'));e.querySelector('.lat').textContent=lines.find(x=>/^\d+ms$/.test(x))||'—';c.append(e)}c.append(...clouds,add)},cards);
}
if(item.creative)await s.evaluate(()=>renderViewport());
await wait(80);await p.screenshot({path:prefix+'-actual.png',animations:'disabled'});await s.screenshot({path:prefix+'-mapped.png',animations:'disabled'});
const actual=await metrics(p,{rail:'aside.sidebar',header:'.management-header,.studio-header,.studio-heading,.project-header',content:'.mg-main,.studio-hero,.reference-desk,.project-content,.mg-media',primary:'.dock .gen,[data-testid=stage-confirm-btn]',footer:'.project-footer',card:'.mg-card,.storyboard-card,.asset-card',input:'#desk-prompt',modal:'.modal-panel',frame:'.print',ring:'.desk-emblem'});
const sampleValues=await metrics(s,{rail:'.rail',header:'#p-'+item.ref+' .ph',content:'#p-'+item.ref+' .mg-main,#p-'+item.ref+' .hero,#p-'+item.ref+' .create,#p-'+item.ref+' .proj-main,#libGrid',primary:'#genBtn,#confirmStage',footer:'#p-project .proj-foot',card:'#p-'+item.ref+' .mcard,#p-'+item.ref+' .scard,#libGrid>div',input:'#posTa',modal:'.pop.on',frame:'.frame,.player',ring:'.empty-stage .ring'});
result.rows.push({name:item.name,theme,width,height,basis:item.missing?'missing-reference':item.basis||'content-map-partial',missing:item.missing,mapping,actual,sample:sampleValues});
}
console.log('CAPTURED',item.name,12);fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2));
}
await c.close();ok('no pageerror',result.errors.length===0);
}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2));await browser.close();await new Promise(r=>sample.close(r))}
})().catch(e=>{console.error(e);process.exitCode=1});
+69
View File
@@ -0,0 +1,69 @@
// Targeted real drawer -> HTTP -> database -> library regression; no generation.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright');
const fs = require('node:fs'), path = require('node:path'), http = require('node:http');
const assert = require('node:assert/strict');
const phase = process.env.HEL809_PHASE, out = process.env.HEL809_EVIDENCE;
const graph = {
'1': {class_type:'PrimitiveStringMultiline',inputs:{value:'isolated test prompt'},_meta:{title:'Prompt (Input:prompt)'}},
'2': {class_type:'LoadImage',inputs:{image:'isolated.png'},_meta:{title:'Image (Input:image)'}},
'3': {class_type:'LoadImageMask',inputs:{image:'isolated.png',channel:'red'},_meta:{title:'Mask (Input:mask)'}},
'4': {class_type:'SaveImage',inputs:{images:['2',0]},_meta:{title:'Result (Output:image)'}}
};
const build = path.resolve('calliope-web/build');
const mime = {'.js':'text/javascript','.css':'text/css','.html':'text/html;charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.woff2':'font/woff2'};
const sockets = new Set();
const server = http.createServer((req,res)=>{
if(req.url.startsWith('/api/')) {
const proxy=http.request({hostname:'127.0.0.1',port:18249,path:req.url,method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});
proxy.on('error',()=>{res.writeHead(502);res.end()});req.pipe(proxy);return;
}
let file=path.join(build,new URL(req.url,'http://localhost').pathname);
if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');
res.setHeader('Content-Type',mime[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res);
});
server.on('connection',s=>{sockets.add(s);s.on('close',()=>sockets.delete(s))});
(async()=>{
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(18250,'127.0.0.1',resolve)});
let browser; const errors=[],responses=[],checks=[];
try {
browser=await chromium.launch({args:['--no-sandbox']});
const context=await browser.newContext({viewport:{width:1440,height:900}});
await context.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-theme','night')});
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
page.on('response',r=>{if(r.url().includes('/api/workflows') && ['POST','PATCH'].includes(r.request().method()))responses.push((async()=>({method:r.request().method(),url:new URL(r.url()).pathname,request:r.request().postDataJSON(),status:r.status(),response:await r.json()}))())});
await page.goto('http://127.0.0.1:18250/workflows');
await page.getByRole('button',{name:'导入工作流',exact:true}).click();
await page.getByPlaceholder('Qwen Image 2.1 文生图').fill('HEL809 图像与遮罩');
await page.locator('textarea.json').fill(JSON.stringify(graph,null,2));
await page.getByRole('button',{name:'分析',exact:true}).click();
await page.getByRole('button',{name:'确认保存',exact:true}).click();
const row=page.locator('tbody tr').filter({hasText:'HEL809 图像与遮罩'});
await row.waitFor();
await page.screenshot({path:path.join(out,phase+'-library.png'),fullPage:true});
const rowText=await row.innerText();
assert(rowText.includes(phase==='before'?'仅提示词':'图像'),rowText);checks.push('real drawer row label');
await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('image');
// The output is image too: media filter uses both input and output declarations.
if(phase==='before')await row.waitFor({state:'hidden'});else await row.waitFor();
await page.screenshot({path:path.join(out,phase+'-image-filter.png'),fullPage:true});
checks.push('image filter '+(phase==='before'?'miss reproduced':'hit'));
const api=async(p,b,method)=>{const r=await fetch('http://127.0.0.1:18249/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 meta=await api('/workflows?view=meta'),detail=await api('/workflows/'+meta[0].id);
assert(detail.input_schema.some(x=>x.role==='mask'&&x.kind==='image'));checks.push('mask is image');
if(phase==='after'){
assert.deepEqual(meta[0].media_io,{in:['image'],out:['image']});
const promptGraph={'1':graph['1']};
const analyzed=await api('/workflows/analyze',{workflow_json:promptGraph});
const prompt=await api('/workflows',{name:'HEL809 纯提示词',workflow_json:promptGraph,input_schema:analyzed.inputs,usage:'text2image'});
assert.deepEqual(prompt.media_io,{in:[],out:[]});
await page.reload();await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('all');
const promptRow=page.locator('tbody tr').filter({hasText:'HEL809 纯提示词'});await promptRow.waitFor();assert((await promptRow.innerText()).includes('仅提示词'));
await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('image');await promptRow.waitFor({state:'hidden'});await row.waitFor();
checks.push('pure prompt label and exclusion from image filter');
await page.screenshot({path:path.join(out,phase+'-prompt-excluded.png'),fullPage:true});
}
const network=await Promise.all(responses);
fs.writeFileSync(path.join(out,phase+'-responses.json'),JSON.stringify({network,meta,detail,rowText,checks,errors},null,2));
assert.deepEqual(errors,[]);console.log(phase,checks);
} finally {if(browser)await browser.close();for(const s of sockets)s.destroy();await new Promise(r=>server.close(r));}
})().catch(e=>{console.error(e);process.exitCode=1});
+40
View File
@@ -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});
+25
View File
@@ -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
View File
@@ -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});
+31
View File
@@ -0,0 +1,31 @@
"""Disposable canvas data. Only touches this checkout's isolated test database."""
import json,sqlite3,urllib.request,subprocess,shutil
from pathlib import Path
root=Path(__file__).resolve().parents[1]
state=root/'e2e/shots/reference'
def api(path,body=None,method=None):
r=urllib.request.Request('http://127.0.0.1:18247'+path,data=json.dumps(body).encode() if body is not None else None,headers={'Content-Type':'application/json'},method=method or ('POST' if body is not None else 'GET'))
with urllib.request.urlopen(r) as response:return json.load(response)
settings=api('/api/settings');assert Path(settings['data_dir']).resolve()==(state/'data').resolve()
project=api('/api/projects',{'title':'海岸纸船','idea':'隔离画布视觉与操作夹具'})
session=api('/api/agent/sessions',{'title':'海岸纸船 · 故事板','project_id':project['id']})
assets=state/'data/assets';assets.mkdir(exist_ok=True,parents=True)
img=assets/'coast.jpg';shutil.copy(root/'calliope-web/static/refract/coast.jpg',img)
video=assets/'coast.mp4'
shutil.copy(root/'e2e/tiny.mp4',video)
db=next((state/'data').glob('*.db'));conn=sqlite3.connect(db)
for table,name,field in [('characters','小满 · 角色','sheet_path'),('locations','清晨海岸 · 场景','reference_image_path'),('items','旧纸船 · 道具','reference_image_path')]:conn.execute(f'INSERT INTO {table}(project_id,name,{field}) VALUES(?,?,?)',(project['id'],name,str(img.resolve())))
for role,content,agent in [('user','请按当前项目素材整理画布。',None),('assistant','已将角色、场景和道具整理到画布。','main')]:conn.execute('INSERT INTO agent_messages(session_id,role,content,agent_name) VALUES(?,?,?,?)',(session['id'],role,content,agent))
conn.commit()
canvas=api('/api/canvas',{'project_id':project['id'],'title':'海岸纸船 · 灵感画布'})
for node,(x,y) in zip(canvas['nodes'],[(40,84),(360,84),(656,130)]):api(f"/api/canvas/{canvas['canvas']['id']}/nodes/{node['id']}",{'x':x,'y':y},'PATCH')
a=api(f"/api/canvas/{canvas['canvas']['id']}/nodes",{'type':'image','title':'镜头01 · 纸船入海','x':320,'y':330,'artifact_path':str(img.resolve()),'status':'done'})
v=api(f"/api/canvas/{canvas['canvas']['id']}/nodes",{'type':'video','title':'片段 · 潮水退去','x':616,'y':348,'artifact_path':str(video.resolve()),'status':'running'})
# Terminal job is held by the fixture, never queued for a worker. Lifecycle tests
# explicitly patch this local row and publish canvas.updated via the real API.
jid=conn.execute("INSERT INTO jobs(project_id,kind,status,output_paths_json) VALUES(?,'video','done',?)",(project['id'],json.dumps([str(video.resolve())]))).lastrowid
conn.execute('UPDATE canvas_node SET job_id=? WHERE id=?',(jid,v['id']));conn.commit();conn.close()
api(f"/api/canvas/{canvas['canvas']['id']}",{'viewport_json':json.dumps({'x':0,'y':0,'zoom':.8})},'PATCH')
emptySession=api('/api/agent/sessions',{'title':'空白画布隔离会话'});empty=api('/api/canvas',{'agent_session_id':emptySession['id']})
(state/'canvas818.json').write_text(json.dumps({'project':project,'session':session,'canvas':canvas['canvas'],'artifact':a,'video':v,'job':jid,'empty':empty['canvas'],'image':str(img.resolve()),'db':str(db.resolve())}))
print('SEEDED HEL818',canvas['canvas']['id'])
+28
View File
@@ -0,0 +1,28 @@
"""Render evidence comparisons and a numeric table from captured browser results."""
import json,csv,hashlib
from pathlib import Path
from PIL import Image,ImageDraw,ImageFont
out=Path('../evidence/hel818');data=json.loads((out/'results.json').read_text())
font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',20)
comparisons=out/'comparisons';comparisons.mkdir(exist_ok=True)
thumbs=[]
for row in data['geometry']:
theme,width,height=row['id'],row['width'],row['height']
left=Image.open(out/f'shots/reference-mapped-{theme}-{width}.png').convert('RGB');right=Image.open(out/f'shots/app-{theme}-{width}.png').convert('RGB')
im=Image.new('RGB',(width*2,height+44),'#202127');draw=ImageDraw.Draw(im);draw.text((12,10),f'Approved sample + real data/state mapping | {theme} {width}x{height}',font=font,fill='white');draw.text((width+12,10),'Application | same data/state | offline font condition',font=font,fill='white');im.paste(left,(0,44));im.paste(right,(width,44));im.save(comparisons/f'compare-{theme}-{width}.png');im.thumbnail((960,350));thumbs.append(im.copy())
# Rows follow viewport-major browser capture order, keeping labels visible.
overview=Image.new('RGB',(1920,350*((len(thumbs)+1)//2)),'#202127')
for i,im in enumerate(thumbs):overview.paste(im,((i%2)*960,(i//2)*350))
overview.save(out/'comparison-overview.png')
with (out/'geometry.csv').open('w',newline='') as f:
w=csv.writer(f);w.writerow(['theme','viewport','component','app_x','app_y','app_width','app_height','reference_x','reference_y','reference_width','reference_height','delta_width','delta_height','app_font','reference_font','app_bg','reference_bg','app_border','reference_border','app_radius','reference_radius'])
for row in data['geometry']:
for key,a in row['actual'].items():
b=row['reference'][key]
w.writerow([row['id'],row['width'],key,a['x'],a['y'],a['width'],a['height'],b['x'],b['y'],b['width'],b['height'],a['width']-b['width'],a['height']-b['height'],a['font'],b['font'],a['bg'],b['bg'],a['border'],b['border'],a['radius'],b['radius']])
references=Path('../references')
(out/'reference-sha256.json').write_text(json.dumps({p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in references.iterdir() if p.is_file()},ensure_ascii=False,indent=2))
print('BROWSER CHECKS',len(data['checks']),'PASS',sum(bool(x['pass']) for x in data['checks']),'ERRORS',len(data['errors']))
print('ANIMATION RECORDS',len(data['animations']))
for row in data['geometry']:
if row['id']=='night':print('GEOMETRY',row['width'],{k:[row['actual'][k]['width'],row['actual'][k]['height'],row['reference'][k]['width'],row['reference'][k]['height']] for k in ['title','controls','minimap','chat','entity','artifact']})
+8
View File
@@ -0,0 +1,8 @@
// Test-only static build server. Every API request goes to our isolated API.
const http=require('node:http'),fs=require('node:fs'),path=require('node:path');
const build=path.resolve('calliope-web/build');
http.createServer((req,res)=>{
if(req.url.startsWith('/api/')||req.url.startsWith('/assets/')){const q=http.request('http://127.0.0.1:18247'+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});q.on('error',()=>{res.statusCode=502;res.end()});res.on('close',()=>q.destroy());req.pipe(q);return}
let file=path.join(build,new URL(req.url,'http://localhost').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','.jpg':'image/jpeg','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res);
}).listen(15173,'127.0.0.1');
+123
View File
@@ -0,0 +1,123 @@
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
const fs=require('node:fs'),assert=require('node:assert/strict'),http=require('node:http'),cp=require('node:child_process');
const OUT='../evidence/hel818',WEB='http://127.0.0.1:15173',API='http://127.0.0.1:18247';
fs.mkdirSync(OUT+'/shots',{recursive:true});
const report={checks:[],geometry:[],errors:[],animations:[],differences:[]};
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,body,method)=>{const r=await fetch(API+p,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});assert(r.ok,p+' '+await r.clone().text());const text=await r.text();return text?JSON.parse(text):null};
const geom=(p,selectors)=>p.evaluate(selectors=>Object.fromEntries(Object.entries(selectors).map(([k,sel])=>{const e=document.querySelector(sel);if(!e)return[k,null];const r=e.getBoundingClientRect(),s=getComputedStyle(e);return[k,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.font,color:s.color,bg:s.backgroundColor,border:s.border,shadow:s.boxShadow,radius:s.borderRadius}]})),selectors);
const mode=async(p,name)=>{await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion').getByRole('button',{name,exact:true}).click();await p.keyboard.press('Escape');await wait(50)};
const theme=async(p,id)=>{await p.evaluate(id=>{document.documentElement.dataset.theme=id;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:id}))},id);await wait(100)};
(async()=>{
cp.execFileSync(process.env.HEL732_PYTHON||'python3',['e2e/hel818-fixture.py'],{stdio:'inherit'});
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/canvas818.json'));
const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../references/canvas-sample.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r));
const browser=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});
try{
const ctx=await browser.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}});
await ctx.route('https://fonts.googleapis.com/**',r=>r.abort());await ctx.route('https://fonts.gstatic.com/**',r=>r.abort());report.fontNetwork='Both app and reference use the same offline fallback fonts; product font sources unchanged.';
await ctx.addInitScript(()=>{if(!localStorage.getItem('calliope.canvas.viewport.1'))localStorage.setItem('calliope.canvas.viewport.1',JSON.stringify({x:0,y:0,zoom:.8}));localStorage.setItem('aigc-lang','zh');if(!localStorage.getItem('aigc-motion'))localStorage.setItem('aigc-motion','off');window.vtCalls=0;const vt=document.startViewTransition?.bind(document);if(vt)document.startViewTransition=(...args)=>{window.vtCalls++;return vt(...args)}});
const p=await ctx.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>report.errors.push(e.stack));
await p.goto(WEB+'/canvas/'+f.canvas.id+'?session='+f.session.id);await p.locator('.canvas-card').first().waitFor();await wait(700);
await p.locator('.artifact-node header').first().click();await p.mouse.move(5,5);await p.screenshot({path:OUT+'/shots/initial.png'});
ok('five real cards loaded',await p.locator('.canvas-card').count()===5);
ok('real image files loaded',await p.locator('.canvas-card img').evaluateAll(es=>es.every(e=>e.complete&&e.naturalWidth>0)));
ok('video first frame without autoplay',await p.locator('.canvas-card video').evaluate(e=>e.paused&&e.readyState>=2));
const sample=await ctx.newPage();
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 theme(p,id);await wait(100);
await sample.setViewportSize({width,height});await sample.goto('http://127.0.0.1:'+server.address().port+'/?theme='+id+'&chrome=0');await wait(150);
await sample.screenshot({path:`${OUT}/shots/reference-original-${id}-${width}.png`});
// Runtime-only data/state mapping: source HTML and its CSS stay untouched.
const mapping=await p.evaluate(()=>{const board=document.querySelector('.flow-panel').getBoundingClientRect();return {title:document.querySelector('.title-btn').textContent,zoom:document.querySelector('.zoom-value').textContent,subtitle:document.querySelector('.chat-subtitle')?.textContent,sendDisabled:document.querySelector('.composer button.primary').disabled,cards:[...document.querySelectorAll('.canvas-card')].map(e=>{const r=e.getBoundingClientRect(),m=e.querySelector('.media');return {title:e.querySelector('.title').textContent,foot:e.querySelector('footer').innerText,kind:e.dataset.kind,x:r.x-board.x,y:r.y-board.y,scale:r.width/parseFloat(getComputedStyle(e).width),image:m.querySelector('img')?.src,selected:e.classList.contains('a-in-frame'),status:e.querySelector('.status')?.textContent||''}}),messages:[...document.querySelectorAll('.chat-panel .row')].filter(e=>e.matches('.user,.assistant')).map(e=>({user:e.classList.contains('user'),name:e.querySelector('.agent-tag')?.innerText,text:e.querySelector('.bubble')?.innerText}))}});
const frame=await p.locator('.canvas-card video').screenshot();mapping.video='data:image/png;base64,'+frame.toString('base64');mapping.minimap='data:image/png;base64,'+(await p.locator('.svelte-flow__minimap svg').screenshot()).toString('base64');
await sample.evaluate(mapping=>{
document.querySelector('.zoomv').textContent=mapping.zoom;document.querySelector('.chat-head .sub').textContent=mapping.subtitle;document.querySelector('.cbar .btn.primary').disabled=mapping.sendDisabled;const nativeMap=document.createElement('img');nativeMap.src=mapping.minimap;nativeMap.style.cssText='width:100%;height:100%;display:block';document.querySelector('.minimap .mm').replaceChildren(nativeMap);document.querySelector('.minimap .mmtag')?.remove();
document.querySelector('.demo-badge').remove();document.querySelector('.titlecard .saved').remove();document.querySelector('.tname').firstChild.nodeValue=mapping.title.trim();
document.querySelectorAll('.card').forEach((e,i)=>{const d=mapping.cards[i];e.classList.toggle('sel',d.selected);e.classList.toggle('on',d.selected);e.classList.remove('running');if(d.kind!=='video')e.querySelector('.chead .play')?.remove();e.style.setProperty('left',d.x+'px','important');e.style.setProperty('top',d.y+'px','important');e.style.transform='scale('+d.scale+')';e.style.transformOrigin='top left';e.querySelector('.t').textContent=d.title;const m=e.querySelector('.media');m.replaceChildren();const img=document.createElement('img');img.src=d.image||mapping.video;img.style.cssText='display:block;width:100%;height:100%;object-fit:cover';m.append(img);const badge=document.createElement('span');badge.className='kind';badge.textContent=d.kind==='video'?'video':'image';m.append(badge);const foot=e.querySelector('.cfoot');foot.replaceChildren();const left=document.createElement('span');left.textContent=d.foot.replace(/\s*#\d+$/,'');const grow=document.createElement('span');grow.className='grow';const number=document.createElement('span');number.className='mono';number.textContent=d.foot.match(/#\d+$/)?.[0]||'';foot.append(left,grow,number);const chip=e.querySelector('.chead .pill');if(chip)chip.textContent=d.kind;});
const body=document.querySelector('.chat-body');body.replaceChildren();for(const d of mapping.messages){const row=document.createElement('div');row.className='msg '+(d.user?'me':'ai');const wrap=document.createElement('div');if(!d.user){const tag=document.createElement('div');tag.className='agent-tag';tag.textContent=d.name;wrap.append(tag)}const b=document.createElement('div');b.className='bub';b.textContent=d.text;wrap.append(b);row.append(wrap);body.append(row)}
},mapping);await wait(50);
fs.writeFileSync(`${OUT}/mapping-${id}-${width}.json`,JSON.stringify(mapping,null,2));
const actual=await geom(p,{board:'.flow-panel',title:'.titlecard',controls:'.svelte-flow__controls',minimap:'.svelte-flow__minimap',chat:'.chat-panel',head:'.chat-head',entity:'.entity-node',artifact:'.artifact-node',media:'.entity-node .media'});
const reference=await geom(sample,{board:'.board-wrap',title:'.titlecard',controls:'.ctl',minimap:'.minimap',chat:'.chat',head:'.chat-head',entity:'.card.entity',artifact:'.card.artifact',media:'.card.entity .media'});
report.geometry.push({id,width,height,actual,reference});
const corner=await p.locator('.artifact-node').first().evaluate(e=>({token:getComputedStyle(e).getPropertyValue('--viewfinder-color'),image:getComputedStyle(e).getPropertyValue('--sc-image'),gold:getComputedStyle(e).getPropertyValue('--gold'),inset:getComputedStyle(e,'::after').inset}));report.geometry.at(-1).corner=corner;ok(id+' '+width+' corners use canvas scene',corner.token.includes(corner.image.trim())&&!corner.token.includes(corner.gold.trim()));
ok(id+' '+width+' 26px grid and 1px dot radius',await p.locator('.svelte-flow__background').evaluate(e=>Math.abs(parseFloat(e.querySelector('pattern').getAttribute('width'))-26)<.01&&Math.abs(parseFloat(e.querySelector('circle').getAttribute('r'))-1)<.01));
ok(id+' '+width+' no document overflow',await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
for(const sel of ['.titlecard','.tidy-btn','.svelte-flow__controls','.svelte-flow__minimap','.chat-head','.composer button.primary'])ok(id+' '+width+' '+sel+' reachable',await p.locator(sel).evaluate(e=>{const r=e.getBoundingClientRect();return r.x>=0&&r.y>=0&&r.right<=innerWidth&&r.bottom<=innerHeight}));
ok(id+' '+width+' board matches token',actual.board.bg===({night:'rgb(10, 12, 18)',day:'rgb(230, 224, 211)',grade:'rgb(30, 30, 33)',bluehour:'rgb(12, 19, 48)'}[id]));
await p.screenshot({path:`${OUT}/shots/app-${id}-${width}.png`});
await sample.screenshot({path:`${OUT}/shots/reference-mapped-${id}-${width}.png`});
}
await sample.close();
// Box selection and guarded delete use the real graph interaction.
await p.setViewportSize({width:1440,height:900});await wait(200);const area=await p.locator('.flow-panel').boundingBox();
await p.keyboard.down('Shift');await p.mouse.move(area.x+12,area.y+62);await p.mouse.down();await p.mouse.move(area.x+area.width-190,area.y+510,{steps:12});await p.mouse.up();await p.keyboard.up('Shift');
ok('box selection selects multiple cards',await p.locator('.svelte-flow__node.selected').count()>=2);await p.keyboard.press('Backspace');await wait(150);ok('project entity delete is protected',(await api('/api/canvas/'+f.canvas.id)).nodes.length===5);
const temporary=await api('/api/canvas/'+f.canvas.id+'/nodes',{type:'image',title:'可删除的隔离产物',artifact_path:f.image,status:'done',x:10,y:540});await p.locator('[data-id="cn-'+temporary.id+'"] header').waitFor();await p.locator('.svelte-flow__pane').click({position:{x:20,y:450}});await p.locator('[data-id="cn-'+temporary.id+'"] header').click();await p.locator('[data-id="cn-'+temporary.id+'"]').focus();ok('delete targets only the temporary artifact',await p.locator('.svelte-flow__node.selected').count()===1);await p.keyboard.press('Backspace');await wait(250);ok('unprotected artifact deletes through real API',!(await api('/api/canvas/'+f.canvas.id)).nodes.some(n=>n.id===temporary.id));
const cw=await p.locator('.chat-panel').evaluate(e=>e.getBoundingClientRect().width);await p.locator('.chat-splitter').focus();await p.keyboard.press('ArrowLeft');await wait(100);report.resize={before:cw,after:await p.locator('.chat-panel').evaluate(e=>({width:e.getBoundingClientRect().width,style:e.getAttribute('style')}))};ok('assistant keyboard resizing works',await p.locator('.chat-panel').evaluate(e=>e.getBoundingClientRect().width)>cw);await p.keyboard.press('ArrowRight');
// Rename, editing and theme/mode changes preserve live DOM and view state.
await p.setViewportSize({width:1440,height:900});await theme(p,'night');
await p.locator('.title-btn').click();await p.locator('.title-input').fill('HEL818 画布命名');await p.locator('.title-input').press('Enter');await wait(250);
ok('rename persisted',(await api('/api/canvas/'+f.canvas.id)).canvas.title==='HEL818 画布命名');
const editor=p.locator('.composer .editor');await editor.fill('尚未发送的画布输入');await editor.evaluate(e=>window.keptEditor=e);
const before=await api('/api/canvas/'+f.canvas.id);
await mode(p,'克制');await theme(p,'day');await mode(p,'完整');await theme(p,'grade');await mode(p,'关闭');
ok('theme and modes preserve input DOM/value',await editor.evaluate(e=>e===window.keptEditor&&e.innerText==='尚未发送的画布输入'));
const after=await api('/api/canvas/'+f.canvas.id);ok('theme and modes preserve card/project/viewport state',JSON.stringify(before)===JSON.stringify(after));
for(const label of ['完整','克制','关闭']){
await mode(p,label);const before=await p.evaluate(()=>window.vtCalls);await p.locator('.sidebar-bottom button[title="主题"]').click();const next=(await p.locator('html').getAttribute('data-theme'))==='day'?'夜片场':'日光棚';await p.locator('.choices.compact .choice').filter({hasText:next}).click();await wait(30);
const transition=await p.evaluate(()=>({vtCalls:window.vtCalls,classes:document.documentElement.className,circular:getComputedStyle(document.documentElement).getPropertyValue('--theme-reveal-duration').trim(),fallback:getComputedStyle(document.documentElement).getPropertyValue('--theme-fallback-duration').trim()}));report.animations.push({theme:label,...transition});ok(label+' approved theme transition',label==='完整'?transition.vtCalls>before&&transition.circular==='520ms':label==='克制'?transition.vtCalls===before&&transition.classes.includes('theme-anim')&&transition.fallback==='280ms':transition.vtCalls===before&&!transition.classes.includes('theme-anim'));await wait(550);await p.keyboard.press('Escape');ok(label+' theme UI preserves editor',await editor.evaluate(e=>e===window.keptEditor&&e.innerText==='尚未发送的画布输入'));
}
// Zoom/pan and persistence, lock and tidy still use real controls/API.
await p.locator('.svelte-flow__controls-zoomin').click();await wait(600);const zoomed=(await api('/api/canvas/'+f.canvas.id)).canvas.viewport_json;
ok('zoom persists viewport',JSON.parse(zoomed).zoom>.8);
await p.reload();await p.locator('.canvas-card').first().waitFor();await wait(350);ok('viewport survives reload',(await p.locator('.zoom-value').innerText())===Math.round(JSON.parse(zoomed).zoom*100)+'%');
await p.locator('.svelte-flow__controls-interactive').click();ok('lock toggles interactivity',await p.locator('.svelte-flow__node.draggable').count()===0);await p.locator('.svelte-flow__controls-interactive').click();
await p.locator('.svelte-flow__controls-fitview').click();await wait(300);
const card=p.locator('.entity-node').first();await card.click();await wait(100);ok('entity opens preview',await p.locator('.lightbox').count()>0||await p.getByRole('dialog').count()>0);await p.keyboard.press('Escape');await p.locator('.artifact-node[data-kind=video] .media-btn').click();ok('video card opens preview without inline autoplay',await p.getByRole('dialog').count()>0||await p.locator('.lightbox').count()>0);await p.keyboard.press('Escape');
const box=await card.boundingBox();await p.mouse.move(box.x+60,box.y+18);await p.mouse.down();await p.mouse.move(box.x+100,box.y+50,{steps:8});await p.mouse.up();await wait(400);
ok('drag persists real coordinates',(await api('/api/canvas/'+f.canvas.id)).nodes.some(n=>n.type==='entity'&&n.x!==40&&n.x!==360&&n.x!==656));
await p.locator('.tidy-btn').click();await wait(450);ok('tidy remains usable',await p.locator('.canvas-card').count()===5);
await p.locator('.chat-toggle').click();await wait(100);ok('assistant collapses',await p.locator('.chat-panel').count()===0);await theme(p,'night');await p.screenshot({path:OUT+'/shots/collapsed.png'});await p.locator('.chat-expand').click();
ok('assistant expands',await editor.count()===1);
await p.goto(WEB+'/canvas/'+f.empty.id);await p.locator('.canvas-empty-hint').waitFor();await wait(150);ok('real empty canvas has zero fake cards',await p.locator('.canvas-card').count()===0);await theme(p,'night');await p.screenshot({path:OUT+'/shots/empty-night.png'});await theme(p,'day');await p.screenshot({path:OUT+'/shots/empty-day.png'});
// Upload, workflow and skill menus keep their existing real behavior.
const input=p.locator('.composer .editor');await input.fill('@Qwen');await input.press('End');await input.press('n');await p.locator('.mention-menu').waitFor();ok('workflow menu remains usable',await p.getByRole('option').count()>0);await p.keyboard.press('Escape');await input.fill('/');await input.press('End');await input.press('h');await p.locator('.mention-menu').waitFor();ok('skill menu remains usable',await p.getByRole('option').count()>0);await p.keyboard.press('Escape');await input.fill('未发送的附件与编辑状态');
await p.locator('input[type=file]').setInputFiles(f.image);await p.locator('.tile:not(.uploading)').waitFor();await input.evaluate(e=>window.uploadEditor=e);const tiles=await p.locator('.tile').count();await mode(p,'克制');await theme(p,'grade');ok('theme/motion preserve uploaded attachments and editor',await p.locator('.tile').count()===tiles&&await input.evaluate(e=>e===window.uploadEditor&&e.innerText.includes('未发送')));
// Run only against our fake ComfyUI, capturing real pending/running -> done.
const workflows=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
await p.evaluate(()=>{window.canvasAnimations=[];document.addEventListener('animationstart',e=>{if(e.animationName.startsWith('a-develop')&&e.target.closest('.canvas-card')){const a=e.target.getAnimations()[0];a.pause();a.currentTime=0;window.canvasAnimations.push({name:e.animationName,duration:a.effect.getTiming().duration,frames:a.effect.getKeyframes()})}},true)});
for(const [label,id] of [['完整','night'],['完整','day'],['完整','grade'],['完整','bluehour'],['克制','night'],['克制','day'],['克制','grade'],['克制','bluehour'],['关闭','night']]){
const animationCount=await p.evaluate(()=>window.canvasAnimations.length);await mode(p,label);await theme(p,id);fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true,image_file:f.image}));
const created=await api('/api/playground/generate',{workflow_id:workflows.dual,input_values:{prompt:'HEL818 isolated canvas arrival',negative:'',seed:42}});
const n=await api('/api/canvas/'+f.empty.id+'/nodes',{type:'image',title:'当前隔离任务 '+created.job.id,job_id:created.job.id,artifact_path:f.image,status:'running',x:80,y:160});
await p.waitForFunction(()=>document.querySelector('.running-badge'));
await p.waitForFunction(id=>!document.querySelector('[data-id="cn-'+id+'"] .running-badge'),n.id,{timeout:30000});
const node=p.locator('[data-id="cn-'+n.id+'"]');
if(label==='关闭'){ok('off has no arrival replay',await node.locator('.a-result-develop').count()===0)}else{
await node.locator('.a-result-develop').waitFor();await p.waitForFunction(count=>window.canvasAnimations.length>count,animationCount);const data=await p.evaluate(()=>window.canvasAnimations.at(-1));report.animations.push({label,id,...data});ok(label+' '+id+' correct reveal duration',data.duration===(label==='完整'?700:350));
await p.screenshot({path:`${OUT}/shots/reveal-${label}-${id}-000.png`});await node.locator('.media-el').evaluate(e=>e.getAnimations()[0].currentTime=e.getAnimations()[0].effect.getTiming().duration/2);await p.screenshot({path:`${OUT}/shots/reveal-${label}-${id}-mid.png`});
await node.locator('.media-btn').dispatchEvent('pointerdown');ok(label+' '+id+' click skips reveal',await node.locator('.a-result-develop').count()===0);
}
await theme(p,id==='day'?'night':'day');ok(label+' '+id+' theme does not replay',await node.locator('.a-result-develop').count()===0);
}
await p.reload();await p.locator('.canvas-card').first().waitFor();ok('history does not replay reveal',await p.locator('.a-result-develop').count()===0);
await p.emulateMedia({reducedMotion:'reduce'});await wait(100);ok('OS reduced motion forces off',await p.locator('html').getAttribute('data-motion')==='off');await p.emulateMedia({reducedMotion:'no-preference'});
// Real chat/tool execution and cancellation against the loopback fake model.
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',agent_delay:0}));await p.locator('.composer .editor').fill('请查看画布项目素材');await p.locator('.composer button.primary').click();await p.locator('.chat-panel .tool-card').first().waitFor();await p.locator('.chat-panel .tool-card .head').first().click();ok('real tool parameters expand',await p.locator('.chat-panel .tool-card .head').first().getAttribute('aria-expanded')==='true');await p.waitForFunction(()=>!document.querySelector('.composer .stop'),null,{timeout:30000});
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',agent_delay:3}));await p.locator('.composer .editor').fill('请继续整理');await p.locator('.composer button.primary').click();await p.locator('.composer .stop').waitFor();await p.locator('.composer .editor').fill('调整为近景');await p.locator('.composer button.primary').click();await p.locator('.user-bubble').filter({hasText:'调整为近景'}).waitFor();ok('running chat accepts guidance',true);await p.locator('.composer .stop').click();await p.waitForFunction(()=>!document.querySelector('.composer .stop'),null,{timeout:30000});ok('stop cancels real isolated agent run',true);
// Unlink uses the existing confirmation and leaves the project intact.
await p.goto(WEB+'/canvas/'+f.canvas.id+'?session='+f.session.id);await p.locator('.unlink-btn').waitFor();p.once('dialog',d=>d.accept());await p.locator('.unlink-btn').click();await p.waitForURL(u=>!u.pathname.endsWith('/'+f.canvas.id));ok('unlink preserves project and session history',(await api('/api/projects/'+f.project.id)).id===f.project.id&&(await api('/api/agent/sessions/'+f.session.id)).project_id===null);
await p.goto(WEB+'/build-scene');await p.locator('.composer .editor').waitFor();ok('shared composer retains original default button',await p.locator('.composer button.send:not(.stop)').count()===1);await p.screenshot({path:OUT+'/shots/shared-build-scene.png'});
await ctx.close();
const blocked=await browser.newContext({viewport:{width:1280,height:720}});await blocked.addInitScript(()=>{Storage.prototype.getItem=()=>{throw new DOMException('blocked')};Storage.prototype.setItem=()=>{throw new DOMException('blocked')}});const q=await blocked.newPage();q.on('pageerror',e=>report.errors.push(e.stack));await q.goto(WEB+'/canvas/'+f.canvas.id);await q.locator('.canvas-card').first().waitFor();ok('storage unavailable still renders real canvas',await q.locator('.canvas-card').count()===5);await q.screenshot({path:OUT+'/shots/storage-blocked.png'});await blocked.close();
ok('no page errors',report.errors.length===0);
}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(report,null,2));await browser.close();await new Promise(r=>server.close(r))}
})().catch(e=>{console.error(e);process.exitCode=1});
+57
View File
@@ -0,0 +1,57 @@
"""Run the isolated regression stack in the foreground and always stop children."""
import os, signal, socket, subprocess, time, urllib.request
from pathlib import Path
root=Path(__file__).resolve().parents[1]
os.chdir(root)
py=os.environ.get('HEL732_PYTHON', 'python3')
logs=root/'e2e/shots/hel732'; logs.mkdir(parents=True,exist_ok=True)
env=os.environ.copy()
env['PYTHONPATH']=str(root/'calliope-backend/src')
for port in [8247,5173,18188,18189]:
with socket.socket() as probe:
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
reference=root/'e2e/shots/reference'
reuse_fixture=os.environ.get('HEL732_REUSE_FIXTURE')=='1'
if reference.exists() and not reuse_fixture:reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
mode_path=root/'e2e/fake_mode.json'
saved_mode=mode_path.read_bytes() if mode_path.exists() else None
processes=[]; handles=[]
try:
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/fake_agent_b4.py']),('web',['npm','--prefix',os.environ.get('HEL732_WEB_DIR',os.environ.get('HEL729_WEB_DIR','calliope-web')),'run','preview','--','--host','127.0.0.1','--port','5173'])]:
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
for i in range(100):
try:
urllib.request.urlopen(url,timeout=2); break
except Exception:
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
if i==99:raise
time.sleep(.2)
if not reuse_fixture:
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
failures=[]
scripts=os.environ.get('HEL733_TESTS','e2e/hel732.cjs,e2e/hel733-live.cjs').split(',')
for script in scripts:
print('RUN',script,flush=True)
result=subprocess.run(['node',script],env=env)
if result.returncode:failures.append(script)
print('FAILED SCRIPTS',failures,flush=True)
if failures:raise SystemExit(1)
finally:
import json
daemon_pid=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
assert all(p.pid != daemon_pid for p in processes)
for p in processes:
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
for p in processes:
try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
print("STOPPED TEST CHILDREN", [{"pid":p.pid,"exit":p.returncode} for p in processes],flush=True)
for h in handles:h.close()
import shutil
if reference.exists():shutil.rmtree(reference)
if saved_mode is not None:mode_path.write_bytes(saved_mode)
elif mode_path.exists():mode_path.unlink()
+69
View File
@@ -0,0 +1,69 @@
"""Foreground isolated before/after API + browser proof; always remove own state."""
import io
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.request
root = Path(__file__).resolve().parents[1]
os.chdir(root)
out = root.parent / 'evidence/hel809'
out.mkdir(parents=True, exist_ok=True)
for port in (18249, 18250):
with socket.socket() as s:
assert s.connect_ex(('127.0.0.1', port)) != 0, f'Port in use: {port}'
bootstrap = '''from pathlib import Path
import os
from calliope import config
root = Path(os.environ['HEL809_DATA'])
config.CONFIG_FILE = root / 'settings.json'
config.settings = config.Settings(data_dir=root/'data', assets_dir=root/'data/assets',
comfyui_base_url='http://127.0.0.1:1', llm_base_url='http://127.0.0.1:1/v1', dry_run=False)
config.settings.ensure_storage_dirs()
from calliope.queue.manager import queue_manager
queue_manager.paused = True
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(),host='127.0.0.1',port=18249,log_level='warning')
'''
cleanup = []
with tempfile.TemporaryDirectory(prefix='hel809-', dir=out) as tmp:
work = Path(tmp)
old = work/'before'; old.mkdir()
archive = subprocess.check_output(['git','archive','cb410fbdeaa87528aaaab1f0dbffeb0240a9a32e','calliope-backend/src'])
with tarfile.open(fileobj=io.BytesIO(archive)) as bundle:
bundle.extractall(old, filter='data')
server = work/'server.py'; server.write_text(bootstrap)
for phase, src in [('before',old/'calliope-backend/src'),('after',root/'calliope-backend/src')]:
data = work/phase/'runtime'; data.mkdir(parents=True)
env = {**os.environ, 'PYTHONPATH':str(src), 'HEL809_DATA':str(data),
'HEL809_PHASE':phase,'HEL809_EVIDENCE':str(out)}
with (out/(phase+'-backend.log')).open('w') as log:
child = subprocess.Popen([sys.executable,str(server)],env=env,stdout=log,stderr=subprocess.STDOUT)
try:
for attempt in range(100):
try:
urllib.request.urlopen('http://127.0.0.1:18249/api/health',timeout=1);break
except Exception:
if child.poll() is not None: raise RuntimeError('Backend failed: '+phase)
if attempt==99:raise
time.sleep(.1)
subprocess.run(['node','e2e/hel809.cjs'],env=env,check=True,timeout=100)
finally:
daemon = json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))
assert child.pid != daemon['pid']
if child.poll() is None:child.send_signal(signal.SIGTERM)
try:child.wait(timeout=10)
except subprocess.TimeoutExpired:child.kill();child.wait()
cleanup.append({'phase':phase,'pid':child.pid,'exit':child.returncode})
# TemporaryDirectory removes only this run's database and before-source copy.
(out/'cleanup.json').write_text(json.dumps({'children':cleanup,'temporary_state_removed':not work.exists()},indent=2))
print('CLEANUP',cleanup, 'state removed',not work.exists())
+54
View File
@@ -0,0 +1,54 @@
"""Foreground isolated stack with task-local ports; owns and cleans exact children."""
import os,signal,socket,subprocess,time,urllib.request,json,shutil
from pathlib import Path
root=Path(__file__).resolve().parents[1];os.chdir(root)
py=os.environ['HEL732_PYTHON'];env=os.environ.copy();env['PYTHONPATH']=str(root/'calliope-backend/src')
logs=root.parent/'evidence/hel818/logs';logs.mkdir(parents=True,exist_ok=True)
state=root/'e2e/shots/reference';assert not state.exists(),'Preserve unexpected test data'
ports=[18247,15173,19188,19189]
for port in ports:
with socket.socket() as s:assert s.connect_ex(('127.0.0.1',port))!=0,f'port {port} is busy'
processes=[];handles=[];mode=root/'e2e/fake_mode.json';saved=mode.read_bytes() if mode.exists() else None
code="""from pathlib import Path
from calliope import config
root=Path('e2e/shots/reference').resolve();root.mkdir(parents=True)
config.CONFIG_FILE=root/'settings.json'
config.settings=config.Settings(data_dir=root/'data',assets_dir=root/'data/assets',llm_base_url='http://127.0.0.1:19189/v1',llm_model='reference-test',comfyui_base_url='http://127.0.0.1:19188',dry_run=False)
config.settings.ensure_storage_dirs();config.settings.ensure_llm_profiles();config.settings.save_config_file()
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(),host='127.0.0.1',port=18247)
"""
try:
commands=[('api',[py,'-c',code]),('comfy',[py,'-c',(root/'e2e/fake_comfy.py').read_text().replace('18188','19188').replace('Path(__file__).parent', 'Path('+repr(str(root/'e2e'))+')')]),('llm',[py,'-c',(root/'e2e/fake_agent_b4.py').read_text().replace('18189','19189').replace('import json', 'import json,time').replace(" msgs=payload.get", " time.sleep(json.loads(Path('e2e/fake_mode.json').read_text()).get('agent_delay',0) if Path('e2e/fake_mode.json').exists() else 0)\n msgs=payload.get")]),('web',['npm','--prefix','calliope-web','run','preview','--','--host','127.0.0.1','--port','15173','--strictPort'])]
# preview proxy is fixed at 8247; use an owned HTTP proxy at a dynamic port
# in front of static build, forwarding API to the task's isolated backend.
commands[-1]=('web',['node','e2e/hel818-web.cjs'])
for name,cmd in commands:
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
for url in ['http://127.0.0.1:18247/api/health','http://127.0.0.1:15173']:
for i in range(100):
try:urllib.request.urlopen(url,timeout=2);break
except Exception:
if any(p.poll() is not None for p in processes):raise RuntimeError('service failed; see logs')
if i==99:raise
time.sleep(.2)
for name in ['reference/seed.py']:
seed=(root/'e2e'/name).read_text().replace('8247','18247').replace('18188','19188').replace('18189','19189')
seed=seed.replace('Path(__file__).resolve().parents[1]',repr(root/'e2e')) if name=='reference/seed.py' else seed
if name=='reference/seed.py':seed=seed.replace(repr(root/'e2e'),"Path("+repr(str(root/'e2e'))+")")
subprocess.run([py,'-c',seed],env=env,check=True)
subprocess.run(['node','e2e/hel818.cjs'],env=env,check=True)
finally:
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid'];assert all(p.pid!=daemon for p in processes)
for p in processes:
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
for p in processes:
try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
print('STOPPED OWN CHILDREN',[(p.pid,p.returncode) for p in processes],flush=True)
for h in handles:h.close()
if state.exists():shutil.rmtree(state)
if saved is None:
if mode.exists():mode.unlink()
else:mode.write_bytes(saved)
+85
View File
@@ -0,0 +1,85 @@
"""Package lossless original/content-mapped/actual captures; never infer visual approval."""
from pathlib import Path
import concurrent.futures, hashlib, html, json, zipfile, csv
from PIL import Image, ImageDraw, ImageFont
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT.parent/'evidence/hel806-final'
REPORT=ROOT.parent/'evidence/hel806/report'
REPORT.mkdir(parents=True,exist_ok=True)
data=json.loads((OUT/'results.json').read_text())
rows=[r for r in data['rows'] if r['name']!='toast']
assert len(rows)==408, f'Expected 34 states x 12, got {len(rows)}'
assert len(rows)==len({(r['name'],r['theme'],r['width']) for r in rows})
font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',14)
manifest=[]
def convert(p):
im=Image.open(p).convert('RGB');q=p.with_suffix('.webp')
if not q.exists() or Image.open(q).convert('RGB').tobytes()!=im.tobytes():im.save(q,lossless=True,method=3)
assert Image.open(q).convert('RGB').tobytes()==im.tobytes()
return {'source':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'delivered':q.name,'sha256_webp':hashlib.sha256(q.read_bytes()).hexdigest(),'pixels_identical':True}
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
manifest=list(pool.map(convert, sorted((OUT/'shots').glob('*.png'))))
for r in rows:
stem=f"{r['name']}-{r['theme']}-{r['width']}"
pair_path=OUT/'shots'/(stem+'-pair.webp')
if pair_path.exists() and pair_path.stat().st_mtime>=max((OUT/'shots'/(stem+'-actual.webp')).stat().st_mtime,(OUT/'shots'/(stem+'-mapped.webp')).stat().st_mtime):continue
a=Image.open(OUT/'shots'/(stem+'-actual.webp')).convert('RGB');s=Image.open(OUT/'shots'/(stem+'-mapped.webp')).convert('RGB')
pair=Image.new('RGB',(a.width*2,a.height+32),'#181818');d=ImageDraw.Draw(pair)
d.text((10,8),'REFERENCE CONTENT MAPPED / '+r['basis'],font=font,fill='white');d.text((a.width+10,8),'ACTUAL ISOLATED API / '+stem,font=font,fill='white');pair.paste(s,(0,32));pair.paste(a,(a.width,32));pair.save(OUT/'shots'/(stem+'-pair.webp'),lossless=True,method=3)
# Twelve actual viewports per state, for layout inspection; native pairs remain available.
contacts=REPORT/'contacts';contacts.mkdir(exist_ok=True)
for name in sorted({r['name'] for r in rows}):
sheet=Image.new('RGB',(1920,3*302),'#202020');d=ImageDraw.Draw(sheet)
for r in [r for r in rows if r['name']==name]:
col=['night','day','grade','bluehour'].index(r['theme']);line=[1440,1366,1280].index(r['width'])
im=Image.open(OUT/'shots'/f"{name}-{r['theme']}-{r['width']}-actual.webp").convert('RGB');im.thumbnail((480,270));sheet.paste(im,(col*480,line*302+28));d.text((col*480+6,line*302+6),f"{name} / {r['theme']} / {r['width']}",font=font,fill='white')
sheet.save(contacts/(name+'.webp'),lossless=True)
# Source values, with no tolerance-based pass flag. A hidden region is not comparable.
with (REPORT/'key-values.csv').open('w') as f:
w=csv.writer(f);w.writerow(['state','theme','viewport','region','actual_xywh','reference_xywh','delta_xywh','basis'])
for r in rows:
for key,a in r['actual'].items():
b=r['sample'].get(key)
if not a or not b or a['width']<=0 or b['width']<=0:continue
values=lambda o:[round(o[k],4) for k in ['x','y','width','height']]
av,bv=values(a),values(b);w.writerow([r['name'],r['theme'],f"{r['width']}x{r['height']}",key,av,bv,[round(x-y,4)for x,y in zip(av,bv)],r['basis']])
(REPORT/'capture-manifest.json').write_text(json.dumps({'source_png_to_lossless_webp':manifest,'states':len({r['name']for r in rows}),'groups':len(rows),'errors':data['errors'],'blocked_external_origins':sorted(set(data['blocked']))},indent=2))
# Self-contained parts keep every state/three sizes together and each upload under 40 MB.
archives=[]
for theme in ['night','day','grade','bluehour']:
theme_rows=[r for r in rows if r['theme']==theme]
b4files=[p for p in (ROOT/'e2e/shots/hel732').glob(f'*-{theme}-*.png') if p.name.startswith(('home-','task-'))]
b4size=sum(p.stat().st_size for p in b4files)
chunks=[];current=[];objects={};weight=b4size
def files_for(group):
return [OUT/'shots'/f"{r['name']}-{theme}-{r['width']}-{suffix}.webp" for r in group for suffix in ['original','mapped','actual','pair']]
for name in dict.fromkeys(r['name'] for r in theme_rows):
group=[r for r in theme_rows if r['name']==name]
candidates={hashlib.sha256(p.read_bytes()).hexdigest():p for p in files_for(group)}
extra=sum(p.stat().st_size for h,p in candidates.items() if h not in objects)
if current and weight+extra>40_000_000:
chunks.append(current);current=[];objects={};weight=0
extra=sum(p.stat().st_size for p in candidates.values())
current+=group;objects.update(candidates);weight+=extra
if current:chunks.append(current)
for part,part_rows in enumerate(chunks,1):
media={};objects={}
for p in files_for(part_rows):
h=hashlib.sha256(p.read_bytes()).hexdigest();media[p.name]='assets/'+h+'.webp';objects[h]=p
doc=['<meta charset="utf-8"><title>HEL806 '+theme+'</title><style>body{font:15px system-ui;background:#181818;color:#eee}img{max-width:100%}a{color:#8ed3ff}details{margin:20px 0}</style><h1>HEL806 '+theme+' / '+str(part)+'</h1><p>原图只读;mapped只映射内容。missing-reference 是缺依据;并排左参考、右隔离实图,不代表全页通过。此分包可独立解压阅读。B4 gallery 位于本主题 part1 的 B4-index.html</p>']
for r in part_rows:
stem=f"{r['name']}-{theme}-{r['width']}"
link=lambda suffix:media[stem+'-'+suffix+'.webp']
doc.append('<details><summary>'+html.escape(stem+' · '+r['basis']+' · '+str(r.get('missing','')))+'</summary>'+f'<a href="{link("original")}">未改原图</a> · <a href="{link("mapped")}">内容映射参考</a> · <a href="{link("actual")}">实图</a><img loading="lazy" src="{link("pair")}"></details>')
target=REPORT/f'HEL806-{theme}-part{part}.zip'
with zipfile.ZipFile(target,'w',zipfile.ZIP_DEFLATED,compresslevel=3) as z:
z.writestr('index.html','\n'.join(doc));z.writestr('results.json',json.dumps(part_rows,ensure_ascii=False,indent=2));z.writestr('media-files.json',json.dumps(media,indent=2))
for h,p in objects.items():z.write(p,'assets/'+h+'.webp')
if part==1:
b4doc=['<meta charset="utf-8"><h1>B4 '+theme+'</h1><p>沿用已批准内容/hero映射。原PNG保持不变;不把获批例外扩大解释。</p>']
for p in sorted(b4files):z.write(p,'b4/'+p.name);b4doc.append('<p>'+html.escape(p.name)+'</p><img style="max-width:100%" loading="lazy" src="b4/'+p.name+'">')
z.writestr('B4-index.html','\n'.join(b4doc))
assert target.stat().st_size<45_000_000
archives.append({'name':target.name,'bytes':target.stat().st_size,'groups':len(part_rows)})
(REPORT/'archives.json').write_text(json.dumps(archives,indent=2))
print(json.dumps({'groups':len(rows),'states':len({r['name']for r in rows}),'archives':archives},indent=2))