223 Commits

Author SHA1 Message Date
khannurien
364c498a24 v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:05:29 +00:00
khannurien
c8bf0d583c v3: the tests learn to expect trouble, and seven bugs they now catch are fixed — a review of the two test-suite commits found them written mostly for the happy path; each fix below comes with the test that fails without it. Infinite scroll: a later page that failed turned loadingMore back off, which made a new IntersectionObserver, which reports a sentinel still in view at once — so an offline feed asked again as fast as the request could fail, on Search, Index, the followed feeds, user dumps and user playlists alike. useInfiniteScroll now waits 1s, 2s, 4s… up to 30s while the sentinel stays put, counted from the last load so re-renders don't restart it, and asks at once again when the sentinel leaves the view or is pushed down by new items; the fake observer in src/test can report an element in view on observe(), as a browser's does. Playlist covers: the row was updated and playlist_updated sent before the file was written, so a client fetching image?v=<new> in between cached the old cover (or a 404) under the new URL for an hour, and a failed write left image_mime set with no file behind it. The file now goes in first, written beside and renamed over, and only then does the row change. Playlist order: the server wanted every member named, but GET hides private dumps the requester can't see, so a playlist holding someone else's dump that went private — or one a moderator reorders around the owner's private dumps — could never be reordered. The requester now orders what they can see and hidden members keep their slots; the visibility rule lives in api/lib/playlist-members.ts, shared by GET, reorder and the socket. playlist_dumps_updated also sent every member's id, private ones included, to a public playlist's whole audience; each viewer now gets the ids they can see, plus a dumpCount of all members so playlist cards keep the server's count. File dumps: replacing the file and changing visibility took two requests, so a failed second one left the new file live under the old visibility; PUT /api/dumps/:id/file takes isPrivate and applies both in one UPDATE, and it now broadcasts like any edit (it sent nothing, so other viewers never saw a new title). Notifications: one that arrived while the list was loading was dropped, then marked read by the read-all after the load, so it never showed; it's kept and merged in, once. Ranges: bytes=0-0,5-6 got a 416; several ranges, another unit or an unparseable header are ignored now and the whole file sent with a 200, as RFC 9110 allows — the old test asserted the 416.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:13:57 +00:00
khannurien
54e86c4366 v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened.

Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it.

Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:35:41 +00:00
khannurien
25601edaf8 v3: the backend gets a test suite, and the bugs it turned up are fixed — deno task test runs 186 tests over api/ in about 18s, against a throwaway database and uploads directory (GERBEUR_DB_PATH and GERBEUR_UPLOADS_DIR now override the defaults; api/test/setup.ts sets them before any app module loads), with requests sent through the whole middleware stack in-process via app.handle, outbound fetches and DNS stubbed, and the socket tests on a real Oak listener since an upgraded request has no response for handle() to return. It covers auth (token purposes, revocation after a password change, live roles, login rate limiting, invites claimed once even under parallel registrations, password resets), dumps end to end (create, privacy, by-url matching on the canonical key, backlinks, categories, edits, file replacement, thumbnails, refresh, delete), file serving headers and ranges, attachments, avatars, comments and likes, playlists, follows and feeds, notifications and mentions, categories, search, chat cursors and read markers, link previews and the SSRF guard on every redirect hop, the Bandcamp and YouTube resolvers, migrations (a fresh schema is left untouched; the data rewrites in 0004/0005 and 0010–0013 do what they say), CORS, OG tags, static caching, and the WebSocket protocol. Fixed along the way: an unknown category id on a new dump returned 400 but kept the dump row, and for a file dump deleted the file while keeping the row; an edit or file replacement with one was half-applied before the 400 — categories are now checked before anything is written. A suffix range (bytes=-3) was read as an end offset and got a 416 instead of the last three bytes. Reordering a playlist accepted a list naming a member twice. verifyPassword threw on a stored hash without a salt instead of answering false. And a request with no Accept-Encoding, which RFC 9110 lets a server treat as accepting anything, was sent the brotli sibling; curl and bots now get the plain file.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:31:05 +00:00
khannurien
16b510764c v3: the frontend ships compressed, caches for good, and stops reaching for other people's servers — a review of the bundle's size found the server was the biggest cost. Oak's send() compressed nothing and sent every hashed chunk with max-age=0, so a first visit to the home page moved about 710 KB of JS and CSS where 200 KB would do, and every later one revalidated some thirty files. The build now writes .br and .gz next to every JS, CSS, JSON and SVG file (a precompress plugin, brotli 11), which send() already serves when the browser accepts them; /assets and /emojibase are cached for a year as immutable, index.html is no-cache, and static responses carry Vary: Accept-Encoding. A missing file under either prefix is a 404 rather than index.html with a 200, which an open tab after a deploy used to try to run as a script; the app now reloads on vite:preloadError, at most once a minute. manualChunks sent everything in node_modules to one vendor chunk, so react-hook-form and frimousse loaded with every page though only the lazy form and editor chunks use them, and its markdown rule missed hast, vfile, unified and property-information while every feed loads Markdown through DumpCard anyway; only react, react-dom, scheduler and react-router get a chunk of their own now, the rest is left to the bundler. Up-front JS goes from 133 to 96 KB gzip, the home page from 178 to 160. Fonts: index.html blocked rendering on a Google Fonts stylesheet, opening two more hosts and handing each visitor's IP to Google; they come from @fontsource now, with src/fonts.css declaring only the latin and latin-ext faces (the packages' own CSS declares every subset, which added 6 KB gzip to the stylesheet), so Vite hashes the woff2 files into /assets and a browser still only downloads its theme's font. The families are the variable builds ("Saira Variable" and so on); Fraunces keeps its opsz, SOFT and WONK axes. Emoji: frimousse fetched emojibase-data@latest from jsDelivr, unpinned and third-party; emojibase-data 17.0.0 is a dependency now, its data and messages for each lingui locale are emitted under /emojibase/17.0.0 (served from node_modules in dev), and the picker is pointed there.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:44:08 +00:00
khannurien
bf28e49c34 v3: left and right step through emoji results too — with a :query picker open, only up and down went to the grid; left and right moved the caret in the textarea, so the first press of either did nothing visible. Every arrow now steps into the grid, landing one along from the automatic first match; Escape gives the caret back.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:29:44 +00:00
khannurien
85d0703077 v3: the editors stop fighting the person typing — a review of every edit box turned up an Enter that sent a chat message mid-IME, an emoji picker that grabbed focus on ":D" and never went away, markdown that folded every line break, and limits that were counted but not kept. TextEditor: an Enter confirming an IME candidate no longer also sends, saves or picks. The : trigger wants two letters (Unicode ones too), not one, so emoticons, "note:" and "12:30" type through, and it keeps focus in the textarea — the picker's search follows what's typed, Enter or Tab takes the best match (a name equal to the query, then one starting with it, rather than frimousse's first in emoji order, which made ":rocket" an astronaut), the arrows step into the grid and typing there carries on in the textarea, and Escape closes just the picker, not the chat around it. It closes on a press outside, which it never did, and neither autocomplete fires inside code or a ``` fence. A ☺ button in each editor's corner opens the picker for mouse users, with a skin-tone selector that's remembered, and its search takes focus only once the picker is placed (a hidden field can't). Emoji, mentions and pasted images go in through execCommand, so Ctrl+Z takes back the insertion instead of wrecking the undo stack. Uploading an image made the textarea disabled, which dropped focus for good; it's read-only now, says "Uploading image…" under the box rather than only in an empty one's placeholder, reports a failed upload instead of swallowing it, and holds Enter until it's done. Any dropped file is caught — a non-image used to be opened by the browser in place of the page, draft and all — and a paste carrying text (a spreadsheet's cells come with a picture of themselves) pastes the text. The mention list follows the caret (a stale lookup can't close a newer one, lookups abort), wraps, scrolls its selection into view, closes on blur, and is a real listbox with aria-activedescendant. The counter shows from 80% of the limit by default rather than a standing "0 / 5000". Limits: chat Send and edit Save refused nothing over 2000 and let the server bounce it; they're disabled past it now. RichTextField's bare maxLength rule failed without a message, so an over-long "Why?" made Save do nothing and say nothing; it says so now. Inline editors answer Escape through a new onCancel (comment reply and edit, profile description, playlist description and title), the playlist editor takes Ctrl+Enter and can't be saved without a title, and saving a comment unchanged no longer stamps it edited. Markdown: a single newline is a line break, so a message written over three lines stops reading as one; links into the app route in place instead of opening a tab; headings are sized for prose instead of 2em page titles, and dropped from card teasers; GFM tables scroll in their own box and task lists lose the bullet; images load lazily. A chat message of only an emoji or three is shown large. Themes: brutalist and geocities give the new buttons no chrome, and geocities' ghost-button list, split in two by the placeholder-thumbnail rules in 1cb904d, is whole again, so its modal ✕, emoji cells and the picker's ✕ are flat as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:24:30 +00:00
khannurien
5fd1d364da v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:15 +00:00
khannurien
bfce3186f8 v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:24:38 +00:00
khannurien
2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00
khannurien
3a09d0e354 v3: a journal headline stays readable when you point at it — over an image card the title rests white on the scrim and hovers in the theme's accent, which over a busy or bright thumbnail could sink into the picture, and in nyt's light scheme went black on black. A hovered title now picks up a backing plate of its own, grown around the text by padding that an equal negative margin cancels, so nothing shifts, and hugging the headline rather than spanning the card. The plate is a dark glass by default and each theme reskins it through --journal-title-hover-* properties: smooth frosts it in page colour so terracotta on cream and jasmine on Prussian Blue stay the pairs, kanagawa sets it on sumiInk with a selection edge, nyt in square flat ink, brutalist as a solid accent stamp with a hard offset shadow, geocities as a neon marquee, melange as a coffee pillow in warm shadow. nyt's unread dot, inked in the theme's black accent, goes white over a photo too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:53:39 +00:00
khannurien
c7ce31c1bd v3: a feed card opens in a new tab on a middle click, wherever you point — the cards are clickable elements rather than links, so only the title, the one real anchor, answered a middle click; the rest of the card, the play button included, did nothing, and a middle press there started the browser's autoscroll instead. A shared useDumpCardLink now gives DumpCard and JournalCard an auxclick that opens the dump in a new tab, with the middle press's default suppressed so autoscroll doesn't swallow it, and a ctrl/cmd/shift click does the same — caught ahead of the play button, so a modified click on a playable card opens it rather than playing it, while a plain click still plays. Anchors inside a card keep their own target: the title, category chips, and a source link's ↗ still open what they point at.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:39:21 +00:00
khannurien
73b80af08e v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 20:36:49 +00:00
khannurien
b313f70d31 v3: chat mentions stop going missing, and live events get a light sound — the server skips a mention notification for anyone with the chatbox open, on the grounds that they're reading it, but "open" also meant a chatbox left open in a background tab, and the same modal cleared the unread badge on every arrival, so a mention there left no bell, no badge and no trace. Reading the chat now means open and on screen: the focus flag is re-announced on every visibilitychange, and the unread count only clears while the tab is visible. A reconnect with the chatbox open refetches the latest page, since whatever was said while the socket was down never reached the live buffer. The server's mention regex excluded a backslash and the letter w rather than word characters (\\w inside a class), so an email address mentioned its domain; it now matches the autocomplete's rule. On top of that, sounds synthesised with Web Audio — a soft blip for chat you aren't looking at, throttled, and a two-note chime for notifications and for a mention you're reading live, which gets no notification of its own — claimed through localStorage so several tabs chime once, with an on/off toggle under a new Notifications section in settings. The tab title carries the unread count, composed with the page title in useDocumentTitle so navigation doesn't drop it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 19:25:19 +00:00
khannurien
c9fb4450ad v3: the cards under "related" and "in collections" come back from transparent — both lists lighten a card by dimming it and tightening the box, and also blanked its fill, which assumed the border was what drew the card. A theme is free to draw one with the surface alone: smooth and melange both set --card-border: transparent, so with the background gone there was nothing left — text floating on the page in smooth, a shadow around a hole in melange. The fill stays now and the theme's own skin comes through; the weight still comes down through the opacity, the tighter padding and the smaller type, which is where it belonged. nyt is untouched, its own flat-ink rule still winning the list back to hairline rules.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:39:42 +00:00
khannurien
7b12dcbe40 v3: the dump button keeps hovering once you point at it — a flat 1px jump was the whole of the header button's hover, so it now lifts and then rocks, tilting a couple of degrees either way over a shallow float. The three motions ride on three different properties — translate for the lift, rotate for the tilt, transform for the float — since that's what lets them compose rather than the last one declared winning outright; the tilt and the float run on periods that don't divide each other (2.6s against 3.4s) so the pair never lines up the same way twice and never settles into a metronome, and the pivot sits on the bottom edge so it reads as a head on a neck rather than as a button spinning about its middle. Arriving and leaving is what the machinery is for: the amplitude is a registered custom property the keyframes multiply through, so pointing at the button eases the rocking up from nothing while the lift is still settling, and leaving eases it back down under an animation that is merely paused — no switch from still to full throw, and no snap square when the cursor goes. Geocities and brutalist re-declare the whole transition shorthand on every button and neither list mentions translate, which outweighed the header's own rule and dropped the lift onto the frame it started on, so that rule takes a :not(:disabled) to win the weight back. nyt stays flat ink and brutalist goes on pressing into the page, both opting out by name; reduced motion keeps the lift and drops the rocking.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:47:22 +00:00
khannurien
e780a3c96f v3: kanagawa's headline stays readable on the scrim — over a photo the title rests white on black in either scheme, but hovered it took --color-accent, and Lotus's lotusBlue4 is picked to sit on warm paper: on the scrim it falls to about 3.8:1, worse where the gradient thins and the picture shows through. The ground over an image is Wave-dark whichever scheme is on, so the accent there is now Wave's too — crystalBlue, which is what the dark scheme already hovers to, so both schemes behave alike over a picture instead of one carrying a second paper-tuned blue. The overlay's resting vote button takes the same swap, ink and border, since it read the same token; the voted one keeps the true accent fill, which its pale label needs underneath it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:29:26 +00:00
khannurien
e4d50f047c v3: the journal feed breathes — one mosaic running unbroken down the page reads as a slab, so the composer now cuts it into sub-blocks, each its own grid with a themable rule between. A break can only fall where a row starts clean and isn't reached into from above, which the exact tiling makes cheap to test: every row closes flush, so the only boundaries ruled out are the ones a portrait or a hero straddles.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Two things set the rhythm, and neither is a row counter — that beats like a metronome. It comes due on a count of *cards*: a block is a set of stories and a hero is one story however many cells it takes, so a stretch thick with big footprints reaches the count over more rows than a stretch of squares does, and block heights end up tracking the size mix (measured: four to five rows through square-heavy runs, six to seven where art clusters). And the break *makes* its own section front rather than waiting for one, promoting the card that opens a new block to a hero. Waiting was the first attempt and it failed on the numbers — a banner lands at the start of a row on its own two to five times in thirty-six, so the preference almost never fired and a max-rows cap did all the work. Promotion always fits, since a break only falls on a row with all four cells free. A tail guard keeps the last cut from leaving a rule with a fragment under it.

The break sits between two grids rather than inside one: a grid item would be stretched to the 9rem row track, and out here its height is its own, down to nothing. Three custom properties are the whole contract a theme writes against — the air around it, the rule through it, an optional glyph centred on that rule — with `none` on the last two meaning the real absence of each, so a theme can remove the separation entirely rather than leave an empty band. Themes wanting something a rule can't be reach past the properties to plain CSS: nyt pairs hairlines in the grid's own pen and clips them to the art inset (which moves up to the feed, since a property on the grid can't reach a sibling); brutalist spends air around the 2px border it already divides everything else with; kanagawa takes a comment divider, geocities the rainbow its header ends on, melange a thinner band of the mélange. The page gutter moves to a feed wrapper so every block lines up on the same edges, and nyt's masking frame now runs once per block, which is what makes each of them read frameless and not just the feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:01:42 +00:00
khannurien
6449569b34 v3: the quote card's opening mark lands on the text — two things had it reading as a smudge in the corner rather than as the note's own quotation mark. The card clips its overflow, so hanging the glyph off the top edge never bled it past the corner; it just cut away the half carrying the ink. And a fixed right offset put what survived outside the text column, floating against the card's edge instead of lining up with the words it belongs to. So the whole glyph stays inside the card and is set against the very padding the note is set against — one custom property now, since nyt pads these cards more than the base theme does — and the top offset takes back the leading above the ink so the mark starts on the note's first line rather than below it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 07:46:30 +00:00
khannurien
a0738f5fc5 v3: the journal mosaic closes its rows — dense auto-flow was papering over a tiler that didn't tile: a 2-column banner meeting a 1-column gap wrapped, left a hole, and dense filled it by pulling a later card up out of rank order. So the composer now walks its own occupancy grid in reading order and clamps each footprint to the run of cells actually free to the right of the cursor (a banner that meets one cell becomes a square rather than wrapping), plus drops the second row of a portrait within a row of the end, where nothing is left to fill the cells beside it. Four columns close on every row, no gap to backfill, and the order read is the order ranked — dense stays only below the full width, where four-column tiling no longer describes the grid and a hole is the worse alternative. The page also leads with art now: when the hottest dump is a bare note, the first picture within three places of it takes the front instead, since the top few sit within noise of each other on score and which one opens a section front is an editorial call. nyt's frameless block follows: rules overshoot their cell so neighbours meet and read continuous, which leaves ticks around the outside of the grid, and one page-coloured frame as thick as the art inset cuts them all back at once — replacing the two strips that only masked the first column and first row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 07:46:24 +00:00
khannurien
27c55ea96f v3: melange joins the styles — savq/melange-nvim's palette taken verbatim (the a.* neutrals, b.yellow as the accent since it's the scheme's Function colour, the dusty c.* row as furniture), but built as a warm 70s print instead of a second editor skin: one cut of Fraunces with opsz pinned low, so a headline and the caption under it stay the same face; pillowy borderless cards lifted on warm sunlight shadow rather than held inside a line; the mélange itself — every desert hue blended once — laid along the bottom edge of the header; and grain over the whole page, which has to differ per scheme because soft-light grey noise is invisible on a pale ground: fine film grain on the coffee dark, a coarser multiply tooth on the plaster light, both switched through custom properties since light-dark() only carries colours. Light keeps the warmth in the page and the bleach in the cards — a.float underneath, tints above — since melange's own light bg reads grey next to its floats; the header takes a lighter shade of its own so it sits over the page as a board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:32:26 +00:00
khannurien
d70761ecae v3: youtube playlists get a tracklist too — the embed keeps playing the list, so the rows come from two halves: /api/youtube/playlist scrapes titles and lengths off the playlist page (current lockup rows and the older renderer, cached 6h), while the IFrame API attached to the iframe reports the position and takes row clicks and ⏮/⏭; the header names the playing video with the dump's title below it, like a bandcamp track; either half can fail — no scrape leaves the bare embed, no API leaves the rows as a table of contents — and the pause an API-enabled embed gives itself when the tab goes to the background is undone within a short window, so a later pause still stands
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:51:10 +00:00
khannurien
084ebcb627 v3: dump edit balances its two columns — the dump itself (preview, refresh, thumbnail, URL or replacement file) on the left, its presentation (title, why, categories, visibility) on the right, actions in a full-width footer bar so both columns end level; "Why?" stretches to absorb a taller preview on desktop
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:32:18 +00:00
khannurien
e9ebc3fee6 v3: tooltips ease in — a short hover delay, then a quick fade that rises and grows from the anchor on the independent translate/scale properties (so the inline placement transform stays put); reduced motion keeps just the fade
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:27:04 +00:00
khannurien
e2ed493bdd v3: play and open stop competing on a card — list thumbs carry a quiet corner ▶ badge that grows a touch on card hover and a touch more (with a soft dim) on the thumb itself, while the title's "open" cue stands down; link-out thumbs show ↗, compact audio gets its ▶; journal image cards move ▶ to a top-corner badge that settles while the title or vote is hovered; nyt image-card headlines stay white on hover instead of going ink-on-scrim
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:25:40 +00:00
khannurien
5205097bb6 v3: modals stop shifting the page — scroll lock moves to <html> and holds the scrollbar's gutter only while locked (and only if a bar was showing), shared by Modal and ConfirmModal with stacked-lock counting; scrollbars take a theme-tinted thumb, inner scrollers go thin and contain their overscroll
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:12:19 +00:00
khannurien
a9edd8fbb8 v3: invite panel reworked — "+ Invite someone" toggles a panel sized to the action row, easing in like the inline editors: link field fused with a full-width Copy, single-use/7-day hint, no longer clipped or faded on mobile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:06:21 +00:00
khannurien
1af719f838 v3: times theme stops faking what Instrument Serif lacks — no synthetic bold or small caps, italic datelines, spaced-capital kickers and tabs, hairline borders, a lifted small-type scale
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:57:43 +00:00
khannurien
f89d776601 v3: Kanagawa back in tone — Lotus keeps its yellow parchment, Wave borrows the syntax hues: comment-grey italic dates, string-green quotes, number-pink votes, keyword-violet brand
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:51:49 +00:00
khannurien
dde3fe8fe2 v3: type checking and lint pass clean — thumbnail placeholder takes file-dump props, no manual memo the compiler can't keep, no setState straight in effect bodies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:42:32 +00:00
khannurien
6026286bbf v3: journal becomes the default feed tab for visitors and for users who haven't picked one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:39:17 +00:00
khannurien
20d717f879 v3: new Kanagawa style, after kanagawa.nvim — Wave palette in dark, a cooled Lotus in light, all JetBrains Mono
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:37:18 +00:00
khannurien
b8af349ed7 v3: header feed tabs sit centred with the brand and buttons, with symmetric padding that keeps room for the focus ring
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:37:18 +00:00
khannurien
44fcfc5b77 v3: chat message list gets a slim scrollbar whose thumb only shows on hover or focus
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:27:12 +00:00
khannurien
8c3efe6c30 v3: inline edit forms and the narrow-viewport search row ease open instead of popping in
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:26:18 +00:00
khannurien
5e5f984d87 v3: journal image cards give the resting vote button white-on-glass ink so it reads over the scrim
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:17:56 +00:00
khannurien
decea1c614 devcontainer: update lock file
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 45s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:12:37 +00:00
khannurien
f7e7e68146 v3: smooth card hover border back to 2px, in a softened accent
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:11:44 +00:00
khannurien
7e9262d2b4 v3: smooth keeps its uppercase accent-pill tabs and flat, borderless cards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:10:53 +00:00
khannurien
65d5ed3471 v3: browser theme-color follows the smooth palette in light and dark
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:34:47 +00:00
khannurien
7c91e68cd1 v3: brutalist, geocities and nyt on the token system — single light-dark() palettes, radius scale instead of per-selector overrides
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:34:32 +00:00
khannurien
971cc4988e v3: modernized smooth theme — surface ladder, hairline borders, soft tabs, calmer accents, focus ring, AA contrast
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:25:53 +00:00
khannurien
21fb0c6e16 v3: design tokens — shared radius, type, elevation, motion and layer scales; single light-dark() palette for smooth
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:15:33 +00:00
khannurien
1adf7b04d7 v3: stop cards and modal bodies from clipping the editor's popups
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
The emoji picker and the @-mention list were absolutely positioned inside
.mention-textarea-wrap, so every ancestor with a clipping box cut them off —
and TextEditor sits inside plenty: .dump-card-inner, .journal-card and
.rich-content-card clip with overflow: hidden, while .modal-body and the chat
scroller clip just as hard with overflow-y: auto. The picker also always
opened upward, so a textarea near the top of the viewport pushed it off-screen.

Both popups are now portaled to <body> and anchored to the textarea with
position: fixed, which no ancestor can clip. The new useAnchoredFloat hook
flips to whichever side has more room, clamps horizontally on narrow screens,
and publishes the space left as --float-max-h so the list shrinks and scrolls
instead of overflowing. It re-places on scroll, on resize, and through a
ResizeObserver, since autoResize grows the textarea as you type.

z-index goes to 1100 to clear the modal backdrop, staying under tooltips.

Drops two point-fixes that only existed to work around the clipping: the
hardcoded upward flip for the chat composer's mention list, whose leftover
margin would now double the gap, and the claim that the chat modal's body
needed overflow: visible for the popovers' sake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:28:48 +00:00
khannurien
231559b3cd v3: denser dump cards in the two-column profile view
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m2s
The dump card is proportioned for the 860px feed; in a ~400px profile
column the title wrapped four lines deep, the meta row stacked, every
category chip took a line of its own and the vote button floated in the
middle of the resulting void. Re-proportion it for the column — smaller
thumb, title clamped to two lines, no teaser, meta and chips sharing the
line under the title — scoped to the width where the columns split.

Also stop the playlist card's inner from clipping its own hover: it
inherits overflow:hidden from the rule it shares with the dump card,
which needs it for the collapse animation. A playlist card never
collapses and its height is set by its 80px square thumb, so the clip
shaved the corners off the scaled thumb and sliced its lift shadow into
a hard-edged slab against the card border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:17:07 +00:00
khannurien
fb8364e24d v3: reworked posting — three panels, drafts, duplicate detection, upload progress
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Posting a dump was a single form where the important choices were the easiest
to miss. It is now three panels: link or file, why & where, playlists.

Composition:
- No more URL/File toggle. An empty panel offers both ways in at once and the
  kind follows what you actually did; a file dropped anywhere in the modal is
  accepted, not just on the zone.
- Categories and visibility get their own panel instead of a disclosure that
  read as optional, and the primary button stays "Next" until they've been
  seen. Visibility carries a real label now.
- The draft (link, title, why, categories, visibility) is mirrored to
  localStorage on every change and restored on reopen, so Escape or a stray
  backdrop click costs nothing. Only an attached file can't be restored, so
  that is the one case that asks before closing.
- URL dumps can carry a poster-supplied title instead of being stuck with
  whatever the page scraped, editable right under the preview.
- Multipart uploads go through XHR so there is a real progress bar and a
  percentage on the button, rather than 50 MB of silence.

Duplicates:
- New dumps.url_canonical column (+ index, backfilled by 0013) holding a lossy
  key that ignores scheme, www., trailing slashes, tracking parameters and
  YouTube share shapes. GET /api/dumps/by-url reads it, and the create form
  warns "already dumped by X" while it fetches the preview. Never blocking.

Fixes:
- /api/preview now reports whether the page was actually reached: a failed
  fetch still yields a hostname-only stub, so a dead link and a page without
  metadata used to render identically.
- The Web Share Target never worked. The manifest posts to "/", but the index
  redirect dropped the query string, so every Android share landed on the feed
  with nothing pre-filled.
- File dumps no longer take the extension into their title.
- The link field no longer autofocuses on touch, where it raised a keyboard
  over the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
2026-09-08 14:40:37 +00:00
khannurien
d76154d15d v3: keep pasted description images out of the dump kind and the card teasers
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
The dump modal's window-level paste listener looked at the clipboard files
before it looked at the event target, so an image pasted into the "Why?"
field was claimed twice: the editor uploaded it as an inline attachment,
and the modal switched itself to file mode with that same image as the
dump. Writing a URL dump with an illustrated description was impossible.
The target guard that already existed for text pastes now runs first and
covers files too — fields own their paste, and the URL input keeps its own
handler for the image-to-file-dump shortcut. Pasting onto the modal body,
where nothing else is listening, still starts a file dump.

Card teasers are line-clamped boxes, which an embedded image blows apart.
Inline markdown — the mode the dump and journal cards already ask for —
now drops img nodes, so a description image shows up on the detail page
and nowhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
2026-09-08 12:43:59 +00:00
khannurien
79b7adce8f v3: fix the queue and re-resolve edge cases in native bandcamp playback
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s
Re-resolving a stream is a network round-trip, and nothing checked that the
player still belonged to that item once it landed. Picking another dump —
or closing the player — while a resolve was in flight let the stale result
swap the queue back; from onError, which resolves with autoplay, it would
also start playing over whatever was chosen instead. A generation counter,
bumped by playQueue/advanceTo/stop, now drops any result that no longer owns
the player, with a separate counter owning the resolving flag so a
superseded resolve can't clear a newer one's spinner.

A track that stays dead after a re-resolve is one track, not one album: the
cooldown path now steps to the next entry and only falls back to the iframe
on the last one. A failed resolve still goes straight to the embed, since
that's an album-wide failure rather than a single bad URL. And a track that
has vanished from the release, or lost its streamable flag to Bandcamp's
free-play cap, no longer resumes track 1 at the dead track's offset — the
findIndex miss resets the offset instead of seeking past the end of another
track.

Clicking the playing row rewound it but never resumed: seekTo only moves
currentTime, so re-selecting a finished or paused track looked like a
no-op. It now resumes as well. Same idea in the compact rich-content card,
whose button advertises "Pause" while active but re-played on click — in
native mode that meant a fresh /api/bandcamp/tracks and a restart from
track 1. It pauses for streams now; embeds have no transport of their own
and keep restarting, as before.

playRichContent reports whether playback actually started, so a native-mode
Bandcamp page with no streams and no stored embedUrl — an artist root, a
/music index, a preorder — is no longer a dead click: the journal card
navigates to the dump as it used to, and the rich-content card opens the
source page.

Also: the tralbum fetch releases the response body on its two error paths.
The endpoint is unauthenticated, so random /track/<slug> URLs would leak a
connection per request while also defeating the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
2026-08-30 17:29:14 +00:00
khannurien
2606ef4ccc v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
The player now holds a queue instead of a single item, so albums play
through and single tracks share the same code path. Adds a "stream" item
kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs
for ~24h, so an item carries the page it came from and re-resolves itself
on error or on a stale restored session, falling back to the iframe embed
if that fails too.

Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a
meta tag the same way as site-name/site-emoji.

Also: player header gains artwork and a subtitle, volume persists across
queue advances and reloads, and cross-origin streams get a plain seek bar
rather than a waveform that can never decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
2026-08-22 12:14:28 +00:00
khannurien
eb323a8ba8 v3: try to autoplay media opened in global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
2026-08-22 09:25:57 +00:00