Skip to content

fix(add): make chosen variables actually take effect, in the CLI and the preview - #3316

Merged
miguel-heygen merged 5 commits into
mainfrom
fix-add-vars
Aug 18, 2026
Merged

fix(add): make chosen variables actually take effect, in the CLI and the preview#3316
miguel-heygen merged 5 commits into
mainfrom
fix-add-vars

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

What

Reported as one command that did nothing useful:

$ npx hyperframes add blur-in --vars '{"size":76,"tone":"accent","align":"center"}'
Install failed: fetch failed

Five bugs were behind it or found while proving the fix:

# Bug Blast radius
1 --vars silently discarded for components 221 of 375 catalog items
2 Catalog preview ignored every chosen variable every catalog page
3 fetch failed was undiagnosable, real cause thrown away anyone whose install fails
4 Item files had no retry, one blip killed any install everyone
5 Compound words split the answer in two any query with one

Plus the two transcribe tests that were red before this branch.


1. --vars for components

How it broke. buildSnippet had two branches. For a block it emitted a mount element and hung the values on it. For a component it returned <!-- paste from ... --> and dropped values on the floor. The flag parsed, validated, and went nowhere.

Why the obvious fix is wrong. You cannot put the values on a mount element, because a component has no mount element. It is markup you paste into a host composition.

How it resolves values instead. Through __hyperframes.getVariables(), which merges the declared defaults of every [data-composition-variables] element in the document with render-time overrides. A component carries its own declaration, and that declaration travels with the markup when you paste it.

So the fix is registry/variableDefaults.ts, which rewrites the default field of the component's own declaration at install time. That is the only place a chosen value can live and still be there after the paste.

Four things it refuses to do:

  • An unlisted enum value or an out-of-range number is reported, not written. Writing one produces a file that renders exactly as if the value were ignored, because the composition's own guard falls back to the default. That is the failure this whole change exists to remove, so reintroducing it silently would be perverse.
  • Numeric strings are coerced. The catalog page puts values in a query string, where every value is a string. Writing "76" where the composition expects a number trips that same silent fallback.
  • Ids the item never declared are reported, not dropped.
  • Only the requested item is rewritten. A dependency dragged in behind it never declared these variables.

Blocks deliberately keep the mount attribute. Per-mount values are strictly better where a mount exists: the file on disk stays byte-identical to the registry's, so a reinstall can still tell an edit from an update, and two mounts of the same block can differ.

add now prints variables applied: size, tone, align, because a component's values live in the file rather than the snippet. Without it, the command looks identical whether --vars worked or was thrown away.


2. The preview

catalog/components/badge-pop?vars-badge-pop={"count":"10","accent":"green"} rendered 3, in red.

How it broke. The player injects the runtime by appending a <script src> to an already-loaded document, and only when shouldInjectRuntime says to: a nested composition, or five polls with a timeline present. A pasted component has neither. Meanwhile the component reads its values in an inline IIFE that runs while the body is parsing:

var vars = window.__hyperframes && window.__hyperframes.getVariables
  ? window.__hyperframes.getVariables() : {};

That guard always took the empty branch, so the component used the defaults hardcoded in its own script.

The values were never the problem. The preview sets window.__hfVariables correctly. Nothing was there to read it.

The fix is ordering, not plumbing. prepareSrcdocForElement now puts the same runtime URL in the document's <head> before the srcdoc is set. A classic external script in head is parser-blocking, so it runs before body scripts. Nothing new is loaded and no dependency is added that the player did not already have. A CLI render never had this bug because the engine already orders it this way.

Skipped when the page carries the runtime already, so a CLI-rendered page (which inlines it) does not get a second copy re-initialising the runtime under a live composition. The probe's late injection stays for the src= path, where there is no srcdoc to prepare.

Seen on the docs site

The same page, the same panel state, the docs running locally against each build of
the player. The field asks for TXT_MAIN_1_Line = "VARS REACH THE PREVIEW" in both,
and the install command under the panel is the CLI half of this PR carrying the same
value.

