Menu buttons (ContextMenuTarget): render the trigger as a focusable, labelled
role="button" with aria-haspopup="menu" and aria-expanded. Enter, Space or
ArrowDown opens the menu and focuses its first item. Opening with the mouse is
unchanged and does not move focus.
Menus (ContextMenu): role="menu", with menuitem / menuitemcheckbox (with
aria-checked) / separator items and roving focus. Up/Down/Home/End move
between items, Enter/Space activate, Right/Left (or Enter/Escape) open and
close submenus, and Escape/Tab close the menu and return focus to the trigger.
Activating an item from the keyboard also returns focus to the trigger.
Handled keys don't propagate, so global hotkeys don't also act on them.
Keyboard focus is shown with the existing hover highlight, via :focus-visible,
so pointer users see no change.
* Add setTracks instance method
setTracksToPlay always starts playing the first track, so embedders who want
to swap the source without autoplaying had to dispose() and create a new
instance. setTracks replaces the playlist, stops any current playback, and
loads the first track (like initialTracks) without playing it.
* Add changelog entry and cite version in docs
The feed was its own shuffled list (museum favorites plus random skins).
Now it continues through whatever the grid shows, the museum or a search:
tapping a skin opens the feed on it with the list before and after it,
and going back lands the grid on the skin you were on. Feed URLs are
plain permalinks; the list lives in the tab (listPosition.ts), and a
permalink opened fresh is followed by the museum. /feed redirects to the
museum's first skin.
- Event logging v3: one session per tab visit spanning both views, with
view_switch events and each feed view's list, list index and depth.
Older events are still accepted.
- The feed loads readmes and like counts on demand.
- Keyboard paging steps from the skin actually on screen, so it can't get
stuck after a scroll is interrupted.
- Going back to the grid animates the skin into its tile. React's
<ViewTransition> can't animate Back with Next's router, so it's done
by hand.
- Grid tiles and view transition snapshots render pixelated.
- Download saves the skin under its file name, not its md5.
- A feed URL for a missing skin returns a 404 status.
- The list position is kept apart from the server actions, so pages with
only the menu bar don't each compile another copy of the server code;
this kept the build within the server's memory.
- Declare reselect (used by legacy-client) and give knexfile.ts named
exports.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
* Add onMediaError instance method
When a track fails to load or decode, Webamp logs the error and continues as
if the track had ended, so embedders could not tell an unplayable file from
the end of a track.
ElementSource now emits an "error" event with the MediaError code, which
Media re-emits and the media middleware dispatches as MEDIA_ERROR. The new
webamp.onMediaError(cb) subscribes to it. The existing behavior of treating
the error as the end of the track is unchanged.
* Fix lint and type errors in onMediaError test
* Add changelog entry and cite version in docs
* Expose toggle state of toggle buttons with aria-pressed
Shuffle, Repeat, the EQ and PL window toggles, and the equalizer's On and Auto
buttons show their state visually via the skin's selected sprite. Mirror that
state with aria-pressed so screen readers announce it.
Originally #1367, which was merged into its stacked base branch instead of
master.
* Add changelog entry for aria-pressed
npm install -g npm@latest now installs npm 12, which requires Node 22+, so the
release job fails on Node 20 before publishing anything. Every push to master
since npm 12's release has failed to publish webamp@next.
npm 11 supports Node 20 and includes trusted publishing (11.5.1+).
Each page drew its own fade over the bottom of its readme, so it scrolled
along with the page. One fixed fade now sits above the menu bar; the expanded
readme keeps its own.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- NSFW skins are blurred in the grid and feed (and Webamp isn't loaded over
them) until the visitor chooses to see them, for the rest of the visit.
- The feed's back arrow goes back to the grid you came from, with its search
and scroll position. The grid's URL updates now reach Next's router, so the
search survives in history.
- A page loaded with ?query= starts with that search's results, run on the
server, instead of the museum's first page.
- SQLite search supports filter:nsfw / -filter:nsfw, as Algolia did.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- The feed's spotlight takes on a faint tint of the visible skin's average
color, morphs to the next skin's as you scroll, and fades in on arrival.
- Grid tiles show the skin's file name (always .wsz) on hover and focus.
- One-time hint pills for the feed and the grid.
- More room between the skin and its readme on desktop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A long press fires contextmenu, which Webamp handles as a right-click. Block
it (from Webamp too, not just the browser) when it follows a touch on Webamp;
a mouse right-click still opens Webamp's menu. The outline shown while a
press-and-hold had grabbed Webamp read as a stray focus ring, so it's gone
(the vibration remains).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The outline was on Webamp's host, which stays Webamp's unscaled size while
Webamp is zoomed to fit the skin, so it showed up smaller than the player and
behind it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
webampTouch.ts holds back the start of each touch on Webamp until it can
tell what it is: a tap or horizontal drag goes to Webamp, a vertical swipe
scrolls the feed (so swiping over the equalizer no longer changes it), and a
press-and-hold grabs the control under the finger for dragging. Webamp gets
the press replayed only when it's meant for it. This replaces the earlier
special cases (the equalizer/scrollbar touch-action exceptions and the
playlist touch block).
Also: ?ui=new / ?ui=classic set a per-visitor override of MUSEUM_UI (and
?ui=default clears it), so the new UI can be tried in production before it's
the default.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A deploy failed its health check because the running (pre-pull) script
checked a path the new build redirects. Following redirects means a page
moving can't fail the check again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
middleware.ts maps the public URLs (/, /?query=, /feed, /skin/:md5[/:fileName],
/about, /feedback) onto the new UI's routes, unless MUSEUM_UI=classic. It's
read per request, so `scripts/deploy.sh --classic` / `--new` switch UIs with a
restart rather than a rebuild. Upload, review and a skin's debug/file views
stay on the classic UI. The /scroll experiment's URLs and /index.html redirect
to the public ones in either mode.
- The new UI links to and shares the public URLs, and the grid uses ?query=
like the classic museum.
- Only the classic UI creates the legacy Redux store (it fetched museum data
on every page), so new UI pages now render on the server. The grid no
longer touches `window` while rendering.
- scripts/check-routes.sh checks every URL pattern against either UI.
- Enter dismisses the keyboard in the grid's search; touches on Webamp's
playlist no longer drag tracks; swiping down closes the expanded readme.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Event tracking (schema v2):
- Batch events client-side and send them to /scroll/events (sendBeacon when
the page is hidden), each with a version, sequence number and client time.
- Only write sessions once a browser reports in, so crawlers and link
previews no longer create sessions.
- Record dwell as total and active (visible) time, mark the view a session
exits on, and log page hide/show and Webamp playback changes.
- Give each view its feed position, source (curated/random/link) and how it
was reached; sessions record entry point, layout and pointer type.
- Log the first Webamp interaction per view, and grid opens, searches and
clicks (joined to the feed session they lead to).
Feed:
- Let vertical swipes over Webamp scroll the feed on touch screens.
- Up/Down arrows move between skins instead of changing Webamp's volume.
- Back-to-grid arrow and a centered title; placeholder for skins with no
readme.
Also point the legacy client's GraphQL requests at the same origin, so dev
uses the local server instead of production.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
next build adds the dist directory's types to tsconfig.json's include when
they're missing, which left the server checkout dirty after a build into
.next-build and blocked the next deploy's pull. Include it up front, and
have deploy.sh restore the file after building in case it changes again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The random half of getScrollPage sorted all ~100k classic skins with
ORDER BY RANDOM() and checked each against every session that had ever
seen it. In production that took ~1.5s, nearly all of the time to open a
skin from the grid. Sample random rowids and filter them instead (~5ms).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
deploy.ts expected pm2 and an Apache config the server no longer has, so it
failed immediately. deploy.sh does what the server actually needs: pull,
install, build into .next-build with a capped heap while the live sites keep
serving .next, swap it in, restart blue and green, and check both come up,
rolling back automatically if they don't. `--rollback` restores the previous
build.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
NEXT_DIST_DIR lets a deploy build into .next-build while the running
servers keep serving .next, then swap. Also turn on Next's webpack memory
optimizations, since the production server has little memory to spare.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Share page/column/screenshot layout classes between the feed page and
its loading state, and merge the scattered desktop media queries.
- Remove leftovers from earlier iterations: stale comments, an unused data
attribute and class, redundant CSS and a ref mirroring state.
- Pass `focused` to pages explicitly and memoize SkinPage so a swipe only
re-renders the pages whose focus changed.
- Read Webamp's own closed state instead of tracking it separately.
- Reuse existing helpers: screenshotUrlFromHash, the shared GraphQL client
for feedback, and one FEED_COLUMN_RATIO constant.
- Record a page of skins in the session with one insert, and load each
skin's details in parallel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Feed:
- Keep a single Webamp instance alive across skins, swapping skins and
resetting window layout/shade/open state, so music keeps playing.
- Hide a skin's screenshot once Webamp has loaded over it.
- Stop fetch retry loops, dedupe skins, and await session bookkeeping.
- Log skin views and session end accurately (sendBeacon on page hide).
- Report NSFW for human review via Discord, like the legacy site, instead
of marking skins NSFW directly.
- Show the full readme clipped under the skin; tap to slide it up.
- Title above the skin; action icons in a row on mobile and a column
beside the skin on desktop; compact like counts.
- Copy share links on desktop instead of opening the share sheet.
- Replace the bounce scroll hint with a one-time pill on mobile; add
previous/next buttons and J/K/Space on desktop.
- Stop Webamp swallowing wheel events meant for the feed.
- Ship a smaller first page and a loading state for faster navigation.
Grid:
- Load pages into their own slots so out-of-order loads land correctly.
- Debounced, race-free search with the legacy SQLite fallback, keeping
old results mounted while a search is pending.
Also: fix the feedback form endpoint, sync the legacy store with
client-side navigation (fixes the Upload button), use dvh units, and
fix the hamburger menu toggle and z-index.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Algolia account is blocked, breaking upload processing. Since search
reads already use SQLite FTS, gate all Algolia write operations behind
an opt-in ALGOLIA_ENABLED=true flag so uploads can proceed without it.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Prepared but not deployed, and not the cause of any outage -- this
predates the Algolia swap.
Scrolling the grid quickly queues up many chunks, and the mergeMap that
fetches them had no concurrency limit, so they all went in flight at
once and then shared the visitor's downlink.
Measured against production over a 10 minute window, the three heaviest
clients -- ordinary Chrome users scrolling, not scrapers -- accounted for
55% of all large GraphQL responses and saw medians of 5713ms, 1547ms and
690ms on 26KB chunks, an implied ~8KB/s. Over the same window the server
answered that identical query in ~50ms, and each of those clients' own
fastest request was ~50ms. Meanwhile 79 other client IPs sat at 43-75ms.
So the server was never the slow part; the requests were starving each
other for bandwidth.
Capping in-flight chunk fetches at 3 keeps prefetch useful while letting
each chunk land at close to server speed.
Worth knowing: this makes the aggregate POST /graphql p50 in the access
log (~900ms through this window) misleading as a measure of how the site
feels. It was dominated by a handful of self-congesting clients, and the
typical visitor was always getting tens of milliseconds.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ex3RJHBceDNPsHZpaFtn9V
Prepared but not deployed; the museum is comfortably within capacity as
of this commit.
The debounce is the main lever we have on how much search traffic
reaches the server, and right now changing it means rebuilding. That's
the expensive way to change a number: the build needs ~2GB of heap,
which this server can only just manage.
So read SEARCH_DEBOUNCE_MS in the root layout, which is a server
component on a force-dynamic route, and hand the value to the client
through the store provider. Being resolved per request rather than
inlined at build time is the whole point -- a NEXT_PUBLIC_ var would be
baked into the bundle and still need a rebuild. This way tuning it is:
sudo systemctl set-environment # or edit the unit's Environment=
sudo systemctl restart skin-database-{blue,green}
which is seconds, with no build.
Details worth knowing:
- The epic reads the value per search (debounce + timer) rather than
once when it's constructed, so the result doesn't depend on the store
being created after the config is set.
- Values are clamped to 50-2000ms. Below ~50ms the debounce isn't doing
anything and above a couple of seconds search feels broken, so a value
outside that is more likely a typo in the service config than an
intent.
- An unset or unparseable value falls back to the current 400ms rather
than to zero, so a fat-fingered env var can't turn every keystroke
into a query.
Verified against a local production build: SEARCH_DEBOUNCE_MS=1200
reaches the client as 1200, 99999 clamps to 2000, and "soon" and unset
both fall back to the default.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ex3RJHBceDNPsHZpaFtn9V
We're out of Algolia credits, so flip the museum's search over to the
SQLite FTS index we already maintain (the `skin_search` table and its
triggers), via the graphqlSearch fallback that was already sitting in
legacy-client/src/algolia.js unused.
That fallback wasn't usable as-is: it passed the raw user input straight
into an FTS5 MATCH expression, and FTS5 reads a lot of punctuation as
operators. On prod today, "rock's", "drum-n-bass", "C++", a bare "AND"
and an unbalanced quote all returned an "Unexpected error" rather than
results. So add ftsMatchExpression(), which tokenizes the input the way
FTS5's unicode61 tokenizer does and quotes each token as a phrase, and
run every FTS search through it.
Two other things that fall out of no longer having Algolia:
- Prefix match the last token, as Algolia did, so results still appear
while you're typing. Searching "metall" went from 6 hits to 100+.
Single character tokens are left unprefixed: "a"* matches a large
share of the index and takes ~350ms to rank, versus ~10ms for a
realistic query.
- Order by rank. The old query had no ORDER BY at all, so hits came back
in rowid order, which is to say arbitrary.
An empty expression now returns no results instead of raising, which is
what the museum sends when a query is nothing but `filter:` tokens.
Also bump the search debounce 300ms -> 400ms, since these queries now
land on our own box rather than Algolia's, and stop showing the "Search
by Algolia" badge while Algolia isn't what's answering.
Known gap: the `filter:nsfw` / `has:likes>5` query operators are
Algolia-only and silently match nothing on this path.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ex3RJHBceDNPsHZpaFtn9V
The equalizer's On/Auto toggles and the shade/close buttons of the equalizer,
playlist and Milkdrop windows had no title, so screen readers announced them
as unnamed buttons. Name them with aria-label (rather than title, which would
add tooltips for pointer users), matching the main window's existing titles.
When a WinampButton has an onClick handler, render it with role="button" and
tabIndex=0, and activate it with Enter or Space. WinampButtons used as plain
containers (no onClick) are unchanged.
ClickedDiv now also sets its "clicked" state on click, since keyboard
activation does not fire pointerdown.
The Milkdrop window binds Space to "next preset" via a native listener that
runs before React's, so ignore Space there when it targets a button.
The main, equalizer and playlist windows have #main-window, #equalizer-window
and #playlist-window. Add #milkdrop-window for parity so embedders and tests
can target it.
The package is not "type": "module", so under moduleResolution node16/nodenext
TypeScript interpreted the emitted .d.ts files as CommonJS. Each entrypoint's
default export was then seen as a namespace, making `new Webamp()` a type error.
Writing a nested built/types/package.json with "type": "module" after tsc
fixes this without changing how the JavaScript bundles are resolved.
The ./butterchurn export pointed its types at built/types/js/butterchurn.d.ts,
but tsc emits built/types/js/webampWithButterchurn.d.ts (named after the source
file). As a result `import Webamp from "webamp/butterchurn"` failed to find
type declarations.
The top-level "overrides" field is npm's; pnpm 9 only reads
"pnpm.overrides", so both the existing graphql pin and the new
prebuild-install 7.1.3 pin (added to fix installs on Node 22.12+) were
silently ignored. The lockfile confirms it: it still resolved
[email protected].0 and [email protected].
Moving them under "pnpm" makes the overrides take effect and dedupes
graphql to 16.8.1 and prebuild-install to 7.1.3 in the lockfile.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
- MUSEUM sort's `count` returned the total classic-skin count (~102K)
but pagination stopped at museum_sort_order's row count (~93K).
Switch count() to a new getMuseumPageCount() that matches what's
actually paginable.
- Orphaned museum_sort_order rows (skin deleted, MSO entry left) made
specific offsets throw `path.basename(null)`. Use INNER JOIN so those
rows are skipped.
- `filter + sort: MUSEUM` threw a plain Error, which graphql-yoga masked
as "Unexpected error." Throw GraphQLError so the real message reaches
the client.
Reverts the temporary diagnostic logging added in 86588f56,
which introduced prettier formatting violations that broke CI.
Co-authored-by: Claude Opus 4.6 <[email protected]>
NSFW reports posted via the website-side GraphQL mutation aren't
triggering reviews when moderators react in Discord, but the bot-command
path (!review) works. Adds structured logging to disambiguate the two
paths and pinpoint which step in the path-B (graphql) flow is failing:
- DiscordEventHandler: client lifecycle events (ready, error, disconnect,
reconnecting, shardError), login failures, top-level
messageReactionAdd listener so we can see whether the Next.js-side
gateway receives reactions at all
- DiscordEventHandler.handle: entry log + try/catch so swallowed errors
surface (notify is fire-and-forget)
- requestReview: entry, resolved tweetStatus, explicit "skipping
already-reviewed" log
- postSkin: new optional source param so logs can distinguish the bot
command from the graphql mutation; logs at entry, after send, on bot
reaction success/failure, awaiting reactions, awaitReactions resolved
- loggingFilter wrapping the awaitReactions filter to log every observed
reaction with pass/fail and any filter errors
Uses @easyops-cn/docusaurus-search-local to provide client-side
full-text search with no external service dependency.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Bump package.json and Webamp.VERSION to 2.3.0
- Move changelog Unreleased items into 2.3.0 section
- Update "unreleased" references in docs to v2.3.0
- Switch all examples from renderWhenReady to renderInto
- Update multipleMilkdropPresets to use stable @^2 instead of @next
- Update lazy example to webamp@^2.3.0
- Update release skill with version bump and changelog steps
Co-Authored-By: Claude Opus 4.6 <[email protected]>
centerWindows() was unconditionally adding window.scrollX/Y, which
incorrectly offset window positions when using renderInto with a scrolled
page. Move scroll offset responsibility to callers so each can decide:
- contained (renderInto): no scroll offset needed
- non-contained (renderWhenReady): add scroll offset to viewport coords
- body fallback (centerWindowsInView): add scroll offset only for body
Also make parentDomNode required in browserWindowSizeChanged,
ensureWindowsAreOnScreen, and centerWindowsInView since the serialized
state code (the only caller without a parentDomNode) was removed.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
The serialized state feature (LOAD_SERIALIZED_STATE action, SerializedStateV1
types, getSerializedState/getSerlializedState functions) was unused external
API surface. Removing it simplifies the codebase and eliminates the only
caller of ensureWindowsAreOnScreen() without a parentDomNode argument.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
The RichText import from @atproto/api was unused after refactoring the
Bluesky posting logic to use manual facets instead of RichText helpers.
This caused the ESLint no-unused-vars rule to fail in CI.
Co-authored-by: Claude Sonnet 4.6 <[email protected]>