Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions docs/web-client-gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,21 @@ entièrement stylée à la main. C'est le bord non fini le plus visible de
l'interface.

**La typographie, décidée une fois.** Le serif d'affichage est le seul geste qui
ne soit pas générique, et il est orphelin : il sert sur l'écran de connexion,
que personne ne regarde, et nulle part ensuite. Deux issues cohérentes — en
faire un système, en le portant sur les titres de page ; ou l'abandonner. La
seule mauvaise décision est de le laisser où il est.
ne soit pas générique.

> **Rectifié le 2026-09-06.** « Il est orphelin : il sert sur l'écran de
> connexion et nulle part ensuite » est faux, et c'est la cinquième affirmation
> de ce document à ne pas résister à la lecture du code. Le serif porte
> `h1, h2, h3` — donc **tous les titres de page** —, la marque de la barre
> latérale, la lettre qui remplace une pochette absente et le code de la page
> 404. Il était déjà un système ; ce qui le faisait passer pour un accident,
> c'est qu'il était déclaré **quatre fois, en deux orthographes** — trois des
> quatre omettaient « Palatino Linotype », donc le repli n'était pas le même
> selon l'écran regardé.
>
> Décision de l'opérateur : en faire un système. Concrètement il ne restait
> qu'à le nommer, `--font-display` et `--font-body` en tête de `styles.css`,
> une seule pile chacun. **Cette question est close.**

**La carte dans la carte**, sur la connexion : deux rectangles arrondis
emboîtés dont les fonds diffèrent de trois pour cent. C'est le signal
Expand Down Expand Up @@ -276,6 +287,21 @@ de risque.
historique, aléatoire, sélecteur de bibliothèque. Onze routes qui existent, un
écran chacune ou presque. C'est ce qui ferme l'écart Navidrome.

> **État au 2026-09-06.** Six des sept sont branchés : notes cinq étoiles sur le
> tableau de pistes, genres (liste et page par genre), écoutes récentes,
> aléatoire, paroles et positions enregistrées dans une vue « en écoute ».
> **Le sélecteur de bibliothèque est laissé de côté volontairement** — c'est la
> première des questions ouvertes ci-dessous, et la trancher en passant
> reviendrait à décider la navigation du client web sans le dire.
>
> Trois choses apprises en branchant. `GET /songs?genre=` prend le genre en
> forme d'affichage et le canonicalise avant de comparer, donc « Hip-Hop » et
> « hip hop » sont un seul genre côté serveur. `GET /history` répond des
> **écoutes** et non des pistes — `track_id`, `submission`, `played_at` — donc
> un écran qui veut des titres les résout lui-même. Et le favori, jusque-là
> rendu par une étoile, est devenu un cœur : cinq étoiles de note à côté d'une
> étoile de favori auraient fait un seul contrôle de deux questions.

**Lot C — le lecteur et l'exploitation.** Volume, aléatoire, répétition, file
accessible, retour vers l'album. Puis côté administration : progression de scan
en direct, en écoute maintenant, membres, jetons.
Expand All @@ -298,14 +324,19 @@ n'est pas sur ce chemin critique.
- **Le sélecteur de bibliothèque** est le seul point où Navidrome est devant sur
une fonction que WaveFlow possède. Reste à décider si la navigation web est
cadrée par une bibliothèque à la fois, ou agrégée avec une bibliothèque comme
filtre.
filtre. **C'est le seul point du lot B qui n'a pas été branché**, et c'est
pour cette raison : le reste du lot ne demandait qu'un `fetch`, celui-ci
demande une décision sur ce qu'est une session de navigation.
- **Ce qu'on montre d'une correction de tags** quand elle diverge du fichier :
la valeur corrigée seule, ou les deux avec leur provenance.
- **Le sort du serif.** Système ou abandon, mais pas le statu quo.
- **Le vide de la barre latérale**, entre la navigation en haut et les réglages
en bas. La question n'est pas comment le combler mais avec quoi : tout candidat
honnête est du lot B — genres, écoutes récentes, bibliothèque courante — et le
choix engage la navigation, pas seulement l'espace.
- ~~**Le sort du serif.**~~ **Tranché le 2026-09-06 : système.** Et la question
reposait sur une prémisse fausse — voir le geste rectifié plus haut. Il ne
restait qu'à nommer les deux piles au lieu de les répéter.
- ~~**Le vide de la barre latérale.**~~ **Refermé par le lot B**, sans avoir été
traité pour lui-même : les quatre entrées de navigation ajoutées — genres, en
écoute, aléatoire, écoutes récentes — occupent l'espace que le lot A avait
laissé vide. C'était le bon ordre : le vide voulait du contenu, et le contenu
a fini par exister.
- **Les langues.** Deux aujourd'hui, trente-quatre chez Navidrome. La question
n'est pas d'y arriver mais de savoir si l'infrastructure de `i18n.tsx` tient
au-delà d'une poignée.
11 changes: 11 additions & 0 deletions tests/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ async fn embedded_web_client_serves_shell_without_shadowing_the_api() {
.unwrap()
.starts_with("text/html"));

