Merge branch 'master' into worktree-mosaic-library

Renumbers the mosaic's requirement IDs out of the way of the download work
that landed on master in parallel: it had already claimed DR-163/DR-164 and
UT-162, so the mosaic layout is now DR-172, the library favourites scope
DR-173, and its composition test UT-167.

Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166,
none of which are defined in requirements.md — that branch defined DR-167..171
instead. Those references are orphaned and want a look; nothing here touches
them.
This commit is contained in:
2026-08-16 00:04:26 +02:00
26 changed files with 2495 additions and 1554 deletions
+22 -3
View File
@@ -870,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
},
/**
* Pause a download
* Pause a download.
*
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
* streaming task knew nothing about the row and kept running, then overwrote it
* with `completed`/`failed` when it finished. The row flicked to "paused" and
* undid itself — the reported "pause does not work". Signalling the worker is
* what actually stops the bytes; it leaves the `.part` file in place so
* [`resume_download`] can continue from it.
*
* A queued (not yet started) download has no worker to signal, and the status
* write alone is enough — the pump skips anything that is not `pending`.
*
* TRACES: UR-055 | DR-168
*/
async pauseDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("pause_download", { downloadId });
},
/**
* Resume a paused download
* Resume a paused download.
*
* Flipping the row back to `pending` is likewise not enough on its own: the
* pump is not a poller, it runs when something calls it, so a resumed download
* sat untouched until some unrelated event happened to pump the queue. That is
* the other half of "resume does not work".
*
* TRACES: UR-055 | DR-168
*/
async resumeDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("resume_download", { downloadId });
@@ -2063,7 +2082,7 @@ export type Library = { id: string; name: string; collectionType: string; imageT
* collection-type → category table any more than an item-type one. See
* `SearchScope::for_collection_type`.
*
* TRACES: UR-075 | DR-164
* TRACES: UR-075 | DR-173
*/
favoritesScope?: SearchScope | null }
/**
+1 -1
View File
@@ -15,7 +15,7 @@
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
* layout that sizes boxes from artwork (the mosaic) use the shape the image
* actually has rather than the one its item type suggests.
* TRACES: UR-075 | DR-163
* TRACES: UR-075 | DR-172
*/
onNaturalSize?: (width: number, height: number) => void;
}
+1 -1
View File
@@ -11,7 +11,7 @@
arrives over a few hundred milliseconds, and re-packing on each arrival would
shuffle the grid under the viewer's cursor several times over.
TRACES: UR-075 | DR-163
TRACES: UR-075 | DR-172
-->
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
+1 -1
View File
@@ -7,7 +7,7 @@
lines would no longer line up with its neighbours. Keeping everything inside
the box is what lets `layoutMosaic` own the geometry completely.
TRACES: UR-075 | DR-163
TRACES: UR-075 | DR-172
-->
<script lang="ts">
import type { Snippet } from "svelte";
+1 -1
View File
@@ -9,7 +9,7 @@
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
// decides what to *call* it and where to put it.
//
// TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-162
// TRACES: UR-075, UR-067 | DR-172, DR-173 | UT-167
import type { Library } from "$lib/api/types";
import {
+1 -1
View File
@@ -9,7 +9,7 @@
//
// Presentation only — nothing here knows what a library or a media item is.
//
// TRACES: UR-075 | DR-163 | UT-158, UT-159, UT-160
// TRACES: UR-075 | DR-172 | UT-158, UT-159, UT-160
/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
export interface MosaicInput {
+1 -1
View File
@@ -41,7 +41,7 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
* than folded into "all": a caller asking "which category is this?" wants no
* answer, not the cross-category one.
*
* TRACES: UR-075 | DR-164
* TRACES: UR-075 | DR-173
*/
export function asFavoritesScope(
scope: SearchScope | null | undefined,
+1 -1
View File
@@ -118,7 +118,7 @@
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
// artwork. It used to force 16:9 on everything so square music covers lined up
// with wide backdrops — which lined them up by cropping the covers.
// TRACES: UR-075 | DR-163
// TRACES: UR-075 | DR-172
const LIBRARY_STRIP_HEIGHT = 132;
const libraryTiles = $derived(
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
+2 -2
View File
@@ -45,7 +45,7 @@
// The overview is a mosaic: rows of one height, tiles of their own widths, so
// a square music cover sits beside a wide backdrop without either being
// cropped to the other's shape. Each category also gets a favourites tile of
// its own, beside the library it belongs to. TRACES: UR-075 | DR-163, DR-164
// its own, beside the library it belongs to. TRACES: UR-075 | DR-172, DR-173
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
// Track if we've done an initial load and previous server state
@@ -255,7 +255,7 @@
category's own favourites sits beside its library — a labelled tile
at the same weight as a library is the difference between a feature
people find and one they don't. ux-flows §5C.2.
TRACES: UR-067, UR-075 | DR-117, DR-163 -->
TRACES: UR-067, UR-075 | DR-117, DR-172 -->
<MosaicGrid items={mosaicEntries} gap={8}>
{#snippet tile(entry)}
{#if entry.kind === "favorites"}