Skip to content

feat(console): make the recordings list a list again - #141

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/console-recordings-overview
Aug 23, 2026
Merged

feat(console): make the recordings list a list again#141
TheMeinerLP merged 1 commit into
mainfrom
feat/console-recordings-overview

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

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 /recordings was a container for a MultiTrackPlayer, 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:

  • The date, in a column, with the day set apart from the clock. This is the scanning key and the only thing that lines up. 2026-08-21 14:05 as 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.
  • The channel, as the row's heading. Where a meeting happened is half of what somebody remembers about it.
  • Who was recorded, three names then a count. Two meetings in the same channel on the same afternoon are told apart by who was in them and by nothing else. Three is what stays on one line at 360 px.
  • How long it ran. One number, tabular, and free — it is the difference between the five-minute one and the ninety-minute one.
  • Whether a protocol exists. This one is here because the filter bar above asks about it. "The meeting whose document never got written" is the question that control exists to answer, and a filter whose effect is invisible in the results is one nobody can check.
  • The reader's own tags. Same argument: they are what the chip field filters by.

What was cut:

  • The player. No MultiTrackPlayer on this page. It still works; it is just not mounted here.
  • The Listen/Close toggle and the accordion behind it.
  • The separate "Open" link, because the row is the link now.
  • The enumerated "Also in the channel, not recorded:" list. That is a fact about one session, not a way of telling two apart — and the recording's own page has room to say why those people have no audio. The one case that stays in the list is a session with no tracks at all, which is a surprise worth meeting before the click rather than after it.
  • The permanent row of tag buttons, which was an inventory of somebody's whole vocabulary sitting above the list they were trying to read.

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 NuxtLink wrapping 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

channelLabel falls 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 called Channel 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.

channelNaming splits 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 treatment UiSelect already gives a snowflake through its detail slot.

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. channelLabel itself 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 one UiChipInput now: 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 from GET /me/tags are 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.

UiDatePicker grows a granularity

The dates go to UiDatePicker and the protocol to UiSelect, 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.filters documents since and until as inclusive UTC days and reads them with date.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 native date field, a value that is the day itself, and no note underneath. It is a strategy object in ~/utils/uiDatePicker rather than a ternary at each of the six places the component touches its value, and both shapes are tested. /dev/ui gains the state.

The protocol select deliberately does not get the '' → null adapter 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 to null would 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

  • It loses no state. Error, first-load skeleton, past-the-end, no-match, empty and the live-region page summary are all still there and still distinct. The skeleton is six row-shaped blocks rather than four card-shaped ones, because a row is about half the height.
  • It does not make a filtered list unshareable. Every control still writes to the URL and nothing else, and changing a filter still drops ?page=.
  • It does not lose the row's description for a reader who cannot see the columns. The row is one link, so its accessible name is the whole row; the parts that are only legible from their position — who was recorded, the tags — carry sr-only labels so the name reads as a sentence.
  • It does not touch /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-01 with 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, so palette.spec.ts guards it in both themes; no <style> block anywhere; the one animation kept its motion-reduce:.

Checks

vitest run 1372 passed, 43 files (46 new, one new file) · lint · typecheck · build — all clean. Baseline on main was 1326 in 42.

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.
@TheMeinerLP
TheMeinerLP merged commit a6e09fb into main Aug 23, 2026
10 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant