designStyleExtractor now also extracts chip/pill/badge/tag, stat/metric cells, and
tab components — by class-substring selector plus a shape fallback (small + fully
rounded + short text) so hashed/utility class names (Tailwind, CSS-modules) are
still caught. It also emits a "transparent" sentinel for fully-transparent
(rgba(...,0)) grounds instead of collapsing them to #000000, so a transparent
chip/tab/stat on a light-ground site no longer reads as solid black.
fontMetadataExtractor now flags icon fonts (isIcon) by glyph coverage: a font is an
icon font only when it BOTH lacks a real Latin alphabet (<26 of A-Za-z) AND is
mostly (>50%) Private-Use-Area glyphs. The Latin gate matters — some text fonts pack
thousands of PUA glyphs yet are plainly text (Apple SF Pro is ~81% PUA but ships a
full alphabet; Descript's Booton ~50%); flagging by PUA ratio alone would strip a
brand's real typeface. Measured icon fonts: "hushly" 63% PUA / 7 letters, Font
Awesome 95% / 0 letters. Names alone can't identify icon fonts ("hushly",
"swiper-icons"), hence the glyph-based test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(skills): make hyperframes package version resolution non-fatal and overridable
Global skill installs (e.g. ~/.claude/skills) have no hyperframes
package.json in the loader's ancestor chain, so readBundledHyperframesVersion
returns null and hyperframesPackageSpec threw. Because the spec is passed
eagerly as an argument to importPackagesOrBootstrap, it threw even when
@hyperframes/producer was already installed and no bootstrap was needed.
Resolution order is now: HYPERFRAMES_SKILL_PKG_VERSION env override first,
then the bundled/in-repo version, then a non-throwing @latest fallback that
warns to stderr. @latest satisfies the pinned-spec guard so bootstrap still
installs, and already-installed packages import fine; the eager argument is
now harmless. In-repo resolution is unchanged (same pinned version).
Applied to both package-loader.mjs copies (animation + creative) and
documented the env var in animation-map.mjs and contrast-report.mjs usage.
Adds a focused test covering override wins, in-repo pin, and unresolvable
@latest fallback.
* test(skills): cover creative package loader fallback
* fix(cli): keep doctor resilient to a corrupt browser cache
A partial or corrupt browser cache (a stub file where a version directory
is expected, a missing executable, or malformed metadata) makes
getInstalledBrowsers throw ENOTDIR. That throw propagated up through
findBrowser -> checkChrome -> runEnvironmentChecks, and since doctor.run
calls runEnvironmentChecks before any try/catch or the --json output, the
command crashed with exit 1.
doctor --json is documented to exit 0 even when checks fail, so it must
report a corrupt cache as "Chrome not found", not crash on it.
- checkChrome now catches any error from findBrowser and converts it to the
existing ok:false "Chrome not found" outcome with the browser ensure hint,
so runEnvironmentChecks never throws for a missing or corrupt browser.
- findFromCache treats a throwing getInstalledBrowsers as "no cached
browser", letting resolution fall through to system/download instead of
crashing every caller (render included), not just doctor.
A healthy browser still reports ok:true. Adds a preflight test asserting an
ok:false Chrome outcome when discovery throws, instead of propagating.
* fix(cli): warn on corrupt browser cache fallback
* fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits
A clip-path can shrink an element's painted region to nothing (a typewriter
span pre-reveal at clip-path: inset(0 100% 0 0), or circle(0px)) while its
layout box, opacity, visibility and display all still read as present. Such
an element paints zero pixels, so the layout audit flagged the visible block
beneath it as a content_overlap, and the contrast auditor measured it as a
meaningless background-on-background ratio (~1:1) and reported a WCAG failure.
Both auditors already filtered opacity:0, visibility:hidden and display:none,
but neither accounted for clip-path. Add a shared check: when a non-none
clip-path is in effect on the element or an ancestor, probe a grid of points
across the element's box with elementFromPoint; if none resolve to the element
or a descendant, it is clipped to nothing and is skipped. The probe runs only
when a clip-path is present, so a genuinely occluded (but unclipped) element is
still measured and still flagged.
Wired at the in-page collection chokepoint so it covers content_overlap,
text_occluded and the contrast auditor consistently. Genuine overlaps between
visible elements remain flagged; data-layout-allow-overlap and
data-layout-ignore are honored unchanged. The two audit scripts and the
layout-audit test are added to the fallow ignore lists: their pre-existing
IIFE-level complexity and per-rule test scaffold re-flag under the line-shift
fingerprint when the small probe helpers are inserted.
* test(cli): cover clip-path audit edge cases
* fix(cli): satisfy clip audit test types
The #1817 deploy added 15 pages but Mintlify only published changed files
incrementally; the 14 unmodified pages 404'd on production while
editing-affordances (touched in 9140ee51b) deployed fine. Trailing-newline
touch forces Mintlify to re-emit all SDK pages.
VP9-alpha detection (shouldUseVp9AlphaDecoder -> extractMediaMetadata)
spawns ffprobe with no timeout. For the new remote http(s) fallback that
ran before the bounded extractVideoFrameToBuffer, so a stalled remote host
could wedge `hyperframes snapshot` in ffprobe before the 30s extract timer
ever started. Probe only local files; for remote URLs skip it (pass false).
Local alpha behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concrete version now that the /editing subpath shipped in v0.7.22 (review
follow-up). Also forces a fresh Mintlify build to register the new SDK tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sdk): comprehensive SDK reference + guides
Adds a dedicated SDK tab to the Mintlify docs documenting the entire
@hyperframes/sdk surface, verified against source:
Reference (6 pages):
- openComposition + OpenCompositionOptions
- Composition (every typed method, query, selection, dispatch/batch/can,
events, serialize, override mode, lifecycle)
- Edit Operations (all 33 EditOp variants for dispatch/can/batch)
- Types (every exported type + constants)
- Adapters (PersistAdapter/PreviewAdapter + memory/fs/headless/iframe factories)
- Utilities & Constants (history, persist-queue, document utils, origins, errors)
Guides (7) + Overview + Quickstart:
- querying-and-editing, timing-and-animation, undo-redo-and-patches,
persistence, embedded-override-mode, canvas-integration, editing-affordances
The existing packages/sdk.mdx stays as the package card and now links the
new SDK tab. editing-affordances documents the @hyperframes/sdk/editing
subpath shipping in #1814 (flagged with a version Note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sdk): address PR review feedback
Correctness fixes from PR #1817 review (Miga + Rames):
- types.mdx: FindQuery.text is a substring match (String.includes), not exact
- persistence.mdx: import PersistAdapter/PersistVersionEntry/PersistErrorEvent
from @hyperframes/sdk (no @hyperframes/sdk/adapters/types export exists)
- open-composition.mdx: createHeadlessAdapter is a PreviewAdapter, not a persist
adapter; PersistAdapter is exported from @hyperframes/sdk (no /adapters subpath)
- types.mdx / adapters.mdx: note KeyframeSpec, ElementAtPointResult, DraftProps
are structural shapes, not barrel exports (no import to copy)
- overview.mdx: drop leaked authoring meta-comment
- timing-and-animation.mdx: getElementTimings is keyed by scopedId
- embedded-override-mode.mdx: history is already off by default in embedded mode
- editing-affordances.mdx: /editing subpath is merged; soften the version note
- querying-and-editing.mdx: bare id only resolves top-level; use find() for
sub-composition leaves
- canvas-integration.mdx + persistence.mdx: explain the comp closure forward-ref
and the fs-adapter subpath (tree-shaking) asymmetry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`hyperframes snapshot` works around Chrome-headless's inability to seek
`<video>` elements by extracting a frame via FFmpeg and injecting it as an
overlay. That path only resolved `<video src>` to a project-LOCAL file and
skipped everything else — so a composition whose embedded `<video>` points
at a remote http(s) URL (e.g. an S3-hosted clip embedded by an upstream
agent) rendered as a blank box in every snapshot, while `render` (which
plays the element in-browser) showed it fine.
Add a remote fallback: when the src doesn't resolve to a project-local file
but is an http(s) URL, pass the absolute URL straight to FFmpeg (it reads
http(s) input directly). Local-first is preserved (fast, sandboxed); the
existing 30s extract timeout bounds remote fetches.
Verified on a real composition: remote-src snapshot was blank, local-src
rendered; `ffmpeg -ss N -i <https-url> -frames:v 1` extracts in ~0.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(core): add pure resolveEditingAffordances (edit capabilities + section applicability)
* fix(core): replace prohibited as-cast and !-assertions in isIdentityTransform
* refactor(studio): consume core resolveEditingAffordances; drop duplicated capability + section logic
- affordances.ts: add matrix3d identity-transform branch (was missing, caused test regression)
- domEditingLayers: add domEditSelectionToFacts mapper; resolveDomEditCapabilities is now a thin
wrapper over core (kept for backward-compat — tests + barrel import it); isTextEditableSelection
delegates to core sections.text; drop parsePx + isIdentityTransform imports (now in core)
- PropertyPanel: import resolveEditingAffordances + domEditSelectionToFacts; compute sections once;
replace isMediaElement/isColorGradingCapableElement/timing inline check with sections.*
- propertyPanelMediaSection: delete isMediaElement (no remaining callers)
- propertyPanelColorGradingSection: delete isColorGradingCapableElement (no remaining callers)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): add browser-only resolveElementAffordances adapter over core
* fix(sdk): add position to inlineStyles, replace ! assertion with guard in test
- Add missing 'position' key to inlineStyles in affordances.ts to match computedStyles
- Replace non-null assertion (doc.defaultView!) with proper null guard in test
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(editing): resolve code-review findings on affordances feature
Max-effort review (8 verified findings) fixes:
Correctness regressions (studio behavior):
- SVG selection crash: dropped `classNames` from EditableElementFacts
entirely (it was never read by the resolver), which removes the
`.className.split()` calls that throw on SVGElement (className is an
SVGAnimatedString, not a string). Masked in tests by happy-dom.
- Timing panel hidden for GSAP-only layers: domEditSelectionToFacts now
takes animationCount from the caller; PropertyPanel feeds the live
gsapAnimations prop (selection.gsapAnimations is never populated).
Cleanups:
- Removed dead inline `position` key from SDK adapter (core reads position
only from computedStyles).
- Added sections-only `resolveEditingSections` export; PropertyPanel uses it
so panel re-renders no longer re-run the capability geometry parse.
- Declared happy-dom in packages/sdk devDependencies (was root-hoist only).
- Deduped the two capability fact-construction sites behind a shared
capabilityFacts() helper.
- parsePx now has a single source of truth in core; studio domEditingDom
re-exports it so the copies can't drift. isIdentityTransform is now
core-internal (studio's only consumer moved to core in the prior task).
bun.lock also reconciles stale 0.7.17->0.7.21 package versions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Opt-in --public flag on `hyperframes publish` sends is_public to the
publish endpoints (staged complete body and direct multipart form) so a
claimed project's studio session can be created public instead of the
default private. Absent the flag the request shape is unchanged.
Add an opt-in --file-issue flag to hyperframes feedback. When set, after
sending the usual feedback the CLI publishes a minimal repro of the project
to a public URL (consent-gated, mirroring publish --yes) and opens a
pre-filled GitHub bug issue draft containing the rating, comment, public
repro link, and environment summary. The user reviews and submits the issue
under their own account; there is no token, backend, or gh invocation. New
--dir selects the project to publish; --yes skips the consent prompt for
scripts. URL/body building is extracted into pure, unit-tested helpers.
The missing_three_script rule only treated a bare import from 'three' as
loading Three.js, so ESM imports whose specifier is a URL or path (e.g.
.../+esm CDN builds, esm.sh/three, unpkg three.module.js, or a local
three.module.js) were not recognized. Compositions using THREE. with such
an import got a false blocking error, pushing authors onto the deprecated
UMD global build.
Generalize the module-import detection to count any import/from whose
specifier contains "three" (case-insensitive), matching the existing
loose /three/i treatment of <script src>. Bare 'three', importmap, and
<script src> paths are unchanged; the specifier must still contain
"three", so unrelated imports do not satisfy it.
The font_family_without_font_face rule raised a blocking error on two
legitimate, very common cases:
1. The `-apple-system, BlinkMacSystemFont` system-ui stack. These two
tokens are the cross-browser incantation for the platform UI font
(synonyms of `system-ui`); they name no font file, so demanding an
@font-face for them is wrong. They appear in almost every CSS reset.
2. `font-family: var(--x)` indirection. The shared family extractor took
the literal `var(--heading)` as a font name. The linter cannot
statically resolve a custom property, so it must not flag it.
Fix at the root: add the two system-ui synonyms to GENERIC_FAMILIES, and
skip any parenthesised (function) token in the shared family extractor so
both this rule and system_font_will_alias stop misreading var(). A real
undeclared font sitting in the same stack is still flagged.
* fix(engine): pad odd output dimensions up to even for H.264/H.265 encode
A composition with an odd data-width or data-height (e.g. a custom 3:1
canvas at 1080x723) failed to encode to MP4. libx264/libx265 with 4:2:0
chroma subsampling (yuv420p, yuv420p10le) require both dimensions to be
even and abort before writing a packet:
[libx264] height not divisible by 2 (1080x723)
Error while opening encoder ... Invalid argument
Both the streaming encoder and the chunk encoder built the software
range-conversion filter ("scale=in_range=pc:out_range=tv") with no
even-dimension enforcement, so any odd-sized canvas reached libx264
unmodified and the whole render failed.
Add a shared withEvenDimensionPad helper that appends
pad=ceil(iw/2)*2:ceil(ih/2)*2 to the filter chain only for 4:2:0 pixel
formats. The pad rounds each odd dimension up by one pixel (a no-op when
already even) without scaling, so content is never resampled. Formats
that accept odd dimensions (ProRes 4444 yuva444p10le, VP9 yuva420p) are
excluded, so transparent/alpha output is untouched.
* fix(engine): extend even-dimension pad to GPU 4:2:0 encode paths
The odd-dimension pad added for libx264/libx265 only covered the software
encoder branches. nvenc, videotoolbox, qsv, and amf feed software frames
straight to the hardware encoder with no -vf chain, so an odd-sized 4:2:0
canvas on --gpu (or an auto-selected hardware encoder) reproduced the same
"height not divisible by 2" abort before any packet was written.
Add the even-dimension pad to the software-side -vf chain for those four
GPU paths in both the chunk and streaming encoders, reusing the shared
withEvenDimensionPad helper (the pad runs on CPU before the encode). vaapi
is left as-is: its existing format=nv12,hwupload conversion already aligns
odd dimensions before upload, so it is not double-padded. ProRes 4444 and
VP9 alpha stay untouched, exactly as the software fix excludes them.
nvenc/videotoolbox/qsv/amf arg construction is logic-tested (the pad filter
is asserted on the built arg list for 8-bit and 10-bit 4:2:0, with alpha
ProRes asserted padless); runtime hardware encode is not exercised here.
* fix(examples): host the kinetic-type A-roll so the video actually renders
The A-roll <video> was authored inside the main-graphics sub-composition
template. The runtime only seeks and decodes media that is a direct child of
the host root, so the footage was never driven by the live runtime and renders
blank in Studio preview and the player (the producer's compile step happened to
hoist it, masking the contract violation in server renders). The example also
failed lint with eight errors.
Root cause and fixes:
- Move the <video> out of compositions/main-graphics.html and into index.html
as a direct child of #root, with data-start/data-duration/data-track-index
and class="clip". Its per-scene opacity reveal now runs on the main timeline
at global time, since a sub-composition timeline cannot reach host elements.
- Make the sub-composition a transparent overlay shell (background: transparent)
and stack it above the host video so the type renders over the footage.
- Drop the crossorigin attribute (plain displayed media never needs it) and mark
the audible footage with data-has-audio="true".
- Replace the three CSS translateY values that GSAP would overwrite with
fromTo tweens (carson-line-1, wes-text-top, wes-text-bottom).
- Swap the unbundled Libre Baskerville for the bundled Playfair Display serif so
the renderer can supply the Wes Anderson typography, and drop the redundant
-apple-system token from the body font stack.
lint reports 0 errors, validate passes, and frame extraction confirms the A-roll
composites correctly under every type scene.
* docs(examples): note the A-roll reveal-time coupling to the sub-composition
* fix(lint): stop overlapping_gsap_tweens flagging distinct unresolved targets
The GSAP parser assigns the sentinel `__unresolved__` to any tween whose
target it cannot statically resolve to a concrete element (a computed
variable, a helper call, etc.). The overlap check compared tweens by that
target string, so two tweens aimed at completely different elements via
unresolvable selectors (e.g. `#s0 .hl .w` and `#s1 .hl .w` produced by a
helper) both collapsed to `__unresolved__` and were reported as
overlapping, a false positive.
An unresolved target is an unknown element: two of them are not provably
the same element, so an overlap between them cannot be asserted. Skip
overlap analysis when the target is the sentinel. Genuine overlaps on a
resolved element are still flagged.
* fix(lint): guard gsap_exit_missing_hard_kill against the unresolved-target sentinel
The overlap rule already skips tweens whose target collapses to the
__unresolved__ sentinel, but the sibling exit rule in the same file did not.
A scene-boundary exit on an unresolved target could emit a finding like
GSAP exit on "__unresolved__" ... with a meaningless tl.set("__unresolved__", ...)
fix hint. An unresolved target is an unknown element: you cannot assert a
missing hard kill on it, so skip the window early in the loop, mirroring the
overlap rule. Exits on resolved selectors are still flagged.
* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"
The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.
Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.
Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.
* fix(cli): route remaining browser/process error sites through normalizeErrorMessage
The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].
Route those sites through the shared helper:
snapshot.ts (the closest sibling to validate/inspect, same bug class),
render.ts (Chrome launch + Docker build), capture/index.ts and
commands/capture.ts (page-driven extraction), auth/browser.ts,
browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
error objects.
Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
* fix(cli): skip AI skills install when git is unavailable
init and `skills update` route through installAllSkills, which shells out
to `npx skills add`. That CLI clones the repo with git, so on a machine
without git the clone aborts mid-run and dumps a noisy multi-line
`spawn git ENOENT` / "Installation failed" / "Canceled" block. init still
exited 0 and scaffolded the project, but the output read like a hard
failure (and surfaced as exit 1 on some platforms).
Detect git up front alongside the existing npx check via a small
table-driven preflight: best-effort callers (init) print one calm line
and continue; strict callers (`skills update`) throw so the
check-or-update recovery contract still fails loudly. The skills
freshness check already degrades gracefully without git, so the happy
path is unchanged.
* feat(cli): record a diagnostic event when a skills install is skipped for a missing prerequisite
When init's best-effort skills install bails because git (or npx) is absent
from PATH, the skip was silent, so the rare boxes that hit it (fresh Windows
without git) were invisible. Emit one low-cardinality event (reason:
git_missing / npx_missing) on the best-effort skip path only, never on the
happy path or the strict throw. Reuses the existing typed-event pattern, and
trackEvent's opt-out gate already applies.
The standalone `feedback` command runs separately from `render`, so it
has no access to the prior render's elapsed time, yet it always passed
renderDurationMs: 0 to the feedback analytics event. Since that path is
the one used in practice (the auto-prompt returns early for agent and
non-interactive runtimes), nearly every feedback event recorded a render
duration of exactly 0, which is misleading rather than absent.
Make renderDurationMs optional and only include render_duration_ms in the
event when a real value is supplied. The standalone command no longer
passes a duration; the auto-prompt path still forwards the real elapsed
time.
The render-complete summary printed `<fileSize> · <time> · completed`
where `<time>` was the wall-clock render duration. Presented as a bare
middle value, users read it as the video length and compared it to
ffprobe, repeatedly reporting a "wrong duration".
Show the actual output video length (from the perf summary's
compositionDurationSeconds, which equals the rendered frame span) as the
primary figure and label the render time explicitly:
`<fileSize> · <videoLength> video · rendered in <renderTime>`.
png-sequence (directory) output has no single muxed video, so it shows a
frame count instead; when neither is known the summary falls back to
render time only. Docker renders run the producer in a child process
with no perf summary threaded back, so they show render time only rather
than a misleading number.
* fix(cli): serve project media with HTTP Range so validate reads WAV duration
The local static server used by validate/snapshot/layout answered every
asset request with a plain 200 and no Accept-Ranges header. Chromium treats
such resources as non-seekable, and for WAV that makes the media element
report `.duration` as Infinity no matter how long it buffers (readyState
reaches HAVE_ENOUGH_DATA but duration never resolves). The duration audit in
validate then emitted a spurious "Could not read the duration of N media
element(s) within the validate timeout" warning for a perfectly valid local
WAV, and a longer --timeout never helped because the value is never going to
arrive. MP3/MP4 carry duration in their container metadata so they were
unaffected.
Serve files with Range support (206 + Content-Range, plus Accept-Ranges on
the full 200) so the element is seekable. WAV duration now resolves, the
false warning is gone, and the genuine "media shorter than its slot" check
works for WAV for the first time.
* perf(cli): stream Range responses instead of buffering the whole file
serveFileWithRange read the entire asset with readFileSync and then sliced
it, so a 1KB Range of a 50MB MP4 still allocated the full 50MB per request.
Switch to statSync for the total size and createReadStream(filePath, { start,
end }) piped to the response, reading only the requested window. Behavior is
unchanged: 206 + Content-Range + Content-Length for a satisfiable range, 416
for an unsatisfiable one, Accept-Ranges advertised on every response, and a
plain 200 full-body stream when there is no Range header. writeHead is
deferred to the stream's open event so a failed open still answers 500, and
the fd closes on end/error. This benefits MP4 seek too, not just WAV duration.
Extend staticProjectServer.test.ts with an 8MB-file case that pulls a 4-byte
slice from deep inside and asserts the streamed bytes, Content-Range, and
Content-Length are correct.
* fix(skills): storyboard review — bg-on-clip rule, slideshow output, parser parity guard
Addresses the storyboard-angle review (jrusso1020):
- M1 (invisible text): frame-worker.md (x3) + SKILL.md Step 5 (x3) now require a
frame's full-bleed background on a class=clip layer, never the #root /
data-composition-id element (the root is clip-gated to its scene window, so a
background on it is not a dependable ground and dark text can land on the black
host body). The assembler already paints frame.md's canvas onto index #root as
the base ground; the per-frame clip rides on top.
- B3 (slideshow truncates to slide 1): slideshow/SKILL.md gains an Output section
(decks render via 'present'; 'render index.html' captures only the first
composition; linear main-line MP4 export is deferred).
- Parser drift: vendoredParity.test.ts guards the three vendored storyboard.mjs
copies (byte-identical + parse-parity with @hyperframes/core).
- skills-manifest.json regenerated for the edited SKILL.md files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): storyboard review — lint, validate help, snapshot, inspect, capture, render
Addresses the CLI findings from the storyboard-angle review (jrusso1020):
- lint (@hyperframes/lint): accept vendor-prefixed system-font keywords
-apple-system / BlinkMacSystemFont so a system stack with a generic fallback no
longer trips font_family_without_font_face (+ test).
- help: list 'validate' under Project in 'hyperframes --help' (was runnable but
undocumented).
- snapshot: honor -o/--output (the flag did not exist; output was hardcoded to
snapshots/). The dir is resolved once and threaded through capture + contact
sheet + Gemini.
- snapshot: split font status into loaded / error / unused with a one-line
summary; only a real 'error' is reported as FAILED (an unrequested @font-face
is 'unused', not a contradiction with 'loaded').
- inspect: suppress text_occluded across a scene-to-scene crossfade (occluder in
a different data-composition-id mount while a scene is mid-fade); a same-scene
or two-settled-scenes overlap still flags.
- inspect: suppress content_overlap between in-flow siblings governed by the same
flex/grid container (tight stacks / number lockups are layout slop).
- capture: record source resolution (videoWidth/Height) in video-manifest.json
alongside the DOM display box; consumers size off the source dims.
- render: warn when the target carries a slideshow island (render captures only
the first scene, so the MP4 is truncated to slide 1; use 'present').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sdk_resolver_shadow tripwire flagged element_not_found for nodes a
composition <script> creates at runtime (caption word/group spans, etc.).
These have no static data-hf-id, so the SDK session (a static parse) cannot
model them by design; the divergence is noise, not a resolver bug.
- Runtime-node filter: suppress element_not_found when the resolved hf-id is
absent from the on-disk source. An id PRESENT in source but missing from the
session stays flagged (the genuine v0.6.110-class resolver divergence).
- Add sessionElementCount to all element_not_found / animation_not_found emits
(0 = empty/broken session, >0 = element-specific).
- Add sourceHfIdCount to emitted element_not_found: =1 = static node the parse
dropped (foreign-content exclusion / sub-comp gap), >1 = duplicate-id
resolver ambiguity.
Scoped to the DOM-edit path. Telemetry-only; no disk writes, no edit change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(studio): make storyboard view default available (remove FF)
Removes STUDIO_STORYBOARD_ENABLED. The storyboard view-mode toggle was
gated behind a default-off feature flag (VITE_STUDIO_ENABLE_STORYBOARD)
since #1529. With the storyboard experience now ready for broad
exposure, drop the gating and make the toggle available unconditionally.
Changes:
- packages/studio/src/components/editor/manualEditingAvailability.ts:
delete the STUDIO_STORYBOARD_ENABLED constant.
- packages/studio/src/App.tsx: drop the import + FF arg to
useViewModeState(). Hook is now called argument-free.
- packages/studio/src/components/StudioHeader.tsx: drop the import + the
conditional-render guard on <ViewModeToggle />. The toggle always
renders in StudioHeader's center slot.
- packages/studio/src/contexts/ViewModeContext.tsx: remove the enabled:
boolean parameter from useViewModeState() and simplify.
- packages/studio/fixtures/storyboard-sample/README.md: drop the
VITE_STUDIO_ENABLE_STORYBOARD=1 prefix from the preview command.
The VITE_STUDIO_ENABLE_STORYBOARD / VITE_STUDIO_STORYBOARD_ENABLED env
vars become no-ops after this change.
Co-Authored-By: Jerrai <noreply@anthropic.com>
* docs(skills): drop stale VITE_STUDIO_ENABLE_STORYBOARD reference
The Storyboard view is now available by default (the FF removed in this PR);
storyboard-format.md no longer points at the dead env var, and skills-manifest
is regenerated for the hyperframes-core hash. Closes the Via/Magi review nit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jerrai <noreply@anthropic.com>
`crossorigin` on <video>/<audio> forces a CORS-checked fetch. The
server-side renderer downloads media directly (no CORS) so renders
always work, but Studio preview runs in the browser — a media host that
omits Access-Control-Allow-Origin silently fails the load, so the media
shows blank/black in preview while the render looks fine, hiding the bug.
Plain displayed media never needs crossorigin; it's only required to read
pixels/samples back (canvas/WebGL texture, WebAudio createMediaElementSource)
and only when the host is known CORS-enabled. New rule
media_crossorigin_breaks_preview flags it as an error with that guidance.
Claude-Session: https://claude.ai/code/session_01NsmfF5FzhqXY6hZ8buXgUE
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `hyperframes feedback` CLI command and the post-render agent hint
already exist, but no skill told agents to act on it — so the feedback
channel is starved (agents see a dim stdout line and move on). Add the
instruction to the three paths agents actually read in the hyperframes-cli
skill: workflow step 8, an Agent Conventions bullet, and a dedicated
`feedback` subsection in the preview-render reference.
* fix(core): root-cause the id-less media wash in getAttr, drop the band-aid
The blank-wash/dropped-audio fix in #1790 added assignMissingMediaIds in the
producer to stamp ids onto id-less timed media. That was a band-aid: the real
cause is timingCompiler's getAttr, whose regex had no name boundary at all, so
getAttr(tag, "id") matched the trailing id="…" inside data-hf-id="…". compileTag
saw a phantom id and skipped its existing hf-video-N/hf-audio-N injection,
leaving the element with no real el.id — which the render pipeline keys off of.
Fix getAttr with the same (?<![\w-]) lookbehind used for the lint readAttr fix.
compileTag's auto-id injection now fires for data-hf-id-only media, in both the
main composition and sub-compositions (parseSubCompositions runs the same
compileTimingAttrs pass), so assignMissingMediaIds is removed entirely.
Extends the regression fixture with a standalone id-less <audio> (the dropped-
audio side, previously untested) and raises minAudioCorrelation to 0.9. Adds a
timingCompiler test for the data-hf-id/id boundary.
* test(producer): use seeded pink noise (not a pure sine) for fixture audio
A continuous sine anti-aligns under the audio cross-correlation (correlation
-1.0 from a sub-period offset). Broadband seeded noise correlates robustly.
* test: cover audio-side id injection via unit test; keep render fixture video-only
The audio render-baseline used synthetic sine/noise, which anti-aligns under
the harness audio cross-correlation (deterministic -1.0). Real audio fixtures
are unaffected. Cover the audio side of the boundary fix with a deterministic
timingCompiler unit test (id-less <audio> gets hf-audio-N) instead, and keep
the render fixture video-only.
* test(producer): regenerate baseline under the root fix (hf-video-N from compileTag)
* fix(producer,lint): id-less media renders blank wash instead of footage
A timed <video>/<audio> identified only by a Studio-stamped `data-hf-id`
(no real `id`) rendered as a flat white/grey wash with dropped audio, and
lint stayed silent so it surfaced only at render.
Root cause, two layers:
- lint `readAttr(tag, "id")` used a `\b` boundary, which treats the hyphen
in `data-hf-id="…"` as a word break — so reading "id" matched the trailing
`id="…"` inside `data-hf-id` and returned a phantom id. `media_missing_id`
therefore never fired for media carrying only a data-hf-id. Switched to a
`(?<![\w-])` lookbehind so a short name can't match the tail of a longer
hyphenated attribute (also fixes "width" matching `data-width`, etc.).
- the render pipeline identifies media by the real `el.id`: frame extraction
keys injected stills as `__render_frame_<id>__`, the runtime frame-swap
matches on `el.id`, and the audio mixer selects `audio[id][src]`. An empty
`el.id` meant injected frames/audio never matched. compileForRender now
assigns a stable positional id to every id-less timed media element before
any stage parses or serves the HTML.
Adds a producer regression fixture (video with data-hf-id, no id) and a lint
test covering the data-hf-id/id collision. Baseline mp4 generated separately.
* test(producer): baseline for video-hfid-no-id regression fixture
Golden compiled.html + output.mp4 (generated on linux/amd64 in the
Dockerfile.test image). Compare-mode passes: compilation, visual (0 failed
frames), and audio (correlation 1.000). A regression to the blank-wash
behaviour fails the visual check.
The kinetic-letter-in motion-primitive (a music-to-video reference authors copy)
animated the word's letterSpacing as a settle — a reflow tween that micro-stutters
under seek-by-frame capture, and outside the registry scan so the rule never fired
on it. The chars are already per-glyph spans, so migrate the settle to a per-glyph x
spread ((0.04em − −0.04em) × 280px = 22.4px/gap, centered about index 3), with a
comment naming the hazard and the rule. Render-verified: faithful, smooth.
Document two intentional design choices in gsap_non_transform_motion so future
readers don't read them as misses:
- No per-line/per-file suppression by design — the stance is fix-the-motion, not
silence-the-rule; every plain-DOM case has a faithful transform equivalent.
- set() is skipped intentionally: a set() that seats an integer-snapped layout
position before a later transform tween is a single from-state frame, not motion.
Also hoist loadParseGsapScript() above the per-script loop (the other async rules
do the same; dynamic-import cache makes it equivalent, but the placement no longer
reads as load-bearing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI failures were two root causes (preview-regression was a cascade — its gate
mirrors preview-parity, which was skipped because the Preflight lint+format job
failed):
- Preflight (lint + format): the migrated registry comps (flowchart,
flowchart-vertical, liquid-glass-notification, vignette demo) were committed
unformatted — the lefthook format step only ran oxfmt on staged TS, not HTML,
so `oxfmt --check .` failed in CI. Ran oxfmt on all four. The vignette title's
per-glyph spans use oxfmt's `</span>`-split form, which keeps the glyphs
adjacent (no inter-element whitespace), so rendering is unchanged.
- Skills: manifest in sync: the pre-commit hook's manifest generator and CI's
canonical `gen-skills-manifest.ts` produced different content hashes for the
same skill; regenerated with the canonical generator so the committed manifest
matches what CI computes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes#1782)
Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.
Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.
Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).
* feat(studio): restore drag-to-retime on timeline keyframes
Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:
- previews visual-only (the dragged diamond follows the pointer; nothing touches
the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
dragged keyframe's animation via resolveKeyframeTarget.
tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).
* feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize
Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
duration,
- single-keyframe tweens resize either direction.
Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.
Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.
tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.
* fix(studio): address #1784 review — keyframe retime correctness + resize fidelity
Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
any tween shorter than its clip (Move to Playhead + the inherited Delete silently
no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
(retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
(<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
resolves stale position-encoded ids).
Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).
* feat(player,studio): favicon-blade play icon with pause<->play morph
Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.
* fix(cli): discover local-studio (Vite) preview over IPv6 loopback
The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.
Adds an IPv6-only discovery regression test.
* fix(studio): wire the Add-keyframe (K) shortcut
The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.
* fix(studio): clear orphaned GSAP transforms on soft reload
A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.
Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.
* fix(studio-server): bust thumbnail cache on composition edits
The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.
Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.
* fix(parsers): remove-all-keyframes holds position static instead of re-animating
removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.
Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.
* fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation
The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.
* fix(studio): timeline 'Delete All Keyframes' holds position too
The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.
* fix(studio): one position write per element + clean remove-all-keyframes
Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.
- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
recast, in parity); findExistingPositionWrite matches degenerate duration:0
holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).
Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
The rule sourced a tween's animated props from anim.properties only — the acorn
parser's to-vars. A fromTo() exposes its first ("from") vars object separately as
anim.fromProperties, so a layout or reflow prop animated only in the from-object
(e.g. tl.fromTo("#t", { left: 100, letterSpacing: "0.3em" }, { opacity: 1 }))
escaped the rule entirely and shipped stuttering. fromTo is the most common tween
form, so this was a real recall hole.
Union fromProperties into the checked property set; add the field to the lint
parse type. Registry re-scan unchanged (0 comps animate a layout prop only in a
from-object today), so no collateral.
Known remaining gaps (documented, not fixed): standalone gsap.fromTo only scans
its first vars object via regex (and aborts on nested braces); roundProps:true
(boolean form) is dropped by the parser. Both are rare and the clean fix is
disproportionate to the rule's planned sunset when drawElement render lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fixHint and skill guidance told agents to "settle via scale" for
text-reflow props — but uniform scale resizes the glyphs, it does not change
the gaps between them, so swapping a letterSpacing tween for scale lints clean
while silently animating a different thing.
Make the guidance faithful per property:
- fontSize -> scale (same visual, sub-pixel smooth).
- letterSpacing / wordSpacing -> split the text into per-character elements and
animate each glyph's x (the spread); uniform scale is NOT equivalent. Or hold
the value statically if it's a minor flourish.
Add a "preserve the intent" principle to gsap-transforms-and-perf.md: a fix must
reproduce the same start/end state and trajectory and be verified against the
ORIGINAL render, not just pass lint — lint-clean-and-smooth is not the bar,
faithful-and-smooth is.
Redo the vignette demo title with the faithful per-glyph x spread (15.12px =
(0.32em-0.18em)*108px, centered about 8 chars). Render-verified: settled
endpoint matches the original at 46.5 dB, settle is smooth (24/24 unique frames).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the rule beyond positional layout props to the text-reflow props
letterSpacing / wordSpacing / fontSize. Animating them reflows text and snaps
glyph positions to the pixel grid, so a slow ease-out tail micro-stutters
exactly like left/top — measured on a real composition, a slow letterSpacing
"settle" rendered 19/30 unique frames vs 30/30 for the transform-driven
motion in the same piece. They have no transform replacement (fix: settle via
scale or hold the value), and the snap happens during browser layout, upstream
of the canvas raster, so they are never html-in-canvas-exempt. width/height
stay excluded (legit animated uses — progress bars, reveals).
Restructure the finding's message/fixHint to compose per category (positional
-> x/y; reflow -> scale/hold; roundProps -> remove) instead of a two-branch
ternary. Skill guidance (gsap-transforms-and-perf.md) broadened: "layout
property" includes reflow; letterSpacing/fontSize named as the settle-trap.
Migrate the one surfaced positive: registry/components/vignette/demo.html
title settle letterSpacing -> a subtle scale settle (render-verified 20/20
unique frames, smooth). Blast radius across the registry was this one comp,
zero false positives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reframe the gsap "prefer transforms" guidance from a GPU-perf nicety to the
real HyperFrames render-correctness reason: the seek-by-frame capture engine
integer-snaps layout props, so slow tweens stutter. Add the CSS-rest + x/y
offset conversion recipe, the parent-relative %/px note, and the one
exception (html-in-canvas <canvas layoutsubtree> elements). Frames the
gsap_non_transform_motion lint rule as the backstop, not the teacher, so
agents reach for transforms up front instead of relying on lint to reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Animating layout properties (left/right/top/bottom/margin*) or using
roundProps snaps motion to integer device pixels. Under the seek-by-frame
capture engine this is invisible at high per-frame deltas (fast tweens) but
visibly stutters at low deltas (slow tweens / ease-out tails): sub-pixel
movement rounds to the same pixel for several frames, then jumps a whole
pixel. Transforms (x/y/scale) interpolate sub-pixel and stay smooth.
New rule gsap_non_transform_motion (error):
- Sources timeline tweens from the acorn parser's full animation list, so
label- and relative-positioned tweens (tl.to(..., "label") / "+=1") are
covered and real AST keys avoid phantom-prop / nested-brace misreads.
- Exempts html-in-canvas elements (a <canvas layoutsubtree> ancestor): they
are rasterized from sub-pixel getComputedStyle and do not integer-snap.
Per-element resolution, so a grouped glass+text tween still flags the
plain-DOM half. roundProps is never exempt (it rounds before the style).
- Uses Object.hasOwn for the layout-prop lookup, skips set(), and merges
standalone gsap.* calls.
Migrated 9 registry compositions off layout-prop animation onto transforms,
each render-verified visually identical:
- liquid-glass-{notification,widgets,media-controls,context-menu}: plain-DOM
text overlays -> transforms; glass panels stay on left/top (canvas-exempt).
- vignelli: curtain wipes left:% -> xPercent.
- lt-mask-reveal: sweep left:% -> x px.
- flowchart, flowchart-vertical, decision-tree: cursor paths left/top -> x/y.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>