Before, on the published player: the preview renders the declared default,
BILD EXKLUSIV. The reader's choice is visible in the field and nowhere else.

Catalog page on the published player: the panel field reads VARS REACH THE PREVIEW while the preview still renders BILD EXKLUSIV

After, on this branch's player: the preview renders what the panel asked for.

The same catalog page on this branch's player: the preview renders VARS REACH THE PREVIEW

3 and 4. The error message, and the retry

fetch failed is two words that describe every network problem equally badly. Three things were missing, each of which was the whole answer in a different case.

The URL. undici throws with none attached. A project pointing registry at a private host in hyperframes.json got a message that read as the public registry failing.

The cause. undici buries the real reason one or two levels down in cause, and it was being dropped. In the reported case that turned out to be self-signed certificate in certificate chain — a private registry whose certificate node refuses and curl accepts, which is exactly why the host looked healthy when checked from a terminal. describeCauseChain now flattens the chain, guarding against cycles.

A retry. Item files are the one uncached path: manifests fall back to a stale copy, but every install downloads its files fresh, so a single blip killed the whole command. Two extra attempts with short backoff, and deliberately not for TLS failures — a self-signed certificate fails identically every time, so retrying only makes the user wait three times as long for the same message.

Both failure paths name the host now. Fixing only the item-file path would have left the manifest path (Item "x" not found — registry unreachable or empty) with the identical dead end.


5. Compound words

Found after this document declared zero ranking failures left.

countdown returned exactly one item, the only thing tagged with that spelling. count down timer returned sixteen, and that one was in none of them.

How it broke. The tokenizer splits on word boundaries, so countdown is one token and count down is two, and neither can ever match the other. The two spellings of a single idea produced disjoint sets, and whichever phrasing an author happened to type decided which half of the answer they saw. Neither half was the whole answer: the one-word spelling hid count-up and decline-chart, the two things you would actually build with.

The fix goes both directions, each gated on the catalog's own vocabulary so it can only add signal:

  • A query token is split when both halves are words the catalog uses (countdowncount + down).
  • Adjacent tokens are joined when the compound is (count downcountdown).
  • A word in neither form, like timer which appears in zero of the 375 items, is left alone. This widens phrasing; it does not invent matches.

The part worth reviewing closely: everything inferred this way carries a fraction (0.35) of a real token's weight. My first version relied on the halves being statistically common in a 375-item catalog, which is not the same as making them count for less. In a small corpus that version let type matching the name of type-match-cut outrank typewriter matching the name of typewriter — searching a word returned something that merely contained half of it. Two tests written against that failure caught it before it shipped.

Result, all spellings returning the same 17 items:

query yt-circle-pointer count-up decline-chart
countdown #1 #4 #7
count down timer #7 #3 #6
count down #7 #3 #6

Each spelling still ranks its own exact match first, which is the correct behaviour — nothing is hidden either way now.


Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

cli 2680 passed · player 338 passed · studio 4249 passed. Zero failures.

The two transcribe tests that were red before this branch are fixed: they assert the whisper soft-skip path but never pinned the engine, and auto picks Parakeet whenever parakeet-mlx is installed, so on those machines they shelled out to a real ASR binary and failed. Pinned, plus an assertion that the mock actually ran — the file now takes 18ms instead of 3.7s.

A 39-query eval set holds at 33/39 top-1 and 39/39 top-3 through every ranking change here, so no query regressed while fixing compounds.

Verified with the built dist/cli.js, not the source, in the reporter's own project directory:

✓ Added blur-in (hyperframes:component)
  variables applied: size, tone, align

Bad values are refused rather than silently written:

Warning: --vars tone ignored: not one of strong, muted, accent
Warning: --vars size ignored: above max 120
Warning: --vars ignored (not declared by blur-in): nope

A registry with a bad certificate explains itself:

Item "blur-in" not found — registry unreachable or empty. Contacted
https://self-signed.badssl.com/registry, set by this project's hyperframes.json,
not the public registry.