// A route parameter carrying an encoded slash still resolves to the shell.
// Genres are the case that raises it — "Rock/Pop" is one tag, and the
// client routes it as /genres/Rock%2FPop — so a link to one, shared or
// reloaded, must not decode into a two-segment path that matches nothing.
let encoded = get("/genres/Rock%2FPop").await;
assert_eq!(encoded.status(), StatusCode::OK);
assert!(encoded.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("text/html"));

// An unknown API path stays a JSON 404 instead of silently returning HTML.
let missing_api = get("/api/v2/does-not-exist").await;
assert_eq!(missing_api.status(), StatusCode::NOT_FOUND);
Expand Down
183 changes: 182 additions & 1 deletion webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,59 @@ const track = {
user_rating: null,
};

/**
* 120 distinct plays, newest first. More than the screen resolves, which is the
* point: each distinct track costs its own request.
*/
const history = Array.from({ length: 120 }, (_, index) => ({
track_id: `t${index}`,
submission: true,
played_at: 1_000_000 - index,
}));

/** Resolved by default; one test replaces it to stall `album-1`. */
let slowAlbum: Promise<void> = Promise.resolve();

const song = (
index: number,
title: string,
rating: number,
starred: boolean,
) => ({
id: `song-${index}`,
library_id: "library-1",
album_id: "album-2",
title,
album: "Vespertine",
artist: "Björk",
artist_id: "artist-1",
artwork_hash: null,
duration_ms: 300_000,
track: index,
disc: 1,
starred_at: starred ? 1 : null,
user_rating: rating,
});

const albumDetail = {
...albums[1],
songs: [
song(1, "Hidden Place", 5, true),
song(2, "Cocoon", 0, false),
song(3, "Undo", 2, false),
],
};

const genres = [
{ name: "Art Pop", song_count: 412, album_count: 31 },
{ name: "Shoegaze", song_count: 233, album_count: 18 },
];

const genreSongs = [
song(1, "Hidden Place", 5, true),
song(2, "Cocoon", 0, false),
];

