`add <a> <b> <c>` silently installed only `<a>` and exited 0, discarding
`<b>` and `<c>` with no warning. citty binds only the first positional
token to `name`; every token after it still lands in `args._`, but
nothing read it. `add` now rejects the extra arguments up front, before
touching the registry, naming exactly what was dropped.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
* fix(catalog): name the better search tier when a search finds nothing
The hint that tells a scripted caller the on-device tier exists was wired
to the branch where the search succeeded, and was silent on the branch
where it found nothing. The caller with the most reason to hear it was the
only one who never did.
It also only ever reached stderr, so a --json caller never saw it at all.
The hint is now a returned sentence pushed into the same warnings array the
zero-result envelope already serializes, and printed from that one value, so
the terminal and the envelope cannot drift.
Withheld when the query parsed to no searchable tokens, where the advice is
already to search in English, and when a warning has already explained why
the tier cannot run here.
The consent gate is untouched: nothing downloads, and the sentence still
asks the caller to check with a person before enabling it.
* fix(review): pass the resolved model status, and pin both hint guards
Deriving the status inside the hint helper re-read and re-hashed the
on-device model from disk to answer a question the caller had already
answered a few lines earlier. It now takes the resolved value.
Both guards below the status check were invisible to the suite: every
other case pinned a status that already returned null, so deleting either
guard passed everything. Two tests now hold them, and each fails for its
own mutation.
* docs(skills): tell agents to read the tier hint the envelope now carries
The CLI skill said that under --json nothing about the offline tier is
printed at all. An agent following that sentence would never read the
warnings array this branch writes the tier sentence into, so the fix
would land in the code and never reach the caller it was written for.
It also described warnings as firing only when a tier was asked for and
could not run. That is now too narrow: a zero-result search emits the
hint with no tier requested.
Regenerate skills-manifest.json for the changed content hash.
## Summary
- `findPython()` correctly resolves `HYPERFRAMES_PYTHON` before falling back to the PATH probe — that part already works, confirmed by direct testing. But when the override is set and fails validation (nonexistent path, non-executable, non-Python-3 output, timeout), it silently falls through to the PATH probe with zero diagnostic. A user whose override had any subtle issue got a plain "Not installed" from `doctor` with no signal the variable was even seen.
- This is a corrected, narrower version of a report that originally claimed `doctor` ignores `HYPERFRAMES_PYTHON` entirely — that claim was refuted directly (the override resolution works). The real defect is the silent validation-failure path.
- Extracts the override-validation logic into `validatePythonOverride()` and adds `describeRejectedPythonOverride()`, which `doctor`'s TTS (Kokoro) and BGM (MusicGen) checks now call to append the rejection reason to their `detail` when applicable. `findPython()`'s own behavior (including its fallback) is unchanged.
PRINFRA-669
## Test plan
- [x] New `packages/cli/src/tts/python.test.ts`: `describeRejectedPythonOverride` returns null when unset / when the override validates; names the override + exception message when the override can't run; names the override + actual output when it isn't Python 3; `findPython` still falls back to the PATH probe when the override is rejected (unchanged behavior) and still uses a valid override directly.
- [x] Confirmed RED against the pre-fix source (tagged stash) — all 4 new `describeRejectedPythonOverride` tests failed with "not a function"; GREEN after restoring the fix.
- [x] `bunx tsc --noEmit`, `bunx oxlint`, `bunx oxfmt --write` clean on changed files.
- [x] `bunx fallow audit --base origin/main --fail-on-issues`: no issues in the 3 changed files.
- [x] Full `packages/cli` vitest suite: 3033/3038 passing (2 pre-existing unrelated failures — a PID/socket sandbox quirk and an agent-env-var-pollution test — plus 4 browser-test files failing at collection on a pre-existing `node:` builtin import issue under this sandbox's happy-dom setup; all confirmed identical on pristine `origin/main` and unrelated to this change, consistent with every other fix from this backlog-drain session).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.
## Why
A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.
This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.
## How
Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.
## Testing
Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.
- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`check`'s sweep_static guard false-positives on a composition that swaps between equal-size, equal-position opaque `<img>` elements — a common authoring pattern for revealing frame N of a still sequence from a paused GSAP cursor. The render itself is correct; only `check`'s verdict is wrong.
## Why
The sweep guard fingerprints every visible element's box + opacity + font-variation-settings per seeked sample. That's deliberately blind to pixel-only motion (a canvas repainting, a video playing) with no element moving, so an existing carve-out downsamples each visible `canvas`/`video` to 8x8 and folds its pixels into the fingerprint specifically to catch that class of motion.
That carve-out's element selector (`root.querySelectorAll("canvas, video")`) never included `img`. An img src/visibility swap between equal-size opaque images moves zero geometry and zero opacity, so it stays outside both the base fingerprint and the pixel-hash carve-out — the whole-run fingerprint reads byte-identical across every sample and `sweep_static` fires on an animating composition.
## How
Widened the selector to `canvas, video, img`. No other change was needed: `mediaPixelHash` already handles `img` correctly — `drawImage` accepts any `CanvasImageSource`, and its width/height detection already falls back to the element's bounding rect the same way it does for `canvas`/`video`.
**Scope note:** this repo has an open PR (#3707) touching the same function (`collectLayoutGeometry` in this same file) for a different, unrelated bug (text/counter fingerprinting). This change is deliberately isolated to the `canvas, video` → `canvas, video, img` selector line and a new comment above it — verified against #3707's current diff that neither touches this exact loop, so the two PRs shouldn't conflict regardless of merge order.
## Testing
Added a test mirroring the existing "changes the sweep fingerprint when visible video pixels advance" test, using an `<img>` element instead of `<video>` with the same pixel-mock approach.
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
- Full CLI suite (excluding known-broken browser-launch tests unrelated to this change): 217 test files / 3023 tests passing
One local-environment caveat, disclosed for transparency: `layout-audit.browser.test.ts` (the file the new test lives in) can't execute in my local sandbox — it fails identically with or without this change (`No such built-in module: node:`, a happy-dom + Vite externalization issue affecting every test file in this repo that imports Node builtins at the top under `@vitest-environment happy-dom`, not specific to this change). I verified the new test's logic and mocking approach are structurally identical to the existing, CI-passing video test it's modeled on, and will confirm via this PR's CI run rather than a local one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`.hyperframesignore` negation rules (e.g. `!/.media/`) could never re-include a hidden (dot-prefixed) directory in the publish or cloud-render project archive, even though negation works correctly for every other kind of path.
## Why
`collectProjectFiles`'s walker called `shouldIgnoreSegment` first for every directory entry, and that check unconditionally excluded any name starting with `.` — before the project's ignore matcher (built from `DEFAULT_PROJECT_IGNORE` + `.hyperframesignore`, where negation is evaluated) ever ran on that path. A hidden directory was discarded at the walk step, so no negation rule downstream could ever reach it.
## How
- Moved the dot-prefix exclusion out of the hard `shouldIgnoreSegment` short-circuit and into the same ignore matcher that already parses `.hyperframesignore`, as a new default pattern (`.*`) in `DEFAULT_PROJECT_IGNORE`. Dot-prefixed paths are still excluded by default, but now via the same gitignore-style negation path as everything else, so a project's `.hyperframesignore` can override it.
- `shouldIgnoreSegment` is now reserved for the fixed, non-negotiable exclusions only (`.git`, `node_modules`, `dist`, `.next`, `coverage`, `.DS_Store`, `Thumbs.db`) — the set no `.hyperframesignore` rule should ever be able to reach.
- Added regression coverage for both directions: a `.hyperframesignore` negation re-including a hidden directory, and an unmatched hidden directory still being excluded by default (no behavior change for existing projects without an explicit negation rule).
## Testing
- `bunx vitest run packages/cli/src/utils/publishProject.test.ts` — 40/40 passing (2 new)
- `bunx vitest run packages/cli/src/commands/cloud/render.test.ts` — 10/10 passing (cloud render reuses the same archive builder)
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The two skills exempt from the catalog search were pinned only by the
sentence that declares the exemption, so the exemption would go stale the
day either gained a way to install a registry item. The test now also
asserts neither skill contains an install command or a registry path.
The templates list in the scaffold reference claimed to be lint-checked
but lacked the marker that arms the check, so a misspelt template would
have passed. It now carries the marker with its presets and skill names
allowlisted, and a misspelt template fails the lint.
* fix(motion-graphics): make the catalog search fire before hand-authoring
The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.
The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.
Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.
* fix(skills): search the component catalog before hand-building a look
Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.
All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.
- Eight workflows now run the search at the point they decide what to
build, before authoring. The two that compile through a closed
authoring vocabulary (embedded-captions, talking-head-recut) document
why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
treatment or transition) instead of the command name; the router table
and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
as a hand-author gap in two files while shipping in the registry;
stat-motion was named as an installable block and is not one; the
caption-* family count was one high; the registry discovery tables
claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
names an item the registry does not have.
* refactor(scripts): reuse native recursive readdir and the shared registry type
Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
lint passes.
* refactor(scripts): name the registry check's blind spots and stop self-arming
Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
empty array, so "not a snapshot" and "a clean snapshot" have one owner
and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
marker syntax in an example no longer arms the check on itself. The id
scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
identifiers outside backticks, and single-word item names. Measured on
the six marked files, dropping the hyphen requirement would monitor 3
more items and force 46 allow= entries for ordinary prose words, so the
requirement stays and the gap is stated instead of silent.
* chore(skills): regenerate skills manifest after catalog-search edits
* feat(registry): add 25 image carousel blocks (5 families × 5 variants)
Five carousel families, each with 5 style variants:
- Orbit (1–5): image cards on a spinning 3D Fibonacci sphere
- Path (1–5): cards following animated CSS motion paths
- Circle (1–5): circular carousel layouts
- Vision (1–5): Apple Vision-style spatial presentations
- Text Circle (1–5): circular carousels with text overlays
All blocks are 1920×1080 at 6s, with 12–24 configurable image slots.
Includes catalog preview thumbnails for each block.
Co-Authored-By: Jake Moran <jake.moran@heygen.com>
* refactor(registry): host carousel block images on the CDN
The registry is served straight out of this repository
(DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a
block ships is permanent history. The 25 carousel blocks added 421 JPEGs,
27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images
were distinct: the same 12-24 placeholders were copied into every block.
files[] entries gain an optional `url`. When set, the installer fetches the
bytes from there instead of joining the registry base. `path` does not change
and still says where the file lands relative to the item, so composition HTML,
target mirroring and `hyperframes add` behave exactly as before.
Keys are content-addressed, so the 396 manifest entries resolve to 23 objects,
and a changed image gets a new URL rather than a stale one cached behind
`immutable, max-age=31536000`.
The catalog preview renderer copies an item's directory and renders it, so it
needs the same materialisation step. Without it the preview draws every card
blank and reports success, which is worse than failing.
Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing;
catalog previews are rendered by CI and served from docs/images/catalog.
Verified: all 23 objects return 200 from the CDN with hashes matching their
keys; `hyperframes add carousel-orbit-1` against a local registry installs 24
real JPEGs; the preview render produces the album art, and produces blank
cards when the fetch step is removed.
* style(registry): format the carousel composition HTML
`oxfmt --check .` covers the whole tree, and these 25 files were never run
through it. The pre-commit hook only formats staged files, so nothing local
caught it.
* feat(catalog): publish the carousel blocks without republishing their images
These 25 blocks had no Catalog page. Every other item in the registry has one,
so they shipped invisible: installable by name, unfindable by browsing.
Generating them naively undid the change they were added by. The Catalog
payload copies an item's assets into docs/public/, which is tracked, so the 396
images this PR just removed came back as 43 MB one directory over — worse than
the 3 MB they started as, because each block got its own copy.
The copy exists because these compositions assemble `img.src` at run time out
of a variable value, so there is no `src="..."` in the markup for the payload's
asset scan to resolve. An unpredictable path can only be satisfied by serving
every file beside it, which is what `needsOwnDirectory` asks for.
An absolute URL needs no directory: the scan already skips any `https:`
reference. So for the payload path only, hosted files are left undownloaded and
the composition's variable defaults are rewritten to their URLs. The preview
renderer still downloads them, because it paints real frames and a missing file
is a blank card.
The explorer posts every value to the preview frame on mount, including
untouched ones, so the page's variable list carries the URLs too. Left as local
paths they would have overridden the payload's own defaults and asked the frame
for a file that was deliberately never published.
Result: 25 pages, 25 payloads, zero bytes of image added.
Verified: a spike item declaring no assets at all rendered its 24 covers from
the CDN, proving the variable-default path; payload generation for a carousel
block now writes no item directory and no shared asset; the preview render
still produces the album art. mint validate and mint broken-links pass on the
new pages. test:scripts is green.
* refactor(catalog): split the hosted-asset step out of prepareProjectDir
Two functions rather than one: finding the composition and rewriting its
variable defaults are separate jobs, and inlining the mode branch pushed
prepareProjectDir past the complexity gate it was already sitting on.
Behaviour is unchanged. Re-verified both paths after the split: the payload for
a carousel block still writes no item directory and no shared asset, and the
preview render still produces the album art.
* feat(catalog): give the carousels their own shelf
25 image carousels landed in Showcases and were 53% of it, so the scenes that
shelf exists for disappeared underneath them. That is the same shape the 24
editor themes made, and it gets the same fix they got.
Keyed on the first tag, which is this file's stated grouping rule, rather than
on the name. `screen-flow-carousel` leads with `product-demo` and stays on the
shelf that says what it is for; a future carousel that is not named
`carousel-*` still lands here.
Showcases 47 -> 22, Carousels 25, and no existing item changed shelf.
* fix(registry): centre the circle-5 carousel path in its composition
Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the
right edge and most of it fell outside the frame. Only a few cards were ever
visible, cropped, with two thirds of the composition empty.
The exported path carried absolute coordinates from a layout that was never
recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on
the composition centre. Handles are relative, so only the anchor points move
and the shape is unchanged.
carousel-text-circle-5 shares the identical path and had the identical fault.
The other three circle variants sit within 12% of centre, which reads as
authored placement rather than the same bug, so they are left alone.
* fix(catalog): rebuild the circle-5 payloads after recentring the path
The Catalog preview plays the payload, not the composition on disk, so
recentring the source changed nothing a reader sees. The payload still carried
the old vertices and the ring still hung off the right edge of the frame.
Verified the consumer this time, not just the producer: both payloads now
resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the
formatting pass did not reach them.
* feat(catalog): promote Carousels to its own section
It was a shelf inside Scenes & demos, which is where a scene type belongs by
kind but not by weight. At 25 items it is larger than Data & charts (17) and
Blocks (13), each of which is already a section holding a single shelf, so the
catalog's own precedent puts it one level up.
Pulling it out also takes the largest section in the catalog from 120 items to
95, which is the reason the shelf was added in the first place.
The two circle-5 pages change because their embedded source block carries the
recentred path; nothing else in them moved.
---------
Co-authored-by: Jake Moran <jake.moran@heygen.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* test(producer): pin unpinned drawElement stall retry
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing (#3172)
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing
A single drawElement frame could kill an entire render. On one comp
(caption-editorial-emphasis) drawElementImage returns normally and the renderer
then stops draining its task queue: the setTimeout(…, 0) that schedules
toDataURL never fires, the capture page.evaluate never settles, and 60s later
the stage watchdog fails the whole render. Deterministic on 152.0.7977.30,
always the same frame. Root-cause detail in PRINFRA-488.
Two gaps, both closed here.
1. Nothing below the 60s stage watchdog bounded a frame. Adds a per-frame
deadline (HF_DE_FRAME_TIMEOUT_MS, default 15000, 0 disables) around the WHOLE
frame operation, not just the drawElementImage call — the stall surfaces at
whichever page round-trip comes next, and was observed at both the capture
and the seek's background-image decode. Bounding one call missed it.
Deliberately NO per-frame screenshot fallback for this error: once the
renderer stops scheduling it is wedged for every subsequent round-trip on
that page. Measured — the screenshot fallback blew the same deadline. The
frame fails fast instead, so the recovery that actually works can run.
2. That recovery was ineligible. shouldRetryViaPinnedFallback only retried a
generic capture failure when the worker count was PINNED by the inversion or
the router; a comp that engaged drawElement on the ordinary single-worker
path had no whole-render fallback at all. A renderer stall is now retryable
on any routing — the failure is a property of drawElement itself, and the
retry re-renders on a fresh page via screenshot. Reported as
de_fallback_reason "de_renderer_stall" so it is distinguishable from
capture_error in telemetry, and counted per-session as deFrameTimeouts.
Verified end to end on the repro: previously exit 1 with "stalled: no frame
progress for 60000ms"; now the deadline fires, the render retries via
screenshot, and RENDER_OK with a valid 1920x1080 / 240-frame / 8.0s MP4.
Tests cover the predicate both ways plus the cross-package error match. Engine
1,481 and producer 587 green (audioPadTrim.integration flakes only under
full-suite parallel load — passes in isolation, and on clean HEAD it is green
too, so it is not from this change).
Refs PRINFRA-488
* fix(engine,producer,cli): count drawElement frame timeouts where they happen
`session.deFrameTimeouts` was incremented inside `captureFrameCore`'s catch,
but the deadline races that function from OUTSIDE it — `withFrameDeadline` is
applied by `captureFrameToBuffer`, and `DeFrameTimeoutError` is constructed
nowhere else. So the branch was unreachable: on a wedged renderer the counter
stayed 0, the engine-side stall log never printed, and
`CapturePerfSummary.deFrameTimeouts` reported 0 on every affected render. The
end-to-end recovery worked throughout — `isDeRendererStallError` matches by
name at the producer — only the observability was dead.
Moved the increment and the log into an `onTimeout` hook that fires from the
deadline itself, deleted the unreachable branch and the now-unused
`isDeFrameTimeoutError`, and threaded the count out to
`render_completed.de_frame_timeouts` so the rate is graphable apart from the
other `capture_error` fallbacks. A stall always costs a whole-render re-run,
which is why it deserves its own series.
Also corrected the `fallbackReason` docstring, which still listed only
`psnr | blank | oom | capture_error` after this branch added
`de_renderer_stall`.
`withFrameDeadline` is exported for a two-case fake-timer test: the deadline
fires and calls `onTimeout` once, and a resolving inner promise passes its
value through with no late timer. The first case fails if the counter moves
back inside the work promise.
(cherry picked from commit ec2b2b5b2a)
* fix(render): retry sequential screenshot stalls
* fix(render): bound parallel worker capture phases
---------
Co-authored-by: Vance Ingalls <vance@heygen.com>
* feat(lint): catch leftover marker heads and guessed marked shafts
Dash-draw on a marked path shows the arrowhead before the shaft exists.
A long marked path that misses every node in both user and screen space
is the same detach as the CTM-paste bug, without the counterfactual.
* feat(check): flag orphan connectors and unbalanced style tags
Catch a visible shaft while fewer than two nodes are on stage (enter-early /
exit-late), and extra </style> that dumps CSS onto the frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): judge connector_orphan by the shaft's own endpoints
The rule counted anchors stage-wide and skipped whenever two were on. That
tier is every visible text-bearing or opaque element under 15% of the
stage, so a title and a footer alone satisfied it — the check could not
fire on a composition that had any chrome, which is all of them.
Endpoints are now resolved per shaft, against candidates gathered by
layout instead of by visibility, so a node hidden with opacity:0 still
owns the endpoint that meets it and its visibility is what the rule tests.
An endpoint that meets no node is left to connector_detached.
* revert(lint): drop the marker rules this repo would only duplicate
marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python
connector lint, down to the same regexes and the same message and fixHint
strings. Zephyr concatenates its local findings onto this bridge's result
with no dedupe, so shipping them here reports each one twice and counts it
twice, and every later edit has to land in two places.
The browser-check work stays: a marked shaft that meets no node, and an
orphan shaft, are both runtime facts this repo can see and a source-reading
rule cannot.
* chore(check): drop the comments added with the orphan rewrite
* fix(check): score orphan and detach on overflow-opted connector layers
Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses
the same connector names as detach so flow and arrow shafts are judged.
* fix(check): see axis-aligned shafts, and script tags closed with a space
connector_orphan gated on isVisibleElement, which rejects anything whose
bounding box has no height or width. A straight horizontal or vertical
connector is exactly that, and it is the common shape, so the rule was
blind to most of what it exists to catch: across 47 production
compositions it produced nothing. It now tests paint -- display,
visibility and the opacity chain -- and leaves size to the 80px chord
floor it already had. The same 47 compositions now yield three findings,
each naming the endpoint that is still dark.
unbalanced_style_tags stripped scripts with a regex that only matched
`</script>`. `</script >` is valid, so the script survived the strip and
a `"</style>"` string literal inside it counted toward the tag balance,
reporting an error on a composition whose tags are paired. Both the strip
and the closer count now tolerate whitespace before the `>`, which is also
what CodeQL flagged on this branch.
* fix(lint): count style tags in one pass instead of stripping scripts
CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML
sanitizer. It was never one — the rule only needs to not count `<style`
tokens that live inside a script string. A single alternation scan, where
the script branch consumes the whole block, gets the same counts without
a replace.
* test(check): cover the two orphan guards that no fixture was crossing
Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity
threshold, left the suite green. Both are false-positive guards: a
connector staged hidden before its reveal, and a hidden element that
happens to be the nearest thing to an endpoint without belonging to it.
The hidden-shaft case now runs over all four ways a shaft can be
invisible, so the display and visibility clauses are crossed too.
* test: pin the two boundaries raised in review
A closer inside an html comment is already dropped upstream by
stripHtmlComments, so the rule never sees it. The test fails if that
stripping regresses; adding a comment branch to the rule's own scan
would have reintroduced the polynomial-redos pattern that stripper
exists to avoid.
A node hidden with display:none has no geometry, so nothing can place it
near an endpoint and connector_orphan stays silent. connector_detached
does not pick it up either, since it allows a half-attached shaft by
design. That case is uncovered, and the fixture says so.
* fix(check): type the hidden-shaft fixture table as a style record
* fix(check): key connector_orphan by geometry and let a live node win the endpoint
Two defects found in review.
connector_orphan was persistence-tiered but absent from the geometry key,
so several id-less shafts orphaning at one sample each collapsed into a
single finding that then read as held rather than transient. On the 47
corpus compositions this recovers 9 findings across 5 compositions where
3 across 2 were reported; one composition fades its nodes and its
connectors on a shared stagger, so four shafts each outlive a different
node and only one of the four survived the collapse.
The endpoint scan took the nearest candidate and asked whether that one
was hidden, without asking whether a visible box was also in range. A
staged halo sitting on the node it belongs to produced a finding naming
the halo. A visible candidate within threshold now settles the endpoint.
Fixtures added for both, and for the two guards a mutation sweep found
unpinned: the connector-shape test and the dash-offset skip.
* test(check): kill every surviving mutant in the connector_orphan path
Seven guards still passed the suite when deleted: the four candidate
filters, the connector layer's own visibility gate, the defs/marker skip
and the chord floor. Each now has a fixture where that guard alone
decides the outcome.
The earlier sweep that missed them was mutating the wrong copy of a
shared line — connectorAnchorRects and connectorEndpointCandidates carry
the same filter text, and a first-match replace edited the detached one.
Mutations are scoped by enclosing function now.
* test(check): order the halo before the node it hides under
The halo fixture proved nothing. Both boxes contain the endpoint, so both
score gap 0, and the strict tie-break keeps whichever comes first in
document order — which was the visible node. The old code picked the same
winner and stayed silent too.
Putting the halo first makes the tie-break hand it the slot, so only the
visible-candidate check keeps the endpoint attached.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
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.
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(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>