The preview fix, end to end against the real runtime and a real registry component, asking for size 96 / accent / right:

font-size colour align runtime
before 52px rgb(243,243,243) flex-start absent
after 96px rgb(60,230,172) flex-end present

rgb(60,230,172) is #3ce6ac, the accent green.

Three player tests asserted byte-identical srcdoc forwarding; they now assert what they were actually protecting (the composition arrives intact) plus the new runtime guarantee. The variableDefaults fixtures gained the label the schema requires — without it they were not valid declarations, which the stricter reader caught.

A note on types

variableDefaults originally modelled a declaration as a local interface of six unknown fields, re-checked at every use. Core already owns this shape as a discriminated union and exports isCompositionVariable, the same predicate parseCompositionVariables filters with, so the union is used directly and the duplicate type is gone. A declaration the schema rejects now leaves the file untouched rather than being partially rewritten from guesses.

Size

Over the 1000-line convention, deliberately and with sign-off. The natural seam is the player fix (runtime-in-srcdoc.ts, runtime-url.ts, shader-options.ts and their tests, ~200 lines). It is kept here because it is the same user-visible defect — a value you choose does not take effect — and splitting it would ship half an answer to the report.

Not covered

  • Registry item files are still uncached. The retry survives a blip; a genuinely offline machine still cannot install an item it has never fetched. Caching them is a separate design question (staleness, disk, invalidation).

  • top-down-letters still ranks initial code #2 for count down on a name match against down alone. Legitimate token, field weighting doing its job; worth revisiting only if name matches on single common words prove noisy more broadly.

  • There is no discrete 3-2-1 countdown primitive. count-up runs backwards (its driver is direction-agnostic) but eases continuously through the range instead of holding a beat per number. That is a catalog gap, not a ranking one.

  • Reaching the docs site needs a player release. The docs load the player from a CDN on the 0.7 range, which today resolves to a build without this fix, so the screenshots above were taken with the docs pointed at this branch's build. The range picks the fix up on the next publish; no docs change is needed.

  • Component pages still cannot answer their panel, for a different reason. Their preview payload is built from demo.html, which is a hand-copy of the snippet rather than the snippet itself, and 166 of the 168 components declaring variables have a copy that dropped the data-composition-variables block. There is nothing in that markup to read a value from, so the panel cannot move it whatever the player does. I built the one-place fix (mount the snippet the page hands the reader, instead of the copy) and measured what it costs: all 168 payloads then carry their declaration, and every value takes effect, but the preview stops moving, because a component's snippet ships its timeline as a commented recipe while the demo supplies a real one. Five of five sampled items animated before and were static after. A worse preview is not a fix, so it is not in this PR. Making demos stop hand-copying their snippet is the real change, and it needs a decision per demo about where its timeline lives.

  • Reproducing the preview locally needs the payload served yourself. The local docs server publishes .webp out of the public directory but not .json, so the preview panel reports the payload as unavailable on localhost regardless of any of this.

Customising an item on the catalog page, copying the printed command and
running it did nothing for a component. `--vars` was accepted, documented
and then dropped: buildSnippet put the values on a block's mount element
and returned a bare "paste from ..." comment for a component, so 221 of
the 375 catalog items silently ignored every value the page produced.

A component has no mount element to hang values on. It is markup pasted
into a host, and it resolves values through __hyperframes.getVariables(),
which merges the declared defaults of every [data-composition-variables]
element in the document with render-time overrides. So the component's
own declaration is the only place a chosen value can live and still be
there after the paste. `add --vars` now rewrites those defaults.

Blocks keep the mount attribute. Per-mount values are strictly better
where a mount exists: the file on disk stays byte-identical to the
registry's, so a later reinstall can still tell an edit from an update,
and two mounts of the same block can differ.

A value the item cannot accept is now refused rather than written. An
out-of-range number or an unlisted enum value falls back at runtime and
warns, so writing one would produce a file that renders exactly as if the
value had been ignored -- the failure this change exists to remove. Ids
the item never declared are reported too, instead of vanishing. Only the
requested item is rewritten; a dependency dragged in behind it never
declared these variables.

