connectedCallback defers init to a setTimeout(0) so the parser can append
the element's children first. With the bundle loaded from <head>, as
`hyperframes present` emits it, headless Chromium fires that timer while
the parser is still inside <hyperframes-slideshow>: init finds no player
or a half-streamed island, returns, and the deck never binds.
While document.readyState is "loading", init now retries once at
DOMContentLoaded. After parsing there is nothing to wait for, so a missing
or malformed island still fails quietly as before. The subtree check is
split out as locateSlideshowParts so the classification is testable
without the element lifecycle.
* docs(changelog): weekly digest 2026-08-31–2026-09-07
Rewritten from the 111 non-merge commits in the window, grouped by theme.
The week was mostly hardening: file reads pinned to checked descriptors,
bounded parser scans, and isolated concurrent writes. Studio agent control
and site capture continued.
Every commit sha and PR number was resolved and verified against the repo,
not hand-typed.
— Rames
* docs(changelog): add weekly changelog video for 2026-08-31–2026-09-07
Embeds the rendered 49.5s digest video. Uploaded to
static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-aug31-sep07.mp4
and verified live before committing the src (200, content-length matches the
object, cache miss).
— Rames Jusso
tsc on the Windows jobs rejects a direct cast from node:stream/web's
ReadableStream to the global one (TS2352), which broke the build after
#3745. Cast through unknown, the bridge the error itself recommends.
The Studio preview asset route answered every Range request by reading the
entire file into memory with readFileSync and slicing the window out of the
buffer. A browser refills a playing <video> or <audio> with a fresh Range
request every few hundred milliseconds and issues one per seek, so a source
of a few hundred MB cost a full synchronous read per refill and per scrub
step. The read also blocked the event loop, so the voice track, saves and
the file-change stream all waited behind it. Sources over 2 GiB could not
be served at all, because readFileSync refuses them.
Stream only the requested window with createReadStream, take the size from
stat instead of the buffer, and answer 416 for a range that starts past the
end. Text assets keep the in-memory utf-8 round trip. The sibling static
project server already did this.
* feat(lint): flag video/img src pointing at an audio file
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(lint): drop m4a from the audio src kinds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Hiding several elements in one call is the shortest way to stage a scene, and it was
the one form the hidden-selector extractor could not read: a multi-element array
failed the target regex, which forbade commas, and a single-element array then failed
the selector parse, which accepted only a quoted string or a known alias. So the two
error rules that ask whether a hidden element is ever properly revealed had an empty
hidden set for every grouped hide.
Each part of a group now resolves on its own, and a comma-separated selector string
resolves the same way. The target pattern stays paren-free so a set whose vars are a
variable cannot run past its own closing paren and swallow the next call.
Two false positives the wider hidden set exposed in the fullscreen-overlay rule: a
fromTo at 0 seats its from-vars immediately, so hidden from-vars there mean the
overlay does start hidden; and an overlay hidden by a standalone gsap.set is what
that rule's own fixHint prescribes.
Combines two fixes to the layout audit:
- `staticIssueKey` no longer includes `issue.text` for `content_overlap`
and `text_occluded`; both builders now emit a unique selector for both
ends, so the selector pair is the identity. An animating subject
(count-up, typewriter, rotating word) no longer splits one held
collision into transient groups.
- A clipping box that is its own nearest constraint reports
`clipped_text` only, instead of also reporting `text_box_overflow`.
A non-clipping self-constraint still reports.
A code-explainer composition renders selectors and API calls as text.
`stripJsStringLiterals` blanks string and template contents (offsets
preserved) so `composition_id_selector_in_comment`,
`template_literal_selector` and `raf_animation_loop` scan real code only.
The scanner tracks regex-vs-division context so a regex literal's own
quotes cannot open a phantom string and blank the rest of the script; on
any unbalanced scan it returns the source unchanged, so an unmodelled
parse degrades to the pre-existing behaviour rather than blanking code on
an error-severity gate.
Combines two fixes to the motion sidecar path:
- An ambiguous selector no longer aborts the whole spec. It is reported
as a finding, the assertions that depend on it are skipped and named,
and every other assertion still evaluates. `assertionTargets` is now
exhaustive over the assertion kinds so a new kind cannot silently fall
back to an arbitrary first match.
- A sidecar that will not parse is reported as a spec finding instead of
ending the run, so the composition is still audited.
* fix(cli): clarify publish visibility and claim links
* style(cli): apply oxfmt to publish visibility test
* chore(skills): regenerate skills manifest for updated references
* fix(cli): stop an in-place re-publish claiming it made the project private
* test(cli): pin in-place visibility copy to the plain re-publish route
A brand band shows logo artwork on a transparency checker, where a badge (a
mark knocked out of a full-bleed disc or square) reads as a solid blob. The
capture kept only the first icon that downloaded, so which of a site's icons
survived was decided by ranking alone, and nothing recorded what the survivor
actually looked like.
Keep them all. Each declared icon is written as assets/icon-<rel>-<sizes>.<ext>
and classified as bare-mark, badge, or unknown, then the best one is copied onto
the historical assets/favicon.<ext> stem so a stem match still finds it.
Classification is deterministic and cheap. SVG is read from markup, not
rendered: these files routinely fill via CSS custom properties behind a
prefers-color-scheme query, and librsvg resolves neither, rasterising one real
favicon to a fully transparent image while Chrome draws it correctly. Raster
icons are sampled at the midpoint of each edge rather than the corners, because
a full-bleed disc has four transparent corners and would otherwise read as a
bare mark.
The headline prefers a bare mark, then falls back to the existing ranking.
Preference is binary on purpose: an "unknown" is not promoted above a badge,
or an undecodable .ico would outrank a good SVG purely for being unexaminable.
extracted/icons-manifest.json records every icon, its shape and the evidence
for that shape, plus which file became the headline, its rank, and why. A
choice whose losers are invisible is indistinguishable from having had no
choice, which is how a substituted icon went unnoticed in the first place.
Worth noting for consumers: sites often declare no bare mark at all. An
apple-touch-icon is composited onto an opaque tile per Apple's spec, so it is
a badge, not a mark. The manifest says so rather than implying the preference
was satisfied.
A capture is one session with two halves: Chrome navigates the page with a
browser User-Agent, then Node fetches the assets that page referenced. Those
halves sent three different identities — "HyperFrames/1.0" from the asset and
media downloaders, a bare "Mozilla/5.0" from the stylesheet inliner, and the
real Chrome UA from the navigation itself.
An origin is free to answer those differently, and anti-bot edges do. Capturing
one large site, GET /favicon.svg answers 403 text/html to "HyperFrames/1.0" and
200 image/svg+xml to the UA the very same capture had just navigated with. The
favicon ranker had already picked that SVG as the best declared icon; the 403
discarded it and the downloader fell through to the next candidate, so the icon
written to assets/ was chosen by the CDN's bot rules rather than by the ranker.
The capture reported it as one "unavailable" drop and carried on.
Hoist the navigation UA into CAPTURE_USER_AGENT and use it for every
out-of-band fetch the capture makes: favicons, images, og:image, fonts,
stylesheets, Lottie JSON and videos. One constant is what stops the two halves
drifting apart again.
Verified end to end against that site: before, assets/favicon.png (the
apple-touch icon) plus one unavailable drop; after, assets/favicon.svg, byte
identical to the file the site itself serves.
* fix(core): bound inert region scans in timing compiler
* fix(core): use literal search for comment terminators
* fix(core): recognize end-bang HTML comment boundaries
* fix(studio): prevent preview hang on burst external file rewrites
Two interacting bugs caused Studio to freeze when multiple processes
(generator, check, snapshot) burst-wrote index.html within seconds:
1. SSE listener leak: the /api/events handler added a watcher listener
per client connection but never removed it on disconnect. Reconnects
accumulated dead listeners, each triggering readFileSync on every
file change and writing to closed streams.
2. Generation starvation: processChange incremented generationRef and
awaited drainPendingChanges. A second event arriving mid-drain bumped
the generation, causing the first drain to bail at the generation
check. With rapid writes, no drain ever completed and Studio stayed
frozen on stale content.
Fix 1: use stream.onAbort() to remove the watcher listener when the
SSE connection closes.
Fix 2: gate processChange with a draining ref. While a drain is in
progress, stash the latest event. On completion, process the stashed
event — the last write in a burst always completes its reload.
Closes#3646
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): align coordinator tests with drain serialization
Update existing test to expect the new behavior: when two events
fire in quick succession, the first drain completes and triggers a
reload (previously it was silently discarded). The stashed event
then starts a second drain.
Also fix the burst-write test to use the drains array pattern and
explicit act() flushes for stashed event processing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): stash events with allowDuplicate and harden listener cleanup
Address Rames's review findings:
- Stash with allowDuplicate: true so re-dispatched events are not
swallowed by the duplicate guard (the identity was already written
on the way in, so the stashed event matched itself on re-entry).
- Wrap SSE keepalive loop in try/finally so the listener is removed on
both abort and throw, not just abort.
- Restore stale-completion guard test coverage lost in the rename.
- Use await act(async () => {...}) for burst dispatches so assertions
depend on the stash guard rather than scheduling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): simplify drain serialization and restore SSE cleanup
Restructure processChange into intake + drain loop:
- processChange is now synchronous — validates, dedupes, checks own
echoes, enqueues the accepted payload, and starts the drain loop
- startDrainLoop runs while the pending slot is non-null, draining
one event per iteration via drainOnePending
- No recursive void processChange(...) from finally, so no
allowDuplicate escape hatch needed — stashed events never re-enter
intake guards
SSE listener: restore stream.onAbort alongside try/finally. Hono's
sleep() never throws, so finally alone doesn't fire on disconnect.
Both paths call removeListener (Set.delete is idempotent).
Tests: remove stale-drain test that contaminated subsequent tests by
emptying the shared roots array mid-test. Use sync act() for burst
dispatches — the stash decision is synchronous.
All 10 coordinator tests pass locally (NODE_ENV=test).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>