Address Miguel's R1 blocker + Rames/Miga's testability nit:
- The `-frames:v 3` decode samples AT MOST 3 frames; a legitimate 1- or
2-frame WebM (256 or 512 bytes) was returned as `undefined` (probe
failure), silently skipping the advisory even when every available
pixel was opaque. Accept any positive whole-frame byte count ≤ 768
(multiples of 256), distinguishing successful short-EOF from partial/
malformed decode.
- Export `sampledAlphaIsFullyOpaque` and add 11 direct tests covering:
3/2/1-frame opaque decodes → true; transparent pixel at pos 0 or
final byte → false (guards the alpha-byte stride); non-frame-multiple
/ over-3-frame / zero byte counts → undefined; execFileSync throw →
undefined; findFFmpeg missing → undefined; and one args-shape guard
pinning the load-bearing `-c:v libvpx-vp9` before `-i` (without which
the default decoder silently discards VP9 alpha and the whole check
would false-positive on genuinely-transparent WebMs).
- Update advisory wording from "3 sampled decoded frames" to "every
sampled decoded pixel" so the message is honest for short WebMs.
18/18 tests pass locally under `vitest run`.
Review feedback on #2358: the batch-miss RENDER_FAILED in
runAssetImportMany (asset.ts) throws the same typed error as
client.ts's single-node renderNode, but wasn't labeled — so
cli_error.endpoint would silently come back undefined for the
flow that most heavily exercises /v1/images.
cli_error had no way to tell which figma REST call (images, files_nodes,
variables_local, styles, ...) actually hit RATE_LIMITED/FORBIDDEN/etc, so
the dashboard could see failures spike but not which call caused them.
FigmaClientError now carries a low-cardinality endpoint label (never the
raw fileKey/nodeId), threaded through to cli_error's endpoint property.
Extends webmAlphaCheck.ts (from #2044) with a pixel-level decode probe.
After the tag check passes, decodes 3 sampled frames via
`ffmpeg -c:v libvpx-vp9 -pix_fmt rgba -f rawvideo` at 8x8 and emits a
distinct advisory if every alpha byte reads 255.
#2044 detects the "tag absent" failure mode (ffprobe shows no
`alpha_mode` in stream tags). It doesn't catch a stricter case reported
on CLI 0.7.56 / Windows 11: ALPHA_MODE=1 present but BlockAdditional
alpha side data empty. Under current logic webmAlphaAdvisory sees
`alphaMode: true` and stays silent, so the render ships as opaque
without any signal.
The -metadata:s:v:0 alpha_mode=1 push is a muxer directive that some
ffmpeg builds write unconditionally, independent of whether libvpx-vp9
emitted the alpha plane. Tag presence is necessary but not sufficient
evidence of preserved alpha.
Advisory text names both possibilities (opaque composition OR silent
alpha drop) plus the concrete workaround (png-sequence + prores repack).
Fast path (no tag or missing tag) is unchanged. Probe adds ~1s per
WebM render only when the tag says alpha.
Adopt the fake-timer pattern the sibling "recovers when a crashed reclaimer
leaves both lock directories" test in the same file already uses. Without
fake timers, if the dynamic `import("./manager.js")` beat between
`installFsMocks({ initialMtimeMs: Date.now() })` and the `withInstallLock`
call exceeds `staleMs` (50 ms on a busy shared runner with `vi.resetModules()`
per beforeEach), the new immediate-stale short-circuit added in #2328 fires
on iteration 1, breaks out before `waitedMs` reaches `waitNoticeMs=20`, and
no "Waiting for another hyperframes process" warn ever emits — the assertion
at `manager.test.ts:428` (`expected false to be true`) then fails.
Under fake timers, `Date.now()` is frozen at the mtime seed, so the lock
stays non-stale across the dynamic import and the wait-notice branch
observes real polling; `vi.advanceTimersByTimeAsync(staleMs + pollMs * 5)`
then drives the loop past both the wait-notice threshold and the stale
deadline so the reclaim + acquisition still resolves.
Test-only change; no production-code diff. Verified 27/27 in
`packages/cli/src/browser/manager.test.ts` under `vitest run`.
* fix(cli): three occlusion-probe false-positive sources in text_occluded
- pointer-events:none text is invisible to elementFromPoint, so the probe
always hit whatever paints beneath and misread visible text as buried;
restore hit-testing on the element for the duration of the probe
- a backgroundImage counted as opaque regardless of alpha, so a 4%-alpha
grid/scrim gradient qualified as an occluder; gradients now occlude only
when their colours reach alpha > 0.6 (url() images unchanged)
- a visible container whose every text-bearing descendant is still at
opacity 0 (entrance not started) was probed anyway; skip when no text
ink is on screen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): address review — document-wide hit-testing restore, gradient compositing, whitespace ink
- restore hit-testing for ALL pointer-events:none elements during the text
audit pass (not just the probed text): an occluder that itself carries
pointer-events:none is invisible to elementFromPoint, which made truly
buried text read as clean once the text alone became hittable
- hasVisibleTextInk ignores whitespace-only text nodes (indented markup
defeated the gate) and uses a 0.05 floor so mid-fade text keeps its
persistence occurrences
- hasOpaqueBackground composites gradient alpha with background-color
(two 0.5-alpha layers paint at ~0.75); gradientMaxAlpha returns opaque
for any colour function it cannot score (oklch/lab/...); percentage
alpha values now parse as fractions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): walk the elementsFromPoint stack in occluderAt
A transparent layer that becomes hittable (pointer-events restored) must
not mask an opaque occluder painting beneath it — single-point
elementFromPoint returned the transparent top and dropped two genuinely
buried cases in the census acceptance run; the stack walk keeps 10/10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): composite stacked background-image layers when judging occluder opacity
Two 0.5-alpha gradient layers paint at 0.75 combined; taking the max
color-stop alpha across the whole declaration under-counted them and
suppressed real text_occluded findings. Split layers at top-level commas
(paren-aware), score each, composite as 1-prod(1-a_i). Also pins the
0.05 text-ink floor with a boundary test (review feedback on #2357).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): keep walking the occlusion stack past pair-specific exemptions
sharedPreserve3d and isCrossSceneTransitionOverlap excuse one hit, not
the whole probe; returning null let a transparent decorative layer in
the text's 3D context mask a real occluder below it (review feedback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`hyperframes lambda deploy` runs `sam deploy --resolve-s3`, and SAM's
managed artifacts bucket (aws-sam-cli-managed-default) is created with
default SSE encryption. Setting that requires s3:PutEncryptionConfiguration,
which the generated deploy policy did not grant, so a first deploy by a
user provisioned exactly per `lambda policies user` 403s on the bucket and
the managed stack rolls back.
Add s3:GetEncryptionConfiguration and s3:PutEncryptionConfiguration to the
s3Bucket action set (Get pairs with Put for CloudFormation update/drift
reads, matching the existing Get/Put pairs in the list). Also add a hint to
the sam-deploy failure path pointing at the ROLLBACK_COMPLETE recovery step,
since first-time users hit the stuck-rollback error on their retry.
Fixes#2137
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review on #2266: the gradient-text exclusion was too broad — any
background-clip:text skipped the invisible-text check, so a broken/missing
gradient (clip:text with no image and a transparent background, which paints
nothing) went unreported. Now exclude only when a real background fills the
glyphs (background-image != none, or an opaque background-color). Expands the
test suite to the reviewer's full case set: direct transparent fill, inherited
transparent fill over an opaque child color, color:transparent fallback, opaque
baseline, gradient-over-real-background exclusion, broken-gradient still flagged,
and empty-text no-op.
The layout-audit test mock returns computed styles as plain camelCase
properties (no getPropertyValue), so the invisible-text detector threw
'cs.getPropertyValue is not a function' and broke the whole audit in CI. Read
webkitTextFillColor/webkitBackgroundClip/backgroundClip by property to match
the rest of the script (works in a real browser too). Adds tests: flags
transparent -webkit-text-fill-color, ignores opaque color, ignores gradient
text (background-clip:text).
Wild report (5th in cluster, CLI 0.7.53): snapshots omitted all text while
check passed — text painting with a transparent -webkit-text-fill-color (which
overrides `color` for the glyph fill AND inherits, so a parent's transparent
fill silently blanks descendant text that has its own opaque `color`) renders
invisible, but every geometry/occlusion/contrast audit missed it. Contrast in
particular reads `color`, not the fill that actually paints, so white-`color`
+ transparent-fill text scored as high-contrast and passed.
Add an invisible-text detector to the layout audit: flag any text element whose
effective fill (computed -webkit-text-fill-color, which already resolves to
`color` when unset) is transparent. Gradient/clipped text (background-clip:text)
legitimately uses a transparent fill and is excluded. Verified: check now fails
on an inherited-transparent-fill fixture (text_not_painted) while gradient text,
body-inherited color, sub-composition color, and real registry examples stay
clean.
Addresses review on #2264: the localization helper had one broad catch around
both dynamic producer resolution and injector execution, so it couldn't tell a
benign 'producer not in this environment' from a real injector/fetch failure,
and emitted no diagnostic. Split into loadFontInjector() (returns null when the
module is absent — silent fail-open) and localizeWithProducer() (warns ONCE per
distinct message when the injector itself throws, then fails open). Per-family
resolution failures remain the injector's own responsibility (producer's
warnUnresolvedFonts). The localizer seam is injectable; tests now cover success,
producer-unavailable, injector-throw, warn dedup, and call-site integration.
The CLI test job builds with --filter '!@hyperframes/producer', and render.ts
imports producer only as a type — so a static import("@hyperframes/producer")
in the font-localization helper failed Vitest's transform-time module
resolution ("Failed to resolve entry for package"), breaking checkBrowser
tests and the helper's own test. Keep the specifier out of the static module
graph (@vite-ignore + variable specifier) so it resolves at runtime only:
production/installed CLI has producer in node_modules and localizes fonts;
the test env fail-opens to the plain bundle. Localizer is now injectable so
the helper's unit tests cover it without needing producer resolvable.
* fix(telemetry): expose stalled render stages
* fix(telemetry): preserve capture data on terminal stage events
* fix(telemetry): fix calibration TDZ crash, tag encode/assemble, extend heartbeat cadence
capture_calibration referenced captureStageObservationData before its
declaration (later in the same scope), which would throw a ReferenceError
for any render hitting the calibration path. Hoist the closure and split
workerCount's declaration from its resolution so calibration can safely
read it as undefined before capture strategy resolves worker count.
Also address the two non-blocking review items: wire encode/assemble
stages through captureStageObservationData for consistent tagging, and
extend the heartbeat schedule to repeat every 120s after the initial
30/60/120s ramp instead of going dark on stalls beyond two minutes.
Two non-blocking review notes from Rames, both addressed:
1. Trial polarity inverted to OPT-IN: disableDeParallelRouterTrial →
enableDeParallelRouterTrial. renderLocal is exported, so any programmatic
consumer (future studio-server path, test harness, distributed runner)
previously inherited the trial and its process-wide env-var/module-latch
state without knowing to disable it — and concurrent invocation races
that state. Now only the CLI's own sequential call sites opt in (the
single top-level render, and batch at concurrency 1); everyone else gets
no trial by default. The doc comment names the sequential-invocation
assumption explicitly.
2. deSelfVerifyFallback semantic narrowing documented at both declarations
(RenderCaptureObservability + RenderPerfSummary.drawElement): since the
pinned-fallback retry was widened, the flag means verify-triggered
SPECIFICALLY — OOM/capture_error fallbacks report false with
deFallbackReason carrying the reason. Dashboards keyed on
de_self_verify_fallback=true as "any fallback fired" must migrate to
de_fallback_reason IS NOT NULL (also called out in the PR body for the
observability rebuild to pick up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:
- Install: target selection could trust a stale local skills-manifest.json
(findRepoManifest) while `skills add` always installs from the canonical repo.
isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
into the target set, `skills add` silently declined it (exit 0), and strict
verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
retired before it ever shipped a bundle has nothing to match — a silent
exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
every run.
The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.
Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.