fix(add): make chosen variables actually take effect, in the CLI and the preview - #3316
Merged
Conversation
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.
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.
…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.
`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
reviewed
Aug 17, 2026
terencecho
left a comment
Contributor
There was a problem hiding this comment.
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 (
--varsfor components):applyVariableDefaultsrewrites the declareddefaultin the component's owndata-composition-variablesat install time — the only surviving home once markup is pasted. Only the requested item gets its vars rewritten (dependencies pinned tonull,add.tsL208). 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):
ensureRuntimeBeforeBodyScriptslands the runtime<script src>inside<head>(parser-blocking) so it's defined before any body IIFE runs. Wired throughprepareSrcdocForElementinshader-options.ts— every srcdoc path picks it up.alreadyHasRuntimeguards against a double copy on CLI-rendered pages (which inline it).runtime-url.tsextraction means probe-late-injection and srcdoc-parse-injection can't drift onto different URLs — good consolidation. - Bug 3 (
fetch faileddiagnosis):describeCauseChainwalks.cause, cycle-safe viaseenSet, appends[CODE]only when message doesn't already carry it. Tests pin the cases. - Bug 4 (retry):
fetchWithRetryis scoped to item files (rightly — manifests fall back to stale copies).isRetryableTransportexplicitly 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):
expandCompoundssplits + joins gated on the catalog's own vocabulary and inferred tokens carry 0.35× weight, so atypewriterquery can't be dislodged bytype-match-cutmatchingtypeat 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-variablesgoes throughinstallOneFile → applyVariableDefaultswhen the file is a component snippet. Block compositions still hang values on the mount element via existingbuildSnippetpath; that path unchanged. No bypass sites found. assertSafeTargetun-exported cleanly — only in-file caller remains.registry/index.tsre-export slimming: verifiedresolveItem,fetchRegistryManifest,fetchItemManifest,fetchItemFile,assertSafeTarget,InstallResult,InstallOptions,ResolveOptionswere the ones dropped — all read at their source modules by remaining consumers.
Nits (non-blocking)
remote.ts::isRetryableTransportregex andadd.ts::describeInstallFailuretransport regex are two separate lists (network+timeouterrorin one,ENOTFOUNDin 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/variablesInvalidareflatMap(union),variablesUnknownintersects. 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. fetchWithRetryusesAbortSignal.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 testsboth SUCCESS.Analyze (javascript-typescript)SUCCESS.Preview paritystill IN_PROGRESS at review time.Testshows FAILURE from the earlier cancelled run (32078543814) — stale rollup row; the newerTestjob (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
approved these changes
Aug 17, 2026
terencecho
left a comment
Contributor
There was a problem hiding this comment.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Reported as one command that did nothing useful:
Five bugs were behind it or found while proving the fix:
--varssilently discarded for componentsfetch failedwas undiagnosable, real cause thrown awayPlus the two
transcribetests that were red before this branch.1.
--varsfor componentsHow it broke.
buildSnippethad two branches. For a block it emitted a mount element and hung the values on it. For a component it returned<!-- paste from ... -->and droppedvalueson 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 thedefaultfield 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:
"76"where the composition expects a number trips that same silent fallback.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.
addnow printsvariables applied: size, tone, align, because a component's values live in the file rather than the snippet. Without it, the command looks identical whether--varsworked 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 whenshouldInjectRuntimesays 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: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.__hfVariablescorrectly. Nothing was there to read it.The fix is ordering, not plumbing.
prepareSrcdocForElementnow 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.After, on this branch's player: the preview renders what the panel asked for.
3 and 4. The error message, and the retry
fetch failedis 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
registryat a private host inhyperframes.jsongot 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 beself-signed certificate in certificate chain— a private registry whose certificate node refuses andcurlaccepts, which is exactly why the host looked healthy when checked from a terminal.describeCauseChainnow 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.
countdownreturned exactly one item, the only thing tagged with that spelling.count down timerreturned sixteen, and that one was in none of them.How it broke. The tokenizer splits on word boundaries, so
countdownis one token andcount downis 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 hidcount-upanddecline-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:
countdown→count+down).count down→countdown).timerwhich 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
typematching the name oftype-match-cutoutranktypewritermatching the name oftypewriter— 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:
countdowncount down timercount downEach spelling still ranks its own exact match first, which is the correct behaviour — nothing is hidden either way now.
Test plan
cli 2680 passed · player 338 passed · studio 4249 passed. Zero failures.
The two
transcribetests that were red before this branch are fixed: they assert the whisper soft-skip path but never pinned the engine, andautopicks Parakeet wheneverparakeet-mlxis 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:Bad values are refused rather than silently written:
A registry with a bad certificate explains itself:
The preview fix, end to end against the real runtime and a real registry component, asking for size 96 / accent / right:
rgb(243,243,243)rgb(60,230,172)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
variableDefaultsfixtures gained thelabelthe schema requires — without it they were not valid declarations, which the stricter reader caught.A note on types
variableDefaultsoriginally modelled a declaration as a local interface of sixunknownfields, re-checked at every use. Core already owns this shape as a discriminated union and exportsisCompositionVariable, the same predicateparseCompositionVariablesfilters 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.tsand 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-lettersstill ranks initial code #2 forcount downon a name match againstdownalone. 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-upruns 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.7range, 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 thedata-composition-variablesblock. 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
.webpout of the public directory but not.json, so the preview panel reports the payload as unavailable on localhost regardless of any of this.