Rendering a composition and mounting one now derive root discovery, scope
identity, asset sources and order, hoisted links, variable carriers and
nested-host enumeration from the same module. Each keeps its own I/O, which is
where they genuinely differ. The compiler's local depth cap and root lookup and
the runtime's three pre-filtered head parameters are gone; the runtime hands over
the head node and lets the module decide what comes out of it.
Four divergences are settled here. Each changes behaviour, so each is stated with
what actually differs rather than with a description of the edit.
Inline head scripts. The compiler looped head scripts with a src branch and no
else, so an inline one was silently discarded on render while the runtime ran it.
The compiler was losing code, not holding a convention. Head and content scripts
now run through one loop, head first, order preserved. A non-templated
sub-composition with an inline head script went from zero collected scripts to
one, wrapped, with its body intact.
Link hoisting. Conditional on render, unconditional on mount, so a templated
sub-composition's webfont link was dropped in video and kept in preview. Hoisting
is the superset and matches what the author declared, so hoisting wins. A
templated composition with a stylesheet link in its head went from no external
links to that link. The parity fixture that previously recorded this shape as a
known exclusion now gates it instead.
Anonymous hosts. With a host naming no id, the compiler fell back to the first
declared composition and scoped to it; the mount left the content unflattened and
injected its stylesheet into the host head unscoped, so a composition's CSS leaked
into whatever mounted it. The compiler's answer wins. The mount now flattens like
every other mount and restores the declared id afterwards. The injected rule went
from a bare class selector to one scoped to the composition.
This flips an assertion that documented the old behaviour as intentional. Its
premise no longer holds. What that test actually cared about, the root and its
content being present under the host, still holds and is still asserted; the
claim that nothing was flattened is now false and the test asserts the scoping
instead.
Scope ids. The compiler splits the CSS scope id from the script composition id;
they differ only when a host names an id the content does not declare, and there
the scripts follow the declared id so their self-referencing queries resolve. The
runtime used one for both. The split wins: a host naming captions-comp over
content declaring captions now emits scripts bound to captions while its CSS
still scopes to captions-comp.
Left alone deliberately: the variable-carrier divergence and its TODO, recursion
on the mount path, and the three copies each of the flattened-root helper and the
id assignment. The first two are behaviour changes with their own units. The third
looks mergeable and is not cheaply, and it touches the instancing contract the
pixel harness guards.
Verified: core 1694 passing, producer 573 passing, the parity contract now nine
tests with fixtures gating the two divergences it can observe. Lint clean,
typecheck:runtime and the runtime preview guards clean, package cycles unchanged.
A trap worth recording: the parity test's compiler arms import core's built dist
while the mount arm imports source, so core must be rebuilt before that lane means
anything after a compiler change. Skipping it produces a phantom divergence.
The parity test already assembled a fixture two ways and deep-equalled a
contract across them. Both ways were static-compiler paths, which is precisely
why the runtime could drift away from them unnoticed for as long as it did.
This adds the third arm: mount the same fixture through loadExternalCompositions
and extract the same contract from the resulting document. Three fixtures run
through all three arms, and one of them authors its style and script as siblings
of the composition root, which is the shape that broke.
authoredStyleSignatures was already in the contract and is exactly the signal
that was missing, so no contract field was added.
Proven in both directions rather than assumed. With the fix reverted the sibling
fixture fails and names the rule verbatim, reporting the composition's own scoped
selector against an empty list. The other two fixtures stay green, so the arm is
targeted rather than blanket-red. Restored, seven of seven pass, and the full
producer lane is 571 passing.
Two forced deviations. happy-dom rather than jsdom, because jsdom is a
devDependency of core only and does not resolve from producer, while happy-dom is
already a root devDependency used by three other packages; adding jsdom would
have been a lockfile change for no gain. And a deep import of the runtime loader,
because the mount path is deliberately not in core's export map -- it ships
inside the runtime bundle. The engine's frame-extractor test reaches it the same
way, and package-cycles and typecheck both pass.
Two things are asserted rather than compared. The runtime and variable bootstraps
are injected around a mount by the player or producer, never by the loader, so
comparing them would compare harnesses rather than assembly. Every other contract
field is deep-equalled.
One divergence is deliberately outside this gate. A templated sub-composition's
head link is hoisted on mount and dropped on render; that reproduces, and it is a
behaviour decision for a later change rather than something to quietly correct
here. The fixture set covers the non-templated head-link shape, where all three
paths agree, and the exclusion is recorded in the file with its reason.
The fixture set is asserted non-empty and asserted to contain the sibling shape,
so a later refactor cannot empty it and leave a green no-op behind.
A composition mounted as a sub-composition lost its entire stylesheet and
scripts whenever they were authored as siblings of the composition root inside
its template. mountCompositionContent collected assets from the composition root
element, so sibling nodes were invisible to it.
The shape is legal and common, so the result was three catalog components
rendering completely unstyled in live preview. oversized-cursor drew its pointer
at 1280px against an authored 7cqw, roughly 134px at 1920, because width: 7cqw
was never declared at all -- the whole stylesheet was missing. Confirmed in the
mounted document, where only the host's own style element was present.
Collect from the source node, which is a superset of the root and the single
point every mount path routes through: external fetch, inline template, nested.
Strip the mounted clone rather than the source. The previous code removed the
extracted nodes from the node it was given, which on the inline-template path is
a live template still in the document -- a remount would have found it emptied.
Why nothing caught it: every CLI gate reaches the compiler path through
bundleToSingleHtml, and the compiler always collected from the whole template.
Nothing in the CLI exercises the mount path, which is reachable only through the
player and Studio. So the rendered video was correct the entire time and only
the preview was wrong. A test spanning both paths lands separately.
The regression test fails on the pre-fix code, asserting that the mounted
document contains the composition's own container-type declaration and finding
an empty string instead -- the stylesheet that never arrived -- and passes
after. Verified in both directions rather than assumed.
Mounting a composition and rendering one are two implementations of the same
job. They answer the same questions -- which nodes are this composition's
assets, in what order its scripts run, how its CSS is scoped, which head
elements hoist, how nested sub-compositions are discovered, which element
carries variable defaults -- and they answered one of them differently. Assets
authored as siblings of the composition root were collected on render and
dropped on mount, so three catalog components rendered unstyled in live preview
while their video was correct.
This adds the module those answers now live in. No behaviour changes yet; the
next change routes both paths through it.
It holds decisions only, never I/O. The two paths differ at their boundary in
ways that are essential: Node with a linkedom document, synchronous, emitting
strings on one side; a browser that fetches, mutates a live DOM and executes
scripts on the other. The runtime also ships as an esbuild bundle to a CDN, so
anything it can reach is weight and risk. Hence no imports at all, a structural
input type rather than Document, and a test that asserts the import surface
stays empty rather than a comment asking politely.
The shape follows compositionScoping, which is already DOM-free and already
imported by both sides.
Four divergences surfaced while deriving the decisions, all currently live and
none of them the reported bug:
- the compiler drops inline scripts in a sub-composition head; it handles the
src case and has no else branch, while the runtime executes them
- link hoisting is conditional on render and unconditional on mount, so a
templated sub-composition's webfont link is dropped in video and kept in
preview
- for a host that names no id, the compiler falls back to the first declared
composition in the content and scopes to it; the runtime mounts the content
whole, unflattened and unscoped
- the compiler keeps two scope ids, one for CSS and one for scripts, so a
script's self-referencing query resolves when a host names an id the content
does not declare; the runtime uses one
The module reports each in the shape the next change will need. Which side wins
is a behaviour decision and is made there, not here.
Verified: 17 tests, and the module's own defect reproduced by mutation --
collecting from the composition root instead of the whole template fails three
of them, including the sibling-asset case. Core suite 1690 passing,
typecheck:runtime and lint:runtime-preview-guards clean, oxlint and oxfmt clean.
de_parallel_router is present on 95.4% of render_complete events and 0.83%
of render_error. Capture context itself survives failures fine (capture_mode
is on 98.6% of them), so this is not renders failing before capture — the
routing state specifically is being dropped.
Cause is ordering. deParallelRouter is assigned twice: once before the
capture-observability update, and again inside syncCapturePlan where routing
is actually resolved — including the 'reverted' case, which the earlier
assignment cannot know. The update in between recorded whatever was true
first, so a render that failed while routed reported no routing state at all.
The existing comment at the earlier call site says it is recorded there
precisely so hard failures carry it; that intent was correct and the value
just arrived too late.
This matters for the #2840 ramp specifically. The per-install circuit breaker
only arms on a revert, which requires the render to finish and self-detect —
it cannot catch a crash or hang. Those are exactly the failure modes a
percentage ramp exists to bound, and they were the ones telemetry could not
see.
Also makes the ffprobe contract sweep resilient per entry. A dangling symlink
under packages/studio/data/projects threw ENOENT on stat and aborted the whole
traversal, so every package sorting after 'studio' — both studio-server
callers included — silently stopped being checked. main is currently red on
this. The manifest assertion is what caught it, which is what it was added
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A near-uniform drag collapses to the `scale` shorthand, but the finalize step
measured the element at the per-axis pair it computed rather than the single
value the commit writes. The element was measured at a scaleY the file never
gets, so the position correction came out tilted by the difference.
Adds a sweep over the shapes a composition produces — shrink, grow, first
resize, rotated, steeply rotated, non-uniform, near-zero, inline-sized, no
position write, animated position, and two drags in a row — each checking the
element renders on its drop point from the PERSISTED scale and position. The
geometry model is calibrated against real gesture traces: the same inputs
reproduce the rects the browser reported to three decimals.
The pull_request webhook for this branch was dropped during the GitHub
Actions incident (major_outage 15:22-00:0x UTC), so #3075 received only the
WIP check and a skipped Mintlify. Dropped webhooks are not replayed, and
ci.yml has no workflow_dispatch, so an empty commit is the only way to fire
the event without closing the PR.
No content change: the release commit 026e6941a is unmodified beneath this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scale resize measured its drop-point correction while the gesture's own
translation was still applied, but the position commit adds that correction
onto the element's PRE-gesture position, which it reads from the gesture's
base attributes. The two disagreed by the whole drag distance, so the commit
persisted a position a drag-length from where the element was dropped: it
held the drop point for one frame and then slid off.
Move the element back to that base before measuring, so the residual and the
commit share one origin. For an element whose position is a static hold that
usually means no correction at all, which is the right answer: scaling about
the centre already leaves it on the drop point.
An element whose position is animated left the drop point anyway. The
finalize step wrote its correction as a static position hold, and the
element's position tween rendered its own value a frame later and won.
Before that it stood down entirely on such elements, on the grounds that
a keyframed path has no single anchor to preserve, which had the same
visible result: the element moved.
It has an anchor, the frame the user is looking at. The correction now
goes into that tween at the playhead, through commitGsapPositionFromDrag,
which is the same commit a drag on the same element already uses. Static
holds keep the existing path.
This is the difference the debug log showed between an element carrying
position:to, which moved after release, and one carrying position:set,
which did not.
The finalize step measures where the committed scale put the box and
shifts the position hold by the difference. Whether the commit had
actually rendered when it measured was luck: on a first resize the
timeline had not re-seeked, so it measured the element at its natural
size still sitting on the drop point, saw no residual, and skipped the
correction. The scale then landed, GSAP rendered it about the element's
centre, and the element jumped by the whole drag distance. Elements
resized before got a correction only because their previous scale made
the residual non-zero by accident.
The committed scale is now applied to the live element before measuring,
so the measurement means what its comment says either way, and a skipped
correction is logged rather than silent.
Confirmed against a real session: a first resize of a 630px chip now
reports residual -109.93 and lands on the drop point, where it previously
logged no scale-finalize at all.
* feat(sdk): expose a paint query for transparent-composition hit-testing
A host layering a transparent composition over other content has to know
whether a point carries ink before it decides to swallow a click. The adapter
only answered "what element is here", so AI Studio wrote its own answer and
could not reach the per-pixel alpha the adapter already samples for <img>.
Add PreviewAdapter.paintsAt, plus the pieces it is built from on a new
./adapters/iframe subpath so a host with different hit-test policy can compose
its own walk.
The walk is geometric rather than elementsFromPoint-based: that stack omits
pointer-events:none nodes, and a decorative overlay carrying it still paints,
so a z-stack query would report no ink over visible artwork — the direction
that makes a composition vanish from under the cursor.
fullBleedFraction is an option rather than a constant because "a layer covering
the whole frame is background, not artwork" is host policy, not a fact about
the composition.
* fix(sdk): scope the full-bleed frame to the root under the point
compositionFrameArea took the smallest [data-composition-id] in the whole
document, so an unrelated sub-composition sized the reference frame for points
nowhere near it: a 300x300 badge in a corner made every mid-size painter in a
1920x1080 outer frame read as full-bleed, and the composition went
click-through under artwork the user can plainly see. That is the direction the
fail-safe exists to avoid, and the docs already described the intended
behaviour — the innermost root CONTAINING the point.
Also read the alpha channel instead of matching known transparent spellings.
Only the `transparent` keyword computes to rgba(0, 0, 0, 0); a faded-out white
stays rgba(255, 255, 255, 0), which the set counted as painted. That erred
toward absorbing clicks rather than losing them, so it was a false positive
rather than a hazard, but it is wrong.
Both are pinned by tests that fail when the fix is reverted.
* fix(sdk): stop the paint query answering "no ink" over visible artwork
Four cases where the walk landed on the wrong side of its own fail-safe.
The full-bleed veto tested the winner's border-box area even when the alpha
sampler had just read an opaque pixel there, so a full-frame transparent PNG or
SVG overlay — the case this feature exists for — reported background over
visibly opaque artwork, with no fullBleedFraction that worked. Ink now carries
how it was established, and a measured pixel is never vetoed. An image whose
pixels could NOT be read stays inferred, so a tainted CDN overlay still yields
to the veto rather than absorbing every click.
Composition roots were excluded from candidacy outright, so a root carrying a
background answered false even at fraction 0, where the docs promise every
painting box counts. Roots are candidates now; the veto discounts them without
a special case, since a root's box is the frame.
An <img> with a clear pixel early-returned past its own background, padding
plate and border, which any other element would have counted.
A same-origin iframe mid-navigation exposes a readable but empty document, so
the !doc guard never fired and a loading composition answered a confident
"no ink" — the exact failure the null convention exists to prevent.
Also: the sort comparator's epsilon tie was intransitive, leaving the
smallest-first guarantee (and the lazy single-sample property that rides on it)
engine-dependent; the guide's pass-through recipe called a function that does
not exist and hand-waved the coordinate mapping that makes it correct; and the
reference now states the under-counts alongside the over-counts, the walk's
blindness to runtime-mounted content, and compositionPaintsAt's preconditions.
Each fix is pinned by a test that fails when the fix is reverted.
* refactor(sdk)!: invert the paint query to isProvablyEmptyAt
paintsAt handed callers three falsy bottom values with opposite safe readings:
false meant "no ink, pass the click through", null meant "not knowable, treat as
painted", and undefined from an adapter without the method also meant painted.
The idiomatic `if (!preview.paintsAt?.(x, y)) passThrough()` therefore did the
dangerous thing for two of the three, and the convention needed defending in the
interface docstring, the reference and the guide — plus a dedicated comment and
a pinning test on the headless adapter to stop null regressing to false.
Inverting the polarity collapses the tri-state to a plain boolean and makes the
safe reading structural: true only when the composition was readable and nothing
painted there, so ink, an unreadable or still-loading document, and a missing
implementation all land on "keep the composition clickable". The prose stays as
rationale, but nothing depends on a reader remembering it.
PaintsAtOptions becomes PaintQueryOptions, since it now describes the walk that
both the adapter method and the exported compositionPaintsAt share rather than
one method's arguments. compositionPaintsAt keeps its ink-positive name: it
answers the other question, and its docstring points callers who need the
fail-safe contract at the adapter.
Nothing is released yet, so no consumer is on the old name.
A uniform drag committed the `scale` shorthand. If the tween's keyframes
already stated `scaleX` and `scaleY`, the commit left both forms in the
same keyframe, and GSAP animates each property name independently, so the
longhands ran alongside the shorthand and won.
The resize therefore computed the right number, wrote it to the file, and
did nothing: the element snapped back to its old size the moment the
handle was released. Reproduced from a real session, where a drop at 384px
on a 630px element wrote {scaleX: 1, scaleY: 1, scale: 0.61} and rendered
at the original size.
The mixing hazard was already known in the other direction, where a
non-uniform drag takes a rewrite path that normalizes every keyframe to
the longhands. This makes the condition symmetric: whenever the tween
already speaks longhands, a uniform drag speaks them too.
Resizing an element whose size is driven by a scale animation committed a
scale computed against a hardcoded 200px fallback, because the only
original size the draft recorded was the element's INLINE width, and a
composition sizes its elements from the stylesheet.
A 630px chip dropped at 1260px wide committed a scale of 6.3 instead of 2,
so it landed at over three times the size it was dropped at. The next drag
compounded it, because that wrong scale then counted as the element's live
one.
The draft now records the box it measured, once, before it writes a width
of its own, and the intercept reads that. The inline attributes keep their
own job of restoring an inline style, which is why they cannot answer this
question.
Review caught that the same anti-pattern was still live in the Studio binding:
canaryEventProperties destructured only `enabled` and dropped the reason. Its
own doc comment promised 'identical shape to the CLI, so a rollout spanning
both reads as one flag' — which the CLI-only fix had just made false.
This matters beyond symmetry. A CLI-launched Studio adopts the CLI's decisions
and shares its bucket seed, so a cohort flip can surface on either surface.
Emitting attribution on only one leaves Studio-observed flips unattributable
and makes the two flip counts irreconcilable — and Studio is the surface most
likely to expose a shared-seed-with-diverging-id pattern, which is the open
question the reason exists to answer.
Also adds the no_unit_id emission test the CLI side advertised but never
asserted, and a Studio pair pinning that a URL override and a cohort roll
produce the same assignment with different reasons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The calibration contract deferred this until the stability check came back
dirty. It did: the first fleet read found 304 installs (1.08%) reporting both
values for a canary whose percentage never moved, and the genuinely anomalous
ones could not be separated from a developer toggling HF_CANARY_*, because the
assignment alone is identical in both cases.
resolveCanary has always computed the reason and canaryEventProperties dropped
it. Now every canary emits canary_reason_<name> beside its assignment.
Deliberately outside the $feature/ namespace: PostHog treats those as flag
values, and a non-boolean there would corrupt the flag's own breakdowns.
Two of the six wire values are immediately useful beyond override attribution.
'excluded' identifies CI installs, which today have to be dropped by joining
on is_ci — conflating them with out_of_cohort is what made the first accuracy
read look like a significant failure (9.22% against a 10% target) when it was
not. 'no_unit_id' surfaces the fails-closed corner.
The reason is optional on the core helper so existing callers are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): classify identity persistence on every telemetry event
Install-grain metrics currently trust every anonymousId equally, but
ephemeral/isolated-HOME workloads mint a fresh id per run — one machine
produced 2,956 rotating render identities since Jul 30 (94.4% seen on a
single render command), inflating acquisition and diluting per-install
penetration while looking like real product usage.
Every event now carries:
- identity_persistence: durable (id loaded from a preexisting config —
proven to survive a process boundary) | unknown (minted+persisted this
run; an ephemeral HOME is indistinguishable from a genuine first run
from inside one process) | process_only (persist failed). Sticky per
process so a fresh install re-reading its own write cannot self-promote.
- config_write_outcome: ok | ok_unmirrored | failed for the identity-
establishing write; absent when the id came from disk.
- invocation_id: random uuid per CLI process, so one invocation's events
group even when the install identity is untrustworthy (unlike run_id,
which needs an orchestrator to set HYPERFRAMES_RUN_ID).
Install metrics can then count only durable identities, and a daily
churn monitor can alert on the unknown share.
* fix(cli): require the anonymousId to come off disk before classifying durable
Review finding: materializeConfig mints a replacement anonymousId when a
hand-edited/image-baked config lacks one. That replacement only reaches
disk when the bucket-seed backfill happens to write; with a seed present
the read path performs no write at all, so the install re-mints a fresh
id every run while the unconditional durable branch stamped each of them
with the one label durable-only counting is allowed to trust.
durable now requires parseNonEmptyString(parsed.anonymousId): a minted
replacement classifies like a fresh mint — by the backfill write outcome
when that path runs (unknown/process_only), and process_only on the
no-write path where the id provably dies with the process. Two tests pin
both shapes.
The long assert.match line tripped oxfmt --check in CI (Preflight/Format red,
which cascaded regression/player-perf/preview-regression into skip-then-fail). The
pre-commit format hook doesn't cover scripts/*.mjs, so it slipped through. Wrapped
per the formatter. Flagged by Magi and Rames.
- html-schema: the previous clause claimed a composition host's data-media-start is
never read. It is: readElementPlaybackStart (media.ts:16) resolves
data-playback-start ?? data-media-start and timeline.ts calls it on composition
clips. Rewrote to the accurate reason both reviewers gave — composition hosts are
only inspected by the playback-start-first readers, media-start works as a
fallback, but playback-start is what Studio writes/normalises to.
- motion test: pin the HoverVideo click-suppression (preventDefault +
stopPropagation). Removing it left the gate 12/12; now it fails. This is the bug
that escaped static review and only surfaced by driving the live preview.
- replica-compare: fold the visible 'Sound off/on' text into the aria-label so the
accessible name contains it (WCAG 2.5.3, Rames).
Round-5 findings from Magi and Rames.
Grid cards with a public source are wrapped in an <a>; the sound/play button
lives inside it, so a click — mouse or keyboard — bubbled up and navigated to
GitHub instead of toggling sound. preventDefault + stopPropagation on the control.
Found by driving the live preview (self-review).
- replica-compare: the offscreen teardown now resets muted (element + React state),
matching HoverVideo — a pair unmuted before it scrolled away no longer returns
reading 'Sound on' over a paused, sourceless pair (Magi blocker).
- Both controls' aria-labels now follow the mode: under reduced motion the button
plays/pauses the whole comparison, and when a preview is already autoplaying
muted the action is 'unmute', not 'play with sound' (self-review + Rames).
- Hardened the motion-suite assertion to scope 'startBoth' to toggleSound's body
(a defined-but-unused helper no longer satisfies it) and pin the offscreen
muted-reset transition (Rames mutation-test gap).
- html-schema: 'hyperframes validate' inspects <audio> only; note that no
media-start-only reader inspects a composition host, so the kind rule strands
nobody (Rames).
Per Rames: add the engine audio mixer (audioMixer.ts:350 -> ffmpeg -ss) to the
media-start-only readers — it's the live path that makes a lone data-playback-start
a shipped output bug (trimmed picture over untrimmed audio). 'Set one' isn't enough;
give the element-kind rule: <video>/<audio> use data-media-start, nested composition
uses data-playback-start (Studio writes it; it's the child-timeline offset). Also fix
editing-existing-videos.mdx, which offered both names as interchangeable for front
trim of a clip — the exact kind where they aren't.
- replica-compare: the voluntary control now starts and pauses BOTH films (not
just the reference), and the replica-sync effect attaches in view regardless of
the preference, so a reduced-motion visitor who presses play sees the whole
synchronized pair. Added a focused source-level assertion to the motion-check
suite (the repo has no React runtime harness for snippets).
- html-schema: describe each layer precisely instead of grouping the CLI —
timing compiler, HTML parser, producer audio, and 'hyperframes validate' read
only data-media-start; runtime, Studio and 'hyperframes snapshot' read
data-playback-start first (Studio also writes it).
Round-3 findings from Magi.
The merged '## Timeline navigation' section documents a third arrow-key behaviour;
the 'selected area decides' paragraph read as exhaustive with only two. Fold in the
timeline-focus case and link the section. Coherence note from Rames.
carriedSectionsFrom() decided whether a ## Usage section was generated by matching
its first line against a list of historical opener phrases — so a hand-written
Usage section that happened to open that way was classified as generated and
silently deleted on regeneration. Ownership is now purely set membership: a
section is generated iff its heading is one the template emits, and ambiguous
'usage' is no longer in that set (the template never emits it), so any ## Usage is
carried. Exported carriedSectionsFrom behind an entrypoint guard and added two
executable preservation fixtures. Flagged by Magi (#5).
Both snippet players now keep a source assigned whenever in view (preload='none'
so nothing downloads until asked), gating only autoplay on reduced motion — so a
reduced-motion visitor can press play/unmute instead of being left with an inert
poster. HoverVideo renders a control on hasAudio={false} cards too (a play/pause
toggle), and ReplicaCompare no longer calls play() on a source it just removed.
Gave ReplicaCompare's button the same aria-pressed + focus-visible as HoverVideo.
Round-2 finding from Magi (#1) and Rames.
- edit-operations: dispatch() does not consult can() (session.ts:608 -> applyOp
with no validation; a no-timeline addGsapTween/addLabel is a no-op but a missing
target still writes via selector fallback). Use Rames's wording: call can() first
and skip on failure — no false 'applies nothing' guarantee.
- timing-and-animation: the second E_NO_GSAP_TIMELINE site — gated setGsapTween on
an error it cannot return and called shipped parser code 'a later phase'. Rewrote
to gate addGsapTween (which can return it); dropped the stale can() comment in
types.ts:595.
- html-schema: data-playback-start is read by runtime, Studio and CLI (Studio also
writes it, timelineEditingHelpers.ts); only the compile path is media-start only.
Document the layered precedence instead of calling it runtime-only.
Round-2 findings from Magi (#2/#3/#4) and Rames.
ReplicaCompare now assigns/decodes its reference+replica only near the viewport
and releases both (pause + removeAttribute + load) on exit, closing the last
eager-load surface from Magi's #5. Sync + reduced-motion guard preserved.