Redesign an existing website to make it better. Built on top of impeccable.
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Motion-specific validation discipline that runs after the impeccable
craft loop produces a proposed file with scroll-driven choreography,
rAF loops, or CSS animation-timeline: patterns. Sits alongside the
existing critique + audit + adapt gates as a separate pass because
motion failure modes are not detected by static-DOM gates.
Invoke this when the proposed file's _provenance.motion declares
≥ 1 named choreography (per the page-shape brief's motion stack) or
when ANY of the following CSS / JS patterns appear in the rendered
HTML:
animation-timeline: view(*) or animation-timeline: scroll(*)@scroll-timeline rulesrequestAnimationFrame loops reading getBoundingClientRect() to
drive transformsextensions.motion[data-anim], [data-tile-anim], [data-countup],
[data-flip], [data-fill], or [data-split] attribute
anywhere in the document (the cinematic vocabulary —
reference/motion-attributes.md)lenis.min.js (cinematic feature signal —
reference/motion-stack.md)If none of these patterns appear in the rendered file, skip this discipline.
When the rendered file declares
_provenance.motion.register ∈ { "arrival", "kinetic-display", "live-systems", "editorial", "kinetic-grid" }, the motion-validation
discipline runs in cinematic mode — every static-prototype gate
still applies, plus the cinematic-specific gates in § Pass 6 below.
The active register is read from
DESIGN.json.extensions.motion.register (written by direct) or
from the --cinematic=<register> CLI override recorded in
_provenance.motion.registerSource. The register name selects the
expected motion vocabulary against which Pass 6 audits.
The existing gates (critique, audit, mobile-adapt audit, anti-toolbox audit, content-sourcing scan) all read the rendered DOM at scrollY=0 with no time elapsed. They cannot catch:
opacity: 0 until a scroll event fires. An
anim-enter pattern with opacity: 0 initial state is correct
markup but invisible-on-first-paint. Whether it eventually reveals
depends on the scroll timeline aligning with the user's actual
scrolling — which is a runtime property, not a DOM property.translateY(N) outside its parent's clip
region. A garage-door reveal that translates the inner content
by 50vh down works at small viewports (where N is modest) and
breaks at large viewports (where N exceeds the parent's
overflow: clip bound). The bounding-rect math still puts the
element "in viewport" but the rendered output is empty.animation-range: cover -10% cover 100% reveals content only after the section has fully scrolled
past — by which point the user has stopped reading. The DOM is
correct; the timeline is wrong.getBoundingClientRect() once and never re-baselines after layout
shifts produces drift on resize / font-load / lazy-image-load.
Caught only by re-probing after layout settles.prefers-reduced-motion regression. A choreography that
relies on animation-timeline: view(*) for both motion AND
visibility (i.e., the keyframe's "to" state is the visible state)
breaks under reduced-motion when the animation is disabled and
the element is left at the "from" (hidden) state.Run in order. Stop on the first failure that cannot be auto-recovered and surface to the user; do not silently lower a gate.
Scroll the rendered prototype through 8 standard probe positions:
At each position, after a 400-500ms settle delay (let rAF settle
<body>. For each element whose bounding
rect intersects the viewport AND has width >= 80 && height >= 24
AND has visible text or is img/svg/video/picture:
opacity from getComputedStyle.opacity < 0.05 AND element is not display: none AND not
aria-hidden="true" (without text), record as HIDDEN.[data-section] and [data-component] element. Record
their bounding rects (document coordinates). Compare adjacent
sections: if sectionA.bottom > sectionB.top + 1, record as
SECTION-OVERLAP.Reference implementation: tools/playwright/diagnose-motion.mjs in
the redesign-adobecom project — reusable across variants.
Every HIDDEN and SECTION-OVERLAP finding is either:
The classification requires reading the page-shape brief's motion
declarations. If _provenance.motion.choreographies[] does not name
a choreography that explains the finding, the finding is a bug.
Run these four checks against the proposed file:
For every [data-section] with overflow: clip (or hidden) and
an inner element with a transform: translateY(*) animation:
max-translate > spare-room at any frame of the animation,
the inner element will be clipped during that range.<max-translate>px but parent
has only <spare-room>px of slack; content will be clipped between
<start-progress> and <end-progress> of the animation range."Worked example from this project: studio-banner garage-door at
≥1280px had --gd-reveal-from: 50vh (= 450px on 900vh viewport) and
parent banner had overflow: clip with no padding-bottom slack;
content was clipped through cover 0% to cover 50%.
For every element with animation-timeline: view(block) and
animation-range: <start> <end>:
<start> and <end>.[<start>, <end>] AND the
animation's "from" state is the hidden state, the element is
invisible at reading position. This is a bug.Specifically refuse animation-range: cover X% cover 100% for any
reveal-the-content choreography — the animation only completes when
the section has fully scrolled past, which is after the user has
stopped reading.
For every .anim-enter (or equivalent universal entry-trigger
class) element:
sY + 0.85 * vh > triggerTop).> document.maxScrollY
— the element will never reveal because the user can't scroll
that far.Re-render the prototype with Playwright reducedMotion: 'reduce'
context option. At scrollY=0 (no scroll), walk every element with
.anim-enter or scroll-driven animation-timeline: and verify:
matrix(1, 0, 0, 1, 0, <non-zero>)).If any element fails these checks under reduced motion, the
@media (prefers-reduced-motion: reduce) block is incomplete —
some choreographies were registered but no reduced-motion
override was provided. Add explicit overrides:
@media (prefers-reduced-motion: reduce) {
.ds-banner, .ds-banner__bg, .ds-banner__content {
animation: none !important;
animation-timeline: auto !important;
transform: none !important;
}
.anim-enter { opacity: 1 !important; transform: none !important; }
}Re-render the prototype with Playwright javaScriptEnabled: false.
At scrollY=0:
opacity: 0, transform: translateY(*), clip-path: inset(*))
and verify the computed style is the visible state.<noscript> block in <head> overriding the
initial state. Example:<noscript>
<style>
.anim-enter { opacity: 1 !important; transform: none !important; }
.ds-news-item { opacity: 1 !important; transform: none !important; }
[data-parallax-target] { transform: none !important; }
</style>
</noscript>If any motion-hidden element is still hidden under no-JS, the prototype fails the no-JS contract. The proposed file must work without JavaScript (screen readers, AMP, archive crawlers, search indexers all see the no-JS state).
For prototypes with scroll-driven choreographies that use viewport-
dependent magnitudes (vh, vw-based translates, percentage-based
animation-ranges), repeat passes 1–3 at three viewport widths:
Plus the standard mobile / tablet checks (390 × 844, 768 × 1024) that the mobile-adapt audit already covers.
Choreographies tuned for one viewport often break at others because
the magnitude scales linearly with the viewport but the parent's
clip boundary scales with the layout. The studio-banner garage-door
example from redesign-adobecom: --gd-grow-from: -110vh worked on
narrow screens (where -110 × 800 = -880px was close to the
content-render window) but broke on wide screens (where -110 × 1080 = -1188px left the banner above viewport throughout the readable
scroll range).
Reference implementation: tools/playwright/banner-all-widths.mjs
in the redesign-adobecom project. The script (a) reads each
choreography's section in-flow position, (b) computes the
visually-centered scroll position per viewport, (c) verifies inner
content is visible at that position.
Run only when the rendered file declares a register per § Cinematic-mode
entry conditions. Six sub-gates. All hard — block prototyped until
the agent either fixes the issue or the user explicitly acknowledges.
lenis.min.js from a relative or root-
relative path that resolves to a file in the bundle (the
cinematic feature ships Lenis at
skills/prototype/assets/motion/lenis.min.js).window.__lenis is defined and has a scroll accessor.A Lenis bootstrap failure (404, MIME mismatch, missing API) breaks
every scroll-driven choreography. Refuse prototyped if this gate
fails — the page will appear broken to the brand owner.
Re-render with Playwright reducedMotion: 'reduce'. After 800ms
settle:
[data-anim] and [data-tile-anim] has computed opacity: 1 and identity transform.[data-countup] has textContent equal to its
data-countup integer.[data-flip] has textContent equal to its data-flip
integer.[data-fill] has computed width matching its
data-fill percentage.[data-split] span has computed opacity: 1, identity
transform, filter: none..marquee__track has paused animation
(animation-play-state: paused or animation: none)..live-sweep has no active .sweep class and no
recurring interval (the runtime's setInterval must be gated
by the reduced-motion check).Reference: motion-attributes.md § Reduced-motion contract and
motion-runtime.md § Reduced-motion: force final states.
<a href="#section"> scrolls to the section).Lenis is configured with lerp: 0.1 and smoothWheel: true. If
the validation harness detects scroll trapping (a click on an
<a href> produces no scroll-position change after 1500ms), the
gate fails. The runtime should never intercept programmatic scroll
APIs.
Capture full-page screenshots at three scroll positions:
desktop-top.png — scrollY=0, after 1500ms entrance settle.desktop-mid.png — scrollY=900, after 800ms transient settle.desktop-deep.png — scrollY=2400, after 800ms transient settle.Verify at each position:
opacity < 0.05 that should be visible at this
scroll depth (mid and deep should show their respective
bands fully revealed; reveals firing at the wrong depth surface
as HIDDEN findings).deep.Save the three PNGs to stardust/validation/<slug>/cine-<position>.png.
Plus a mobile capture at 390 × 844 (mobile.png) confirming the
register's mobile fallback (parallax off, marquee speeds halved,
configurator un-stickied).
Read _provenance.motion.register and walk the rendered file:
motion-registers.md § The five registers § Data-
attributes consumed.editorial forbids [data-flip], [data-fill], .marquee__track,
.live-sweep, count-ups, parallax > 12vh.live-systems forbids [data-split], hero parallax,
long-fade entrances.kinetic-display forbids [data-fill], weather-style ambient
micro-anims.arrival forbids [data-flip], [data-fill], .live-sweep,
tickers.kinetic-grid forbids page-level parallax, letter-by-letter
reveals, tickers.A register-mismatch finding fails the gate. The fix is one of:
(a) remove the off-register choreography, (b) change the register
(rare — register is a brand-faithful choice from direct),
(c) move the off-register element to a data-motion-register
section opt-out (allowed at most once per page; recorded in the
page-shape brief).
Refuse the page if:
[data-anim] + [data-tile-anim] elements declared
(animation overload)..live-sweep interval registered (would compound).This is the cinematic equivalent of the Variant-C overshoot
failure mode documented in
skills/direct/SKILL.md § The C-cliff. "More motion = more
cinematic" is the same kind of escalation trap the C-cliff
warns about.
The detector counts attributes and class instances against the thresholds above and refuses with the offending count. The remediation is to thin the motion — most cinematic prototypes need fewer animations than they appear to.
Motion validation produces:
_provenance.motionValidation block on the proposed file's
_provenance (added after the existing critique/audit blocks):{
"motionValidation": {
"passedAt": "<ISO-8601>",
"probesRun": 8,
"viewportsTested": [{ "width": 1440, "height": 900 }, ...],
"findings": {
"hiddenInViewport": [{ "section": "<key>", "scrollY": <N>, "classification": "by-design" | "bug", "explainedBy"?: "<choreography-name>" }, ...],
"sectionOverlaps": [{ "a": "<key>", "b": "<key>", "by": <px>, "classification": "by-design" | "bug" }, ...],
"clippedReveals": [...],
"rangeMismatches": [...]
},
"fixesApplied": [
"<short description of each fix>"
],
"cinematic": {
"register": "arrival | kinetic-display | live-systems | editorial | kinetic-grid",
"registerSource": "direct | user-override",
"lenisBoot": "ok | failed",
"reducedMotionFallback": "complete | incomplete",
"scrollJackCheck": "pass | fail",
"registerMatchAudit": "pass | fail",
"cliffDetector": {
"animElementCount": <N>,
"maxSectionStaggerMs": <N>,
"maxParallaxVh": <N>,
"activeInfiniteLoops": <N>,
"verdict": "pass | refused"
},
"screenshots": {
"desktopTop": "stardust/validation/<slug>/cine-top.png",
"desktopMid": "stardust/validation/<slug>/cine-mid.png",
"desktopDeep": "stardust/validation/<slug>/cine-deep.png",
"mobile": "stardust/validation/<slug>/cine-mobile.png"
}
}
}
}stardust/validation/<slug>/motion-<viewport>.png.If a finding is classified as a bug and the agent cannot reach a fix within 3 iterations of the recursive loop, surface to the user with:
Do not mark the page prototyped while motion-bug findings are
unresolved. Per the no-opt-outs principle: the quality gates are
the product.
.tessl-plugin
skills
audit
reference
deploy
diff
direct
extract
migrate
prepare-migration
prototype
replica
reskin
rollout
stardust
uplift
reference