* 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>