feat(console): make the recordings list a list again - #141
Merged
Conversation
A card on /recordings was a container for a multi-track player, a metadata block and three buttons, and it expanded in place. That made every card a worse card in order to be a mediocre player, and a page of them unscannable. It is a row now. One bordered surface with hairline rules, a date column down the left edge, and the whole row is the link to the recording. Nothing plays here: audio, spectrograms, per-track transports and the protocol link all live on the recording's own page, where there is room for them. What a row keeps is what tells two meetings apart -- when, where, who and how long -- plus the two things the filter above asks about and could otherwise not be checked against the results: whether a protocol exists, and the reader's own tags. What it drops is the player, the Listen/Close toggle, the separate Open link, and the enumerated list of everybody who was in the channel without consenting. An unresolved channel no longer reads as a name. `channelLabel` answers "Channel 1240377558927872021", and eighteen digits set as a heading are what made the old cards unreadable; `channelNaming` heads the row with an absence instead, in the muted role and in normal weight, and demotes the id to a subordinate line -- kept, because it is the only handle anybody debugging a channel that has left the guild has. The tag buttons and the search box become one `UiChipInput`: chips out as `?tag=`, words out as `?q=`, the line between them kept in the value. The dates become `UiDatePicker` and the protocol control `UiSelect`. `UiDatePicker` grows a `granularity` prop for that. A recordings filter is a pair of inclusive UTC days -- `sturnus.console.filters` says so and reads them with `date.fromisoformat` -- and an instant here would claim a precision no request carries and, being the browser's offset, would differ from the one a server render attached, on exactly the filtered links this page exists to make shareable. Every state stays: error, first-load skeleton, past-the-end, no-match, empty, and the live-region page summary.
This was referenced Aug 23, 2026
TheMeinerLP
added a commit
that referenced
this pull request
Aug 23, 2026
The list of sessions was the one part of this page that was not a section: a bare heading, three loose paragraphs and a stack of cards, growing until there was nothing to navigate by. It is now two sections, split by the one distinction that decides what can be done to a row -- whether the API gave it a priority. Both are paged five rows at a time, because a row here is a paragraph rather than a line and twenty of them is the complaint. The queued half can be reordered. A row is picked up, moved and put down, and the arrow keys and a mouse drag produce the same grab and therefore the same request -- one per gesture, expressed relative to a neighbour. Home and End send a session to the front or the back of the whole queue, across page boundaries a mouse drag cannot cross, and the handle carries that sentence as its own description rather than leaving it to be discovered. The two quick actions say what they will do before they do it: that they reach sessions the list was cut short of showing, that nothing is ever moved forward so this holds work back globally, and that the other button does not undo them. Shortest-first additionally says that an unmeasured recording ranks last, which is why it does very little on a queue of fresh sessions. A 409 is not an error. Somebody else moved something, nothing was written, and the body carries the queue as it now stands -- so the request asks for that body, redraws from it, and says so in a sentence. The rows follow #141 at last: an unresolved channel reads as an absence with its id demoted, rather than eighteen digits set in semibold as a heading.
TheMeinerLP
added a commit
that referenced
this pull request
Aug 23, 2026
The list of sessions was the one part of this page that was not a section: a bare heading, three loose paragraphs and a stack of cards, growing until there was nothing to navigate by. It is now two sections, split by the one distinction that decides what can be done to a row -- whether the API gave it a priority. Both are paged five rows at a time, because a row here is a paragraph rather than a line and twenty of them is the complaint. The queued half can be reordered. A row is picked up, moved and put down, and the arrow keys and a mouse drag produce the same grab and therefore the same request -- one per gesture, expressed relative to a neighbour. Home and End send a session to the front or the back of the whole queue, across page boundaries a mouse drag cannot cross, and the handle carries that sentence as its own description rather than leaving it to be discovered. The two quick actions say what they will do before they do it: that they reach sessions the list was cut short of showing, that nothing is ever moved forward so this holds work back globally, and that the other button does not undo them. Shortest-first additionally says that an unmeasured recording ranks last, which is why it does very little on a queue of fresh sessions. A 409 is not an error. Somebody else moved something, nothing was written, and the body carries the queue as it now stands -- so the request asks for that body, redraws from it, and says so in a sentence. The rows follow #141 at last: an unresolved channel reads as an absence with its id demoted, rather than eighteen digits set in semibold as a heading.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns the recordings list back into a list. Playback moves off this page entirely, the cards become rows, and the tag buttons and the search box become one field.
The design argument
A card on
/recordingswas a container for aMultiTrackPlayer, a metadata block and three buttons, and it expanded in place to hold the player. That is why it listed everything: once a thing is a container, there is no reason for anything not to be in it. The result is a page where a hundred meetings cannot be told apart, because every one of them says the same eleven things.So the question was not what to delete. It was what a person scanning a hundred recordings is actually doing, which is looking for one meeting they already half-remember. They remember roughly when it was, which channel it was in, who was in it, and — sometimes — that it was the long one. Everything else they will find out after they click.
What stays, and why each earns it:
2026-08-21 14:05as one string makes the eye read eleven characters to find the two it wants, in every row; the day is on its own line now and the time recedes beneath it in the muted role.What was cut:
MultiTrackPlayeron this page. It still works; it is just not mounted here.Seven translation keys went with them, in both files.
A row, not a card
The brief asked for leaner cards. The leanest card is a row, and past a certain point a card stops being lean and starts being the wrong shape: a card is a container, and this has no parts left to contain.
Twenty rounded borders with nineteen gaps between them is twenty separate objects to look at. One bordered surface with hairline rules is one object with twenty lines in it, which is what somebody comparing dates down a column is actually reading. A page of twenty went from roughly 2 500 px of list to roughly 1 600 px, with the date column now the thing the eye tracks.
The row is the link, and looks like one
Not a row with an "Open" button in it. One
NuxtLinkwrapping the whole row: one stop for the keyboard instead of three, a hit target the width of the list, and a focus ring around the thing that is actually activated. It says what it is the way a link says it — the heading takes--action, underlines under the pointer, and a chevron sits at the trailing edge where a list of links puts one. The old card looked like a container and behaved like one; the accordion was how you found out it was not.That is also why the protocol is a word here and a link there. A link inside a link is not something a browser can express, and this row has one job. "Protocol" / "No protocol" answers the filter's question in words rather than in a colour — announced, not merely seen, which is the same call #138 made putting its needs-attention marker inside the tab's own label.
An unresolved channel reads as an absence, not as a name
channelLabelfalls back to"Channel {id}", and that is the complaint. Eighteen digits set as a row's heading read as the meeting's name — nobody has a meeting calledChannel 1240377558927872021— and a column of them is unscannable: the digits are all the same shape, they crowd out the date, and the eye stops at each one to check whether it means anything.channelNamingsplits the answer in two. A named channel heads the row with its name. An unnamed one heads it with "Unnamed channel" / "Kanal ohne Namen", in the muted role, in normal weight and italic — so it reads as the absence it is — and the id goes underneath as a subordinate line, the same treatmentUiSelectalready gives a snowflake through itsdetailslot.The id is demoted, not dropped. It is the only handle anybody debugging a channel that has left the guild actually has, and hiding it would trade one person's confusion for another's.
channelLabelitself is untouched, because/recordings/[id]is a sibling PR's.One field for tags and words
The bar had two mechanisms for one question — a row of toggle buttons and a search box beside them — and the question people actually ask is
#standup #migration the bit where the database fell over, which everybody typed into the box. It is oneUiChipInputnow: chips go out as?tag=, the words as?q=, and the line between them stays in the value, so nothing parses a tag back out of a sentence and nobody ends up with a tag called "the". Tags fromGET /me/tagsare suggestions under the caret rather than a permanent inventory above the list.Text typed and never committed is taken as the search, not discarded and not promoted to a chip. Discarding loses a search somebody wrote; promoting invents a tag they did not ask for — and since the API matches free text against their own tags anyway, an uncommitted word still finds what they meant.
The scope note is rewritten to stay true either way. It names the boundary rather than enumerating the columns, because the columns are due to grow and a sentence listing three of them goes quietly wrong the day a fourth is added: "The words beside them search what a recording is filed under — its channel, who was in it, whatever else names it." The half that must never change is stated hardest and unchanged: it never searches what was said, and why.
UiDatePickergrows a granularityThe dates go to
UiDatePickerand the protocol toUiSelect, as asked. The picker emitted an instant, because the one API that needed a date —POST /consents/.../revoke— answers 400 without an offset. A recordings filter is the other question:sturnus.console.filtersdocumentssinceanduntilas inclusive UTC days and reads them withdate.fromisoformat.Handing that filter an instant would be wrong twice. It would put "Sent as 2026-08-01T00:00:00+02:00" under a field whose request carries
from=2026-08-01, claiming a precision the API does not have — and, because the offset is the browser's and a server has a different one, a server render and the hydrated page would disagree about it on exactly the filtered links this page exists to make shareable. A day carries no offset, so neither failure is available.granularity="day"is a nativedatefield, a value that is the day itself, and no note underneath. It is a strategy object in~/utils/uiDatePickerrather than a ternary at each of the six places the component touches its value, and both shapes are tested./dev/uigains the state.The protocol select deliberately does not get the
'' → nulladapter the dates get: "Either" is a choice this filter can express and the API reads, so it is an option with a value. Mapping it tonullwould leave the trigger reading "Choose an option" for a filter that is perfectly well chosen — and "the ones whose document never got written" is only legible next to a stated "either".What it refuses to do
?page=.sr-onlylabels so the name reads as a sentence./recordings/[id],MultiTrackPlayer,admin/, or anything Python.Verified against a running page
Rendered end-to-end against a stub API at 1100 px and at 360 px, in both languages: the chips arrive from the URL, the day fields carry
2026-08-01with no offset note and no hydration divergence, "Either" reads as "Either", the unnamed rows head with the absence and carry the id below, and nothing scrolls sideways at 360 px. Every colour is a role token, sopalette.spec.tsguards it in both themes; no<style>block anywhere; the one animation kept itsmotion-reduce:.Checks
vitest run1372 passed, 43 files (46 new, one new file) ·lint·typecheck·build— all clean. Baseline onmainwas 1326 in 42.