From e8983243476c5b980dafe5d93f40aa8f1f7a087e Mon Sep 17 00:00:00 2001 From: dhaksdhakshin Date: Thu, 28 May 2026 11:35:54 +0530 Subject: [PATCH 1/3] fix(prompt): require entry animations to preserve their final visible state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #89 The agent was emitting pages where every animated element fell back to inline opacity:0 after the entry animation finished, leaving the page blank except for the (non-animated) title. Root cause was at the prompt layer, not at any individual skill: the shared design directives mentioned 'fade-in' as an acceptable motion but did not constrain how it had to terminate. The model paired inline opacity:0 with a non-persistent 'animation: fadeIn 0.6s ease-out' (no forwards), so once the keyframe sequence ended the inline rule won and hid the body content. Adds a dedicated '入场动画安全规则' section to SHARED_DESIGN_DIRECTIVES that: - accepts three concrete strategies (animation-fill-mode: forwards/both, IntersectionObserver writing element.style.opacity = '1', or CSS transition where the final state is the post-toggle declaration); - forbids the broken pairing the model produced; - requires prefers-reduced-motion to restore visibility (not just shorten duration); - requires a JS-failure fallback so blocked or slow scripts cannot leave the page invisible. Lives in shared.ts so a single edit covers all 75 skills via assemblePrompt — no per-skill change needed. Adds shared.test.ts pinning the directive shape so a future copy-edit cannot silently drop the guardrail. --- .../lib/templates/__tests__/shared.test.ts | 84 +++++++++++++++++++ next/src/lib/templates/shared.ts | 9 ++ 2 files changed, 93 insertions(+) create mode 100644 next/src/lib/templates/__tests__/shared.test.ts 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..cded587 --- /dev/null +++ b/next/src/lib/templates/__tests__/shared.test.ts @@ -0,0 +1,84 @@ +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(/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..25e747d 100644 --- a/next/src/lib/templates/shared.ts +++ b/next/src/lib/templates/shared.ts @@ -30,6 +30,15 @@ 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**: 如果用 IntersectionObserver / scroll 监听控制可见性, 必须在 \`