- A phone's app view covers the chat without removing it from the layout,
so the hint now waits for the chat view, and the hint, pointer and panel
go when the user switches back to the app.
- The clarify card showed Send mid-turn, which told fast mode the turn had
ended and dropped the hint's timer. It now gets the real turn state, and
the hint waits while a clarify card waits on the user.
- With storage that reads but won't take a write (full), the switch and the
hint's count now hold for the load instead of the old stored value.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Any signed-in account can switch fast mode on. The Upgrade button in
its panel, the free plan's Upgrade hint, the plan check at turn start
and the Fast mode tile in the Upgrade dialog are gone.
- The long-build hint shows once a build has run 30 seconds, down from
45, still once per browser and never on a project's first build.
- Fast Mode Opened carries on and mid_build instead of access, and Fast
Mode Hint Shown has no props.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Start a project from an attached zip
A user can download a project as a zip, change it on their computer or
move it to another account, then bring it back by attaching the zip. The
AI unpacks it with a new ExtractZip tool and decides where from the
request: into the working directory to start from, continue or import
the project, or into a folder under assets/ to use the files in it.
- The zip is saved to assets/ like any attachment. The note on the
message lists what it holds (up to 25 paths, names fenced as data) and
points the AI at ExtractZip. If the request doesn't say and the zip
holds an index.html, the AI imports it as the project.
- ExtractZip drops one wrapping top-level folder (my-site/index.html),
skips __MACOSX, ._ files, .DS_Store, .git and .puter, and runs every
entry path through assertPathInProject, refusing any with "..".
- It refuses archives over 2000 files or 200MB unpacked before writing
anything, removes the zip afterwards unless keep_zip is set, takes a
version like any file change, and tells the AI about backend sources
under workers/ that still need create_worker in this project.
Closes#168
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Harden ExtractZip against lying archives and late stops
Fixes from the review of #171:
- Check ".." on the name the archive gives. JSZip resolves it in
file.name (../index.html reads as index.html) and keeps the original
as unsafeOriginalName, so such entries were written under the tidied
name instead of skipped.
- Unpack each entry as a stream that stops once it passes the size the
archive declares for it. The file and byte limits trust those sizes,
and JSZip only compares them with the real output at the end, so a
forged size could inflate a few MB into GBs in the tab first.
- Check for a stopped turn after each wait (unpacking, the file's lock,
the retry's pause) and before removing the archive, so nothing is
written, and the zip isn't deleted, after the user stops the turn.
- Sort a zip into the note by the original file. A zip known only by
its type ("download", application/zip) is saved as a ref with neither
a .zip name nor the type, so it was listed under other files without
its contents or the ExtractZip hint.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Track JavaScript errors in Plausible
Uncaught errors, unhandled promise rejections and script or stylesheet
files that fail to load now each send a "JavaScript Error" custom event
to Plausible, with kind, message, source (path:line:col) and the top
stack frames as props.
The tracker is an inline script right after the Plausible queue stub,
ahead of puter.js and the app bundle, so it also catches errors during
startup, including a syntax error that stops the bundle from running.
Repeats of the same error are dropped and each page load sends at most
10 events, because every event counts toward the Plausible quota.
Browser-extension errors, the ResizeObserver loop notice and failed
images are ignored. The page URL is not sent: Plausible already records
the path, and query strings and hashes are stripped from every URL that
is sent.
scripts/test-error-tracking.mjs runs the real inline script against a
fake window and is part of npm test.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FWCxozGGiqtLLCiSvRCSu3
* Strip query strings and hashes from stack frames
The stack prop went out with frame URLs untouched, so an error thrown by
an inline script, whose frames carry the full page URL, sent the page's
query string to Plausible even though source was already cleaned. Every
URL in a stack now loses its query string and hash before it is used,
keeping :line:col, and the rejection's source is read from the cleaned
stack too.
The privacy test now covers query-bearing stacks in Chrome and Firefox
formats, including a query that holds ":1:2" or parentheses, an eval
frame, and a #private method name that must survive.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FWCxozGGiqtLLCiSvRCSu3
---------
Co-authored-by: Claude <[email protected]>
* Let the chat connect the user's own domain
The AI could only send users to the Publish menu for a custom domain. Now
it calls OfferCustomDomain, which puts the Publish card in the chat in a
domain mode. Its button is the popover's Use custom domain (useOwnDomain),
so Puter's window still opens straight from the user's click, as a browser
requires of a popup.
- Before a first publish the card has the address field and "Publish with
your domain": it reserves the name, opens Puter's window and publishes,
as the popover's route does (publishWithDomain), with the card's own
field, errors and publish passed in.
- On a live site it shows the address (the domain once it works) and Use
custom domain, Change domain or Connect again, with a line while the
domain waits for DNS. A turn doesn't hold it up; an address change does.
- On the free plan the button opens the upgrade, and the card turns into
the paid one when the plan changes.
- The domain chosen is saved on the card's tool result, so an older card
reads "Domain added" after a reload.
- The prompt points own-domain requests at the tool, and older chats whose
prompt sends the user to the Publish menu get a note.
* auto pick subdomain, handling cancel, ui and copy changes
* Keep a picked domain's site publishable
From the review of the own-domain flow:
- A domain picked on a first publish that didn't land (a build started
while Puter's window was open, it failed, or another project opened)
shows on the chat card with Publish, onto the name the domain is on.
An unpublished project's reserved name is also read from the domain
saved on it, so a reload doesn't lose it.
- Plain Publish is held only while the name is reserved or let go, not
for as long as Puter's window is open. A cancel leaves the name alone
when a plain Publish has taken it, and a pick starts no second publish.
- The name a new one replaces is deleted only once a domain is picked.
- No "couldn't open" alert once a domain was picked, and a window error
leaves nothing held.
- The free-plan domain card counts as an upgrade prompt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Reynaldi Chernando <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fast mode on first build with experiment
* Fast first build on Opus only, with build time on Build Completed
- Ask for the fast service speed only when the turn's model is Opus
(ModelCore's fast flag), checked once the plan check settles the model.
- Build Completed reports how long the build took as `took`, in bands up
to 10m+, so the speed experiment can show whether the fast variant was
faster.
- Inside the Puter desktop the Low Balance cohort is 'app' even with a
variant forced, as it was before it moved into experiments.js.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* Offer Add credits to a paid account that runs out
A paid account that hit the usage limit was told to move to a bigger
plan, in the out-of-credits dialog and in the chat message. Puter now
sells add-on credit to accounts with a plan, which is quicker than
changing plans, so both lead with Add credits for an account that can
buy it and keep the bigger plan one click away. Free accounts, team
seats and the Puter desktop see what they saw before.
Add credits opens puter.js's top-up over the page. When credit is
bought, there or in another tab, the build that stopped continues on
its own once Puter reports credit to spend.
* Cover the Add credits announcement in the announce checks
The usage-limit message now has two wordings, which moved the original
announcement past the distance its wiring check allowed. The check
covers both.
* Wait for bought credit to land before continuing the build
A request can be refused with a little credit still left, when it costs
more than what remains. The balance read after a purchase was then above
zero before the purchase landed, and the build continued straight into
another refusal. The balance is now read when the top-up opens, and the
build continues once it has gone above that.
* Forget the stopped build once a new turn starts in its project
The project a build stopped in stayed recorded until the build was
continued. When the user sent a message themselves before the credit
landed, the record outlived that turn, and the next time the purchase was
noticed (on returning to the tab) a second, unasked-for continue was sent.
A turn starting in that project now clears the record.
* Continue the stopped build after Add credits in the chat message too
Buying credit from the chat message's Add credits button left a build the
out-of-credits dialog had recorded as stopped where it was, while the same
purchase from the dialog continued it.
* Leave a Resume in the chat when bought credit is slow to arrive
When the credit hadn't shown fifteen seconds after a purchase, nothing
happened: the top-up closed on a chat that still ended at the limit, and
the build only continued if the user asked for it in words. The chat now
gets the Resume banner, saying the credits haven't arrived yet.
The toolbar's feedback button (an icon beside <puter-user-menu>, a
labelled pill inside Puter) is gone, along with its click handler and
styles. The account panel's Send feedback row stays.
On phones the GitHub link now steps aside for the Settings gear instead
of the feedback icon. The one-row phone preview toolbar no longer has to
make an exception for the wide feedback pill, so those guards are dropped.
With <puter-user-menu>, the toolbar's dark mode toggle now lives in the
Settings modal instead. Settings has two tabs: MCP connections and
Appearance, which holds the account panel's Light / Dark / Device control
(one shared builder, kept in sync wherever it is open). Inside Puter, where
there is no gear, the toolbar toggle stays.
The tabs are a proper tablist: only the selected tab is a Tab stop, and the
arrow keys and Home/End move between them. The gear reopens the last tab;
the account panel's MCP row always opens the MCP tab. The modal keeps one
height across tabs so switching never shifts it, and closing after a theme
change refocuses the re-rendered gear.
<puter-user-menu> has no row for the account panel's MCP connections, so
with it the MCP dialog could not be reached. A signed-in user now gets a
Settings gear just left of the element (not inside Puter), styled like the
other toolbar icons in both themes and on phones.
The gear opens a centered Settings modal, which for now holds the MCP
connections section; the account panel's MCP row opens the same modal. A
click on the backdrop no longer closes it: the modal jolts instead (a ring
under reduced motion), so a stray click never loses a half-typed server.
Escape and the close button still close it.
* Name the signed-in user to the Apps panel's embed
puter.com/embed/apps shows the puter.com session's own apps only to the
user named in its fragment (#user=<uuid>), and the recommended apps to
everyone else. The Apps panel now names the builder's signed-in user; a
signed-out builder, or a temporary account, names no one. The panel is
rebuilt when that user changes, keyed by uuid instead of username.
* Use puter.js's <puter-user-menu> for the apps launcher and account button
When the running puter.js defines <puter-user-menu>, the toolbar places it
beside the controls updateUserMenu re-renders, and the builder's own apps
launcher, avatar and account panel stand down. The element follows the
builder's sign-in through puter.js, names the user to the app browser, and
closes it when the sign-in changes. Without it, the builder's own launcher
and account panel are unchanged.
With the element, a signed-in user's theme toggle and Send feedback sit in
the toolbar, as they do inside Puter; Send feedback is an icon there, and
on phones the GitHub link steps aside so the row fits. Account settings is
no longer offered. The element's Sign In and Log out reach the builder's
usual paths: signing in sets window.user from the event before finishing
the session setup, and logging out forgets the account on this device and
reloads, the same as the account panel's Log out. Its parts take the
builder's icon, avatar and pill looks, in both themes and on phones.
* Update code editor toggle placement
Make the code toggle less visible, fix saving code on iOS Safari, and stop
highlighting the AI's changes in the Code view.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Address review: View code wording, WebKit-only fresh reads, tablet header
- Toasts, the upgrade card and the AI's instructions point to View code
(in the "…" menu on phones) instead of a Code button that is gone.
- The per-read cache buster runs only on WebKit; other engines honor
no-cache, and a unique URL per read cost them a CORS preflight per read.
- New project stays in the header on tablets; only phones hide it.
- Switching project from the preview redraws the unsaved dot, so it can't
follow into the next project.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The apps button now renders left of Sign In as well as left of the
avatar; only the hosted-inside-Puter toolbar omits it. A sign-in or
sign-out discards a built panel so the embed reloads for the new
account on next open.
Signed-in users get a 3x3 apps button left of the avatar that opens a
panel showing puter.com/embed/apps. Same card, caret and dismissal rules
as the account panel; an edge-to-edge sheet on phones; the embed follows
the app's light/dark theme.
Images and files in a sent or queued message now use the same 100px card
as a pasted text card. Images are cropped to fill theirs; files show an
icon and their name over their extension (HTML, PDF...) where a paste shows
PASTED. Replaces the tiny 50px images and the emoji file line.
* Turn a long text paste into a Pasted text card
Pasting a log, a spec or a file's contents filled the composer with a wall
of text. A paste of 2,000+ characters or 30+ lines now goes in the
attachment tray as a card instead, styled after claude.ai's: the opening
lines in small faint type over a PASTED badge, with an edit pencil and the
usual remove button. Clicking the card shows the whole text; the pencil
opens it for editing (saving it empty removes the card).
The paste is not a file: it is never saved to assets/. It is stored as a
text document block marked as pasted, so the sent message (and a reloaded
chat) shows the same card, and the model gets the whole text inline,
labelled as text the user pasted. Shorter pastes are unchanged.
* Keep a pasted text card in the composer draft
A long paste used to be composer text, so the draft saved it: a reload or a
trip to another project brought it back. As a card it lived only in memory
and was lost. The draft envelope now carries the cards (queued messages'
first, like their text), every add, edit or remove saves it, a restore puts
them back in the tray, and a send takes them out of the tray with the text.
Cards count toward the cloud mirror's size cap, and a card-only composer is
kept, not replaced, when the user signs in.
* Hand a pasted text card back when its send never lands
A composer send stopped before its message reached the conversation (Stop
while files upload) puts its text back in the box. A long paste used to be
part of that text; as a card it was dropped. The card now comes back with
the text. Files still don't: their blob URLs are about to be revoked.
* Send a pasted text card with a reply to a clarifying question
While the AI waits on clarifying questions, the composer's text is the
reply. A long paste used to be part of that text; as a card it was left out,
and a paste-only reply did nothing at all. The cards now go in the reply
after the typed text and leave the tray.
* Size the tray's file thumbnails like the pasted text card
Next to the 100px Pasted text card, a file was a 60px square with the file
icon stretched across it. Every tray thumbnail is now the same 100px
rounded card. An image fills it, with its name along the bottom; any other
file shows the file icon small and centred over its name. The marketing
pages' composer copies the app's tray, so it changes with it.
* Draw a queued message's pasted text as a card
The sent message shows a long paste as a Pasted text card, but the queued
message drew it as a plain 📄 Pasted text chip that did nothing. It now
uses the same card, and clicking it shows the text.
* Give each pasted text card its own name
A new card was numbered by counting the cards in the tray, so after
removing an earlier one the next paste reused a name still in use: two
"Pasted text 2" cards, and the model read both under the same label. A new
card now takes the lowest name not in use.
* Read a pasted text card as the user's words in titles and labels
A long paste used to be the message's text, so the project title, the
version label (and its GitHub commit message) and the naming/suggestion
transcript read it. As a card it is a document block those skipped: a
paste-only first message titled the project "New Chat" and its version
"Update". They now read the paste after any typed text.
* Announce a long paste that became a card
The paste never reaches the box, so a screen reader user heard nothing and
found the box empty. Say that it was attached, and under which name.
* Keep unsaved edits to a pasted card on a click outside
A click outside the Edit pasted text dialog closed it and threw the edits
away without a word. With unsaved changes it now keeps the dialog open and
puts the cursor back in the text. Cancel, the close button and Escape
still discard on purpose; an unchanged dialog still closes.
* Stop iOS from zooming in when the pasted text editor opens
The Edit pasted text dialog focuses its 12px text area itself, and iOS
Safari zooms the page into any field under 16px when it gets focus. On
screens up to 900px the text is 16px, as for the app's other dialog fields.
* Don't take a long paste into a read-only composer
While a send sets up, the composer is read-only and the browser drops a
short paste. A long one still went into the tray as a card, and the send's
draft clear then left it out of the draft.
* show relevant ideas to developers
* Report the pill test as pill_variant on Project Created
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* whats new
* Let the upgrade dialog and someone typing come before What's new
Startup leaves What's new out while an Upgrade click is pending, since the
success dialog may be about to open. If the upgrade dialog opens over it
later, What's new closes without being marked seen, so a later load shows
it again.
It also stays closed once the user has typed anywhere since the page loaded,
or focus has moved off the chat box, so it no longer takes keystrokes from
someone already at work.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Once a domain works, the popover shows the site's puter.site address on
an "Also at" line under the URL row. That line could neither wrap nor
shrink, so a long address scrolled the popover sideways: 56px on a phone
in a headless Chrome check, 7px on desktop. It now ends in an ellipsis
like the URL row above it, with the whole address in its title.
Connecting a domain takes it off any other project that had it. To find
that project, every published project's saved file was downloaded in
parallel, and each file holds the project's whole conversation, so one
connect could pull tens of megabytes. The project list now keeps each
project's domain hostname (publishedDomainHost, in any state), and only
the project listed with it is updated, with no other reads.
The popover re-renders when a turn ends or a domain check changes
something, and puts focus back on the control that had it, found by its
first class and data-action. The record copy buttons had no data-action
and share their first class with the site's Copy link button, so focus
on one jumped to Copy link at the top. Each now has its own.
Check now, Connect again and Change domain were disabled while they
worked. The popover re-renders to show that, and a disabled button can't
hold focus, so focus fell back to the popover itself and a keyboard user
had to tab back to where they were. They are now aria-disabled while
busy, which keeps them focusable, and their click handlers ignore them
until they are free.
Before an address change, the warning told every user with a domain that
it "shows your site" and "stops working". A domain still waiting for
its DNS records, or one already found not working, does neither. Those
now read that the domain is set up for the current address and won't
work with the new one.
The own-domain route reserves a project's puter.site name before its
first publish. When that publish never landed, the name is in neither
of the URLs deleteChat removes, so deleting the project left the site
on the account, holding one of its limited site slots.
After an own-domain first publish that didn't land, Use custom domain
went back to the name that try had reserved, even when the user had
typed another one, and put that name back in the field. A plain Publish
already uses the typed name. Use custom domain now does too, and removes
the reserved site it replaces so it doesn't take one of the account's
site slots. A typed name that is taken leaves the reserved one in place.
An own-domain first publish reserves its puter.site name and connects
the domain to it before the publish runs. When that publish failed and
the user closed the popover, the next open filled the address field
with a random name. Publish then created that site and deleted the
reserved one, leaving the domain on an address that no longer exists.
The field now starts with the reserved name.
The popover's progress note says "Pick or add your domain in Puter's
window" while a first publish goes out with its domain. The flag behind
it stayed on afterwards, so a later publish from the chat's Publish card
showed it too when the popover was opened mid-publish. It now clears
when that publish ends, whether it worked or not.
Rebuilt from #141 (by Reynaldi Chernando) on top of main, where publishing
now runs through publishProject (the popover and the chat's Publish card)
and address changes through changePublishedAddress.
- "Use custom domain" / "Change domain" beside the site address opens
Puter's domain dialog (puter.hosting.attach). The free plan gets the
upgrade dialog, which now lists the benefit.
- Before a first publish, the own-domain route reserves the name, opens the
dialog and publishes behind it. publishProject takes the reserved name
(ui.reserved), reuses one a failed try left behind, and holds back any
other publish, including the chat card's, while the name is reserved.
- The popover checks a pending domain until it serves the site, and a
connected one each time it opens. Once working, the domain is the
address the popover, share view, copy button and sidebar show.
- Changing the address asks first when a domain depends on it. The
question sits in changePublishedAddress, so the popover and the chat
card both ask, ahead of the busy checks. Settings asks too.
- savePublishedFields keeps fields a caller leaves out, and changes
domains inside the file lock, alongside main's AI note.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
click(), fill() and press() checked only the element's own disabled
property, which stays false inside a <fieldset disabled>. The browser
ignores a click there, so the test went on and failed later with a
misleading message instead of saying the control is disabled. They
now ask the browser, with :disabled.
The update_preview description told the AI it runs AppTests' tests
even with FEATURE_FLAGS.appTests off, when the tool isn't offered. The
clause now follows the flag, like the prompt rule, so with the flag
off the description is the one from before tests.
verifyPreview waited for the tests file with no limit before starting
its error watch, so a slow read delayed the check and a read that
never settled left update_preview, and the turn, waiting until the
user pressed Stop. The tests now start whenever the read is done,
alongside the watch, and a read not back within 5s of the load counts
as no tests.
The retry merge kept one result per name, so when two tests shared a
name (a copy-pasted name, or two cut to the same 120 characters) and
the first passed, the second counted as passed even though it failed
both times. Both runs go through the page's tests in the same order,
so tests are now matched by position.
Once a test had taken focus and it was given back, the end of the run
focused the composer again even if the user had since left it, which
on a phone brings the keyboard back up. Focus is now restored only
when the hidden frame still holds it as it is removed.
The focus guard learned where focus was only from focusin, which never
fires when the user clicks into the visible preview. If a test then
pulled focus into the hidden frame, it was handed to the composer, so
what the user typed for their app went into the chat (and Enter could
send it). The guard now follows focus on every check.
The layer was a Proxy over a plain object that sent every name to
getItem, so localStorage.hasOwnProperty('theme'), String(localStorage)
and instanceof Storage threw or lied only while tests ran, and calling
Storage.prototype.getItem on it failed. It now sits on Storage's
prototype, whose members win over stored keys as on the real object,
and the prototype's methods reach the layer.
dialogs and setAIReply carried over from one test to the next, so a
test that checked dialogs had length 1, or relied on the default AI
reply, passed or failed depending on the tests before it. Both are
reset when a test ends: the first test still sees dialogs from page
load, and a reply set at the top level holds for every test.
text() took the first match even when it was hidden, so in an app
that hides its other screens text('h2') read a hidden screen's heading
instead of the one the user sees. It now reads the first visible
match, and a hidden one only when that's all there is.
A click or waitFor the test forgot to await surfaced as an unhandled
rejection, which was reported as "The app threw an error during this
test", sending the AI to fix an app that works. The runner now marks
its steps' errors and says the step wasn't awaited.
Nothing cancels a promise, so a test that ran out of time kept
clicking, filling and sleeping through the tests after it, failing
them for no fault of the app (and one left waiting, un-awaited, failed
whichever test was running when it gave up). Each step now remembers
the test it started in and never finishes once that test is over.
The test frame shares an origin with the visible preview, so an app
that keeps its tabs in step with BroadcastChannel sent what a test did
to the preview, which could then save it to the user's real data.
Channels opened in the test frame now talk only to each other, and one
opened before the runner loaded sends nothing out.
puter.js trims a list pattern, drops a trailing * and matches it as a
prefix, but the test overlay matched it exactly, so list('todo:')
missed the todos a test had just added and a working app looked
broken. The { pattern, returnValues } form skipped the overlay and an
options object was taken as a request for values. All three now behave
as they do outside tests.
puter.ai.convo opens a billed realtime session that the placeholders
didn't cover, so a test of a voice-chat button cost real money. It is
refused now. The faked txt2speech also dropped the listVoices and
listEngines puter.js hangs on it, so a voice picker broke only under
test; the fakes keep them.
A hidden test that clicks a Send or Remind me button could send a
real email, push a real notification to the user's devices, or reset
a team member's password through puter.js. Those are refused like the
other writes, and the AI is told.
puter.js keeps clear as its own copy of the real flush, so replacing
flush left clear able to empty the user's real store from a hidden
test. It now goes to the in-memory flush, and kv/fs share and unshare
are refused like the other writes.
The test frame loads each page with a ?__pv= cache-buster, and the runner
named an error's file by the last part of its address. An error in an
inline script was reported as "(?__pv=1793755905277_k1kr96:3)", so the
AI was pointed at a file that doesn't exist. The query is dropped, and
the site's root reads as index.html.
The test frame is sandboxed with an empty allow list and no popups,
unlike the visible preview, so getUserMedia and geolocation are refused
there and window.open returns null. A test of a location or camera flow
would fail on an app that works for the user. The AppTests description
now says these aren't available, as it already did for file changes and
sign-in.
The test frame has no autoplay permission, so its AudioContext stays
suspended and resume() waits for a real click that never comes. In real
Chrome, a game whose Start button awaits ctx.resume() timed out, and the
working game would be sent back to the AI. resume() now resolves at once
in the test frame; the context stays suspended, so nothing is heard.