4283 Commits
Author SHA1 Message Date
Jordan EldredgeandClaude Opus 5.5 165e55658c Stop the grid's window size from looping React into a crash
The grid read the window's size whenever React read its snapshot. React
re-reads the snapshot after every commit and re-renders if it changed, and
rendering the grid can change the window's size: on Windows, a window size
where the full-width grid gives the page a scrollbar shrinks clientWidth by
the scrollbar, and the narrower grid takes the scrollbar away again. That
went round until React gave up ("Maximum update depth exceeded", #185),
on load or on resizing to such a size.

The size is now measured only when the grid subscribes and on resize, and
the snapshot just returns it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:07:45 -08:00
Jordan EldredgeandClaude Opus 5.5 e0ad6417cb Fit the grid's columns beside its scrollbar
The grid always shows a vertical scrollbar but sized its columns to the full
window width. Where scrollbars take up room (Windows, about 17px) the right
column ran under the scrollbar and the grid scrolled sideways. The columns
now fit in the window's width less the scrollbar, measured again as the
window's size (or zoom) changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:58:47 -08:00
Jordan EldredgeandClaude Opus 5.5 d3696a2e75 Get Internet Archive uploads past case-alias clashes
The Archive refuses an identifier that differs only in case from an
existing item, but we only checked for an exact match, so the same skins
picked the same refused name and failed every night. Now a refused name
moves on to the next number.

A name that already holds this skin (an upload that timed out after the
Archive had it) is adopted instead of uploaded again, with its screenshot
added if missing.

Screenshot names also replace an uppercase .WSZ/.ZIP extension, which
had been left in place, so the PNG was uploaded posing as a skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 21:35:33 -08:00
Jordan EldredgeandClaude Opus 5.5 444ea5bb88 Recognize the new UI by its floating bar in check-routes.sh
It looked for data-hamburger-toggle, from the bottom menu bar the floating
bar replaced (19a0d010), so every new-UI page has read as "classic" since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 19:47:18 -08:00
Jordan EldredgeandClaude Opus 5.5 df65d05172 Remove the event dashboard
/dev/events loaded the whole event log into the live server's memory (All
time by default). The log is 2.8M events and growing ~145k a day; loading it
all took over 4GB when it was smaller, on a 4GB server that's already using
swap. So one visit could take down the museum.

