* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-motion): new /design-motion skill: showcase MP4 and GIF loops from a design, adapted from Elaya design-to-motion (GSTA-92 phase 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): run design-motion-showcase in the marathon tier; the periodic census is at its 38-slice cap (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): review and QA fixes before ship - encode: refuse unknown options and --formats tokens, validate --pad (#RRGGBB[AA]) and --crop (compare too) so nothing but a colour or crop reaches ffmpeg's filter graph - encode: export refuses a capture with missing window frames; check prints MOTION_SETTINGS for any threshold/phase override; contact sheet drops -fps_mode - capture: portrait-aware scale, --cycle must be whole 60 fps frames, stills with CSS animations disabled, pauseAt tolerates slow loads, clears stale manifest/stills - skill: <motion-dir> placeholder instead of a cross-block shell variable, GSTACK_FFMPEG-aware probe, stop points before the first capture, doctrine fixes - tests: refusal rows, portrait scale, cycle guard Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-motion): capture-check-export pipeline on real Chromium and ffmpeg Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): second review cycle - capture: refuse an --out that holds a still or frames but no capture.json instead of deleting them; --cycle must be a multiple of 0.05 s so the seam frame lands exactly one cycle later for every PRE (nearest value round-trips) - encode: a capture without a mask ends 'MOTION_RESULT: clean (overflow not checked)' - skill: Step 5 names window.__mark in the capture contract - tests: foreign/previous --out handling on real Chromium, cycle guard and PRE sweep Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): third review cycle and adversarial findings - capture: an ownership marker (.gstack-motion-capture) replaces the capture.json test, so the rerun after MOTION_CONTRACT_MISSING or a crash works and a foreign --out is still refused; offscreen lazy images are made eager and font/image loading is bounded at 30 s instead of hanging - encode: refuse a capture.json whose fields are not what capture writes (they reach ffmpeg args and paths); a cards overflow with no story frames is a planning error, and a zero-frame job never passes - tests: retry after a refused page, no-mask clean line, lazy image still, manifest validator, empty story span Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): mask coverage, manifest relations, Windows stop, Figma token off argv - check fails overflow when the grown mask covers >90% of the frame (an app frame whose clip surfaces fill the stage passed vacuously); --mask-grow is 0-32; --mask refuses braces and semicolons - manifestError checks fps/windowFrames >= 1 and that the window fits the frames - skill: native Windows stops before the first capture call; the Figma token reaches curl on stdin (-H @-) instead of argv - tests: split the --out ownership scenario out of the contract test, full-frame mask, --still --scale 2 size and STILL_OK, manifest relations, coverage parse Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): bounded frames, atomic exports, full-bleed masks - capture: each frame is bounded at 10 s (a __tick that never returns fails with MOTION_CAPTURE_FAILED instead of hanging); a --mask that matches no element stops with MOTION_MASK_EMPTY - check: a grown mask covering >90% of the frame (full-bleed app frame) reports overflow as skipped and ends 'clean (overflow not checked)' - export: writes .partial- files and renames on success, so a failed re-export keeps the last good file; a GIF fps that does not divide the cycle is refused; a missing GSTACK_FFMPEG override is named - fidelity: URL source is untrusted data; full-bleed note - tests: spinning __tick, empty mask, failed re-export, drifting gif fps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): app-only coverage skip, gif fps cap, early empty mask - check: the >90% coverage skip applies only to an app frame's own surfaces (ungrown mask); in cards, or when --mask-grow pushes coverage over 90%, overflow fails with what to change (a cards capture with real overflow passed as 'clean (overflow not checked)' after a large grow) - export: --gif-fps/--small-fps capped at 50 (GIF delays are 1/100 s) - compare resolves its paths (a colon in a relative path read as a protocol) - capture: MOTION_MASK_EMPTY is checked before the first frame - tests: cards vs app full-frame mask, fps cap, no frames on an empty mask Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-motion): seek story transitions from their own frame - capture: an animation first seen in a frame where window.__mark did not move (a story's class toggle) is seeked from that frame instead of the last mark, so it eases in the export instead of popping ~100 ms ahead - encode: an empty numeric option is refused instead of read as 0 - figma: only fill a file key and node id that look like Figma's - tests: class-toggled transition mid-fade at its midpoint, blank --min-db Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: PROJECT_STRUCTURE names Elaya's MIT material and licenses/MIT-elaya.txt Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
8.7 KiB
Motion: empty state, load, stories, cycle
Every timing, distance and curve comes from the defaults table at the end. Never invent ad hoc values; change the table instead.
Start empty
The first frame of every showcase load and every export is the empty state (on a website the doctrine wins: the markup is the finished state until the intro fires): the background plus the static joined surfaces for an app frame, the background alone for separate cards. Empty becomes loaded; never open on the finished frame.
Loading choreography
Elements rise from just below and resolve from blur to sharp, staggered in reading order.
| Kind | For | From |
|---|---|---|
card |
Separate cards, with their shadow | translateY(24px) scale(0.985) blur(6px), opacity 0 |
up |
Text, headers, values, rows | translateY(10px) blur(4px), opacity 0 |
down |
Top bars and toolbars | translateY(-8px) blur(4px) |
left |
Sidebar items, list rows, labels | translateX(-10px) blur(4px) |
pop |
Icons, badges, avatars, buttons, dots | scale(0.82-0.88) from the element's own centre, blur(4px) |
photo |
Images | skeleton shimmer, then blur(14px) saturate(0.6) to sharp |
wipe |
Bars, fills, highlights | left-to-right clip-path in stage pixels: right inset from stageW − x (zero width, no sliver) to stageW − (x + w); top and bottom stay at y and stageH − (y + h) |
line / grow |
Rules, ticks, timelines | scaleX(0) / scaleY(0) from the start edge |
donut |
Rings, gauges | conic mask sweeping clockwise from 12 o'clock |
tray |
Sheets, trays, folders | up 40 px from inside the container's bottom edge, clipped by it |
fade |
Layers under another layer | opacity only |
- Containers load in reading order 80-90 ms apart (cards start about 0.15 s before their own contents). Inside one: header, labels, values, then visuals; items in a group stagger 30-80 ms. The whole intro takes 2 to 2.5 s.
- Covered layers arrive last and leave first, so they never ghost through their cover.
- Skeletons match the real shape and sit 1 px inside it, so no halo shows.
- Data bars, meters and highlights fill like progress: a
wipewith--ioover 0.7-1.1 s. Never add a loading line or any shape the design lacks.
Stories: one per visual
The intro only opens the show. The main act is each visual acting out what it does, on a loop. Write each story as one sentence before building it ("a new lead lands on top and the older ones step back").
| Visual | Story |
|---|---|
| Stack (leads, notifications, files) | A new item lands on top with a blur, the rest step back a slot, the last leaves |
| Line or area chart | A scrubber glides across, a dot rides the curve, the tooltip value counts |
| Bar chart, grid of cells | Bars breathe like a live feed and settle on the design heights |
| Donut, gauge | Segments ease outward one at a time and back, or the ring sweeps again |
| Table, list | A soft highlight inspects rows one by one; a preview loads for the active row |
| Calendar | A highlight searches for an open slot, the event glides into it and back |
| Flow, connectors | Pulses travel the connectors in flow direction and light each node they pass |
| Prompt, form | The prompt deletes and retypes glyph by glyph; the result resolves from blur |
| Counters, KPIs | Numbers count up with the chart that drives them |
| Long content | It scrolls slowly inside its clip and returns |
| Logo row (showcase only) | A marquee moving exactly one full set per loop |
| Media player | The playhead and progress advance |
- Start and end on the exact design. At rest the frame equals
?still. Clear inline styles a story set (style.transform = '') rather than leavingtranslate(0), which antialiases differently and fails the rest check. - Use the design's own elements. Helpers a story needs match the design's style and are absent from
?still. - Text changes only when it is the point: counters (live text in the design's font, landing on the design's exact text) and typing (one glyph every 45-70 ms, caret at the edge; deleting runs a little faster).
- Obvious, not busy. A card with a story shows a clear beat every one to two seconds; if a viewer cannot tell it is animated within two seconds, the story is too timid. A calm screen may rest after load if the user prefers; ask once.
- Text-only cards get no story. They rise with their card and rest.
- Readable in the 800×500 GIF. A moving element under about 4 px there gets enlarged within the design's style or a bigger story.
- Offset the cards. Stories never act in unison; attention travels in reading order.
- One story per card, 3 to 8 s. A short story may run twice in the story phase; every run ends at rest before the dissolve.
- Stories are eased keyframe tracks on the JS clock:
// [[time, value], ...] eased per segment; values may be numbers or arrays
const easeInOut = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); // --io, never past 1
function track(keys, t, ease = easeInOut) {
if (t <= keys[0][0]) return keys[0][1];
for (let i = 1; i < keys.length; i++) {
const [t1, v1] = keys[i], [t0, v0] = keys[i - 1];
if (t <= t1) {
const k = ease(Math.min(1, (t - t0) / (t1 - t0 || 1)));
return Array.isArray(v0) ? v0.map((v, j) => v + (v1[j] - v) * k) : v0 + (v1 - v0) * k;
}
}
return keys[keys.length - 1][1];
}
Accents sit on top of a story, never instead of one, and only on elements the design already has: a pulse ring on a live indicator or play button (1.8-2.2 s, ring 1 → 2.6, opacity 0.55 → 0), a sheen on a photo or button (a band 38% wide, 1.5 s pass every 3.4 s), a skeleton glimmer on placeholder lines (2.4 s). Accents and helpers fade in over 0.6 s once the intro lands, and are gone before the dissolve. None of them ships running on a website.
The loop cycle (showcase)
| Phase | Default |
|---|---|
| Build in | 0 → 2.4 s |
| Stories | 2.4 → 11.0 s |
| Dissolve | 11.0 → 11.6 s (opacity, 0.5 s) |
| Empty beat | 11.6 → 12.0 s |
The story phase holds at least one full story of every card; lengthen the cycle rather than cut one short. The dissolve returns to the empty state: an app frame dissolves only its contents, separate cards dissolve too, the background never. Reset everything: one frame with transitions disabled resets the CSS, and every JS-driven value (masks, tracks, helpers, typed text, pulses) goes back to its empty value too, or it ghosts through the empty beat. The live page keeps its stories running forever after the intro; only ?loop and the exports cycle.
Easing and camera
| Token | Curve | Use |
|---|---|---|
--e |
cubic-bezier(0.16, 1, 0.3, 1) |
Entrance transforms |
--e2 |
cubic-bezier(0.33, 1, 0.68, 1) |
Opacity and blur |
--io |
cubic-bezier(0.65, 0, 0.35, 1) |
Wipes, fills, sweeps, camera moves |
linear |
linear |
Constant motion only (shimmer, playback) |
No zoom and no pan unless asked. When asked, only the app frame scales toward the items, with --io, and it is back at rest before the dissolve.
Website sections
On a website the intro starts when about 30% of the section is visible (IntersectionObserver, once; never a scroll listener), the story pauses off screen and resumes on return, stagger stays inside the section, and prefers-reduced-motion: reduce shows the final state with no story. Map the page section by section: bento and feature cards with a visual get the card intro and a story; a product or dashboard mockup gets the loading intro, and a story only if the user says yes when asked; text-only sections (headlines, testimonials, FAQ, pricing, CTA, footer) get nothing from this skill. Under the one-moment rule, propose one section, not all of them.
Defaults
| Value | Default |
|---|---|
| Rise | 10 px (up), 8 px (down), 10 px (left), 24 px + scale(0.985) (card), 40 px (tray) |
| Entrance blur | 4 px; cards 6 px; photos 14 px |
| Pop start scale | 0.82-0.88 |
| Durations (showcase) | transforms 1.05 s --e (cards 0.9 s); opacity and blur 0.7-0.8 s --e2 (cards 0.6 s); wipes 0.7 s, slow bars 1.1 s --io |
| Durations (website) | 50-700 ms |
| Stagger | containers 80-90 ms, items 30-80 ms |
| Typing | one glyph per 45-70 ms, caret blink 1 s (showcase only) |
| Story | starts 2.4 s, 3-8 s per card, offset between cards |
| Cycle | 12 s (dissolve 11.0, reset 11.6), longer when a story needs it |
| Exports | MP4 60 fps CRF 15 up to 1920×1200; loop GIF 960 px 20 fps; small GIF 800×500 20 fps |
Adapted from Elaya's ai-design-skills design-to-motion (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md.