Separately, `Install failed: fetch failed` is now a sentence. Item FILES
are not cached (only manifests are), so a network blip surfaces as node's
bare message with no URL and no cause, immediately after the user copied
a command off a web page -- which reads as "the command was wrong" rather
than "the network was". It now names what failed, says it is usually
connectivity or a proxy rather than a bad command, and mentions
HTTPS_PROXY.

Also fixes the two transcribe tests that were failing before this branch.
They assert the whisper soft-skip path but never pinned the engine, and
`auto` picks Parakeet whenever parakeet-mlx is installed -- so on those
machines the test shelled out to a real ASR binary, failed with "Parakeet
did not produce output", and landed in the generic failure branch it
claims is never taken. Pinned to `engine: "whisper"`, plus an assertion
that the mocked transcribe actually ran, which is what stops the test
passing on a machine without Parakeet while testing nothing on one with
it. The file now runs in 18ms rather than 3.7s, because it no longer
launches a subprocess.

Test plan: 10 new tests for the rewrite (enum and range refusal, the
numeric-string coercion the catalog URL depends on since every query
value is a string, delimiter escaping, unparseable declarations) and 3
for the failure message. Full CLI suite: 2661 passed, ZERO failures.

Verified as a user, not just in unit tests: installed blur-in with the
exact reported command, confirmed the declaration carried 76 / accent /
center, pasted it into a composition and ran `check` -- which reported
canvas_overflow at 76px, which only happens if the baked size is really
in effect. Bad values warn and are refused; blocks still emit
data-variable-values.
Comment thread packages/cli/src/registry/installer.ts
Customising a component on a catalog page did nothing to the preview.
badge-pop with count 10 and a green accent rendered 3, in red.

The probe injects the runtime by appending a script to an already loaded
document, and only once it has a reason to: a nested composition, or five
polls with a timeline present. A component has neither. It is markup
pasted into a composition, and it reads its values in an inline IIFE that
runs while the body is parsing:

    var vars = window.__hyperframes && window.__hyperframes.getVariables
      ? window.__hyperframes.getVariables() : {};

With the runtime arriving afterwards that guard always took the empty
branch, so the component used the defaults hardcoded in its own script
and every chosen value was dropped. The values were never the problem:
the preview sets window.__hfVariables correctly, and nothing was there to
read it.

prepareSrcdocForElement now puts the same runtime URL in the document's
head before the srcdoc is set. A classic external script in head is
parser-blocking, so it runs before body scripts without changing what
gets loaded or adding a dependency the player did not already have. A CLI
render never had this bug because the engine already orders it this way.

Skipped when the page carries the runtime already, so a CLI-rendered page
(which inlines it) does not get a second copy re-initialising the runtime
underneath a live composition. The probe's late injection stays for the
src= path, where there is no srcdoc to prepare. The runtime URL moved to
its own module so the two injection points cannot drift apart.

Test plan: 8 new tests for the injection (ordering against the reading
script, head placement, both no-op guards, missing head/body, attributes
on the head tag). Three srcdoc tests asserted byte-identical forwarding
and now assert what they were actually protecting -- that the composition
arrives intact -- plus the new runtime guarantee. player 338 passed,
studio 4249 passed.

Verified end to end against the real runtime and a real registry
component, asking for size 96 / accent / right:
  before  52px, rgb(243,243,243), flex-start, runtime absent
  after   96px, rgb(60,230,172),  flex-end,   runtime present
rgb(60,230,172) is #3ce6ac, the accent green. That is the reported bug
before, and the chosen values after.
Comment thread packages/player/src/runtime-in-srcdoc.ts
Comment thread packages/player/src/runtime-in-srcdoc.ts
Comment thread packages/player/src/runtime-in-srcdoc.ts
…d retry