Gone: the page, its charts and styles, the token login (/dev/login) and the
middleware gate in front of /dev, and loadSessions, which read the whole log
at once. /dev/* is a plain 404 now. The event logger no longer has to ignore
tabs opened from the dashboard.

The analysis the nightly Downloads order needs stays, moved from app/dev/events
to events/: analyze() and botReasons, and forEachSessionBatch, which reads the
log a batch of sessions at a time. The dashboard's chart and stats helpers are
dropped with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 19:43:57 -08:00
Jordan EldredgeandClaude Opus 5.5 7089204000 Size the floating bar by the window's width, not its height
Its max width was the feed column's (56.25dvh, from the window's height), so
in a short window it shrank to ~240px and the logo, search box, sort button
and menu piled on top of each other. It's now the window's width less the
gutter, up to 34rem: about what it was in a typical desktop window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 18:47:40 -08:00
Jordan EldredgeandClaude Opus 5.5 e437f23f31 List the Downloads sort in the sort menu again
Its scoring now puts the museum's best-known skins where their downloads say
they belong, so it has earned its place: Top, Downloads, Newest, Random.

This reverts commit dbcbb3d381.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 18:32:38 -08:00
Jordan EldredgeandClaude Opus 5.5 9fdd470fc2 Score the Downloads sort by downloads per grid view, without position
The position correction compared each of the museum's first skins with
itself: those positions always show the same skin, so each had its own
"average" rate, scored ~1.00, and sat around #2,680 (base-2.91, the Classified
skins, Garfield, Zelda...). Random skins, which appear at many positions, get
about as many downloads per view anywhere (0.29% at position 14, 0.19% at
10,000), so position isn't corrected for at all now.

Also:
- Grid views in every order count, not just the museum's own; searches still
  don't.
- Smoothing goes from 2 to 10 expected downloads, so a skin needs ~2,800
  views before its own record outweighs the average.
- Skins with no downloads aren't scored; they follow in the museum's order.
  (28.5k of the 32k scored skins had none, ordered fewest views first.)

On production's events through Nov 3: 3,969 skins scored, led by Winamp5
Classified, base-2.91, Sony CDX-MP3 and Bento Classified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 18:32:33 -08:00
Jordan EldredgeandClaude Opus 5.5 bf6a7eccda Keep Webamp's clip path container from making the page scrollable
The div Webamp appends to <body> for skin window shapes held an inline svg,
which sat in a line box and made the page ~18px taller than the viewport. In
the museum's feed, wheeling over a fixed element (or scrollIntoView) scrolled
the window by that much, pushing each skin's title off the top.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-11-03 17:34:09 -08:00
Jordan EldredgeandClaude Opus 5.5 7f13586b60 Fade the feed's edge shading in with its backdrop
Opening a skin from the grid, the backdrop fades in over 0.8s, but the
fixed fade above the floating bar (over the readme) and the shadow along
the top appeared at once, so for most of that time they were a dark band
on the page's plain background. They now fade in with the backdrop, so
they're never darker than what they blend into.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-10-28 10:58:54 -07:00
Jordan EldredgeandClaude Opus 5.5 f5c578bfa5 Bring back has: search filters, with a menu of them in the search box
The classic museum's search syntax (has:ani, has:transparency,
-filter:nsfw, ...; legacy-client/src/queryParser.js) works in the new grid
again: it was being searched for as words, so "has:avs" found 560 skins
that mention AVS rather than the 43,937 that have it. Filters combine with
words ("zelda has:transparency").

A filters button in the search box (beside the sort button, or the clear
button once there's text) opens a menu of them as toggle pills, in groups:
Windows, Plugins, Cursors, Other. It stays open, so several can be picked;
each adds its has: term to the box (or takes it out), which is where people
see the syntax, and the search runs as the text changes. Picked pills fill
in at the same size, so nothing moves. has:likes (nearly every skin) and the
NSFW filters work if typed but aren't in the menu. The menu and the sort
menu close each other, and on Escape or a press outside.

A link to a search that uses the syntax skips the server's first results
(its SQLite search can only apply the NSFW filter) and the grid searches
Algolia, as when typing. If Algolia is down, the SQLite fallback still
ignores filters other than NSFW.

Event schema version 10: search_filters_open and search_filter_toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168RMPEf7DAZeo7G6so4a6e
2026-10-27 14:00:50 -07:00
Jordan EldredgeandClaude Opus 5.5 dbcbb3d381 Keep the Downloads sort out of the menu for now
It stays unlisted until there's enough data behind it for the results to be
good: /?sort=downloads still shows it (and then the menu lists it, checked),
and the nightly job keeps rebuilding it. To list it, take "downloads" out of
UNLISTED_SORTS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:47:06 -07:00
Jordan EldredgeandClaude Opus 5.5 1c1b9a5623 Let database writes wait their turn instead of failing with SQLITE_BUSY
The first production run of the Downloads order failed with "database is
locked": the site's servers, the bot and the nightly jobs all write to
skins.sqlite3, and no connection set a busy timeout, so a write that met
another in progress failed at once. That also means the site's own writes
(mostly logged events) have been failing for the moment each nightly
rebuild holds the write lock.

Every connection now waits up to 5s (knexfile's pool.afterCreate), and the
Downloads job up to 30s. Its transaction also starts with its write: in WAL
mode, a transaction that reads and then writes fails at once if another
write lands in between, however long it would wait, so the skins it may list
are read before it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:37:55 -07:00
Jordan EldredgeandClaude Opus 5.5 7f56ab5681 Add a Downloads sort, rebuilt nightly; remove /sorts
The museum's sort menu gets "Downloads" (?sort=downloads), between Top and
Newest: skins by how often seeing them in the museum grid led to a download,
against how often it would for an average skin at the same positions. It was
/sorts' "downloads per grid view". Checked on production's events (Oct
14-26): a skin's score from one half of the sessions predicts downloads per
feed view in the other half well (Spearman +0.62 above a shuffled baseline,
against +0.19 for direct page loads), and ~14.8k skins have the 20+ grid
views it takes to be scored.

The nightly museum-order job (--compute-museum-order) rebuilds it after the
museum's own order, into museum_downloads_order: scored skins best first,
then the rest in the museum's own order, leaving out NSFW skins like Newest
and Random. Positions count from 1, so a page is a range of them. It reads
the event log 500 sessions at a time (forEachSessionBatch, using the new
session_id index): ~13s and ~600MB on a copy of production's log, where
loading it all at once, as /sorts did, took 4.3GB on a 4GB server. It's
written in one transaction; if it fails, the previous order stays and the
museum order still updates.

/sorts is gone, and its other rankings with it: the scoring this needs moved
to tasks/computeDownloadsOrder.ts, with its test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:34:58 -07:00
Jordan EldredgeandClaude Opus 5.5 310a2c574e Add the user_log_events indexes its first migration meant to create
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:34:58 -07:00
Jordan EldredgeandClaude Opus 5.5 2ff88e94e3 Let the museum be browsed newest first or in a random order
A sort button at the end of the grid's empty search box opens a menu, like
the "…" menu, of the museum's orders: Top (its own), Newest and Random. The
button names the chosen order. Choosing one closes the menu, scrolls the grid
to the start and puts it in the URL (/?sort=newest), so it can be shared and
Back returns to it. Searches aren't sorted (a search is its own order, best
match first), so while there's text in the box the sort button gives way to
the clear button; clearing the search goes back to the chosen order. The logo
goes back to Top.

Newest is by skins.id. Random is ordered by skins.id times a seed, mod a
prime: one seed per visit, so the order holds between the grid and the feed
and on Back, and a reload shuffles it again. Both leave out skins reviewed as
NSFW, like the museum's random picks. The feed follows whichever order it was
opened from (listPosition now carries the sort).

Each page of Newest or Random sorts the whole museum: ~125ms for the first
page on production data, ~220ms deep in, against ~1ms for Top's first page,
which reads museum_sort_order in its stored order. Fine for now; the fix is a
numbered table of these skins, written by the nightly museum-order job.

Event schema version 9: lists carry `sort` (null for a search) on
session_start, view_switch, skin views, grid_impressions and grid_skin_click,
and there are sort_open and grid_sort events. Grid positions mean something
different in each order, so /sorts and the dashboard's position stats keep
to Top.

Both menus now close on a pointerdown outside them, not a mousedown: the
search box's buttons cancel their pointerdown (to leave focus where it is),
which also cancels the mousedown, so opening the sort menu (or tapping the
clear button) left the "…" menu open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:37:17 -07:00
Jordan EldredgeandClaude Opus 5.5 19a0d0103b Replace the museum's bottom menu bar with a floating bar; drop likes
The bottom menu bar (Grid and Feed buttons, plus a menu) is gone. Every page
now has a bar floating at the bottom: the museum's logo, then the grid's
search box or, elsewhere, a Back button, then a "…" menu (About, Upload,
Feedback, Donate, GitHub). Back goes to the previous page in the tab,
animating into the grid if that's where it was, or offers "Browse skins"
when there's nothing on the site to go back to. It replaces the feed's back
arrow beside each skin's title. On the grid, the logo scrolls back to the
start of the museum.

The feed's like button is gone too. It wasn't saved anywhere: it logged
skin_like and added one to the skin's Twitter like count until the page
reloaded. Over Oct 14-26 it got 5.8 likes per 1,000 feed views from 2.3% of
viewing sessions (downloads: 134 per 1,000, half of sessions), and among
skins with similar download rates, being liked didn't predict more
downloads by others. The feed no longer fetches tweets for like counts.

Event schema version 8 covers both: no "menu" view switches, menu_open
counts the "…" menu, "back" means the Back button, and no skin_like.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:13:52 -07:00
Jordan EldredgeandClaude Opus 5.5 2abd828f15 Search the museum with Algolia again
Our quota reset on Oct 16 (billing periods run from the 16th to the 15th),
and the limit is ~300k search requests a period. Before the Oct 5-12 spike
we used ~200k; the new grid logs ~6k debounced searches a day, ~180k a
period. Both search boxes already wait for SEARCH_DEBOUNCE_MS, and the grid
falls back to SQLite if Algolia fails, so this is just flipping USE_ALGOLIA.

A page loaded with ?query= still renders the SQLite results on the server,
so crawlers and link previews don't spend Algolia quota.

Algolia writes no longer throw. Running out of quota last time made uploads
fail, so a failed write is now logged and skipped. And algolia_field_updates
is only written when the write actually happened: while writes were off it
went on recording updates that never reached Algolia, which hid ~2,500
skins missing from the index.

The mark_skin_nsfw test enables writes itself; it had failed since writes
were gated behind ALGOLIA_ENABLED.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:43:36 -07:00
Jordan EldredgeandClaude Opus 5.5 cfdbf4d36b Stop each skin load keeping a teardown until Webamp is disposed
storeHas(), which skinIsLoaded() waits on for every skin, registered a
teardown with the instance's Disposable and never removed it once the
wait was over. The museum keeps one instance for as long as the page is
open, so each skin it showed kept a few closures and a promise, about
0.26 KB, forever. Unsubscribing from onTrackDidChange() or
__onStateChange() left a teardown behind the same way.

Disposable gains child(): a scope with one owner, disposed along with
it, that removes itself from its owner when disposed on its own. Each
of these now gets one. Disposing twice is now a no-op rather than an
error, since a child can be disposed both on its own and by its owner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:42:07 -07:00
Jordan EldredgeandClaude Opus 5.5 0e313b8b96 Let go of audio, media keys and object URLs when Webamp is disposed
Disposing an instance left several things holding on to it or to what it
had loaded:

- The AudioContext stayed open, keeping its audio thread running and every
  node in it, including the <audio> element, alive. It's now closed.
- The <audio> element kept what it had downloaded and decoded. Its source
  node is now disconnected and its src removed and reloaded.
- The media session's action handlers closed over the instance, so the
  media keys kept controlling it and kept it alive. enableMediaSession()
  now returns a teardown that clears them, unless another instance has
  taken the session over since.
- Object URLs made for blob tracks and album art were never revoked. They
  now go through a createObjectURL extra that revokes them on dispose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:42:07 -07:00
Jordan EldredgeandClaude Opus 5.5 7734cd3c7c Make the museum's search box stand out from the grid, with a proper clear button
The search box was mostly see-through, with a faint edge and a dim
placeholder, so it blended into the busy screenshots behind it. It's now
nearly opaque, with a deeper shadow, a light edge and a magnifying glass.

Chrome's own clear button was small, blue and only appeared on hover, and
Firefox has none. In its place is a round X that shows whenever there's a
query, with the spinner beside it. Tapping it doesn't move focus, so it
doesn't bring up the keyboard on mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:38:48 -07:00
Jordan EldredgeandClaude Opus 5.5 f01369fc2d Give /about and /feedback a title, description and link preview
They had none in either UI: a shared link showed a bare URL, and the tab
showed the path. Both preview with the museum's image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 12:37:46 -07:00
Jordan EldredgeandClaude Opus 5.5 38c47ddbfb Make the museum's link preview images itself, much lighter and crisper
/og/museum.jpg, /og/search.jpg?query= and /og/skin/:md5.png replace the
skin-museum-og service on Vercel, with the same layouts. A skin's image
goes from about 650 KB to about 65 KB, and the grids from about 1.2 MB to
about 160 KB:

- The renderer scales images smoothly whatever the CSS says, blurring the
  pixel art into colors PNG compresses badly. Screenshots are now scaled
  with nearest neighbor to the size they're drawn at, and drawn 1:1.
- Its PNGs are poorly compressed, so they're re-encoded with sharp, and
  the grids (whose screenshots are drawn rotated) as JPEG.

They come straight from the database rather than through GraphQL, and are
cached for a few hours per server process, as skins.webamp.org isn't
behind a CDN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 12:29:53 -07:00
Jordan EldredgeandClaude Opus 5.5 9df8030cf5 Send link preview metadata in <head> to Mastodon and Bluesky
Next streams metadata into the body for crawlers it doesn't know, and
Mastodon, Bluesky and other link preview crawlers read only <head>, so
their previews had no title or image. Add them to Next's list of crawlers
that get it in <head>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 12:11:35 -07:00
Jordan EldredgeandClaude Opus 5.5 3cc53620ef Leave NSFW skins' screenshots out of their link previews
An NSFW skin's page covers its screenshot until you choose to see it, so
its preview image shows only its name, as the grid images already left
NSFW skins out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 12:05:29 -07:00
Jordan EldredgeandClaude Opus 5.5 63483a92b3 Use the OG service's production URL for link preview images
skin-museum-og-captbaritone-webamp.vercel.app stays on whichever
deployment it was last pointed at; skin-museum-og.vercel.app follows
each production deploy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 11:59:44 -07:00
Jordan EldredgeandClaude Opus 5.5 2b40a01563 Fix the skin museum's link preview images
The OG service fetched skins from api.webampskins.org, which loops on
redirects (Cloudflare reaches the server over HTTP, and Apache redirects
HTTP to HTTPS), so every image was a 500. Fetch from skins.webamp.org.

Search images also failed for searches that match a modern skin, which
search_skins returns as {} with no screenshot. Leave those out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 11:59:44 -07:00
Jordan EldredgeandClaude Opus 5.5 c849a31eff Add /sorts, an unlisted page comparing ways to sort the museum
Shows the museum's order next to a random one and four inferred from the
event log: direct loads, clicks and downloads per grid view (against what
an average skin gets at the same positions), and downloads per feed view.
Public, but not linked and not indexed.

Skins opened from it (or from /dev) aren't logged, so looking through a
ranking doesn't count as direct loads of its skins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012wifNHvrLbRC9MT5mRuyPW
2026-10-17 11:40:45 -07:00
Jordan EldredgeandClaude Opus 5.5 e974abe9ad Tell sessions discarded in the background from ones cut off
The dashboard counted every session without a session_end as "discarded
in the background", 21% of them. Most were: the page was hidden, which
sends everything queued, and the browser later discarded it, which sends
nothing. Those lost no events. The rest stopped mid-activity with no hide
either (the browser quit or crashed, the app was killed), and may be
missing their last events.

Sessions now end one of three ways (session_end, hidden or cut_off), and
the dashboard shows the last two separately: 12.4% and 9.6% today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 13:35:39 -07:00
Jordan EldredgeandClaude Opus 5.5 b693283c46 Call the menu's Ko-fi link "Donate", one word like the rest
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 12:15:24 -07:00
Jordan EldredgeandClaude Opus 5.5 99eb624854 Add a Ko-fi link to the museum's menu, and log opening the menu
The bottom bar's menu gets "Support the museum", linking to Ko-fi for
thank-you donations, and the repo gets a FUNDING.yml so GitHub shows a
Sponsor button for the same page.

Opening the menu wasn't logged, only following a link in it, so we
couldn't tell how many people look at the menu. Add menu_open (event
schema version 7). Following the Ko-fi link is already logged as a
link_click with its href, like the menu's other links.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:13:54 -07:00
Jordan EldredgeandClaude Opus 5.5 72858ec18e Rank skins whose file names match a search first
FTS5's rank (BM25) scores a match lower the longer the whole row is, so
a long readme buries a skin even when the search is for its own file
name: "Atlas" put Skinner_Atlas.wsz, with a 49 KB readme, 136th of 165.
Weighting the file_names column doesn't help, since the length penalty
is for the whole row. So order skins with every word of the search in
their file names first, then by rank. It's now 7th, and the broadest
search ("winamp") takes 141ms instead of 44ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:55:43 -07:00
Jordan EldredgeandClaude Opus 5.5 66095c0b9c Keep the SQLite search index up to date
The search_index migration was one multi-statement knex.raw(), and the
sqlite3 driver runs only the first statement of a string, so all it did
was create the skin_search table. Production's index was filled by hand
once and never got the triggers meant to maintain it, so none of the
2,334 skins added since (ids after 220686) can be found by search, and
13 rows are for skins since deleted.

A new migration does the rest, one statement at a time: the triggers
(plus one to drop a deleted skin from the index), then adds the missing
skins and removes the orphans. It can be rerun. The original migration
now creates just the table, which is all it ever did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:55:39 -07:00
Jordan EldredgeandClaude Opus 5.5 00c7349985 Open the event dashboard in production, behind a token
/dev/events was development-only. In production it's now served to a
browser that has signed in at /dev/login with DEV_DASHBOARD_TOKEN (set in
.env.production.local on the server), which sets a year-long HttpOnly
cookie. Everyone else, and everyone when no token is configured, gets a
404. The middleware checks /dev/*, and the page checks again in case the
matcher ever misses it. The token is posted from a form, not put in the
URL, so it stays out of access logs and history.

deploy.sh --classic/--new rewrote .env.production.local with just
MUSEUM_UI, which would now drop the token; it replaces that one line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:05:36 -07:00
Jordan EldredgeandClaude Opus 5.5 a7997c82d3 Skip region.txt sections for windows Webamp doesn't clip
Since clip paths started carrying their window's size, a region.txt with
a section other than the four Webamp clips (say a stray [Playlist])
crashed rendering: the size lookup came back undefined and destructuring
it threw "undefined is not iterable". Skip those sections, as was
effectively the case before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:56:00 -07:00
Jordan EldredgeandClaude Opus 5.5 19e3195a99 Add a Downloads section to the event dashboard
Download rate per view and session, time from opening a skin to
downloading it, downloads per session, rate by device, referrer and
depth into the feed, and the most downloaded skins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 16:04:48 -07:00
Jordan EldredgeandClaude Opus 5.5 84e012ed12 Hide skins' alt text while their screenshots load in Firefox
Firefox draws an image's alt text until it loads, so the grid filled
with skin names in link blue. Make it transparent (screen readers still
read it), in the grid and on the feed's screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 16:04:48 -07:00
Jordan EldredgeandClaude Opus 5.5 c5e24df075 Spot bot traffic in the museum's events, and dashboard fixes
- Record each session's user agent (a new session.user_agent column, from
  its first batch), and in session_start whether the browser is
  automated (navigator.webdriver), whether the page was prerendered, and
  whether it was visible. A prerendered page sends nothing until it's
  opened, so it no longer leaves a session nobody saw. Event schema
  version 6.
- Don't log the Download button's blob: link as a link followed out.
- Dashboard: a Traffic filter (likely people by default) that leaves out
  suspected bots by rule (no start, bot user agent, automated browser,
  never seen) and says how many each caught; merge searches typed letter
  by letter; ignore blob: links already logged; line up bars and
  histogram columns; reopen the pulled copy when it changes. The pull
  script copies the session table too.

The migration has to run on the server before this is deployed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 15:19:56 -07:00
Jordan EldredgeandClaude Opus 5.5 13bc530ee1 Build the repo's Webamp when deploying the museum
The museum depends on this repo's Webamp (workspace:*), whose package
entry points are built files. The server never built them, so the
museum's build silently fell back to a published [email protected] elsewhere
in node_modules and none of Webamp's later changes reached it. The
deploy now builds the two bundles the museum imports, with the same
heap cap as the museum's build.

Webamp's rollup script can now build only named bundles
(`node ./scripts/rollup.mjs webamp.bundle.min.mjs ...`); with no
arguments it builds them all, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 15:07:00 -07:00
Jordan EldredgeandClaude Opus 5.5 760d084ae5 Scale region.txt window shapes with the window
Webamp clips the main and equalizer windows with SVG clip paths built
from a skin's region.txt, in pixels. When a page scales Webamp with CSS
zoom, Firefox applies those clip paths at the unscaled size (Chromium
scales them), so only part of each window showed. Define them relative
to the window's own box (clipPathUnits="objectBoundingBox", with the
pixel polygons scaled down by the window's size), so they scale with
the window however it's scaled. Unchanged at normal size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 15:05:23 -07:00
Jordan EldredgeandClaude Opus 5.5 931f8468f9 Use Webamp in the feed in an explicit mode on touch screens
Guessing whether each touch on Webamp was meant for it or for scrolling
the feed (and replaying synthetic events into Webamp) was never reliable
across browsers. Now, on touch screens, Webamp is either seen or used:

- Seen (default): a transparent shield covers it, so every swipe scrolls
  the feed and no touch reaches Webamp.
- Used: tapping the skin removes the shield and stops the feed scrolling,
  and Webamp gets every touch unaltered. Done, tapping outside the skin,
  or Escape go back to scrolling.

Mouse input is unchanged. Event schema version 5 adds webamp_use_start
and webamp_use_end (how long, and how it ended); the dashboard shows them.

Also make the grid's hover glow switch instantly, drawn as an overlay
rather than a filter on the image: the animated filter put the tile on
its own GPU layer, which re-sampled it and its neighbors' pixels
slightly differently.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 13:45:20 -07:00
Jordan EldredgeandClaude Opus 5.5 d6cb222981 Mix random skins into the museum, log more, and add an event dashboard
The plan is to ship with thorough logging and no ranking algorithm, look
at the data, and then decide what a ranking should learn from.

- The museum list (grid and feed) keeps the museum's top 10, then one
  place in five is a random skin from anywhere in the museum (not NSFW),
  picked by the server for each page (museumList.ts). Searches have none.
  The server marks the random skins, and every view, grid impression and
  grid tap is logged with its source ("list" or "random").
- Event schema version 4 adds grid_impressions (tiles on screen once
  scrolling stops), link_click (links out of the grid and feed),
  skin_load_failure, and skin_view_end's readyMs (how long Webamp took
  to show the skin). Webamp failing to load a skin is detected and the
  screenshot stays up instead of the previous skin.
- Remove the ranking task (tasks/computeScrollRanking.ts) and its CLI
  command: nothing derives a rank from events for now.
- A dev-only dashboard at /dev/events reports on the event log (session
  length, feed retention, dwell, outcomes by candidate signal, random vs
  list, position effects, coverage, grid, Webamp, data quality). It reads
  the dev database or a copy of production's events made by
  scripts/pull-events.sh, and 404s in production.
- Fix a scroll event that lands just after tapping a grid tile replacing
  the tapped skin as the feed's place in the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 13:04:59 -07:00
Jordan EldredgeandClaude Opus 5.5 b79a49b44d Fix tapping a feed readme to expand it
It looked for its page by an attribute the feed no longer sets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 11:21:16 -07:00
Jordan Eldredge c85e44c96b Add setTracks instance method (#1380)
* 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
2026-10-15 10:06:33 -07:00
Jordan EldredgeandClaude Opus 5.5 9999094dde Make the new UI's grid and feed two views of the same list
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
2026-10-15 10:05:25 -07:00
Jordan Eldredge 37523b3e2e Add onMediaError instance method (#1379)
* 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
2026-10-15 09:51:40 -07:00
Jordan Eldredge 84f9156c22 Add changelog entries for merged accessibility and types fixes (#1382)
Un-comment the Unreleased section and note #1362, #1363, #1364, #1365 and
2026-10-15 09:37:16 -07:00
Jordan Eldredge 90877cf5ad Expose toggle button state with aria-pressed (#1383)
* 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
2026-10-15 09:34:49 -07:00
Jordan Eldredge 83177e4922 Pin npm to v11 in the release job (#1378)
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+).
2026-10-15 09:24:38 -07:00
Jordan EldredgeandClaude Opus 5.5 4b59984006 Keep the feed's bottom fade fixed above the menu bar
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]>
2026-10-14 22:47:38 -07:00