# Spec: Library mosaic (library overview + home shortcuts) **Status:** Implemented **Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended) **UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites) ## Summary The library overview and the home "Your Libraries" strip stop being fixed-shape grids and become a **mosaic**: rows share one height, and each tile is as wide as its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row at their own proportions instead of all three being cropped into whichever box the grid picked. Favourites gain a tile per category, placed beside the library that category belongs to, alongside the existing cross-library entry. ## Motivation Every surface here shows artwork of more than one shape. The grid resolved that by choosing one shape and cropping to it — and the home strip said so out loud: > Uniform 16:9 artwork so music (square) and video libraries line up at the same > height in this mixed row. Lining them up is right; cropping the covers to do it is not. Holding the **height** fixed and letting the **width** vary achieves the same alignment with no crop at all, which is the whole idea of a justified layout. Favourites had one entry for everything. With per-category tiles, "my favourite albums" is one tap from the library page rather than a tap plus a tab. ## Layer assignment | Logic / responsibility | Layer | Why it belongs there | |------------------------|-------|----------------------| | Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. | | Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. | | Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. | | Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. | | Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. | Borderline row: the "assumed artwork shape" is a per-collection-type default, and any per-collection-type table deserves suspicion. The tie-breaker: it does not decide *what a category means* or what is fetched — it seeds a pixel dimension that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack, not a wrong result. The scope mapping, which does decide what is fetched, went to Rust. ## Design ### Wire `Library` gains one optional field, derived at construction: ```rust pub struct Library { pub id: String, pub name: String, pub collection_type: String, pub image_tag: Option, pub favorites_scope: Option, // ← new } impl SearchScope { pub fn for_collection_type(collection_type: &str) -> Option; } ``` ```ts type Library = { …; favoritesScope?: SearchScope | null } ``` `Library::new` derives it, so the four construction sites (online views, two offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON, not sent as null. No new command, no new event. ### Layout `src/lib/components/library/mosaic.ts` — pure, no DOM: - `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles with pixel boxes. Tiles join a row until the height needed to fill the width drops to the target; the row closes there and is justified to the container width, the rounding remainder absorbed by its widest tile. The **last row is not justified** (one leftover tile would inflate into a banner) — it sits at the target height, left-aligned. - `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for a horizontally scrolling shelf. - `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller doesn't pick one. Bounded so a phone still fits two tiles across and a desktop doesn't turn each library into a billboard. - Ratios are clamped to a band (0.5–2.5) so one panorama can't own a row. `MosaicGrid.svelte` supplies the two things only the DOM knows — the measured container width (`bind:clientWidth`) and the artwork's decoded ratio — and renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize` callback for the second. Measured ratios are committed in one debounced batch (120 ms): artwork arrives over several hundred milliseconds and re-packing per image would shuffle the grid under the pointer. `MosaicTile.svelte` draws one tile at an exact pixel box, with its label written **over** the bottom of the artwork. A caption below the box would add height the layout didn't compute, and a caption that wrapped to two lines would break the row alignment the mosaic exists to provide. ### Composition `libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library favourites entry first, then each library followed by its own category tile. A category appears **once** — two movie libraries share one favourites list, so a tile each would be two tiles to the same place. A library whose `favoritesScope` is absent (Live TV, channels, books) gets no tile rather than one opening an unfiltered list. Home uses the same tiles in `layout="strip"` but **without** the favourites tiles: home already carries Favourite Movies / Shows / Music rows of its own, and a second entry point in the strip above them would be redundant. ## Out of scope - The item grids inside a library (`/library/movies`, `/library/music/albums`, …). Those show one item type each, so a uniform grid crops nothing; the mosaic buys them nothing but reflow. - Backdrop/collage artwork for libraries with no image of their own. - Reordering or pinning libraries.