`Install failed: fetch failed` was two words that describe every network
problem equally badly. Three things were missing, and each of them was
the whole answer in a different case.

The URL. undici throws with no URL attached, so a project that points
`registry` at a private host in hyperframes.json got a message that
looked like the public registry had failed. Naming the URL is the entire
diagnosis there.

The cause. undici buries the real reason one or two levels down in
`cause`, and it was being dropped. The reported failure turned out to be
`self-signed certificate in certificate chain`: a private registry whose
certificate node refuses and curl accepts, which is why the host looked
healthy from a terminal. That sentence tells the reader which knob to
turn; `fetch failed` sends them to check a connection that is working.

The retry. Item files are the one uncached path -- manifests fall back to
a stale copy, but every install downloads its files fresh -- so a single
blip killed the whole command. Now two extra attempts with short backoff,
and deliberately NOT for TLS failures: a self-signed certificate fails
identically every time, so retrying it only makes the user wait three
times as long for the same message.

Also retypes the declaration reader. It modelled variables as a local
interface of six `unknown` fields and re-checked each one at every use.
Core already owns this shape as a discriminated union and exports
`isCompositionVariable`, the same predicate `parseCompositionVariables`
filters with, so the union is used directly and the duplicate type is
gone. A declaration the schema rejects now leaves the file untouched
rather than being partially rewritten from guesses.

Test plan: 4 retry and URL tests, 5 cause-chain tests, and the add-side
tests now cover the custom-registry hint and its absence on the default
registry. The variableDefaults fixtures gained the `label` the schema
actually requires; without it they were not valid declarations, which the
stricter reader caught. CLI suite 2671 passed, zero failures.

Verified with the BUILT dist rather than the source, in the reporter's
own project directory. The failure now reads:

  File fetch failed: https://<host>/registry/components/blur-in/blur-in.html
    - fetch failed (self-signed certificate in certificate chain
      [SELF_SIGNED_CERT_IN_CHAIN])

and once the project points back at the public registry the original
command succeeds with `variables applied: size, tone, align`.
The item-file failure now names the host it could not reach, but the
sibling path did not. A project whose registry is unreachable at the
MANIFEST stage got `Item "blur-in" not found - registry unreachable or
empty`, which reads as the public catalog having lost the item and sends
the reader to search a registry that never saw the request.

Same fix, same reason, applied where the other three call sites live so
one of them cannot stay behind: the message names the host and says it
came from this project's hyperframes.json, and only when it is not the
public registry, so the common case stays short.

Test plan: 3 tests covering the private-registry hint and its absence on
the default registry and on no registry at all. CLI suite 2674 passed,
zero failures. Verified with the built dist against a host with a bad
certificate:

  Item "blur-in" not found - registry unreachable or empty. Contacted
  https://self-signed.badssl.com/registry, set by this project's
  hyperframes.json, not the public registry.
@miguel-heygen miguel-heygen changed the title fix(add): apply --vars to components, and explain a failed download fix(add): make chosen variables actually take effect, in the CLI and the preview Aug 17, 2026
Comment thread packages/cli/src/registry/remote.ts
Comment thread packages/cli/src/registry/remote.ts
`countdown` returned exactly one item, the only thing tagged with that
spelling. `count down timer` returned sixteen, and that one was in none
of them. The tokenizer splits on word boundaries, so the two spellings of
a single idea produced disjoint sets, and whichever phrasing an author
happened to type decided which half of the answer they saw. Neither half
was the whole answer: the one-word spelling hid count-up and
decline-chart, which are the two things you would actually build with.

Both directions now, each gated on the catalog's own vocabulary so this
can only add signal. A query token is split when both halves are words
the catalog uses, and adjacent tokens are joined when the compound is.
A word in neither form, like `timer` which appears in no item, is left
alone: this widens phrasing, it does not invent matches.

Everything inferred this way carries a fraction of a real token's weight.
That is the part worth keeping honest, because the first version relied
on the halves being statistically common in a 375-item catalog, which is
not the same as making them count for less. In a small corpus that
version let `type` matching the name of `type-match-cut` outrank
`typewriter` matching the name of `typewriter`: searching a word returned
something that merely contained half of it. Two tests written against
that real failure caught it.

