* docs(registry,skills): surface code-highlight 0-based indexing and opacity-reveal sweep guidance
From the 2026-07-14 CLI feedback digest (skills-owner action): a user
building code teaching videos hit two authoring gaps.
1. code-highlight's `line` is intentionally zero-based (`line: 1` =
second displayed line) but the warning lived only in pr-to-video's
code-vocabulary reference — nowhere an author actually touches the
value. Call it out at the block-use sites: the `__BLOCK` declaration
itself, the registry-item description, and the motion-graphics
catalog map.
2. Opacity-only code-typing tripped `sweep_static` for that user, who
worked around it with a slow host y-drift. The sweep fingerprint
does include per-element opacity, so document the actual trap (a
reveal that settles before the sampled window, then holds a static
frame) and the idiomatic fixes (spread the reveal / keep a blinking
caret alive) in the check reference — and pin the fingerprint's
opacity sensitivity with a regression test covering both the
visibility-floor crossing and a mid-fade value change.
* docs(catalog): regenerate code-highlight page from updated registry-item description
Only the code-highlight page is committed: a full generate-catalog-pages
run also surfaces ~34 blocks missing from the git-tracked catalog index
(pre-existing drift on main), which belongs in its own chore PR.
* fix(cli): invalidate the skills nudge cache after a successful install/update/check
The passive "N skills out of date or missing" nudge reads a 24h config
cache that only the background check (on non-skills commands) ever wrote.
The skills commands themselves are excluded from the nudge pipeline, so a
successful `skills update`/install/check never refreshed or dropped the
cached verdict — the pre-install count kept printing on every other
command for up to 24h.
Reconcile commands now drop the cached verdict (counts + timestamp) so
the next command's background check re-runs for real. The offline
presence-only path deliberately keeps the cache: that run learned nothing
about freshness.
* fix(skills): win32-safe npx spawns in media-use + accurate whisper wording
The Whisper transcribe fallback and the Kokoro local-TTS delegation both
spawned a bare "npx" via execFileSync — on Windows npx is npx.cmd, which
spawn cannot exec, so both paths died with `spawnSync npx ENOENT`. Route
them through the skill's existing resolveSpawnCommand (node + npx-cli.js
on win32, no shell:true), same as the audio engine's TTS spawns.
Also corrects the "bundled with the hyperframes CLI" claim about
whisper.cpp: it is resolved from PATH / installed via Homebrew / built
from source with git+cmake on first use, and models download from
HuggingFace — nothing whisper is shipped in the package.
* feat(skills): canonical fully-silent marker + auth status exit-code docs
product-launch's Step 3.1 gate said "or the project is marked silent"
but nothing defined how to mark one, and audio.mjs unconditionally
retrieved BGM. Define the canonical marker — `music: none` in the
storyboard's top YAML block, plus no SCRIPT.md — and honor it:
audio generate produces nothing (removing stale audio_meta.json, since
absence is what assemble treats as silent), and `music: none` with
narration keeps TTS while turning BGM off.
Also documents the `auth status` exit-code contract (exit 1 while
signed out is the normal offline state, not a failure) in the
product-launch Step 0 note and the CLI skill's cloud reference.
* fix(skills): transient-init retry for standalone animation-map and contrast-report
The standalone helpers called initializeSession exactly once, so a valid
modular project — whose sub-composition timelines register asynchronously
— could hit the readiness deadline and die with the transient
"zero duration / Runtime ready: false" diagnostic the render pipeline
retries (probeStage). Add initializeSessionWithRetry to the shared
package-loader (both byte-identical copies): close the crashed session
and retry once with a fresh browser, gated by the engine's canonical
isTransientBrowserError — now re-exported from @hyperframes/producer,
with a frozen fallback pattern list for older published packages. The
"Runtime ready: true" fast-fail (a genuine authoring bug) still fails
without a retry.
* feat(skills): extend the fully-silent marker to faceless-explainer and pr-to-video
Both workflows reuse product-launch's audio model — their Step 3.1 gates
carried the same undefined "marked silent" phrase, and their (intentionally
identical) audio.mjs copies had the same unconditional BGM retrieve. Port
the `music: none` marker handling into both copies, define the marker in
their SKILL.md Step 3.1 and story-design references, and turn the
copies' "intentionally identical" header claim into a byte-identity pin
test so the next fix can't silently miss one of them.
* test(cli): reset the prune mock explicitly instead of relying on restoreAllMocks
The converge test's toHaveBeenCalledTimes(1) held only because vitest 3's
vi.restoreAllMocks() clears vi.fn() call state; vitest 4 restores spies
only, so the count would accumulate across tests and fail. Reset
pruneOrphanedLockEntries in beforeEach like the other manifest mocks —
passes under both vitest 3.2.4 (pinned) and vitest 4.
* test(skills): close review findings — package-loader pin, whisper win32 parity, quoted-none
Review follow-ups on #2476:
- package-loader.mjs byte-identity pin (the elevated concern): the two
copies now carry initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS,
exactly the shared-logic shape a future fix could land in one copy and
miss in the other — same enforcement as the audio.mjs pin.
- whisper win32 call-site parity: runWhisper's npx resolution lifted into
lib/npx-sync.mjs (resolveNpxInvocation, injectable params matching the
localTtsGenerate idiom) with the same three-branch coverage as the
Kokoro site — plus the hard-fail contract (throws actionably, since the
whisper fallback has no next provider to fall through to).
- quoted music: "none" pin: the vendored storyboard parser strips matching
quotes at parse time (stripQuotes), so the silent marker already accepts
the quoted spelling — pinned so that stays true.
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing
Per-frame comment boxes on the storyboard board batch into
.hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed
comments on other frames are kept). Submitted-but-unconsumed comments
stay visible — a toolbar banner plus a per-tile echo — until the agent
consumes the file; the banner also says what to do next (reply anything
in the agent chat).
The board keeps itself current: GET /projects/:id/signature exposes the
watcher-cached project signature, the storyboard payload carries the
signature it was derived from, and the view polls at 2s (hidden tabs
skipped, re-checked on visibility), refetching in place with no loading
flash. Posters bake the signature into their URL so tiles fill in as
sketches land and a poster that failed mid-write retries on the next
version; the empty state upgrades itself when STORYBOARD.md appears,
and its handoff prompt now points the agent at the review loop and uses
the parser's real status vocabulary (outline, not planned).
preview lands the browser on the storyboard view while the board is the
review surface — any frame built, or pure planning (srcs declared, none
on disk yet) — and on the timeline once the video is assembled.
* feat(skills): the review loop — plan, sketch, build as one shared process
hyperframes-core/references/review-loop.md is the single source for the
three-pass collaborative review: the plan proposed on a live board
(§ 1), wireframe sketches marked built with one layout question (§ 2 —
real words on plain blocks, run no CLI; a confirmed board is itself a
valid deliverable when the user asked for a storyboard, not a video),
the build dressing confirmed layouts (§ 3, worker or inline), and the
final look (§ 4). Autonomous runs skip every gate and keep one question
before render.
The three narrative workflows' Steps 3/4/6 collapse to references plus
their sketch stand-ins (captured-asset blocks for product-launch-video,
plain code panels for pr-to-video); the confirmed-sketch handoff stays
in each frame-worker prompt. general-video plans on a board for
multi-scene narrative pieces in collaborative mode — its sketch pass is
layout-before-animation with the user watching. The router treats
"I want a storyboard" as a process request rather than a route, and
closes exploratory intake by recommending a route plus how the run will
review.
The supporting contracts land next door: the comments channel (silent
submit, one reply picks it up, check the file before the words) in
brief-contract § 1; the sidecar schema and the built status rung in
storyboard-format; the mode question asked first and alone in the three
workflows' Step 0.
* feat(media-use): user memory — remembered preferences and frozen recipes
Two tiers of memory on media-use's existing two-tier storage split.
Preferences (lightweight): confirmed brief answers — destination, aspect,
language, mode, voice, style preset — recorded to the project's
.media/preferences.json (committed, the team inherits it) and promoted
to the personal ~/.media/preferences.json once the same value is
confirmed in two different projects (a sightings ledger accumulates the
cross-project evidence user-side, since project files can't see each
other). prefs.mjs get/record; merge reads project-over-user; a changed
value restarts its provenance.
Recipes (heavyweight): one approved run frozen as a named, versioned
bundle — frame.md, the storyboard skeleton (structure kept: durations,
transitions, srcs, Video direction; statuses reset to outline; content
blanked to per-frame fill-ins naming the beat's role), and the confirmed
brief values. Named folders, not content hashes: re-freezing bumps
version and archives <name>@v<N>; a freeze is already confirmed, so it
promotes to the user tier immediately. recipe.mjs freeze/list/use, plus
resolve --type recipe --entity <name> delegating like grade/lut.
16 new node --test cases; the media-use lib suite is 168/168.
* feat(skills): wire user memory into the brief and the review loop
brief-contract § 2 gains Remembered defaults: read the merged
preferences before Round 2 and let a remembered value become the
recommended option with a receipt naming its source project. Memory
changes the default, never the question — every ask-marked field still
gets asked, and what the request says this time beats what was picked
last time. Record only what the user actually confirmed (a defaulted
voice nobody chose is not an answer; a "go" that accepts the
recommended defaults is). The first record announces itself once;
after that the receipts carry the reminder. In autonomous mode a
remembered value becomes the decided value, receipt included.
The three narrative workflows read the remembered defaults before
Round 2, record the confirmed answers at the Step 0 gate, record the
chosen preset at the Step 2 gate (pr-to-video excepted — its preset is
fixed), and fall back to the remembered voice when the request names
none. general-video's discovery reads the same defaults.
Recipes wire in at both ends: Step 0 checks for a matching recipe
before the mode question — one question, plural-aware, and adopting
one fills the brief, skips the design step, and drafts the storyboard
from the frozen skeleton while every review gate still runs. The
review loop's final look (§ 4) offers the freeze once after approval,
and the confirmation teaches the recall phrase — the name is something
the system reminds the user of, never something they must remember.
The router recognizes a named recipe or "like last time" as a route.
* docs(skills): the sketch pass names check, not the deprecated validate
* feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format
* feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys
* feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md
* feat(skills): retire the mode preference key; sync catalog surfaces for intent layer
* refactor(skills): dedupe router vs intent-layer guidance — one owner per rule
* feat(skills): the design ask — own spec, pick by eye from showcases, or defer
* docs(skills): the design ask says the honest line on capture routes
* feat(skills): product-launch-video absorbs website-to-video as the tour angle
* refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch
* feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold)
* refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows
* docs(skills): review-loop pass across skill catalog
* fix(cli): pass project dir to openStudioBrowser in background-server path
* feat(skills): add pitch-round reference - verbalized sampling concept gate
* feat(skills): wire pitch round into intent layer - completeness triage + route eligibility
* feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split
* feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling
* feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline
* fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI
* chore(skills): resync manifest hash after formatter pass reflowed general-video tables
* fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope
Two holes found by a live companion-run freeze: the agent-supplied --workflow
contradicted the run's actual workflow (recipe.json said faceless-explainer,
brief-skeleton said general-video), and the style_preset lookup missed because
the preference had been recorded under the bare key.
- freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a
fallback for briefless projects and a contradicting flag is ignored (noted).
- recordPreference refuses a bare style_preset — the scoped key is the only
writable shape; freeze tolerates legacy bare records via read fallback.
- review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
* fix(pr-to-video): use display names, not GitHub logins, in credits narration
gh pr view already returns a `name` field for the PR author, commit authors,
and mergedBy. ingest.mjs now tracks it in people.json alongside login;
fetch-people-avatars.mjs resolves a name for reviewers/commenters/assignees
gh doesn't name via the public GitHub user API, best-effort.
story-design.md now directs the credits close to speak the person's name
(TTS reading a raw handle like @miguAng18947550 aloud is the failure mode),
with the handle shown as secondary on-screen text only.
* fix(pr-to-video): resolve missing credit names via the agent, not a new script fetch
fetch-people-avatars.mjs already runs inside the orchestrating agent's turn,
which has gh available — no need for the script to duplicate a name lookup
the agent can do itself with `gh api users/<login> --jq .name`. Reverts the
script back to avatar-fetching only; SKILL.md/story-design.md now tell the
agent to resolve any missing name for the credited people itself before
writing the credits close.
* fix(pr-to-video): regenerate skills-manifest.json hash
Stale hash left over from a rebase conflict I resolved by hand — the
generator produces the correct one.
The agent-driven video recipes (heygen video create) went out untagged, so
media-use avatar / image-to-video usage landed as generic cli traffic and
wasn't attributable to media-use. Add --headers "X-HeyGen-Client-Source:
media-use" to the generating recipes (persistent flag, allowlisted by the
CLI) so those videos carry client_source in master_video_table meta and show
up in the API dashboards alongside the TTS path (#2365) and CLI (#2368).
Send `X-HeyGen-Client-Source: media-use` on every media-use HeyGen API
request (both auth types, via heygenAuthHeaders + the heygenJSON transport),
so backend billing meta can isolate media-use consumption from other free
TTS and avatar-video usage. Unconditional of auth type — a paying user's
media-use call is still media-use — unlike the OAuth-only cli-source header
that gates the free allowance.
Keep the image-to-video recipe; remove the broader capability table (photo
avatar, digital twin, cinematic, translate, lipsync, ai-clipping, voice). It
drifted toward the API-reference surface OP1 says media-use shouldn't carry, and
--request-schema already self-documents each command. Fix the now-dangling
'(below)' pointer in the image-to-video note.
* docs(skills): add cloud render + variables to CLI skill, media-use generative use cases
The hyperframes-cli skill only documented self-managed AWS Lambda rendering; the
zero-infra HeyGen-hosted `cloud render` path (a real, shipped command with its
own docs page) was absent from every skill, so agents never surfaced it.
- hyperframes-cli: add `cloud` to the frontmatter verb list + entry point; new
Cloud and Variables sections; new references/cloud.md distilled from
docs/deploy/cloud.mdx; routing + workflow rows.
- media-use: add image-to-video recipe (heygen video create type:image) plus a
table of other HeyGen generative use cases (photo avatar, digital twin, video
translation, lipsync, voice design) in references/operations.md; surface them
in the SKILL coverage/provider rows.
- Sync README + docs/guides/skills.mdx catalog entries to mention cloud render.
* docs(media-use): point HeyGen generative use cases at --request-schema
Verified against the installed heygen CLI (v0.3.0): no capability gap that would
need the raw API. Replace hardcoded body-field lists with a pointer to
`heygen video create --request-schema` (self-documenting, can't rot), correct
the image-to-video motion_prompt/expressiveness support, and add the
cinematic_avatar, ai-clipping, and photo-avatar creation paths.
* fix(skills): correct media-use manifest hash (clean-tree regen)
The prior regen was polluted by the gitignored skills/media-use/eval-report.html
(a suppressed mv error left it present), so the committed media-use hash didn't
match a clean checkout. Regenerate with no untracked artifacts present.
* feat(cli): coordinate-frame layout findings in check
Four production compositions shipped with 100-600px layout drift, each a
different coordinate-frame confusion the check graded info or missed
entirely: viewport pixels written as container left/top, gsap x/y
treated as absolute position, a -350px margin fighting flex centering,
and stage-relative path coords drawn into a nested SVG.
Three new layout findings close the class:
- positioned_out_of_parent: an absolute/fixed element rendering mostly
outside its positioning ancestor (warning) — the parent needs no
overflow clipping, which is what let container_overflow miss it.
- box_out_of_canvas: a painted panel breaching the canvas (warning) —
text is canvas_overflow's, media is frame_out_of_frame's, painted
boxes were nobody's.
- connector_detached: a connector path whose endpoints land far from
every anchorable element (warning) — measured coordinates drawn into
an SVG with a different origin.
canvas_overflow additionally promotes from info to warning when held
across samples AND the breach exceeds 5% of the canvas.
All three are persistence-tiered and respect data-layout-allow-overflow.
Verified against the four incident compositions: every one now surfaces
its drift as held warnings (previously: info or silence).
* fix(cli): harden coordinate-frame findings against review false positives
Reworks all three findings after two-lens review (adversarial FP hunt in
real Chrome + maintainer pass):
- escaped_container (was positioned_out_of_parent): uses offsetParent
(transform-aware, skips fixed-as-canvas), exempts fully-detached
callouts within an attachment allowance while still flagging
touching-but-mostly-outside drift.
- panel_out_of_canvas (was box_out_of_canvas): paint alone qualifies
(flat solid panels were a false negative), fully off-canvas rects are
parked entrances and stay silent, pointer-events:none marks decorative
layers, hero-sized breaches warn while small bleeds stay info.
- connector_detached: endpoints via getPointAtLength + getScreenCTM
(viewBox, preserveAspectRatio, group transforms, every command type),
defs/marker/clipPath subtrees skipped, word-boundary connector naming,
containment tier limited to opaque non-ancestor targets (a text-bearing
wrapper contains its own diagram's endpoints).
- canvas_overflow promotion requires partial visibility — a fully
off-canvas rect is a parked entrance, not drift.
Verified: the four incident compositions still surface their drift as
held warnings; the review's false-positive repros (fixed HUD, callout,
parked entrance, corner bleed, marker arrowheads, g-transform and
viewBox-scaled connectors) are clean at warning level. Docs and the CLI
skill reference now describe the coordinate-frame findings.
* fix(cli): panel ownership is geometric — direct-text panels were a silent false negative
A painted panel whose direct text stays in-bounds while its box breaches
the canvas produced neither finding: canvas_overflow measures the text
range and panel_out_of_canvas skipped every own-text element. Skip the
panel finding only when the element's own text ALSO breaches (that
geometry belongs to canvas_overflow); pin the message/fixHint wording of
all three findings with positive assertions; document the SVG-internal
anchor blind spot.
* fix(cli): classify panel decoration by paint kind, not pointer-events
pointer-events:none exempted the framed-painting incident's gold frame
layers — hero content that happens to disable hit-testing. Decoration is
now gradient-only paint (spotlights, textures, vignettes); url() images,
solid fills and borders are content regardless of pointer-events.
* fix(cli): add fixHint to the test-local AuditIssue shape
* fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance
A gradient with any solid stop (alpha >= 0.6) is content — heroes and
cards painted with linear-gradient were invisible under the blanket
gradient exemption; all-translucent stops (spotlights, vignettes) stay
decoration. The text-ownership check now uses the audit tolerance that
canvas_overflow itself fires at, making the contract strict-mutex: any
text breach past that tolerance cedes the element, so a shallow 20px
text breach no longer double-reports.
The `hyperframes feedback` convention only prompted for a free-text
`--comment` "with the failing composition pattern and what you tried".
Agents dutifully filed vague reports (blank CJK text, mid-run exit, 4K
timeout) with no error string, no failure-mode, and — critically — no
published composition, so none could be reproduced or root-caused.
Two additions to the CLI skill:
- Lead bug reports with `--file-issue` (+ `--dir`), which publishes a
minimal repro of the project to a public URL. A comment alone almost
never lets a maintainer reproduce; the composition is what does.
- Give the `--comment` a concrete bug checklist: exact error string
verbatim + whether output was produced / fell back / hard-exited; the
isolated trigger; exact command + HF_*/PRODUCER_* env; frame/timestamp +
visual defect. Drop the "repeat env" ask (the CLI already attaches it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hyperframes-media): surface the real reason a TTS line failed
synthesizeHeygen() swallowed every failure into a bare { ok:false }: a thrown
HTTP error (e.g. 402 plan_upgrade_required from heygenJSON) was caught and
discarded, a missing audio_url / failed audio fetch / failed transcode all
returned nothing. audio.mjs then logged 'TTS failed — omitted' for every line
with zero detail, so the actual cause took a hand-rolled repro to find.
Each failure path now returns an { error } string (the caught message, the HTTP
status, or the specific stage that failed), and audio.mjs appends it to the
anomaly. The subprocess providers (elevenlabs/kokoro) get the same treatment via
a shared synthResult() helper. synthesizeHeygen takes an injectable deps arg so
the failure paths are unit-tested (thrown 402, non-ok fetch, missing audio_url).
* fix(media-use): report wav transcode failures accurately
* chore: regenerate skills manifest
* fix(product-launch): keep media out of frame subcompositions
* fix(product-launch): hoist approved frame videos at assembly
* fix(product-launch): format and refresh media contract
* test(product-launch): harden approved video hoist
* chore: refresh product-launch skill manifest
* fix(product-launch): validate and sanitize hoisted video attrs
* fix(product-launch): allowlist hoisted video attributes
Scaffolded projects' npm run check now invokes the single check command
instead of chaining lint, validate, and inspect (three Chrome boots
become one). The CLI skill, its correctness reference, the entry skill's
capability map, README/docs catalog rows, the Mintlify CLI page (new
check section, deprecation banner on inspect), template CLAUDE/AGENTS
(byte-identical), root CLAUDE/AGENTS, and every creation-workflow skill
that taught the old sequence all point at check. snapshot keeps its
standalone sections; validate/inspect stay documented as deprecated
aliases with their check equivalents.
snapshot --zoom <selector|x,y,w,h> + --zoom-scale (default 3) crops via
Puppeteer clip at raised deviceScaleFactor — density changes, layout
never does. Selector resolves per frame with 24px padding; no match is
a loud error, and a frame whose clamped region is a sliver (element
collapsed or animated off-canvas) is skipped with a stderr note rather
than written as a useless few-pixel image.
check --snapshots additionally writes finding-NN-<code>.png crops for
error findings with bboxes (cap 12, deterministic re-seek in a second
session) and draws labeled annotation boxes on overview frames via a
transient overlay injected only after audits complete. Skill reference
gains the zoom workflow: check reports a finding, zoom into it, fix,
re-check.
- SSOT: the three assemble-index.mjs BGM fallbacks now import
bgmDefaultVolume() from media-use's bgm.mjs instead of duplicating the
0.12/0.9 literals (both reviewers). The cross-skill relative import
matches the existing dependency (each workflow's audio.mjs adapter
already resolves ../../media-use/audio/scripts/audio.mjs).
- STATUS_ROLE_KEY: extended with info|neutral|alert|caution|critical —
same hue-carries-meaning class as the original set (all 3 copies).
- bgm.md: phrase the default as bgmDefaultVolume()/BGM_BED_VOLUME with
"currently 0.12" so the prose survives future tuning.
- fetch-pr.mjs: drop dead mergeCommit field from the gh pr view FIELDS
list (version resolution uses mergedAt only).
- music-to-video assemble-index.mjs: comment documenting why its BGM
stays at 0.8 under VO — music is the content there, not a narration
bed, so the explainer pipelines' 0.12 default deliberately does not
apply.
Not changed: pickAccent's chroma fallback — both call sites pass keyless
capture palettes (tokens.json hex lists), so no status-role keys exist
to filter on; the keyed preset path goes through semanticColors and the
build-frame remix, which this PR already fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior fixes surfaced by the prompt-guide validation campaign (Tier 1+2
of the upstream bug list; Tier 3 tracked in #2107). Split out from the
doc-only updates, which follow in a separate PR.
- BGM level: default bed volume under narration was 0.8 linear (~-2 dB,
~16 dB too hot vs voice). Now 0.12 (~-18 dB) via shared
bgmDefaultVolume() in media-use bgm.mjs + assemble-index fallbacks in
faceless-explainer / pr-to-video / product-launch-video. Explicit
volume still wins; silent-film 0.9 and music-to-video unchanged.
Adds bgm.test.mjs (3 cases); bgm.md reference updated to match.
- Caption accent: semanticColors() ranked accents purely by chroma, so a
preserved status red (#dc2626) outranked the brand accent and captions
highlighted in error-red. Status-keyed colors now excluded via shared
STATUS_ROLE_KEY regex consumed by both tokens.mjs and build-frame.mjs
(all three skill copies kept in sync).
- Voice threading: workflow SKILL.md Step 3.1 blocks now instruct
choosing the narration voice from the user's ask and passing
--voice <id>; previously "a male voice" was silently ignored and the
default (Marcia/am_michael) always won.
- fetch-pr shipping version: MERGED PRs get best-effort shipped_version
+ version_source in pr.json (first release published at/after merge,
else default-branch package.json marked unreleased); ingest surfaces
it as a 'Shipped in:' brief line; story-design.md forbids inventing
versions when absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two real incidents this week had agents skip /figma entirely and drive
Figma via raw MCP tools (get_metadata/get_screenshot/get_design_context)
when a figma.com URL landed inside a creation-workflow skill. Root cause:
none of the creation workflows mention Figma at all, and the only routing
table that does (/hyperframes) is skipped whenever a workflow is invoked
directly rather than through the entry router — which is the common path.
Going raw loses real infrastructure the CLI/skill guarantees: sanitizeSvg()
before freezing (raw-fetched SVGs are unsanitized), .media/manifest.jsonl
provenance (no cache-hit, no version tracking), and brand-token var()
binding (colors bake as literals, so a later Figma brand change can't
propagate without a full re-import).
Added a "figma source" callout to every creation workflow that could
plausibly receive a figma.com link (product-launch-video, website-to-video,
general-video, motion-graphics, slideshow), plus a defense-in-depth line
in /hyperframes's own routing checklist. The fix lives in the workflows
themselves so it doesn't depend on the entry router being consulted.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
* feat(media-use): usage visibility — shared telemetry identity, miss log, resolve --stats
- U6: join the CLI/studio telemetry identity — read the shared install id from
~/.hyperframes/config.json (seed if absent) instead of a media-use-only
~/.media/anon-id, and $identify to the HeyGen account (email/username) once
per run on sign-in. One PostHog person across surfaces; pseudonymous before
sign-in, account-linked after. Event properties stay coarse (no intent/paths).
- U1: one-time first-run disclosure to stderr + Privacy section in SKILL.md;
honors DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY.
- U2: persist resolve misses to ~/.media/misses.jsonl (local → intent kept;
the media_use_resolve_miss telemetry event stays intent-free).
- U3: `resolve --stats` (+ --days) — local usage report over .media/ + ~/.media
(volume by type, source/provider/via split, hit-rate, top missed intents,
global-cache size/reuse); human + --json.
- U4: reproducible PostHog dashboard definition (references/telemetry-dashboard.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2113 review — shared notice state, legacy id migration, stats robustness
- Notice-shown state now lives in the shared ~/.hyperframes/config.json
(config.telemetryNoticeShown, the CLI's own field) instead of a media-use-only
~/.media marker — so shared-identity users see the first-run notice once per
person, not once per tool.
- Migrate a pre-existing ~/.media/anon-id into the shared config on upgrade, so
media-use-only users keep their PostHog persona instead of resetting.
- buildStats: --days only windows on a positive finite value (negative/NaN → all
time, not an empty report); dropped the top-level catch that masked a real
error as an all-zero "no usage" report (sub-reads are individually guarded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rames's inline findings on #2112:
- forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws
inside) so the caller's single throw covers all cases — no mixed
throw/return contract for a future wrapping caller.
- retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer
silently blocks the CLI for an hour before RATE_LIMITED.
- asset ref gathering extracted to gatherAssetRefs() and made URL-safe:
bare fileKey:nodeId tokens comma-split, but a figma URL with commas in
its query (multi-select node-id=1:2,3:4) is kept whole.
- Documented in SKILL that 429 retry lives in the shared request path, so
EVERY read endpoint retries (not just asset) — blast-radius note the
reviewer asked for. variables intentionally still retries: its fallback
is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate.
Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs
URL-vs-bare split. client 24, cli asset 11.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing
media-use resolves bgm/sfx/image/icon (catalog), voice (TTS), and avatar video
through the heygen CLI — the free-usage path. Agents hit a dead end when it's
missing/unauthed. This guides them to install it fast, at the point of need.
- Centralized actionable diagnostics (lib/heygen-cli.mjs): every heygen-backed
resolve, on failure, prints the exact fix on stderr — not-installed (curl
install one-liner), not-authenticated (heygen auth login), outdated (heygen
update). Routed through heygen-search + voice-provider. stdout stays clean JSON.
- resolve --doctor preflight (human + --json): checks heygen present/version/
auth, ffmpeg, ffprobe, node, a fix per gap. Exit 0 unless ffmpeg missing.
- SKILL reframe: install-first callout; heygen as the free-usage gateway for
bgm/image/voice/avatar-video; removed the false "degrades gracefully" claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2065 review — classifier blocker, doctor contract, telemetry
- Blocker: classifyHeygenError no longer treats a bare "not found" as CLI-missing
(a stale voiceId → "voice not found" was sending users to reinstall a working
CLI); keep only ENOENT + "command not found". Regression test added.
- 401 now matches \b401\b, not any "401" substring (request IDs no longer misread).
- --doctor: top-level ok requires ffmpeg AND ffprobe (matches SKILL.md); emits
media_use_doctor_run telemetry; auth status queried with --json + JSON-only
parse; auth timeout softened (network issue, not a false "unauthenticated");
node version gated on >= 18; version-without-semver labeled, not silently green.
- Nits: install cmd uses && ; dropped the runResolveStatus alias.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): require OAuth-capable heygen CLI (v0.3.0), fix auth-status probe
E2E against the live free-usage backend surfaced three issues:
- HEYGEN_MIN_VERSION was 0.1.6, but that CLI can't use OAuth ("heygen-cli can't
use OAuth yet") — free usage needs >= v0.3.0. Bumped the floor; --doctor now
also nudges `heygen update` when a newer stable exists (always-latest).
- Onboarding pointed at `heygen auth login --key` (API credits / billing); the
free path is `--oauth` (subscription/free credits). Fixed install + auth
guidance and SKILL.md accordingly.
- `heygen auth status --json` is an unknown flag on v0.3.0 (JSON is the default
output) — the added --json broke auth detection. Dropped it; verified
--doctor reports authenticated on a real free (OAuth) account.
Tests assert against the exported message constants instead of brittle literals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2065 review nits — one root cause on old CLI, floor policy
- --doctor skips the auth check when the version check fails (below v0.3.0): an
old CLI's auth probe fails for the same root cause, so users no longer see two
errors ("outdated" + "not authenticated") — one root cause, one fix.
- Comment links the auth-status probe's JSON-default assumption to
HEYGEN_MIN_VERSION >= 0.3.0 so the floor isn't silently lowered later.
- SKILL.md states the uniform v0.3.0 requirement (nudged even for API-key use).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): doctor prints one heygen row per fact
The 'heygen on PATH' and 'heygen version' checks both rendered their
detail as `heygen v0.3.0`, so --doctor printed two byte-identical green
lines. Make the PATH row report presence ("heygen found on PATH") and let
the version row own the version string — one row per fact, no duplicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(media-use): use CLI free HeyGen usage
* fix(media-use): address #2027 R1 nits — gate cli-source header to OAuth, export origin constant
- X-HeyGen-Source is now sent only on OAuth (Bearer) requests, not API-key ones —
the backend ignores it for API-key traffic (normal billing), so it was dead
metadata there. buildAuthHeaders + heygenAuthHeaders + tests updated.
- Export HEYGEN_CLI_ORIGIN_HEADER ("X-HeyGen-Client-Origin") for future cli:<origin>
consumers.
- Document the deliberate paid/X4 confirm-before-call decision on heygen.tts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* refactor(cli): drop unused origin-header export, dedup auth-client tests
Fallow flagged 5 findings on this PR:
- major: HEYGEN_CLI_ORIGIN_HEADER was exported but never emitted or
imported — speculative dead code ("future consumers"). Remove it; a
real consumer can add the constant when one exists.
- 4x minor duplication in client.test.ts: fold the repeated
`.rejects.toSatisfy(auth-code)` assertion into expectAuthCode(), and the
repeated try/catch scrubbed-message assertion into expectRejectionMessage().
No behavior change; auth/client tests still 17/17.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).
- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
else falls back to the endpoint's scope hint. Reads both err and message
(variables endpoint uses message). One fix, honest messages for bugs 1/4/9.
- Batch asset fetch (requested): figma asset accepts multiple refs
(space-separated or comma-joined) of one file and renders them in a SINGLE
/v1/images call via new client.renderNodes — figma's documented per-minute
rate-limit workaround. runAssetImport delegates to runAssetImportMany;
cache-checks per node, batches only the misses, one index.md regen.
- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
the numbered setup list. Indent every line; single-line hints unchanged.
Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## What
Fixes five reported false-positive/false-negative patterns in the WCAG contrast audit (`hyperframes validate --contrast`):
1. **SVG fill vs. text color** — foreground read from CSS `color` instead of SVG `fill`.
2. **Cross-component color bleed** — background estimate bleeds into a neighboring panel/layer.
3. **Backdrop-filter glass text** — background estimate misses the blur/tint and reads the raw backdrop.
4. **Partially-overlapping translucent decoration** — a decorative shape inside or partly touching the text's bbox goes undetected.
5. **Solid-fill pill/button** — investigated, did **not** reproduce; already handled correctly by the existing own-background ancestor walk. Not touched.
## Why
The audit estimated an element's background two ways:
- foreground: always `getComputedStyle(el).color` — wrong for SVG `<text>`/`<tspan>`, which is painted via `fill`, an independent CSS property.
- background: a 4px pixel ring sampled just **outside** the text's bounding box, with a fallback to an ancestor's opaque `background-color` for solid pills/buttons.
The ring is a proximity heuristic. It's wrong whenever what's immediately outside the text differs from what's actually behind it:
- text near the edge of its own panel, with a differently-colored sibling panel/layer just past the bbox — the ring samples the neighbor.
- a `backdrop-filter: blur()` glass panel sized only a couple pixels larger than the text — the ring exits the panel into the raw, unblurred, untinted backdrop.
- a translucent decoration that only partially overlaps the ring, or sits entirely **inside** the bbox — invisible to the ring regardless of size.
## How
**SVG fill (#1):** elements inside an `<svg>` (`el.ownerSVGElement`) now prefer the computed `fill` when it resolves to a solid `rgb()`/`rgba()` color, falling back to `color` for paint values that aren't a plain color (`none`, `context-fill`, gradient/pattern refs).
**Cross-comp bleed / glass blur / partial decoration (#2–#4):** replaced the ring-sampling + own-background-ancestor-walk heuristic with a two-phase capture:
1. `__contrastAuditPrepare()` walks the DOM, computes each candidate's foreground (unchanged logic from #1), and **hides that element's own text paint** (`color`/`fill` → `transparent`, layout-neutral — no reflow).
2. The caller takes **one** screenshot with the glyphs invisible (same number of screenshots as before — just moved after the hide instead of before it).
3. `__contrastAuditFinish(imgBase64, time, candidates)` restores the original paint immediately, then samples the **real composited pixels directly inside each element's own bbox** — no proximity heuristic needed, since these are the exact pixels that were behind the glyphs.
This is a real architectural change to `contrast-audit.browser.js`'s calling contract (single `__contrastAudit` → `__contrastAuditPrepare`/`__contrastAuditFinish`), with `validate.ts`'s `runContrastAudit` updated to match, including a try/finally restore-safety-net so a mid-loop screenshot/decode failure can't leave a later sample auditing a page with stale hidden text.
Mirrored the identical change in `skills/hyperframes-creative/scripts/contrast-report.mjs`, which duplicates the same DOM-walk/sampling logic (not just the WCAG math). There, the **visible** frame for the human-facing overlay image still comes from the producer's normal `captureFrameToBuffer` path (unchanged); only the **background-sampling** capture is a plain `session.page.screenshot()` taken after hiding text — deliberately bypassing `captureFrameToBuffer`, whose static-frame dedup cache knows nothing about the DOM mutation and would hand back a stale pre-mutation buffer.
**Solid-fill pill (#5):** reproduced a rounded pill/button with a busy page background outside it. The existing own-background ancestor walk already resolves the pill's declared `background-color` correctly regardless of the rounded corners — confirmed via repro, both before and after this change report the identical (correct) result. No fix needed; left untouched, and this case is covered by the new architecture too (would give the same right answer even without the ancestor-walk fallback).
Added `packages/cli/src/commands/contrast-sample.ts` (mirroring the existing `contrast-bg.ts`/`contrast-fg.ts` pattern) hosting the pure sample-rect/grid-point computation, unit tested — the browser-injected scripts can't import it directly, so it's kept in sync by hand, same convention as the rest of this file.
## Test plan
- [x] Unit tests: `contrast-fg.test.ts` (SVG fill resolution), `contrast-sample.test.ts` (sample-rect clamping/degenerate cases), plus the full `packages/cli` suite (1424 tests) passes, including an updated `layout-audit.browser.test.ts` case that called the old single-function `__contrastAudit` API directly.
- [x] Manual verification — standalone `puppeteer-core` harness against real `chrome-headless-shell`, one minimal HTML fixture per pattern, comparing the audit's reported ratio/verdict against a hand-constructed ground truth:
- **SVG fill**: `fill:white` / no `color` on black bg → before: `fg=rgb(0,0,0)` ratio `1:1` (false FAIL); after: `fg=rgb(255,255,255)` ratio `21:1` (correct PASS).
- **Cross-comp bleed**: text on a black sibling highlight box 2px larger than the text, white page bg outside it → before: `bg=rgb(255,255,255)` ratio `1.23:1` (false FAIL); after: `bg=rgb(0,0,0)` ratio `17.14:1` (correct PASS).
- **Glass blur**: black text on an 18%-white-tinted `backdrop-filter: blur(14px)` panel over a yellow/blue gradient, panel only ~2px larger than the text → before: `bg=rgb(0,64,255)` (raw gradient color, blur/tint completely missed) ratio `3.18:1` (false FAIL); after: `bg=rgb(159,160,165)` (correct blurred/tinted blend) ratio `8.05:1` (correct PASS).
- **Partial decoration**: text 92%-covered by a translucent white badge on a dark bg → before: `bg=rgb(16,16,16)` (ring never touches the badge, which sits entirely inside the bbox) ratio `17.45:1` (false PASS); after: `bg=rgb(171,171,171)` (correctly detects the badge) ratio `2.11:1` (correct FAIL).
- **Solid pill sanity**: unaffected — `bg=rgb(10,10,10)` ratio `19.8:1` before and after.
- [x] End-to-end: ran the actual `hyperframes validate --contrast` CLI command (via `tsx src/cli.ts`) against a real scaffolded project containing all 4 patterns simultaneously — only the genuinely-failing case (the 92%-covered decoration) is reported (`1.09:1`, need `3:1`); the cross-comp-bleed, glass-blur, and solid-pill cases are correctly silent. A second vanilla scaffold with plain white-on-dark text produces zero false positives.
- [x] `oxlint`, `oxfmt --check`, and `tsc --noEmit` all pass on the changed files.
Two bugs from live figma-integration use:
1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles
needs library_content:read — a scope the setup docs and the generic
FORBIDDEN message both omitted, so the user saw "missing a read scope"
with no way to know which. Each endpoint now carries a scope hint; the
403 names the exact scope (styles → library_content:read). Setup text and
skill scope list updated to include Library content: Read-only.
2. `asset` (and every per-node component render) had no 429 handling — the
message said "back off and retry" but the client didn't. Two imports in
a row tripped the per-minute limit and hard-failed. get() now retries 429
with exponential backoff, honoring Retry-After when present, before
surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable
so tests don't wait.
Batch multi-node asset syntax (the documented /v1/images comma-ids rate
workaround) is a separate enhancement — retry makes the reported failure
self-heal, including the many-node component path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A figma text node whose box is shorter than its line-height carries
vertically-trimmed (cap-to-baseline) bounds. The mapper positioned the box
at those bounds but let the browser lay glyphs with half-leading, pushing
them ~6px low on a 70px font (glyph-centroid measurement against figma's
own render: +9.1px vs figma's +3.4px inside the same pill). Emitting
text-box-trim: trim-both / text-box-edge: cap alphabetic reproduces the
trim in the render engine; post-fix centroid agrees within 0.4px and the
motion verifier's min window score improved 20.3 -> 25.3dB. Trim applies
only to single-line trimmed text; boxes matching their line-height are
untouched.
Skill: component imports now include a static fidelity self-check step
against figma's PNG export of the same node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- motionContextToDocs: escape regex metacharacters in arrayAfterKey /
scalarAfterKey key interpolation (safe today for \\w+ keys; now safe for
any future caller), and document balancedBlock's no-strings invariant.
- verify-motion.mjs: execSync shell string -> spawnSync with array args
(JSON.stringify is not shell escaping); verifier re-calibrated unchanged
(faithful render still PASS at min 20.30dB).
- command-failure-tracking: rebase folded the group-delegation skip into
upstream's recursive wrapCommand (HF#2033) — leaf commands now assert
their own flag tables, so `figma component --namee` is rejected at the
leaf while `--name` passes the group; heuristic invariant documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>