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>