All spellings now return the same 17 items, and each still ranks its own
exact match first: `countdown` leads with yt-circle-pointer, `count down`
leads with the two-word items, and count-up and decline-chart appear in
both.

Test plan: 6 new tests covering both directions, the identical-set
property that was the actual defect, exact-match precedence, an unknown
word left alone, and the typewriter case. Eval set unchanged at 33/39
top-1 and 39/39 top-3, so no query regressed. CLI suite 2680 passed.

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT — read through end to end, no blockers found. Solid bugfix stack; the ordering fix (runtime injected into srcdoc <head> so the component's parse-time getVariables() guard doesn't take the empty branch) is the key insight and it's tested where it matters.

Verified

  • Bug 1 (--vars for components): applyVariableDefaults rewrites the declared default in the component's own data-composition-variables at install time — the only surviving home once markup is pasted. Only the requested item gets its vars rewritten (dependencies pinned to null, add.ts L208). Enum/number values are rejected with a reason rather than written to a file that would silently fall back at runtime — that's the right call.
  • Bug 2 (preview): ensureRuntimeBeforeBodyScripts lands the runtime <script src> inside <head> (parser-blocking) so it's defined before any body IIFE runs. Wired through prepareSrcdocForElement in shader-options.ts — every srcdoc path picks it up. alreadyHasRuntime guards against a double copy on CLI-rendered pages (which inline it). runtime-url.ts extraction means probe-late-injection and srcdoc-parse-injection can't drift onto different URLs — good consolidation.
  • Bug 3 (fetch failed diagnosis): describeCauseChain walks .cause, cycle-safe via seen Set, appends [CODE] only when message doesn't already carry it. Tests pin the cases.
  • Bug 4 (retry): fetchWithRetry is scoped to item files (rightly — manifests fall back to stale copies). isRetryableTransport explicitly refuses TLS failures so a self-signed cert on a private registry fails once, not three times. Bounded 150ms×attempt backoff.
  • Bug 5 (compound-word search): expandCompounds splits + joins gated on the catalog's own vocabulary and inferred tokens carry 0.35× weight, so a typewriter query can't be dislodged by type-match-cut matching type at full strength. The rationale for the weight (rather than relying on common halves having low IDF) is exactly right for small corpora.
  • Transcribe fixes: pinning engine: "whisper" + expect(transcribeMock).toHaveBeenCalled() closes the silent-skip gap on machines with parakeet-mlx installed.

Consumer sweep

  • Every writer that emits data-composition-variables goes through installOneFile → applyVariableDefaults when the file is a component snippet. Block compositions still hang values on the mount element via existing buildSnippet path; that path unchanged. No bypass sites found.
  • assertSafeTarget un-exported cleanly — only in-file caller remains.
  • registry/index.ts re-export slimming: verified resolveItem, fetchRegistryManifest, fetchItemManifest, fetchItemFile, assertSafeTarget, InstallResult, InstallOptions, ResolveOptions were the ones dropped — all read at their source modules by remaining consumers.

Nits (non-blocking)

  • remote.ts::isRetryableTransport regex and add.ts::describeInstallFailure transport regex are two separate lists (network + timeouterror in one, ENOTFOUND in the other). Same intent — worth consolidating into one shared classifier so a future error name added to one gets both. Not a correctness issue today: describeCauseChain's output already contains the text either regex hits.
  • Cross-file component variable aggregation in installItem: variablesApplied/variablesInvalid are flatMap (union), variablesUnknown intersects. If a component ever ships two HTML files with divergent declarations, the same id could report as both applied and invalid. Theoretical — components in the catalog today ship one snippet — but worth an assertion or a doc line.
  • fetchWithRetry uses AbortSignal.timeout(FETCH_TIMEOUT_MS) per attempt: 3× the timeout on a genuinely offline machine. Backoff is short, timeout is not — consider a shorter per-attempt timeout on retries.
  • CodeQL flags on runtime-in-srcdoc.ts (<head[^>]*> etc.) look like the usual false positives — [^>]* is linear, no catastrophic backtracking. Fine to dismiss.

