diff --git a/next/src/lib/templates/__tests__/shared.test.ts b/next/src/lib/templates/__tests__/shared.test.ts new file mode 100644 index 0000000..b648291 --- /dev/null +++ b/next/src/lib/templates/__tests__/shared.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { SHARED_DESIGN_DIRECTIVES, assemblePrompt } from "../shared"; + +/** + * Regression tests for the prompt-level guardrail that prevents the + * "content disappears after entry animation finishes" bug reported in + * issue #89. + * + * The bug: the agent emitted an entry animation that paired inline + * `opacity: 0` with a non-persistent `animation: fadeIn 0.6s ease-out` + * (no `forwards`). After the keyframe sequence finished, every animated + * element fell back to its inline `opacity: 0` and the page rendered + * blank except for the title (which was not animated). + * + * The fix lives in `SHARED_DESIGN_DIRECTIVES` because every skill prompt + * is assembled through `assemblePrompt`, so a single directive covers all + * 75 skills without per-skill changes. These tests pin the directive + * shape so a future copy-edit cannot silently drop the guardrail. + */ +describe("SHARED_DESIGN_DIRECTIVES — entry-animation safety", () => { + it("includes a dedicated section about entry animations not hiding content", () => { + // The section header should be findable so reviewers know exactly which + // block of the prompt enforces the rule. + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/入场动画安全规则/); + }); + + it("requires animations that hide elements to use animation-fill-mode forwards (or both)", () => { + // The three accepted strategies all need to be discoverable in the + // prompt body, otherwise the model has no concrete target to copy. + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/animation-fill-mode:\s*forwards/); + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/both/); + }); + + it("explicitly forbids the broken pattern from issue #89 (inline opacity:0 + non-persistent fadeIn)", () => { + // The "禁止" line must reference both halves of the broken combination so + // the agent can pattern-match on it instead of inferring intent. + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/绝对禁止/); + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/opacity:\s*0/); + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/fadeIn/); + }); + + it("requires prefers-reduced-motion to fully disable the hiding state, not just shorten it", () => { + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/prefers-reduced-motion/); + // The reduced-motion branch must restore visibility, not just animation + // duration. Pin the literal so we don't regress to "animation: none" + // alone (which would still leave inline opacity:0 visible). + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/opacity:\s*1/); + }); + + it("requires a JS-failure fallback for IntersectionObserver-driven reveals", () => { + // If the agent gates visibility on JS, broken JS must still leave the + // page readable. Reference at minimum that a fallback is mandatory. + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/IntersectionObserver/); + expect(SHARED_DESIGN_DIRECTIVES).toMatch(/fallback/i); + }); +}); + +describe("assemblePrompt — directives propagate to every skill", () => { + it("prepends the shared directives ahead of the per-skill body", () => { + const out = assemblePrompt({ + body: "【模板: Demo】\nbody-marker", + content: "user content", + format: "markdown", + }); + // Directives ship before the skill body so the safety rules cannot be + // overridden by a skill author who forgets them. + const directivesIdx = out.indexOf("入场动画安全规则"); + const bodyIdx = out.indexOf("body-marker"); + expect(directivesIdx).toBeGreaterThan(-1); + expect(bodyIdx).toBeGreaterThan(directivesIdx); + }); + + it("carries the forwards / prefers-reduced-motion / fallback requirements into the assembled prompt", () => { + const out = assemblePrompt({ + body: "noop", + content: "noop", + format: "markdown", + }); + expect(out).toMatch(/forwards/); + expect(out).toMatch(/prefers-reduced-motion/); + expect(out).toMatch(/IntersectionObserver/); + }); +}); diff --git a/next/src/lib/templates/shared.ts b/next/src/lib/templates/shared.ts index 165a566..3d45748 100644 --- a/next/src/lib/templates/shared.ts +++ b/next/src/lib/templates/shared.ts @@ -30,6 +30,24 @@ export const SHARED_DESIGN_DIRECTIVES = ` - 动效: 仅在必要处使用 \`transition-all\` 或入场 fade-in; 不要喧宾夺主。 - 无障碍: 颜色对比度 ≥ 4.5; 重要交互有 focus 态。 +【入场动画安全规则 — 防止内容动效结束后被永久隐藏】 +- **任何把元素初始设为不可见 (\`opacity: 0\` / \`visibility: hidden\` / \`transform: translateY(...)\` 等隐藏态) 的入场动画, 必须在动画结束后回到可见的最终状态**。具体三选一: + 1. 给 \`@keyframes\` 动画加 \`animation-fill-mode: forwards\` (或简写 \`animation: name 0.6s ease-out forwards;\`), 这样最后一帧会被保留, 不会回退到 \`opacity: 0\`。等价地用 \`both\` 也可以。 + 2. 用 IntersectionObserver / scroll 触发时, 在 callback 里**直接设置 \`element.style.opacity = '1'\` 等可见态**, 而不是只加一个 \`@keyframes\` 短暂播完就消失的 class。 + 3. 用 CSS \`transition\` 替代 \`@keyframes\`: 初始 \`opacity: 0; transform: translateY(8px); transition: opacity 0.6s, transform 0.6s;\`, 加 class 后改成 \`opacity: 1; transform: translateY(0);\` — transition 的最终态自动保留。 +- **绝对禁止**: 把元素 inline / 在 CSS 里写死 \`opacity: 0\`, 然后只用一个不带 \`forwards\` 的 \`animation: fadeIn 0.6s ease-out;\` 当揭示动画。这会导致动画播完后元素回到 \`opacity: 0\`, 内容**消失看不见** (典型 bug: 标题外的所有正文动画结束后被隐藏)。 +- **必须支持** \`@media (prefers-reduced-motion: reduce)\`: 在该条件下取消所有入场隐藏 (\`opacity: 1; transform: none; animation: none;\`), 让内容**直接可见, 不依赖任何动画完成**。 +- **必须支持 JS 失败 fallback** (使用 progressive-enhancement 模式): 如果用 IntersectionObserver / scroll 监听控制可见性, 默认状态必须是**可见**, 然后由 JS 加上一个 \`.js-ready\` 类才打开"先隐藏 → 入场" 的路径。具体: +\`\`\`css +/* 默认 (JS 关闭 / 被扩展拦截 / 加载失败 → 直接可见) */ +.reveal { opacity: 1; } + +/* JS 跑起来后才启用入场动画 */ +.js-ready .reveal { opacity: 0; transition: opacity 0.6s ease-out; } +.js-ready .reveal.in-view { opacity: 1; } +\`\`\` +配合一行 JS: \`document.addEventListener('DOMContentLoaded', () => document.body.classList.add('js-ready'));\`。这样 JS 任何环节失败 (扩展拦截 / CSP / 解析错误), 内容都直接可见, **不会被一段没跑起来的 IntersectionObserver 永久隐藏**。\`