async function mockAuthenticatedApi(page: Page) {
await page.context().addCookies([
{
Expand All @@ -71,8 +121,31 @@ async function mockAuthenticatedApi(page: Page) {
await route.fulfill({ json: { ...albums[0], songs: [track] } });
return;
}
if (url.pathname === "/api/v2/history") {
await route.fulfill({ json: history });
return;
}
if (url.pathname.startsWith("/api/v2/tracks/")) {
const id = url.pathname.split("/")[4];
await route.fulfill({ json: { ...song(1, `Track ${id}`, 0, false), id } });
return;
}
if (url.pathname === "/api/v2/genres") {
await route.fulfill({ json: genres });
return;
}
if (url.pathname === "/api/v2/songs") {
await route.fulfill({
json: url.searchParams.get("genre") ? genreSongs : [],
});
return;
}
if (url.pathname === "/api/v2/albums/album-2") {
await route.fulfill({ json: { ...albums[1], songs: [track] } });
await route.fulfill({ json: albumDetail });
return;
}
if (url.pathname.startsWith("/api/v2/ratings/")) {
await route.fulfill({ status: 204, body: "" });
return;
}
if (url.pathname === "/api/v2/albums") {
Expand Down Expand Up @@ -228,8 +301,17 @@ test("keeps each card's actions guarded while its own fetch is out", async ({
await expect(slowPlay).toBeDisabled();

// The second album answers at once while the first is still held open.
// Synchronising on the response and not on the button is the point: the
// button is enabled before the click too, so `toBeEnabled` can resolve on
// its first poll, before React has even applied the disabling update — and
// the assertion below would then run at a moment when nothing has happened,
// which is exactly when the single-slot guard still looks correct.
const answered = page.waitForResponse(
(response) => new URL(response.url()).pathname === "/api/v2/albums/album-2",
);
await page.locator(".grid li").nth(1).hover();
await fastQueue.click();
await answered;
await expect(fastQueue).toBeEnabled();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The first album has not answered, so its actions must still be refused.
Expand All @@ -238,3 +320,102 @@ test("keeps each card's actions guarded while its own fetch is out", async ({
releaseSlow();
await expect(slowPlay).toBeEnabled();
});

/**
* The song table carries the controls the albums grid does not — a five-star
* rating and a favourite — so the accessibility sweep has to reach a page that
* shows one. Until this test the sweep only ever loaded the grid.
*/
test("rates a track and stays free of WCAG A or AA violations", async ({
page,
}) => {
const rated: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith("/api/v2/ratings/")) {
rated.push(`${request.method()} ${url.pathname}`);
}
});

await page.goto("/albums/album-2");
await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible();

// The rating is a radio group, so the stored value is a checked radio rather
// than a class on a span.
const hidden = page.getByRole("group", { name: "Rating: Hidden Place" });
await expect(hidden.getByRole("radio", { name: "5 stars" })).toBeChecked();
const cocoon = page.getByRole("group", { name: "Rating: Cocoon" });
await expect(cocoon.getByRole("radio", { checked: true })).toHaveCount(0);

await cocoon.getByRole("radio", { name: "4 stars" }).check();
await expect(cocoon.getByRole("radio", { name: "4 stars" })).toBeChecked();
expect(rated).toEqual(["PUT /api/v2/ratings/track/song-2"]);

const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});

/**
* Genres were a route the server answered and the client never called. The
* navigation is the part worth pinning: the genre name travels into the query,
* and it is a display string — "Hip-Hop" and "hip hop" are one genre to the
* server, which canonicalises before matching.
*/
test("browses into a genre and keeps WCAG A and AA clean", async ({ page }) => {
const asked: Array<string | null> = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname === "/api/v2/songs") {
asked.push(url.searchParams.get("genre"));
}
});

await page.goto("/genres");
await expect(page.getByRole("heading", { name: "Genres" })).toBeVisible();
await expect(page.getByText("412 tracks · 31 albums")).toBeVisible();

let results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);

await page.getByRole("link", { name: "Art Pop" }).click();
await expect(page.getByRole("heading", { name: "Art Pop" })).toBeVisible();
await expect(page.locator(".songs tbody tr")).toHaveCount(2);
expect(asked).toEqual(["Art Pop"]);

results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});

/**
* `GET /history` answers plays and not songs, so every distinct track on this
* screen is a request of its own, and they all leave together. Without a cap a
* long history meant a hundred-odd round trips for a list nobody reads to the
* bottom of. The window stays wide — 200 plays asked for — and only what is
* resolved is bounded.
*/
test("resolves a bounded number of tracks from a long history", async ({
page,
}) => {
const asked: string[] = [];
page.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (path.startsWith("/api/v2/tracks/")) asked.push(path);
});

await page.goto("/history");
await expect(
page.getByRole("heading", { name: "Recently played" }),
).toBeVisible();

await expect(page.locator(".songs tbody tr")).toHaveCount(50);
expect(asked).toHaveLength(50);
// The newest plays are the ones kept, not an arbitrary fifty.
expect(asked[0]).toBe("/api/v2/tracks/t0");
expect(asked.at(-1)).toBe("/api/v2/tracks/t49");
});
Loading
Loading