CI status at HEAD c9194f7

  • Latest run 32078742445: Producer: unit tests + Producer: integration tests both SUCCESS. Analyze (javascript-typescript) SUCCESS. Preview parity still IN_PROGRESS at review time. Test shows FAILURE from the earlier cancelled run (32078543814) — stale rollup row; the newer Test job (95538015807) is still in flight and should go green once the producer gate observes the fresh SUCCESS results. Confirm both flip before landing.

— Review by tai (pr-review)

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at c9194f76 on Terence's OK to add you to the trust-list. Upgrading my prior COMMENT review — same findings apply. Non-blocking nits are yours to take or leave.

@miguel-heygen
miguel-heygen merged commit ea7c48f into main Aug 18, 2026
104 of 120 checks passed
@miguel-heygen
miguel-heygen deleted the fix-add-vars branch August 18, 2026 00:31
@miguel-heygen miguel-heygen mentioned this pull request Aug 18, 2026
3 tasks
felipecaldas added a commit to felipecaldas/hyperframes that referenced this pull request Aug 18, 2026
First sync under the TAB-782 merge model: a pinned release tag on `tabario`,
not a rebase and not moving `upstream/main` (3 unreleased commits past v0.8.1).

17 commits, 2 conflicts, both predicted by TAB-782's dry run.

## v0.8.0 is a minor bump that does not apply to us

Its migration notes are entirely about distributed cloud rendering —
redeploying Lambda/Cloud Run infra and Plan v2 becoming the default
transport. Tabario installs only the `packages/cli` tarball and renders
locally with in-container Chromium; nothing in `src/hyperframes/` or the
Dockerfile references planProtocol, aws-lambda, cloud-run or distributed.

## The conflict that mattered: an egress regression from an upstream refactor

`StudioHeader.tsx` was pure adjacency — upstream's new `ffmpegMissing` line
landed where TAB-703's return-to-Tabario handler sits. Both kept.

`composition-probe.ts` was not. TAB-746 had replaced upstream's jsdelivr
runtime fallback with a same-origin URL, next to the single injection site.
v0.8.1 (heygen-com#3316) extracted that URL into a new `runtime-url.ts` *and added a
second injection site* — `prepareSrcdocForElement` in `shader-options.ts`
injects the runtime into a srcdoc at parse time, reading the same constant.

Resolving the conflict as written would have left that new path pointing at
`cdn.jsdelivr.net`: third-party egress from a customer's browser, reintroduced
by a refactor in a file our patch never touched, in the exact surface TAB-697
was opened to clear.

So the patch moved to `runtime-url.ts`, which is where upstream now keeps the
shared constant precisely so the two sites "cannot drift onto different URLs".
One change covers both, and our footprint in `composition-probe.ts` shrinks
from ~45 lines to a thin accessor — less to conflict with next time.

`forkEgressGuard.test.ts` is what caught this, which is what it exists for.

## Also carried

- `ffmpeg.org` added to the egress guard's justified list. Unlike every other
  entry it is not a dependency that could be re-pointed at our origin: it is an
  `<a href target="_blank">` in upstream's new FfmpegRequiredNotice (heygen-com#3314), so
  no request happens without a user click. It is dead code here regardless —
  the notice renders only when the server reports no FFmpeg, and the compositor
  image ships it.
- Two upstream player tests asserted the CDN bundle filename in the srcdoc.
  Rewritten to assert the intent (a runtime IS injected) plus the fork's own
  guarantee (same-origin), which the upstream form cannot express.

## Verified

typecheck clean · studio-server 489/489 (incl. the TAB-780/781 agent tests) ·
studio 4288 · cli 2686 · player 338/338 · egress guard 5/5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants