Address two max-effort code-review findings on PR #2056 not covered by
the earlier review-gap commit:
- captureFrameToBufferPipelined's static-dedup reuse branch never
advanced session.lastEncodeResultFrame, unlike its sibling real-capture
branches. The gap-check window is computed from that watermark, so
every consecutive reuse in a static run rescanned an ever-widening
window instead of just the newest frame — O(n^2) total work over a
long static stretch instead of O(n).
- The "single-threaded, no race" justification on the shared
parallelGuard closure was wrong: the guard has real internal await
points (recapture, PSNR) between reading and writing its
sizes/absFloor/acceptedSmall state, so concurrent workers' calls do
interleave there (confirmed). Replaced with the actual reason it's
safe: absFloor only ratchets down, sizes is append-only and
order-independent for the median, and acceptedSmall's fast path
re-validates by exact byte-equality regardless of which worker wrote
the reference buffer.
Address PR #2056 review feedback:
- Fix totalFrames progress inflation for interleaved tasks — divide
each task's span by its frameStride to match the actual per-worker
frame count (captureFrameRange steps by stride), instead of summing
raw endFrame-startFrame which double(N)-counts interleaved tasks.
- Attach a no-op .catch to each frame's pipelined encodeResult at kick
time so an abandoned promise (loop exits early on abort/error before
draining it) can't surface as an unhandled rejection during teardown.
- Document why the pipelined branch's stride=1 path is validation-only
in production (HF_DE_PARALLEL_STREAM always uses interleaved
distribution) so a future refactor doesn't unknowingly widen it.
- Comment the intentional single shared parallelGuard/parallelStats
across workers (safe single-threaded, better rolling-median signal).
Step 2 of the DE engagement plan: multi-worker drawElement capture through
the streaming encoder, with the full runtime self-verification net riding
along — the confinement rule that kept the parallel clamp in place is now
satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default
routing (including the #2026 single-worker inversion) is unchanged.
Mechanism:
- distributeFramesInterleaved + WorkerTask.frameStride: worker i captures
frames i, i+N, i+2N... — seek-based capture makes stride free and the
ordered writer's reorder window shrinks from totalFrames/N to N (contiguous
chunks serialize workers behind the writer).
- Depth-2 pipelined worker-encode produce in the parallel worker loop (the
same shape as the sequential loop; frame k's in-page encode overlaps
k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker.
- Drain guard extracted to createDrainFrameGuard (session-parameterized):
every parallel frame gets the SAME blank-guard + PSNR self-verify as the
sequential drain, against its owning worker's pre-injection ground truth
(all sessions arm identical sample indices from
CaptureOptions.compositionDurationSeconds).
- FrameReorderBuffer.abort(err): a failed worker (e.g. verification error)
rejects all parked and future waiters — without this, peers park forever
in waitForFrame and the pool (which awaits ALL workers before surfacing
errors) deadlocks. Found by the verify-trip test; unit-tested.
- The typed DrawElementVerificationError is preserved past the pool's
error-string flattening so the orchestrator's verify-retry recognizes it.
- Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY
frame in (lastEncodeResultFrame, i] to be predicted-static (sequential
capture reduces to the old has(i) check).
- Workers get separate browser PROCESSES under the flag: pages co-tenant in
one browser starve non-active pages of BeginFrames on the paint-wait path
(measured 86s vs 30s on a 3,245-frame rAF comp).
Validation:
- Happy path W3: verify samples pass across workers (4x inf on the 2,381f
comp), output vs single-worker DE = 59.3dB (encode noise floor) — the
interleave + dedup-stride produce identical pixels.
- Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649
(32.2dB < 45), peers abort instead of deadlocking, whole render retries
via parallel screenshot, RENDER_OK in 42.6s.
- Canary suite 7/7 with the flag off (default paths untouched); producer
orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main
failures, stash-A/B verified); reorder-buffer abort unit tests.
Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3)
over single-worker DE in the spike; end-to-end numbers on this machine are
currently noisy (separate-browser init overhead + bench load) — clean
benchmarks before any default routing change. The flag stays explicit
opt-in; promoting it into the router replaces the #2026 W=1 pin for the
same cohort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the highest-severity max-effort code-review finding on the now-
merged #2082 (the drawElement Chrome-version-pin fix): findFromHyperframesCache
matched a cached Chrome by browser type only, never comparing its
buildId against CHROME_VERSION. Any machine that already rendered with
an older hyperframes version has an old build (this pin has moved
131 -> 151 -> 152 across releases) sitting in ~/.cache/hyperframes/chrome,
which satisfied the lookup and silently defeated the whole point of
#2082's version bump for exactly the population it was meant to fix —
drawElement's new capability probe would then permanently and silently
fall back to screenshot capture instead of ever fetching a build that
implements canvas.drawElementImage.
Verified directly (not just via review): seeded ~/.cache/hyperframes/chrome
with the old 131 build, confirmed a real render previously kept using it
forever; with this fix it's correctly ignored and 152 is downloaded.
New regression test locks in the buildId mismatch case.
Address max-effort code-review finding on PR #2045 (confirmed, not
addressed by the earlier review-gap commit): the script_failure bail
path skipped the composition-id enumeration entirely, so a render with
multiple sub-compositions sharing a failed script only logged the raw
failed URL(s), never which composition(s) were still waiting on it —
a real observability regression versus the pre-#2045 behavior, which
always logged the missing-id list on any non-ready outcome.
Now enumerate unregistered composition ids unconditionally and log them
alongside whichever reason (script_failure or natural timeout) fired.
Address PR #2045 review feedback:
- Share a SubTimelineWaitOutcome type (engine) end-to-end instead of
widening to string across CapturePerfSummary / RenderPerfSummary /
telemetry, so the three layers can't drift.
- Dedupe scriptLoadFailures on push — a 4xx response and its trailing
requestfailed both recorded the same URL, doubling the failed-URL
list in the fail-fast warning.
- Thread the sub-timeline-wait outcome into render_error (not just
render_complete): a render that fail-fasts and then fails downstream
(pollVideosReady, extract, encode) previously dropped this signal on
the floor. dedupPerfs is now function-scoped so the catch path can
read it, same treatment as the existing captureAttempts array.
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.
- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
response, listeners that already existed for diagnostics) in
session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
2s grace once any script failed, with a loud warning naming the URL(s).
Late-registering fetch-async comps are unaffected: no script failure means
the full timeout still applies, and a registration landing inside the
grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
"script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
(worst across sessions) -> render_complete sub_timeline_wait, so the wild
rate becomes directly trackable instead of setup-histogram forensics.
Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.
Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address PR #2082 review feedback:
- Route studio thumbnail + render call sites through preferManagedChrome
so studio renders no longer silently fall back to whatever system
Chrome happens to be installed.
- `hyperframes browser ensure` now resolves through the same
preferManagedChrome path render uses, so it reports what render will
actually pick instead of any system Chrome it happens to find.
- Point the unsupported-Chrome fallback log at `browser ensure --force`
instead of `doctor`, which doesn't check Chrome/drawElement capability.
- Fix stale findFromCache comment: the HF pin is now a Dev-channel build
that can be newer than a user's puppeteer-cache Stable install.
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).
Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
resolve straight to our pinned/cached build, skipping both the shared
puppeteer-cache preference and system Chrome. Rendering shouldn't depend
on whatever arbitrary Chrome a machine happens to have — that's exactly
how this regressed (any Mac with Chrome.app installed bypassed the CLI's
pin entirely).
- A runtime capability probe in the engine, right before any other
drawElement work: if `drawElementImage` isn't a function on the injected
canvas, route to the existing screenshot-fallback gate instead of
crashing. This is the real backstop — it protects every resolution path
(env override, stale cache entry, a future Chrome regression), not just
the ones `preferManagedChrome` reaches.
Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.
Fixes#2060.
- slugify: replace the anchored alternated trim regex (/^-+|-+$/g) with a
character-scan trim — CodeQL js/polynomial-redos blocker.
- readRenderOverrides: fold the readOverrides wrapper into the exported
function (one name, no pass-through).
- getVariables: deduplicate declarers with a Set, matching
injectCompositionCssVariables.
- Move the tokenSlug import to the top of the file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing of the compile-time variable emission surfaced four gaps:
- The producer render path never emitted the compile-time stylesheet (only
the preview bundler did), so eval-time reads — GSAP .from immediateRender,
top-level getComputedStyle — saw undefined vars in rendered output. The
producer's inlineSubCompositions now calls the shared
emitRootCompositionVariableStyles and passes the variable hooks.
- --variables overrides weren't visible at eval time. They now thread from
the orchestrator / distributed plan through compileStage into the emitted
rules (window.__hfVariables still covers script reads).
- Per-declarer rules anchored on data-composition-id, which two inlined
instances of one sub-composition share — instance A's rule restyled
instance B, and a rule directly on the declarer defeated the host's
inherited data-variable-values. Rules now anchor on per-instance
data-hf-var-scope markers and layer nearest-host values over declared
defaults, mirroring the runtime loader.
- Emission ignored authored CSS; a declared default now yields to a var
already defined in an authored <style> block (define-if-absent, matching
the runtime injection).
Also: the figma importer emits background-color (longhand) for solid fills.
GSAP backgroundColor tweens cannot read a var() through the background
shorthand — its pending-substitution longhands serialize empty, so .from
captured nothing and settled on transparent (pre-existing GSAP interaction,
reproduced with no composition variables involved).
Validated live: eval-time default + override, .from + override, two-instance
host branding, authored :root precedence, SDS brand-loop pixel parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md)
proved the recolor chain end-to-end and surfaced three gaps:
- runtime now defines every declared composition variable as a CSS
custom property (document root at init + scoped sub-comp hosts in the
loader), so imported var(--slug, literal) fills resolve live — without
this the frozen literal always won and variable-driven rebranding
could not propagate. Slug kept byte-compatible with the figma
importer (parity test). render --variables overrides win.
- figma component --name: variant frames are often all named
'Platform=Desktop' and slug-collided across imports.
- imported fragments carry data-hf-snippet and the project linter skips
composition-root rules for them.
- /figma skill documents the field-tested non-Enterprise tokens path
(MCP get_variable_defs joined with REST boundVariables ids).
Shared-helper extractions (injectScopedStyles, flattenedRoot module,
parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the
dedup/complexity audit the runtime changes tripped.
Validated live: brand-loop renders purple from the attribute alone (no
manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer,cli): drawElement priority inversion — single-worker streaming over auto-parallel
clamp:parallel eats 50% of local renders (1,326/fortnight; DE engagement
stuck at 3.8%) by routing multi-worker renders to unverified screenshot
capture. Benchmarks (2026-07-08, 4 comps x W1/W2/W3/W5) show that above the
~900-frame amortization crossover, single-worker VERIFIED drawElement
streaming beats screenshot-parallel at EVERY worker count (2,380f: 66s vs
109-127s; 3,600f: 33s vs 39-56s; parallel scaling flattens past W2), while
below it DE's fixed init cost loses by <=2.2s.
- shouldPreferSingleWorkerDrawElement (exported predicate + 7 unit tests):
inverts an AUTO-resolved multi-worker render to workerCount=1 when the
comp matches the benchmarked configuration — default-on DE (darwin
hardware clamp upstream), no compile gate, no forced-screenshot hint,
mp4 output, single-worker streaming eligible, and totalFrames >=
HF_DE_SINGLE_MIN_FRAMES (default 900; 0 disables). Explicit --workers N
is always honored.
- Inverted renders keep the probe session and land on the worker-encode
streaming drain — the ONLY path with runtime self-verification, so this
moves ~40% of previously-clamped renders onto the verified fast path.
Comps that later hit an init-time gate (~1.5% of local renders) render
single-worker screenshot streaming; accepted trade.
- Telemetry: de_worker_inversion on render_complete (orchestrator ->
perfSummary.workerInversion -> CLI), plus the worker_resolution
observability checkpoint now records deWorkerInversion.
Validation: e2e matrix on 2,381f comp — auto->5 workers inverted to 1,
DE verified 4x inf PSNR, RENDER_OK; short comp (360f) auto stays 5-worker;
explicit WORKERS=3 honored; HF_DE_SINGLE_MIN_FRAMES=0 disables. Canary
suite 7/7 (PSNRs identical). renderOrchestrator tests 86/86.
tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer): review fixes — inversion routing guards, calibration skip, retry revert
Max code-review round on the inversion (13 confirmed findings):
- Streaming spawn-failure disk fallback now clamps default-on drawElement
(deClampReason=disk_path, DE-mode probe closed) exactly like the
pre-capture clamp — previously it carried useDrawElement=true onto the
unverified disk path, the hole the verified-path confinement exists to
close, newly reachable for every inverted render.
- Predicate gained the routing knowledge it was blind to: layered/HDR and
shader-transition comps (drawElement never runs there), supersampling
(deviceScaleFactor>1 init gate), a probe session whose init gates already
disengaged DE, and the PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true explicit
parallel-DE opt-in (honored like --workers N).
- Eligibility is evaluated BEFORE capture calibration and skips it when the
inversion pins workers to 1 regardless of the estimate — the throwaway
calibration browser + sample captures cost ~41s on the 2,381-frame
benchmark comp (auto render: 111.6s -> 70.1s total).
- Self-verify retry reverts the inversion: the re-render returns to the
pre-inversion parallel screenshot path (disk) instead of single-worker
screenshot streaming, the slowest shape for exactly the comps drawElement
damages.
- HF_DE_SINGLE_MIN_FRAMES="" (set-but-empty) now falls back to the 900
default instead of aliasing the 0 kill switch.
- Timeout advisory uses the RESOLVED worker count — an inverted render that
times out no longer prints "Retry with --workers 1" (the configuration
that just failed).
- Telemetry: deWorkerInversion recorded in capture observability (failed
renders are attributable), emitted as literal false when not fired
(queryable denominator), and the drawElement perf input shape is one
exported DrawElementPerfInput type instead of three copies.
- Tests: requestedWorkers undefined (the value production actually passes)
+ the four new predicate guards; 91/91.
Validation: e2e auto render — calibration skipped (deInversionEligible),
inversion fires, DE verified 4x inf, total 70.1s (was 111.6s);
HF_DE_SINGLE_MIN_FRAMES=0 restores calibration + parallel; canary suite
7/7 (PSNRs identical); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer,cli): review round 2 — loss-cohort telemetry, retry-plan helper, boundary tests
- de_worker_inversion is now a tri-state string ("inverted" | "reverted" |
"none") instead of a boolean: the self-verify retry marks the render
"reverted" rather than resetting to false, so the dashboard can segment
the lost-inversion cohort first-class instead of inferring it from
deSelfVerifyFallback + frame-count joins (james-russo #1).
- The retry rollback is extracted to resolveInversionRetryPlan (pure,
exported) with unit coverage: pre-inversion worker-count restore,
streaming re-resolution (multi-worker retry -> disk), "reverted" state,
null when never inverted (james-russo #2).
- WOULD_RESOLVE_MULTI_WORKER named constant replaces the bare sentinel 2
(james-russo #5); minFrames: -1 boundary case added (miga #3).
94/94 renderOrchestrator tests; tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer,cli): emit de_pre_inversion_workers for the parallel counterfactual
The ramp-down decision needs "did DE beat the parallel render it displaced",
not just "did DE beat single-worker screenshot". Emitting the worker count
the auto-resolution chose BEFORE the inversion pinned it to 1 makes the
parallel counterfactual computable per render (screenshot ms/frame from the
verify samples / W x the measured parallel-efficiency curve). Set only when
the inversion fired.
Smoke: 2,381f auto render -> de_worker_inversion="inverted",
de_pre_inversion_workers=5, mode=drawelement, verify armed 4. 99/99 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Live testing against a real community file (Ratings) found a nested
instance node figma refuses to render as svg — which aborted the entire
component import. The rasterize loop now retries the node as png, and
only if both formats fail warns and skips THAT node (placeholder keeps
its data-figma-rasterize marker, no src) instead of failing the import.
On the file that surfaced this, the png retry recovers the node — 31/31
placeholders get assets.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite
crbug 529829538 was closed "working as intended": the html-in-canvas API's
contract is mutate -> canvas.requestPaint() -> await the canvas paint event ->
drawElementImage, which refreshes the subtree's paint records including
compositor-applied properties. Verified on the pinned 151 floor and 152
canary: root opacity, root filter, nested group opacity, and child transforms
(incl. will-change-promoted) all capture exactly; the root element's own
TRANSFORM is the one property still never baked.
- Paint invalidation: all three paint-wait sites (serial capture, worker
produce, batch produce) now call canvas.requestPaint() when available and
fall back to the __hf_de_tick sentinel background toggle on builds without
it. The 250ms unsynchronized-draw safety net is unchanged.
- Root-opacity ratio correction REMOVED (all three draw sites + base-opacity
recording at injection). Since 151 the paint wait bakes current root opacity
into the snapshot as pixel alpha, so the ratio correction DOUBLE-APPLIED
animated root fades: a root-fade A/B tripped the runtime self-verify at
30.1dB (frame 24, ~0.92 expected vs ~0.85 rendered). Post-removal the same
comp self-verifies at inf and matches the screenshot render at PSNR=inf.
The root TRANSFORM correction stays — verified still required.
- autoAlpha rewrite machinery DELETED: the opt-in opacity->autoAlpha tween
rewrite (default-off since the retraction fix; measured ~28dB damage on
comps whose fades it touched), its flush-time transparent-target hiding,
the __HF_FAST_CAPTURE_AUTOALPHA__ flag plumbing, and the deferral-time
retract/re-assert dance. The stub keeps tween-target tracking (3D
projection + at-risk scans depend on it).
Validation: canary suite 7/7 with PSNRs identical to baseline (58.30 /
43.13 / 54.15 dB); root-fade A/B PSNR=inf vs screenshot; engine suite 905
passed (1 pre-existing color-grading failure); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): review fixes — gate opacity correction by paint mechanism
Max code-review findings on the requestPaint adoption:
- Root-opacity ratio correction RESTORED, gated per frame on how the paint
was produced: it applies on BeginFrame (sync=false) captures and on builds
without canvas.requestPaint() — the two paths where the snapshot holds the
root's load-time opacity — and is skipped only on requestPaint-driven
paints, where the snapshot bakes the current opacity and the ratio
double-applies (the proven 30.1dB root-fade failure). Base opacity is
recorded at injection again.
- Invalidation extracted to a page-scope helper (__hfDeInvalidate, installed
by injectDrawElementCanvas) shared by all three paint-wait sites: sentinel
toggle ALWAYS (a paint is guaranteed even if requestPaint elides one on a
clean subtree) + requestPaint() in a try/catch (a throwing implementation
degrades to sentinel-only instead of rejecting the capture). Returns
whether requestPaint ran, feeding the opacity-correction gate. Also removes
the triplicated inline block and its three anonymous `as T` casts.
- HF_FAST_CAPTURE_AUTOALPHA now logs a retirement warning instead of being a
silent no-op (the deleted rewrite's comment documented it as an operator
escape hatch).
- Batch producer docstring updated (still described the tick-toggle-only
paint wait); stub tween observer reshaped to a void fn (observeTweenCall)
so no arg-rewriting seam survives.
Validation: canary suite 7/7 (58.30/43.13/54.15dB, d95f20b6 clean);
root-fade A/B self-verify 4x inf + whole-video PSNR=inf; engine suite 905
passed (1 pre-existing); tsc/oxlint/oxfmt clean; stub regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: WaterrrForever <miao.yang@heygen.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine,producer,cli): verify video comps via deferred DE init + capture p50
Closes the two biggest gaps in the first day of v0.7.38 wild data: 88% of
drawElement renders (video comps initialized via probe sessions) ran with
self-verification unarmed, and speedup was measurable on only 3 of 76 renders.
- Deferred drawElement init: probe sessions initialize before video
extraction, so they have no frame injector — ground-truth screenshots
would capture black <video> boxes, and verification skipped the whole
comp. DE init now stops after the gates for injector-less video comps
(deInitDeferred; autoAlpha flag retracted in case no path completes it)
and completeDeferredDrawElementInit finishes verification + canvas
injection + worker-encode at capture time, once
prepareCaptureSessionForReuse has attached the injector. Validated
end-to-end: a probe-path video comp now arms 4 ground-truth frames with
real video pixels (3x inf + 64.7dB) and renders drawElement verified.
- capture_p50_ms: per-frame capture durations are sampled
(capturePerf.frameMs; batch frames get the batch mean) and the median
ships as CapturePerfSummary.p50TotalMs -> RenderPerfSummary.captureP50Ms
-> render_complete capture_p50_ms. Unlike capture_avg_ms it is immune
to first-frame warmup and stage-setup amortization — smoke: avg 15ms vs
p50 8ms on the same render, p50 matching the measured steady-state
floor. Dashboard speedup tiles can drop their frame-count floor once
this ships.
- video_count on render_complete: segments speedup by video-injection
comps (whose per-frame gain is legitimately lower) vs pure-graphics.
Canary suite 7/7; engine suite 905 passed (1 pre-existing upstream
failure); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer): complete deferred drawElement init on the disk capture path
Review (miga): a probe-initialized video comp falling back to the disk path
kept deInitDeferred and silently stayed in screenshot mode — a regression
for PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true renders that previously ran
drawElement there. Complete the deferred init on the sequential disk path
under the same explicit-opt-in test the orchestrator clamp uses; default-on
renders stay on the screenshot baseline (this path has no drain-time
self-verification, per the #1998 confinement rule).
Validated: video comp + PRODUCER_ENABLE_STREAMING_ENCODE=false + explicit
opt-in logs "(deferred drawElement init)" completion on capture_disk and
renders correct video pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Default-on drawElement ships with a runtime self-verification net (#1998);
this makes its in-the-wild behavior observable. Every render_complete event
now answers: which capture mode actually ran, why drawElement disengaged
when it did (compile gate / producer clamp / engine init gate), whether the
self-verify net fired and why, and how much margin verification had.
Follows the static-dedup telemetry pattern: engine session fields →
CapturePerfSummary → RenderPerfSummary.drawElement → snake_case props on
render_complete.
New event props: de_capture_mode, de_compile_gate, de_clamp_reason,
de_gate_reason, de_worker_encode, de_verify_armed, de_verify_checked,
de_verify_min_db (margin above the 32dB threshold — drift here is the
early-warning signal before fallbacks spike), de_verify_init_ms,
de_self_verify_fallback, de_fallback_reason, de_blank_suspects,
de_blank_deterministic_accepts, de_blank_recaptures, de_boundary_frames,
de_ncpr_fallbacks.
Validated end-to-end on live renders: drawelement path reports mode/verify
counters/minDb/init cost; a blur-gated comp reports mode=screenshot +
gate_reason=css_effect:filter; a forced verification failure reports
self_verify_fallback=true + fallback_reason=psnr.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Field feedback from a raw-API agent build (join-the-world-flow): the
catalog blurb's word 'animatics' encodes the PNG-slideshow architecture
the skill body explicitly forbids — an agent routing by the blurb
concludes the shipped behavior is frames-as-pictures. Reworded to
'reconstructed motion (frames read as states, not slides)' across all
catalog surfaces (skill frontmatter, CLAUDE.md, README, skills.mdx,
hyperframes router, figma guide).
Also codifies the stronger doctrine that build demonstrated as
storyboard rule 10: when every frame is the same product UI in
successive states, rebuild the app as live DOM (Phase-3 for stateful
parts, real pixels for static chrome — code what changes state, freeze
what doesn't) and perform frame deltas as interactions instead of
tweens. Spec §5.1 records the escalation + field origin.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net
Flip useDrawElement + worker-encode defaults on (HF_DE_BATCH default 4),
clamped in resolveConfig to hosts where drawElement can engage (macOS +
hardware-GPU browser) so page-side shader compositing is untouched
everywhere else; explicit env opt-in keeps attempt-and-gate semantics.
Safety net makes default-on safe: the compile/init gates catch predictable
incompatibility; this catches the intermittent residue no static analysis
can see (stale paints, dropped background images, transient blank frames).
- engine: captureDeVerificationFrames — K=4 (HF_DE_VERIFY) ground-truth
screenshots at init, after gates + armStaticDedup, BEFORE canvas
injection (post-injection screenshots show the canvas bitmap, not the
DOM). Runs the video-injection hook per sample; double-captures so
rAF-driven text counters settle (a single immediate screenshot captures
stale text and false-positives). Skips png, <10 frames, implausible
__hf.duration (infinite-repeat GSAP sentinel).
- producer: guardFrame on both worker-encode drains — rolling-median blank
guard with retry-once at drain (byte-identical retry ⇒ deterministic dark
frame, accepted; retry save/restores the static-dedup anchor) + ffmpeg
PSNR self-verify vs ground truth (HF_DE_VERIFY_MIN_DB, default 32dB;
natural agreement ≥45dB, damage ≤25dB). Breach dumps the frame pair to
tmpdir and throws DrawElementVerificationError.
- orchestrator: one-shot retry — on verification error the whole render
re-runs with forceScreenshot (slower, never wrong); telemetry flag
deSelfVerifyFallback.
- tooling: de-canary-suite.sh (7-comp release gate with expected verdicts),
de-gatecheck.sh (init-only corpus routing classifier), we-render.mjs.
Validated: canary suite 7/7; 611-comp routing sample 54% drawelement /
37.5% gated / 8.3% comp-defect; 12/12 risk-band renders clean on bare
defaults (48/48 verify samples); engine suite 888 passed; caught two real
intermittent damage classes in the wild (background-image drop, root-props
offset) that previously shipped silently.
Kill switches: PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false,
HF_DE_WORKER_ENCODE=false, HF_DE_BATCH=0, HF_DE_VERIFY=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): harden the drawElement self-verification net (max code-review findings)
15 confirmed findings from the adversarial review of the default-on flip;
the load-bearing five:
- Ground-truth capture no longer scrubs GSAP state: seek(0) + forced frame
FIRST (lazy .from()/overlap tweens record start values on first seek —
mid-timeline scrubs corrupted them for the whole render, and since DE
frames and truth shared the corruption, PSNR passed on damaged output),
then ascending even-spread fractions, page left at frame 0.
- Default-on drawElement is confined to the verified path: resolveConfig
requires worker-encode (the drain that runs the net), the orchestrator
disengages the default when the render takes the disk path or parallel
capture (no drain verification there), and closes a drawElement-initialized
probe session rather than letting the unverified path reuse it. Explicit
PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true keeps old attempt-and-gate behavior.
- Blank-frame retry can no longer splice wrong-frame pixels: recapture goes
through recaptureDrawElementFrameForVerify — no static-dedup shortcut
(lastEncodeResult runs ahead of the drain) and no "No cached paint record"
screenshot fallback (post-injection that captures the canvas = the LAST
drawn frame); any recapture failure falls back the whole render.
- Verify indices derive from the producer-resolved duration
(CaptureOptions.compositionDurationSeconds) instead of raw __hf.duration,
so samples always land inside the drained range.
- The platform clamp accepts "auto" GPU mode — the stock CLI resolves auto,
and the literal-"hardware" clamp made default-on a no-op for the primary
audience (masked in validation by explicitly-set env).
Also: NaN-safe env parses (HF_DE_VERIFY / HF_DE_VERIFY_MIN_DB / HF_DE_BATCH);
video comps skip verification when the session has no frame injector (probe
sessions — black-video truth false-positived); psnr infrastructure failures
skip the sample instead of failing the render; boundary-saturated sample
indices are skipped; shader-transition comps prefer page-side compositing
over default drawElement and compile-gated comps get page-side compositing
restored; observability.clearFailure un-brands the recovered first streaming
attempt; canary suite exempts known-marginal "any" comps from the cross-path
PSNR gate; dead we-render options removed; clamp tests pin their env.
Validated: canary suite 7/7; auto-GPU bare render engages the full stack;
disk-path and worker-encode-off renders disengage default drawElement;
malformed HF_DE_VERIFY_MIN_DB still verifies at the default threshold;
engine suite 890 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): review fixes — PSC intent, verify-threshold clamp, fail-closed canaries
Addresses miguel-heygen's review on #1998:
- Page-side compositing restore preserves explicit caller intent (blocker):
resolveConfig now records pageSideCompositingAutoDisabled only when IT
turned page-side compositing off because drawElement was on; the
compile-time drawElement gates restore page-side compositing only when
that flag is set. An explicit enablePageSideCompositing:false from the
programmatic API or HF_PAGE_SIDE_COMPOSITING=false stays off. Pinned by
two config tests.
- HF_DE_VERIFY_MIN_DB clamped to [10, 60] with a warning on out-of-range
values: below ~10dB the check passes severe damage; above ~60dB natural
encoder differences force a screenshot fallback on every verified render.
- de-canary-suite.sh + de-gatecheck.sh run under set -euo pipefail with
explicit `|| true` on expected-nonzero commands (render exits handled by
the suite's own checks, grep no-match, kill/pkill/wait races) and a hard
FAIL when the PSNR compare produces no value — release canaries fail
closed. Full suite re-run green (7/7) under the new flags.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Amortizes per-frame CDP protocol overhead (~3.5-9ms/frame) by looping
seek -> paint-wait -> drawElementImage -> createImageBitmap in ONE
page.evaluate for runs of consecutive frames; bitmaps still post to the
encode worker per frame. Validated on 19 stratified DE comps: median
1.20x on top of worker-encode (to 1.56x), zero damaged frames, edge
comps (static-dedup-heavy, clip-cut) bit-identical; mid-batch failure
re-captures via the per-frame path (screenshot-fallback semantics
preserved). Off by default; opt in with HF_DE_BATCH=4.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* feat(engine): 3D projection + compositor-effect risk gate
* fix(engine): gate filter drop-shadow wherever blur gates (review)
detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).
Addresses miguel-heygen's blocker on #1918.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension
# Conflicts:
# packages/engine/src/services/screenshotService.ts
* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)
Addresses miguel-heygen's blockers on #1919:
- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
a documented diagnostic flag: it exists for upstream-Chromium repro work
(gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
classes; renders under it may be damaged BY DESIGN since it bypasses gates
whose thresholds encode measured damage. Never production; the safety-net
blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
before canvas injection, then initializeSession called it again — the
second run overwrote the armed state with skipReason="capture_mode"
(captureMode is "drawelement" by then), producing contradictory telemetry
(armed frames + a skip reason), and re-ran the verification seeks on the
fallback path. It now no-ops once staticFrames or a skip decision exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer): fast-capture render stages + remote bg-image localizer
* feat(lint,player): fast-capture lint rule + player media sync
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* feat(engine): 3D projection + compositor-effect risk gate
* fix(engine): gate filter drop-shadow wherever blur gates (review)
detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).
Addresses miguel-heygen's blocker on #1918.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension
# Conflicts:
# packages/engine/src/services/screenshotService.ts
* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)
Addresses miguel-heygen's blockers on #1919:
- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
a documented diagnostic flag: it exists for upstream-Chromium repro work
(gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
classes; renders under it may be damaged BY DESIGN since it bypasses gates
whose thresholds encode measured damage. Never production; the safety-net
blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
before canvas injection, then initializeSession called it again — the
second run overwrote the armed state with skipReason="capture_mode"
(captureMode is "drawelement" by then), producing contradictory telemetry
(armed frames + a skip reason), and re-ran the verification seeks on the
fallback path. It now no-ops once staticFrames or a skip decision exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer): fast-capture render stages + remote bg-image localizer
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): 3D projection + compositor-effect risk gate
* fix(engine): gate filter drop-shadow wherever blur gates (review)
detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).
Addresses miguel-heygen's blocker on #1918.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension
# Conflicts:
# packages/engine/src/services/screenshotService.ts
* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)
Addresses miguel-heygen's blockers on #1919:
- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
a documented diagnostic flag: it exists for upstream-Chromium repro work
(gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
classes; renders under it may be damaged BY DESIGN since it bypasses gates
whose thresholds encode measured damage. Never production; the safety-net
blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
before canvas injection, then initializeSession called it again — the
second run overwrote the armed state with skipReason="capture_mode"
(captureMode is "drawelement" by then), producing contradictory telemetry
(armed frames + a skip reason), and re-ran the verification seeks on the
fallback path. It now no-ops once staticFrames or a skip decision exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): 3D projection + compositor-effect risk gate
* fix(engine): gate filter drop-shadow wherever blur gates (review)
detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).
Addresses miguel-heygen's blocker on #1918.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on.
## Changes
- **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips.
- **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected.
- **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`).
- **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard.
- **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack.
- **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar).
- **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists).
## Verification
- oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top
- Full studio suite at stack top: 1189 tests pass
## Stack
PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## drawElement fast-capture — config + CLI flag (stack 1/6)
Foundation layer for the drawElement fast-capture feature: the config surface and CLI/Docker plumbing that the rest of the stack builds on.
### What this adds
- **`packages/engine/src/config.ts`** — new config fields for fast capture: `useDrawElement` / `enableDrawElementWorkerEncode` (macOS-GPU `drawElementImage` capture + worker-offloaded JPEG encode), resolved from env in `resolveConfig` (env `HF_DE_WORKER_ENCODE`). Wired alongside main's existing `staticFrameDedup` (unified downstream in 4/6).
- **`packages/cli/src/commands/render.ts`** — `--experimental-fast-capture` flag → sets `experimentalFastCapture`; `--debug` passthrough.
- **`packages/cli/src/utils/dockerRunArgs.ts`** — pass the fast-capture env through to the container.
- **`.github/workflows/fast-video-validation.yml`** — CI job validating fast-capture renders.
- `.oxlintrc.json` / `.fallowrc.jsonc` — ignore-pattern housekeeping for the new paths.
### Notes
- Config-only + entrypoint; no capture behavior yet (that's 2/6–4/6).
- Tests: `config.test.ts`, `dockerRunArgs.test.ts` added.
---
**Stack (drawElement fast-capture, rebased onto current `main`, supersedes #1295 + #1444):**
1. **#1916 config + CLI** ← you are here
2. #1917 drawElementImage capture service
3. #1918 3D projection + compositor-effect risk gate
4. #1919 frame-capture core (routing, worker-encode, static-dedup unification)
5. #1920 producer render stages + remote bg-image localizer
6. #1921 lint rule + player media sync
⚠️ Intermediate PRs (1–5) are split by package boundary for review and **do not each compile independently** (cross-file deps); the complete feature is green at the stack tip (#1921) — tsc-clean on engine + producer, 231 tests pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session
Root-causes the setTiming element_not_found resolver-shadow divergence class:
timeline edits carry hf-ids read from the live preview DOM, but the preview
minted ids AFTER rewriting attributes (and never persisted them for sub-comps),
while the SDK session mints from the raw file — content-keyed minting then
yields different ids for the same element. Template-based comps were worse:
the SDK excluded the whole <template> subtree, so the session had zero
elements and every edit diverged.
- parsers: ensureHfIds now descends into <template> subtrees (linkedom's
querySelectorAll does not), minting and pinning inner ids
- sdk: buildRoots/buildElement treat <template> as a transparent container,
and resolution (resolveScoped, animation-id map) searches template subtrees
via querySelectorAllDeep — template comps now model, resolve, and edit
- studio-server: the sub-comp preview route persists hf-ids to the raw file
BEFORE the rewrite pipeline (mirrors the main route), pinning one id space
across served DOM, disk, and SDK session
- studio: resolver-shadow skips structurally-empty sessions (no event, no
attempt) and tags fail-open emissions with sourceReadFailed so read errors
are distinguishable from unwired readers in telemetry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route
Addresses the 10 verified findings from the PR #1981 review:
- Restrict template transparency to COMPOSITION templates
(<template data-composition-id>) everywhere — ensureHfIds, SDK
buildChildren, querySelectorAllDeep. A plain <template> (runtime
clone-source) keeps its old fully-excluded behavior: stamping its
interior would duplicate one persisted id across every runtime clone,
and modeling it would show phantom timeline clips.
- Guard the sub-comp persist: only .html files (the wildcard route can
serve any project path — stamping an SVG corrupted it on disk),
try/catch the read (file-removed race becomes 404, not 500), salt the
etag (v2) so pre-fix cached clients don't 304 past the id pin, and
thread the stamped content into buildSubCompositionHtml so served ids
match the mint even when the disk write is skipped.
- Rewrite querySelectorAllDeep as a document-order DOM walk — appending
template matches after top-level matches made duplicate-id tiebreaks
disagree with the preview's unwrapped DOM (wrong-element edits).
- Recurse sourceMutation.querySelectorAllWithTemplates so server-side
ops resolve ids at any template depth, matching SDK resolution.
- Replace the empty-session silent skip with ONE tagged session_empty
event per session — silence would blind the tripwire to exactly the
modeling-gap class that exposed the template bug. Attempts stay
uncounted (an unmodelable comp can't cut over).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race)
Replace the route-level stat/read/persist sequence with stampFileHfIds:
validation (fstat), read, mint, and write-back all go through ONE open
file descriptor (O_NOFOLLOW where supported), so the path cannot be
swapped between validation and write. Falls back to read-only stamping
when the file isn't writable — content-keyed minting means the SDK
derives the same ids from the same bytes even without the disk write.
Addresses miguel-heygen's blocking review on PR #1981.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos)
promoteTemplateCompositionId's single-pattern regex backtracked
polynomially on crafted input. Two-step match: grab each <template>
open tag linearly, then find data-composition-id within that short
tag text. Same semantics (first template carrying the attr wins).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes the observability gaps on the figma integration:
- withFigmaErrors takes a command label (figma:asset|tokens|component) and
reports the failure inline before its process.exit — the top-level
trackCommandFailures wrapper never sees self-exiting commands, so typed
codes (NO_TOKEN, BAD_TOKEN, FORBIDDEN, RATE_LIMITED) were invisible.
FigmaClientError codes surface as the error name for dashboarding the
first-run funnel (NO_TOKEN -> later success = onboarding conversion).
- new figma_import event per import: phase, duration, reused (dedup
effectiveness), tokens variables-vs-styles mode + entry count
(Enterprise gating rate), unresolved-binding + rasterized-node counts
(fidelity degradation). No fileKeys, node ids, names, or descriptions.
- /figma skill fires the events beacon (figma-motion / figma-shaders /
figma-storyboard) for the MCP phases that never touch the CLI.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
recordAnimationResolverParity reported a false animation_not_found divergence
for any tween whose selector doesn't currently CSS-match a live DOM element,
because it only checked el.animationIds (DOM-gated). The real server-side op
it shadows resolves purely from the parsed script. Adds
Composition.getAllAnimationIds() as a DOM-independent id set and checks it
too, matching the server's actual resolution behavior.
Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
inventory media-use maintains — format locked byte-identical via a
cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
index table, and <img alt>; component rasterize auto-describes with
the node name. Named brand marks become visible to media-use's
resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
the follow-up queue (shared media-ledger module, global cache for
figma assets, media-use version-keyed idempotency)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(studio): add resolver-shadow attempt counter for soak-gate denominator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio): harden attempt-counter exception safety and tab-hide flush ordering
PR review feedback (4 reviewers): recordAttempt() sat outside the try/catch
in all three emit functions, so a throw inside it (e.g. setInterval/
addEventListener failing in a non-standard environment) would break the
"never throws" contract. Also, the new visibilitychange listener races
studioTelemetry.ts's own tab-hide handler — whichever fires first can beacon
the queue before or after this module's rollup lands in it, silently
dropping the attempt count for short sessions closed before the 5-minute
timer fires.
Fixes: move recordAttempt() inside each function's try block; export
flushViaBeacon() from studioTelemetry.ts and call it explicitly after
queuing the rollup, so delivery no longer depends on listener registration
order; capture the visibilitychange handler by reference so
__resetAttemptSchedulingForTests() actually removes it instead of leaking a
duplicate on re-arm.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate
A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.
Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:
- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
bind — after GSAP parse) renders translate = (data-x − base), a pure
delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
channel (the --hf-studio-dx/dy vars had no consumer outside authored
Studio bridges), and commitPreview mirrors the committed move onto
the live element so it holds without an srcdoc reload
Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.
Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): harden position-edit rendering and the drag draft channel
Fixes six issues from adversarial review of the moveElement stack:
- Runtime: apply position edits at init as well as at timeline bind, so
committed moves render in compositions with no usable GSAP timeline
(CSS/WAAPI-animated or fully static) — previously the apply was
unreachable outside the boundDuration > 0 bind branch and the edit
silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
the previously written translate was consumed externally (a lazily
created tween folding it into GSAP's cached transform), skip instead
of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
drags — compositions with the documented var-consuming drag-bridge
CSS moved by twice the pointer delta (var transform + new inline
translate). The inline translate is now the only draft channel;
deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
when there was none), so a stylesheet-authored translate is never
promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
dispatch throws, instead of leaving the element shifted by an
uncommitted draft.
Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry
Addresses PR #1875 review feedback (Rames, Miga):
- Prime the element's GSAP transform parse (gsap.getProperty) before the
first translate apply — positioned tl.set()s and tweens that first
RENDER after the apply now reuse the cache instead of folding the edit.
This closes the lazy-first-parse fold-loss for any page where GSAP is
loaded at apply time; the residual limitation is GSAP itself loading
after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
that originated the per-axis loss) and a positioned tl.set() element,
asserted across the full seek range.
- Simplify the num() null guard (review nit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4)
Rewrites the skill from MCP-first to the spec 2 split: asset/tokens/
component route through the hyperframes figma CLI (FIGMA_TOKEN), motion/
shaders stay agent-driven over MCP (no REST equivalent). Adds two-
credential guidance, Starter rate-limit tactics (recursive:true, raw-
response cache, opt-in screenshots), the 7.1 binding flow (tokens before
components, one ask per unknown library, never value matching), and the
shader manual-export default. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): register figma component subcommand
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): add storyboard-to-animatic guidance to /figma
Field-tested against a real 26-scene storyboard section: the parsing
grammar (frame-sized nodes incl. loose rectangles = scenes, x-order =
time order, TEXT below the strip = director notes paired by x-overlap),
batched still export (chunk ~4 ids per render call - big frames timeout
past ~12), a note-verb -> transition vocabulary (EXPLOSION/SLIDE/MORPH/
CYCLE), and the stills-vs-component routing rule for within-scene motion
notes. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): storyboard frames are keyframes, not slides
Field-tested against a second real storyboard section: frames sharing an
element (matched by name, else geometry similarity) define that element's
states through time - tween the element between states, crossfade only
when pixels genuinely differ, enter/exit unmatched children, tween frame
backgrounds as a color track. Stills demoted to fallback for frames that
don't decompose. Validated live: a 4-frame logo-rise reconstructed as one
element with four keyframes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(figma): self-explanatory first-run experience + mintlify guide
- NO_TOKEN/BAD_TOKEN errors now carry the full one-time setup (mint URL,
read-only scope checklist, persist hint) instead of a bare pointer
- figma subcommands print clean guidance on typed client errors, not a
stack trace (shared withFigmaErrors boundary)
- CLI help gains component subcommand, FIRST-TIME SETUP and WHAT TO
EXPECT blocks
- /figma skill: preflight the token before the first CLI call and walk
the user through setup up front; narrate landed-artifact + next action
at every step
- new docs/guides/figma.mdx (setup, per-phase walkthroughs, provenance,
troubleshooting table) wired into docs.json nav
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(figma): review fixes — missing withFigmaErrors imports, 401/403 semantics, docs accuracy
- tokens.ts/component.ts called withFigmaErrors without importing it
(tsup doesn't typecheck, so every invocation shipped as an immediate
ReferenceError); imports added, tsc --noEmit now clean
- error boundary widened to all Errors so bad-ref/bad-format input
errors print their message instead of a stack trace
- 401 no longer claims 'missing scopes' (figma signals that as 403);
new FORBIDDEN code maps non-variables 403 to scope/access guidance
- docs: asset/component refs require a node id (bare fileKey is
tokens-only), example snippet matches real output, FORBIDDEN row
- skill: preflight counts a project-.env token as configured (CLI
auto-loads it); BAD_TOKEN/FORBIDDEN guidance split
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): present figma errors via standard errorBox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
resolveBindings: scan the full tree (boundVariables + style ids, alias
chains, children) and partition exact-ID-only against the binding index
before any CSS is emitted per spec 7.1 - never value matching.
nodeToHtml: absolute geometry at figma bounds inside a fixed-size root,
solid/linear-gradient fills, corner radius, opacity, drop shadow, blur,
text styles; resolved bindings emit var(--slug, literal), unresolved
bake literals with data-figma-unresolved; visible:false respected;
vectors/boolean ops route to a rasterize list.
hyperframes figma component: tree -> bindings -> html, rasterize
fallback via Phase-1 asset export with src backfill, registry-item
packaging, unresolved-binding guidance in output.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
tokensToVariables: variables -> composition brand-variable entries
(COLOR->hex/rgba, FLOAT/STRING/BOOLEAN), alias chains walked cycle-safe
to the leaf value while the binding keeps the semantic id. Sidecar
figma-tokens.json + .media/figma-bindings.jsonl records per spec 7.1.
hyperframes figma tokens: variables path, REQUIRES_ENTERPRISE degrades
to published-styles metadata (values resolve at component time).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
M0: renderNode/imageFills/variables/styles/nodeTree/fileVersion over
api.figma.com with injectable fetch and typed capability errors
(NO_TOKEN/BAD_TOKEN/REQUIRES_ENTERPRISE/RATE_LIMITED/RENDER_FAILED/
NODE_NOT_FOUND/HTTP_ERROR) per design spec 4.4.
M1: svg sanitizer (scripts/foreignObject/handlers/external hrefs) +
hyperframes figma asset: render -> sanitize -> freeze under .media/ ->
manifest provenance -> snippet. Idempotent on
fileKey:nodeId:format:scale:version; re-imports when the version moves.
Plus the 7.1 binding index store (.media/figma-bindings.jsonl): exact-ID
lookup incl. alias chains, per-project library-file answers, shared
jsonl reader with the asset manifest.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(core): add figma motion easing mapping
* feat(core): translate figma motion doc to gsap timeline spec
* feat(core): emit paused GSAP timeline script from figma motion spec
* fix(core): restore type exports dropped from figma barrel in Task 8
* feat(skills): add /figma import skill + catalog wiring
Add the agent-facing /figma skill (asset + Figma Motion import via the
Figma MCP connector, built on @hyperframes/core/figma) and wire it into
the skill catalog across CLAUDE.md, README.md, docs/guides/skills.mdx,
and the hyperframes router's capability map. Bumps the skill count from
19 to 20 in CLAUDE.md and README.md.
* fix(core): use replaceAll for figma node-id dash-to-colon conversion
* style: format skills catalog tables
oxfmt-align the README and router SKILL.md tables after the /figma +
/hyperframes-keyframes merge left uneven column padding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): add missing cache fields to telemetry test fixture
ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/
cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry
test fixture was never updated, breaking Typecheck on main and every PR
based on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## What
Foundations of the `@hyperframes/core/figma` module — the pure, transport-agnostic layer every later phase builds on:
- **`types.ts`** — `FigmaRef`, `FigmaProvenance`, `FigmaManifestRecord`, and the Motion model (`MotionDoc`/`MotionTrack`/`TimelineSpec`/`GsapTween`) shared across the stack.
- **`parseFigmaRef`** — normalizes any user input (full `/design|/file|/proto` URLs with `?node-id=1-2`, `fileKey:nodeId` shorthand, bare `fileKey`) into `{ fileKey, nodeId }`, including the URL-dash → API-colon node-id conversion.
- **`freeze.ts`** — `freezeBytes`/`freezeUrl`/`freezeLocalFile` with a 256 MB cap; every Figma asset is frozen to a local file before it can reach a composition (determinism: no render-time network).
- **`manifest.ts`** — the `.media/manifest.jsonl` ledger (same layout `media-use` writes, so a project has one shared media inventory without either skill depending on the other): append/read/find-by-node/next-id, with a pure type-guard (`isFigmaManifestRecord`) instead of `as`-casts.
- **`assetSnippet.ts`** — manifest record → composition `<img>` snippet with escaped attrs + `data-figma-id`.
- **publishConfig fix** — `./figma` added to `packages/core` `publishConfig.exports` (the packed-manifest CI gate requires every source export to have a dist mapping).
## Why
Design spec: `docs/superpowers/specs/2026-06-30-figma-asset-integration-design.md`. These functions are deliberately transport-agnostic — when the project reversed from MCP-first to a REST/MCP split (spec §2), nothing in this layer changed. That was the point.
## Tests
Unit tests per module (URL variants, freeze cap edges, manifest round-trip/malformed-line tolerance, snippet escaping). All colocated `*.test.ts`, vitest, no network.
---
Stack (1/6): this PR → #1869 → #1870 → #1871 → #1872 → #1873🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fix slideshow presenter mode for screen-share workflows by opening the audience view as a regular noopener tab, preserving audience query construction across fragments, and keeping iframe keyboard forwarding diagnosable.
onRuntimeTimelineReady (the cross-origin ready signal for signed CDN
composition URLs) never called _rescale(), leaving the iframe unscaled
and untransformed if the runtime's stage-size postMessage was ever
skipped. Also adds a one-shot diagnostic warning when a rescale keeps
no-oping after ready, latched so a legitimately hidden/zero-size player
doesn't spam the console.
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.
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>
* 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>
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>
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>
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>
* feat(slideshow): auto-set interactive on inner player
The slideshow now sets the `interactive` attribute on its inner
<hyperframes-player> instances at mount time, so pointer events
reach the composition iframe automatically. Removes the
agent-compliance burden of having to remember to add `interactive`
on every player tag inside a slideshow.
Idempotent: an author-supplied `interactive` attribute (any value,
including `interactive="false"`) is preserved. A MutationObserver
also picks up players inserted dynamically after the initial mount.
Standalone player usage outside a slideshow still requires the
explicit attribute — that surface is unchanged.
Skill guidance at skills/slideshow/SKILL.md updated to reflect the
automatic behavior.
* docs(slideshow): clarify interactive attribute semantics
Per Rames R1 review feedback: the test comment implied
`interactive="false"` is an author opt-out, but `:host([interactive])`
is presence-matching per HTML boolean-attribute convention — so any
value (including "false") enables pointer events at runtime. The
slideshow's mechanical wire-up preserves any author-supplied value
verbatim for DOM hygiene, not as a runtime opt-out.
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(slideshow): address split-PR review findings on #1585
Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were
already fixed on this branch: CSP handlers, manifest version, UUID ids, float
keys, presenter 1s-timer):
core (#1580):
- isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly
- resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite
player (#1590):
- present() window.open uses noopener,noreferrer (audience syncs via channel)
- BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin
cross-talk between decks
- add observedAttributes + attributeChangedCallback so runtime sound/mode toggles
re-render
studio (#1591/#1592):
- persistSlideshowManifest no-op gate (skip write when HTML is unchanged)
- surface persist failures (console.error) instead of silent .catch(()=>{})
- confirm before deleting a branch sequence (data-loss + dangling hotspots)
+ tests for the collision + non-object-manifest rejection. 20 core / 106 player /
53 studio pass; tsc/lint/fmt/fallow clean; deck still renders.
* fix(slideshow): finish remaining split-PR review findings
The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in
#1585):
core (#1580):
- dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the
lint rule and the runtime scene-window computation (no more mirror-and-drift)
player (#1590 / #1592):
- onKey: when multiple decks share a page, drop the unfocused-convenience so a
key drives only the focused deck
- slow-iframe recovery: if the scene timeline posts after the wait times out
(empty scenes), re-init once so sceneId slides resolve instead of being dropped
studio (#1591):
- persistSlideshowManifest validates the built island round-trips before writing
- reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line
reorder), with a branch-position indicator
+ tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass;
tsc/lint/fmt/fallow clean.
* fix(player,cli): use fileURLToPath for path resolution (Windows CI)
new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows,
which broke:
- packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias
resolved to a nonexistent path, failing the player slideshow tests on the
Windows render-verification CI (passed on macOS/Linux where pathname is clean)
- packages/cli/src/utils/compositionServer.ts helperDir — same bug in the
play/present bundle-path resolution
fileURLToPath converts file:// URLs to correct OS paths on all platforms.
Player slideshow tests pass; present serves + resolves bundles.
* fix(producer): fileURLToPath for the renders dir (Windows)
DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..."
on Windows and resolves to a bogus path — affects the Windows render pipeline.
Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).
* fix(slideshow): address code-review findings #1580-1584
- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow fullscreen + presenter-view rework
- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
a bare paused seek does not repaint some compositions. resumeSlide now plays a
brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): slideshow no auto-progress + presenter slide fits/pins
- navigation jumps to a static frame instead of auto-playing the timeline:
playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
region above the notes panel, so the player CONTAINS the composition — the
full slide stays visible (letterboxed) at any width and re-fits on resize;
its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons
Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): stop presenter nav buttons flickering / dropping clicks
The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version
Addresses review feedback on the split stack:
- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
to the core schema so future schema changes can migrate older islands.
These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(ci): fix format + fallow gates for slideshow stack
- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
(duplicates.ignore). File-level config, not inline comments — inline shifts line
numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address PR review + CodeQL findings
- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
presenter-test.html — a generated test harness (postMessage w/o origin check,
proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
[start,end], not 'strictly inside'.
IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.
* feat(cli): add 'present' command — serve a deck in presenter mode
hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.
- New utils/compositionServer.ts factors the server scaffolding shared with
'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
wiring) and the per-command startup/logging block from the complexity /
duplication gates.
Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).
* fix(cli): present renders the deck (player sizing + self-driving serve)
Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
zero size — the (absolutely-positioned) chrome showed but the composition
didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
its timelines engine-paused (blank). Slideshow decks self-drive their own
timelines (like demo.html / the standalone harness), so serve them raw.
Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.
* fix(cli): present plays slideshow sound effects
The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.
Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.
* feat(examples): softer mellow slideshow sfx for airbnb-deck
Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.
* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck
Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)
* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)
- Going backward a slide now plays the reverse whoosh (back), not advance —
the sfx logic detects nav direction by scene order instead of firing advance
for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.
Verified: next then prev fires [advance, fragment, back]; no page errors.
* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)
Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
presenter-test.html — add an origin check (same-origin composition iframe)
and an own-property guard so a 'name' like __proto__ can't resolve to and
mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
higher-specificity [data-hf-muted] [data-hf-mute]:hover override.
Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).
* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness
- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray 54a4460 git add -A had re-added the deleted
harness (reviving CodeQL #639/#640); remove it again.
106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow controller state machine
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
* feat(player): <hyperframes-slideshow> web component + presenter
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
* feat(studio): slideshow manifest persistence + panel helpers
Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
* feat(studio): slideshow branching editor panel UI
Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.
* docs(skill): slideshow authoring guidance + standalone harness reference
New /slideshow skill (island schema, slide rules, fragments, branching,
validation) + a standalone-harness reference doc, and a router entry in
the /hyperframes skill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(examples): slideshow demos — airbnb deck, startup pitch, fixture
Three runnable slideshow compositions: a current-Airbnb-branded remake of
the 2009 seed deck (Three.js backgrounds, GSAP entrances, hotspot branch,
HeyGen SFX), an animated startup pitch, and a minimal fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow controller state machine
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
* feat(player): <hyperframes-slideshow> web component + presenter
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
* feat(studio): slideshow manifest persistence + panel helpers
Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
* feat(studio): slideshow branching editor panel UI
Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.
* docs(skill): slideshow authoring guidance + standalone harness reference
New /slideshow skill (island schema, slide rules, fragments, branching,
validation) + a standalone-harness reference doc, and a router entry in
the /hyperframes skill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow controller state machine
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
* feat(player): <hyperframes-slideshow> web component + presenter
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
* feat(studio): slideshow manifest persistence + panel helpers
Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
* feat(studio): slideshow branching editor panel UI
Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.
* feat(player): slideshow controller state machine
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
* feat(player): <hyperframes-slideshow> web component + presenter
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
* feat(studio): slideshow manifest persistence + panel helpers
Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
* feat(player): slideshow controller state machine
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
* feat(player): <hyperframes-slideshow> web component + presenter
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow>
custom element (wraps <hyperframes-player>, drives the controller),
presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
Stack-split from the original ss-player PR (#1581): the SlideshowController
state machine (stack-based slide/fragment/branch navigation) and its tests.
The <hyperframes-slideshow> web component follows in the next PR.
New Slideshow right-panel tab — slide list, inspector (notes + fragment
hold-points), branch tree, hotspot tool — backed by pure manifest-transform
helpers and a debounced SDK persist that writes the JSON island.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DOM-free SlideshowController (discrete nav, fragment holds, branch stack)
driving the existing player; <hyperframes-slideshow> web component with a
unified mute+nav capsule (conditional prev/next), floating hotspot overlays,
presenter mode (BroadcastChannel), keyboard/touch, and a scenes getter fed
via the runtime message handler.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guard blocked on any semver-higher v* tag, including orphan tags on dead
branches (e.g. a stray `chore: release v1.0.3` never merged or published).
Such tags can't appear in the release history and shouldn't block a legitimate
release. Now only tags that are BOTH higher AND reachable from HEAD block;
extracted `findBlockingTags` with unit coverage for orphan/reachable/lower cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Slideshow mode — 1/5: core schema, parser & lint
Foundation for slideshow mode: a composition can declare an embedded **slideshow manifest** that turns its continuous timeline into a discrete, navigable deck. This PR adds the data model, parser, and validation — no runtime/UI yet.
### What & why
A slide is just an existing scene (`data-composition-id` + `data-start`/`data-duration`) plus metadata declared in one embedded `<script type="application/hyperframes-slideshow+json">` island. Keeping the manifest *in the composition* means no new file format and no build step — slides are additive metadata over a normal composition.
### Key changes
- `slideshow/slideshow.types.ts` — `SlideshowManifest`, `SlideRef`, `SlideHotspot`, `SlideSequence` and their resolved counterparts. TTS fields (`ttsScript`/`ttsAudioUrl`/`ttsDurationMs`) are present but **reserved** (playback not built).
- `slideshow/parseSlideshow.ts` — `parseSlideshowManifest(html)` extracts the island; `resolveSlideshow(manifest, scenes)` resolves each `sceneId` to a `{start,end}` range (honouring optional `startTime`/`endTime` overrides) and returns validation errors for: unresolved sceneId, fragment outside a slide's range, hotspot targeting an unknown sequence, and overlapping main-line slides.
- `lint/rules/slideshow.ts` — surfaces those resolve errors under `hyperframes lint`; derives scenes from `data-composition-id` (matching the runtime's scene source).
- `lint/rules/core.ts` — exempts the slideshow island MIME type from the inline-script-syntax check.
- `./slideshow` subpath export (dev + publishConfig) so downstream packages import only the lightweight parser, keeping core's Node-only barrel out of their typecheck graph.
### Testing
`parseSlideshow.test.ts` + `slideshow.test.ts` (vitest) cover parse, resolution, every error path, and the lint rule.
### Stack
Bottom of a 5-PR stack: **core** → player (#1581) → studio (#1582) → skill (#1583) → examples (#1584).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* refactor(core): retire recast/babel, route all GSAP mutations to acorn (WS-E/3.F)
- Delete gsapParser.ts (2595-line recast-based parser/writer)
- Delete gsapParser.test.ts, gsapParser.stress.test.ts, gsapParser.test-helpers.ts
- Add gsapParserExports.ts: re-export umbrella for gsap-parser subpath
- Move SplitAnimationsOptions/SplitAnimationsResult to gsapSerialize.ts
- executeGsapMutation: async->sync, static acorn imports replace loadGsapParser()
- Fix 3 function name mismatches in files.ts switch cases
- generators/hyperframes.ts: imports from gsapSerialize (blocker resolved)
- gsapWriterAcorn.ts: SplitAnimationsOptions from gsapSerialize
- Parity tests: recast oracle removed; acorn-only regression (14 pass)
- Remove recast and @babel/parser from core/package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sdk): harden mutation handlers + widen variable API (code-review)
Self-contained review fixes for the SDK-hotspot stack (#1569–#1573). The
dispatch path (_dispatch → applyOp) never runs validateOp, so the new
WS-D/WS-3.C guards were advisory-only; re-enforce them in the handlers.
- addElement: null-guard the resolved parent (no more `as Element` masking a
null → crash on unknown parent id); reject <script> and multi-root fragments
via parseInsertableFragment instead of inserting raw markup / silently
dropping extra roots.
- addWithKeyframes / replaceWithKeyframes: bail on empty keyframes (no
degenerate `keyframes: {}` tween) and when the animationId resolves to
nothing (no silent degrade-to-add leaving a duplicate tween).
- isObjectVariableValue: exclude arrays so an array override value can't be
misclassified as a font/image object and written into the variable model.
- Composition.setVariableValue: widen the public interface signature to
`… | FontValue | ImageValue` to match the impl + EditOp (B2 object-valued
variables were unreachable via the typed API).
- mutate.gsap.test.ts: import addKeyframeToScript from gsap-writer-acorn —
the gsap-parser subpath no longer re-exports write fns after recast retire,
so the test threw at runtime (red suite).
- Dedup: export EXCLUDED_TAGS from hfIds.ts and drop the verbatim
HF_EXCLUDED_TAGS copy in mutate.ts.
Adds guard regression tests. SDK 340/340, core hfIds 13/13, build green,
fallow --gate new-only clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): variable-model dedup + undo/scoped-parent correctness; test honesty (code-review)
Second batch of review fixes for the SDK-hotspot stack.
- Variable model (#7, #13): extract readVariableDefault/writeVariableDefault into
a shared engine/variableModel.ts used by both mutate.ts (forward) and
apply-patches.ts (replay), so the model shape can't diverge. Add
clearVariableDefault and make a `variable` remove patch DELETE the decl's
`default` key — the exact inverse of a first-set on a default-less variable.
Previously undo of such a set no-op'd and stranded the value.
- addElement scoped parent (#8): record the caller's id verbatim
(scoped "hf-host/hf-leaf" path or composition id) as the patch parentId
instead of the bare data-hf-id, so redo/replay re-resolves the SAME parent via
resolveScoped rather than the canonical top-level dup (or document.body).
- resolveTimings honesty (#5): correct the header + test that claimed a live
"preview == render" parity — neither path consumes the resolver yet (anchor
inputs are Pacific/backend-deferred). It's a pure-function property, not a
current guarantee.
- GSAP writer parity (#12): the recast oracle was deleted in WS-3.F, leaving the
WS-3.C keyframe ops comparing acorn output to itself. Pin them as golden inline
snapshots and drop the now-dead recast scaffolding (replaceWithKfRecast,
removeAnimRecast alias). Remaining pre-WS-3.C parity blocks noted as follow-up.
Adds regression tests (undo of default-less variable; scoped-parent redo).
SDK 342/342, core timingResolver+parity green, build + fallow --gate new-only clean.
Not changed (need design / out of scope): #9 pre-#1569 persisted-override CSS
replay (moot for unreleased data; proper fix is render-time CSS derivation),
#11 replaceWithKeyframes stale positional id (mitigated by the missing-id no-op
guard + type doc; full fix needs non-positional ids).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): replay CSS-prop derivation for legacy var overrides; stale-id selector guard (code-review)
Final review-fix batch — the two items deferred from the prior pass.
- #9 legacy variable-override CSS: applyOverrideSet now derives the `--{id}`
CSS custom prop from any scalar `var.{id}` override on replay (and removes it
for a null override). Sets written before the model/CSS split carried only
`var.{id}`; without this, replaying them updated the JSON model but left
`var(--{id})` bindings rendering the schema default. Replay-path only — the
undo path (applyOne) is untouched, so #1569's separate-patch undo correctness
is preserved. Object (font/image) values are never CSS, so they are skipped.
- #11 stale positional id: replaceWithKeyframes now requires the located
animation to still target the caller's `targetSelector`. Position-derived ids
re-point after structural edits; a stale id resolving to a DIFFERENT element's
tween previously got silently replaced. It now bails (no-op) unless the id
still points at the expected selector.
Adds regression tests (legacy var.{id}-only override restores CSS; object
override writes no CSS; stale-id-wrong-selector replace is a no-op).
SDK 345/345, build + fallow --gate new-only clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F)
Product decision pivot: acorn no longer replaces recast as the GSAP writer.
Recast remains the default server writer; acorn runs only when
STUDIO_SDK_CUTOVER_ENABLED=true (or =1) is set server-side — the same env
flag name as the client Vite var, so a single switch flips both sides.
Changes:
- Restore gsapParser.ts (recast writer) + test/stress/helper files deleted by 3F
- Restore @babel/parser + recast deps in packages/core/package.json
- Add isAcornGsapWriterEnabled() + loadGsapParser() to files.ts (lines 59-82)
- Split executeGsapMutation into async dispatcher + executeGsapMutationRecast
(recast, async via loadGsapParser) + executeGsapMutationAcorn (acorn, sync)
- Dispatcher defaults to recast; acorn branch taken only when flag is on
- Restore gsapWriter.parity.test.ts, gsapWriterParity.acorn.test.ts, and
gsapWriterParity.corpus.test.ts to true recast-vs-acorn differential suites
(not acorn-vs-itself)
- Exempt gsapParser.ts in .fallowrc.jsonc health.ignore + ignoreExports
(pre-existing complexity + barrel re-exports consumed outside diff scope)
- Add fallow-ignore-file code-duplication to files.ts (intentional parallel
switch bodies for two writers)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): image-alpha hit-test phase 1 (WS-G)
Extends the WS-A1 iframe adapter with image-alpha hit-testing:
- Replace `elementFromPoint` with `elementsFromPoint` (z-stack) so
a transparent-image hit falls through to the layer behind.
- For `<img>` hits: map client point → natural-pixel coords via a
pure `mapPointToImagePixel` fn (object-fit cover/contain/fill aware);
draw to an offscreen canvas once (cached by `currentSrc`); sample
alpha via pure `alphaIsOpaque`. Transparent pixel → miss, continue
the stack.
- Cross-origin images that taint the canvas → SecurityError fallback
→ treat pixel as opaque (never drop an unverifiable hit).
- Phase 2 (per-pixel alpha via `drawElement`) NOT built; gated on a
perf spike per plan.
Tests: alphaIsOpaque thresholds, mapPointToImagePixel (fill/cover/contain
+ out-of-box→null), z-stack fallthrough, taint→opaque fallback, non-image
WS-A1 regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): correct image-alpha hit-test edge cases (WS-G review)
- map within the content box, not the border box (object-fit positions
the image inside border+padding; getBoundingClientRect was off for a
bordered/padded <img>)
- normalize vertical-first object-position keyword pairs ("bottom left")
- guard natural.width/height===0 in cover/contain (Infinity/NaN scale)
- fall back to elementFromPoint when elementsFromPoint is unavailable
- guard `instanceof win.HTMLImageElement` when the constructor is absent
- bound _imgCanvasCache with a FIFO cap so it can't leak one canvas per src
- drop the redundant taint-probe getImageData; the real pixel read already
surfaces lazy taint
- one opacity walk per candidate: the hf node is on the already-checked
ancestor chain, so the resolver no longer re-walks for visibility
- remove dead `fit==="fill"||` clause and unused resolveToken param
- tests: none-fit, object-position keyword/px/reversed-pair, zero-natural,
no-throw-without-HTMLImageElement + elementsFromPoint fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): image-alpha review fixes (WS-G) — transform/taint/cache-key/memory
Addresses the phase-1 gaps flagged in review (was documentation-only):
- CSS rotation/skew on the image or an ancestor now fails safe to opaque instead
of sampling the wrong pixel (getBoundingClientRect is axis-aligned). Full
transform-inverse mapping stays phase 2. No-op where DOMMatrix is unavailable.
- Cross-origin canvas taint now warns once per src (was silent) so the
fall-back-to-opaque path is visible, not "hit-test feels wrong".
- Canvas cache keyed on src + natural dimensions (was src only) so a
srcset/responsive re-render of the same URL doesn't reuse a stale canvas.
- Pathological-size guard: images above a pixel budget skip alpha-testing
(opaque) to bound OffscreenCanvas memory.
- Docs: border/padding clicks fall through (intentional) noted on imageAlphaOpaqueAt.
Tests: removed the duplicate a=0 threshold case; added transparent-over-
transparent-over-div fallthrough. iframe.test.ts 59/59. tsc/oxlint/oxfmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>