## Why
#3094 fixed one way the mount and render paths disagreed, and added the gate that catches disagreement. It deliberately left the rest.
Four divergences are still live. Each one means a composition assembles differently depending on whether it is being previewed or rendered — the same class of defect that shipped three catalog components unstyled, just with smaller blast radii.
## How
Both paths now derive root discovery, scope identity, asset sources and order, hoisted links, variable carriers and nested-host enumeration from the shared module #3094 introduced. 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 behaviour changes, each stated by what actually differs rather than by 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. That is losing code, not holding a convention — the runtime's answer wins. Head and content scripts now share one loop, head first, order preserved. A non-templated sub-composition with an inline head script went from **0 collected scripts to 1**, wrapped, 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. A templated composition with a stylesheet link went from **no external links to that link**. The parity fixture that previously recorded this shape as a known exclusion now gates it.
**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 injected rule went from a bare `.label { … }` to `[data-composition-id="scoped-text"] .label { … }`.
**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`.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Core 1694 passing, producer 574 passing, the parity contract now gates the two divergences it can observe (the other two carry no contract field, so they are gated by unit tests naming the exact before/after). Lint 0, `typecheck:runtime` and the runtime preview guards clean, package cycles unchanged.
Characterization-first: both suites were run and recorded green before any decision moved, so a behavioural drift would surface as a red test rather than a silent difference.
**One assertion changed, deliberately.** A runtime test asserted that an anonymous host's composition is *not* flattened, and documented that as intentional. That premise is now false. What the test actually cared about — the root and its content present under the host — still holds and is still asserted; the "not flattened" claim flipped, and the test now also asserts the scoping that was missing.
## Not covered
The variable-carrier divergence and its `TODO(template-var-carriers)` are untouched by design, as is recursion on the mount path — a sub-composition containing its own `data-composition-src` is still silently dropped in live preview. Both are behaviour changes with their own units, and both are now one-line-ish changes because the shared module already reports what they need.
`runtimeScopeCompositionId` no longer falls back to the authored scope id. This is a functional change beyond the four above, surfaced in review: for an anonymous host with authored variable defaults, the runtime previously stashed them under the declared id, and now does not. It removes a runtime-vs-compiler divergence in the correct direction — the runtime was doing work the compiler never did, and the compiler is authoritative for a shipped composition — but a caller relying on runtime-only variable exposure loses it.
The three copies each of the flattened-root helper and the id assignment are left alone: they look mergeable and are not cheaply, and they touch the instancing contract the pixel harness guards.
## Worth knowing
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 that looks exactly like a real one.
Apply the --disable-gpu-compositing workaround to every software capture, not just BeginFrame ones. SwiftShader's compositor re-presents stale raster for a partially invalidated layer, so successive screenshot captures accumulate copies of earlier seeks; alpha renders are forced onto the screenshot path and were the only ones left unprotected.
Refreshes the byte-strict png-sequence alpha baseline for the resulting antialiasing delta (content unchanged, min PSNR 41.3 dB).
Fixes#3049.
* fix(scripts): render template-only blocks in catalog previews
The catalog preview renderer treated any file containing `__timelines` as a
standalone composition and rendered it as index.html directly. The 12 VS Code
snippet blocks register their timeline inside a `<template>`, which stays
inert until a host mounts it, so every one of them failed with "Composition
has zero duration" and no preview could be produced from the registry at all.
Six of the previews on the docs CDN were hand-made from a project still
mounting Monokai, so Dark+, High Contrast, High Contrast Light, Solarized
Light, Visual Studio Dark and Visual Studio Light all showed Monokai's video.
Detect standalone-ness on the document with template content stripped, mount
the mirrored install-layout copy so a block's own `../assets/*` references
resolve, and capture posters opaque: `format: "png"` is the engine's
transparent mode and forces `background-image: none` on every composition
root, which erased the desktop backdrop these blocks paint.
Publishing gets the missing half too: preview URLs are stable and the objects
are uploaded `immutable` with a one-year max-age, so a re-upload alone never
reaches a reader.
* fix(scripts): install ffmpeg in the preview job and fix the sibling renderer
The canary this PR added caught its own regression: the poster transcode
shells out to ffmpeg, which ubuntu-latest does not ship and this job never
needed, so both canaries failed with `spawnSync ffmpeg ENOENT`. Install it
the way every other render job does. `encodeForWeb` has always shelled out to
the same binary; the job only got away with it because `--skip-video` skipped
that path.
generate-template-previews.ts captures posters through the same transparent
`format: "png"` mode, so any template painting its own backdrop loses it
exactly as the code snippets did. Fixing one renderer and leaving its sibling
on the broken call would just move the bug.
Also fold the three separate parses of registry-item.json into one read: they
had drifted into three different failure behaviours for the same file.
* fix(studio): stop a resize writing size into the tween that carries scale
Resizing a scale-driven element failed with "animation not found", and the
element could not be saved again at all.
The tween resolved for the resize's group is, for such an element, the one
carrying `scale`. When it is an instant hold the code handed it straight to the
size commit, which wrote `width` and `height` into it. One tween now spanned two
property groups, so the parser classified it as neither — it lost its group
suffix, and its id with it. Every later edit looked for a scale tween and a size
tween, found a tween with no group at all, and had nothing it could address.
Size goes to a size hold of its own now; the scale hold is left alone. Where the
damage has already happened it is repairable: splitting the mixed tween into
property groups gives back a `scale` tween and a `size` tween.
* fix(studio): let the resize say whether it settled the drop point
Resizing an element whose scale is an instant hold saved the new size and then
snapped the element back to its authored position, every drag.
Whether the caller persists the drag offset was inferred from the element's
tweens: a scale-group tween meant "the resize settles its own position, hold
the offset back". That is true of the scale route, which commits a scale and
then measures where centre-scaling put the box. It is not true of an element
whose scale is an instant hold — that has a scale-group tween and still
commits width/height. So the offset was withheld, nobody wrote it, and the
position tween re-asserted the authored value a frame later.
The outcome carries the answer now. A resize that moved the element says so;
everything else leaves the anchor to the drag, which is what already handles it.
* test(studio): sweep every animated shape a resize can be handed
Both faults on this branch were found one composition at a time, which is a
bad way to find the third.
Drives the real intercept across the cross-product of what an element's tweens
can look like — scale absent, an instant hold, a real tween, longhands; size
absent, a hold, a tween; position absent, a static hold, a tween; plus the 3D
and rotation set a card carries and a tween that already spans two groups —
and holds all 108 to the two rules that were broken: never address an
animation the source does not have, and never leave a tween spanning two
property groups.
The server stand-in answers the way the real one does, rejecting an id it
cannot find, and applies what it is told, so a run that corrupts the animation
list is caught by the next mutation in the same run.
* fix(studio): decide a uniform resize in pixels, not in scale
A free corner drag whose two axes happened to land within 0.01 of each other
was committed as one `scale` value for both, and gave back a box shorter than
the one dropped — 326x213 became 326x211.
The threshold was a fixed amount of scale. That is invisible on a 40px box and
two pixels of height on a 408px one, and the question was never about scale: it
is only ever whether using one value for both axes would move an edge. So it
asks that, in pixels, against the axis the collapse would distort.
Found by a geometry sweep added alongside: 120 runs over the routes a resize
can take, six rotations from none to 180 degrees, and four drops from near-zero
to an aspect flip, each checking the committed scale or size reproduces the
RENDERED box the user dropped — and, where the resize reports it owns the drag
offset, that the box lands on the drop point too. Six runs failed before this
change, all of them the near-uniform shrink, at every rotation including none.
Rotation was the suspect and turned out to be innocent.
* refactor(studio): split the resize sweeps into named steps for the audit gate
* test(studio): pin which tween a resize edits when the element has several
A composition animates the same property more than once — a scale-in early, a
scale-out late — and the one the user means is the one under the playhead.
Editing the wrong one changes a moment they are not looking at and leaves the
moment they are looking at unchanged, which reads as "the resize did nothing".
Six playheads across two scale tweens, including both sides of the midpoint
between them and a time past the end of both. Verified against a stubbed
selection that always takes the first tween: three of the six fail.
* fix(studio): only claim the drop point on the route that settles it
Review caught the inverse of the fault above it. The three returns that report
`ownsDragOffset` hardcoded `true`, and they are reached by the size-tween route
too — a real, non-hold size tween with no scale group. That route never
captures the element, so the finalize step no-ops, nothing writes the position,
and the caller withholds an offset it would otherwise have forwarded. The
release frame looks right because the live DOM was already settled; the
persisted state reverts on the next seek.
Fixed the same way the fault above it was: the finalize step reports whether it
settled the drop point rather than the caller assuming from where it was
called. It answers false when it is not the scale route, false when it cannot
measure, and TRUE when the box is already on the point with nothing to write —
forwarding an offset on top of that would move it off.
The geometry sweep accepted this silently, and the reviewer said why: its live
pose starts at the drop, which is where the gesture leaves it, so a route that
moves nothing trivially "lands" there. Each route now declares whether it
settles the drop point and the sweep holds it to that, which fails on 24 of the
120 runs with the old hardcoded `true`.
## Why
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>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview.
`oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present.
The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite.
## How
**The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied.
**Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — 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. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed.
**The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added.
**The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty.
Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Every claim here was verified in both directions rather than assumed.
The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass.
`bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case.
## Found while doing this, not fixed here
Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately:
- The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — 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. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file.
- For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped.
- The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one.
Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage.
Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them.
## Not covered
This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release.
parseFontFamilyValue() split the family stack on every comma, so
`font-family: var(--brand-font, inherit)` became two tokens:
`var(--brand-font` and `inherit)`. The var() guard from #1655 only
skips tokens starting with `var(`, so the orphan fragment was treated
as a requested family, failed every resolution path, and aborted
fail-closed distributed renders with:
FontFetchError: [Compiler] Unresolved fonts in fail-closed mode:
inherit). Distributed renders require all fonts to be resolvable.
Split on top-level commas only, so a var() expression (including a
nested one) stays a single token. Quotes are tracked as well, both so
parentheses inside a quoted family name cannot skew the depth counter
and so a legal quoted comma no longer splits.
Closes#3066
Both conflicts were import/export unions in the engine package, resolved by
keeping both sides:
- packages/engine/src/index.ts — main widened the urlDownloader re-export
(fetchPublicHttpsText, safeDownloadUrlIdentity, writeUrlDownloadTelemetry and
their types) while this branch added the notMediaPayload exports.
- packages/engine/src/services/audioMixer.ts — main added UrlDownloadError and
writeUrlDownloadTelemetry to the urlDownloader import; this branch added
isNotMediaPayload.
In audioMixer's prepare path both intents compose in order: main's download
telemetry and typed download failure, then the STUDIO-5433 non-media sniff
before the probe.
Panel sizes are reconciled against the window on every resize, with the preview holding a 360x200 floor that panels yield to before it gives.
Measured preview pane: 760px window 192 -> 433, 560px window 2 -> 516. Windows at or above 1280px are unchanged.
- fitPanels owns the who-yields decision for both axes
- panel caps are window-relative, replacing a flat 600px inspector cap
- below 860 the sidebar rails, below 700 the inspector collapses too
- auto-collapse is derived render state and never writes leftCollapsed
(localStorage) or rightCollapsed (synced into the shareable URL)
- the rail and header toggles act on the effective state, so neither is a
dead click that silently persists a collapse the user never asked for
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.