* 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>
4.4 KiB
Figma source (optional, needs a token)
Use this path only when FIGMA_TOKEN (or FIGMA_ACCESS_TOKEN) is set and the source is a Figma link with a node id. Fill <figma-file-key> and <figma-node-id> only with values matching ^[A-Za-z0-9]+$ and ^[0-9]+[:-][0-9]+$; anything else is not a Figma link. The token is a secret: pass it only as a header from the environment, never print it, echo it, write it to a file, or put it in chat or a commit.
_FT="${FIGMA_TOKEN:-$FIGMA_ACCESS_TOKEN}"
printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/files/<figma-file-key>/nodes?ids=<figma-node-id>&geometry=paths" -o "<motion-dir>/figma-nodes.json"
printf 'X-Figma-Token: %s\n' "$_FT" | curl -fsS -H @- "https://api.figma.com/v1/images/<figma-file-key>?ids=<figma-node-id>&format=png&scale=2" -o "<motion-dir>/figma-ref.json"
The nodes response gives the tree, bounds, fills, effects and radii; the images response gives a URL for the reference frame, saved as reference.png. Every layer position is its absolute bounds minus the frame's origin. Figma's image CDN sometimes resets connections: retry each download and check each PNG's size before using it.
Layer export
Export every element that will move as its own PNG at 3x through /v1/images, 20 to 25 ids per call. Placement:
| Case | Do |
|---|---|
PNG size equals absoluteRenderBounds (text always does) |
Place at the render bounds |
| PNG larger than the bounds, even overhang (a clipped effect) | x = bbox.x − (pngW − bbox.w) / 2; y from renderBounds.y unless the top was clipped, then from the effect radius and offset |
| Uneven overhang (an arrowhead, one square cap) | Bounds of strokeGeometry or fillGeometry from geometry=paths |
| Rotated vectors with large blur, arcs, donuts, partial ellipses | use_absolute_bounds=true, placed at the bounding box; blobs can be 1x |
| Zero-width or zero-height stroked vectors | Without use_absolute_bounds (it returns null for them) |
| Mask group with blurred content | Anchor on the edge that was not clipped |
| Pattern far larger than its surface | use_absolute_bounds=true, then crop to the surface |
Instance children (I12:34;56:78) returning null |
Export the parent instance |
| A photo baked into a larger layer | Export the photo node too; cover the baked copy with a skeleton and put the photo above for the photo resolve |
- Text stays an exported layer in a showcase; retyping it changes kerning and line metrics.
- Keep Figma's z order, static layers included. Frame strokes usually draw above children: render a card's stroke as an overlay above its clipped content.
- A container whose children move separately is rebuilt in CSS from the node data (fills, per-corner radii, inside strokes including
individualStrokeWeights, effects), with each child exported alone; exporting the container bakes its children in. - A Figma drop shadow with blur R is
box-shadowblur R on one element, orfilter: drop-shadow()blur about R/2 on a group. A shadow on joined surfaces is the shadow of their union: one static container withdrop-shadow, never per-element shadows that darken the overlaps. - The background is the original image fill (
GET /v1/files/:key/images), with the fill's scale mode andimageTransformapplied in CSS. - Skip hidden layers and layers buried under opaque shapes.
Drawing a line on. Figma outlines strokes into filled shapes, so write the centreline yourself from strokeGeometry (halfway across the stroke; a rounded corner's centreline radius is the outline's inner radius plus half the stroke) and use it as a mask over the exported PNG:
<svg class="ly" viewBox="0 0 1440 990" style="left:0;top:0;width:1440px;height:990px">
<mask id="draw1"><path d="M193 832 V700 A45 45 0 0 1 238 655 H380" fill="none" stroke="#fff" stroke-width="8"
pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></mask>
<image href="data:image/png;base64,..." x="192" y="653" width="190" height="181" mask="url(#draw1)"/>
</svg>
Animate stroke-dashoffset from 1 to 0 on the JS clock; pulses along the line read positions with getPointAtLength on the same path.
Adapted from Elaya's ai-design-skills design-to-motion (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md.