android: the whole discography, on a page of its own

The artist page already filed every release the desktop's way, but it only
ever showed one category at a time and it showed all of it: no slice anywhere
in that grid, so an artist with 140 releases drew 140 cards. There is a page
now — All in front of the categories, a sort, a grouping by year, and the
editions opened in place under their own record, which is the part a flat
list loses — and the grid behind it stops at twelve with the way down under
it. It is the same classifier twice over the same cached albums, which the
desktop cannot do because its filing waits on a background description
harvest; the three things its own page has and this one does not — a
tracklist per record, an accent sampled per cover, the scroll-led banner —
are 140 calls and 140 decodes over mobile data for rows that are one tap
away, and ANDROID.md says so rather than leaving them looking forgotten.

The list caps at sixty with the rest a button away, before the grouping, so
the last year on screen can be a part of itself; under a name order By year
is not offered, because the years would repeat out of sequence. The mock
gains a 140-record artist so both caps are walked rather than argued about.
This commit is contained in:
KeelrLeek committed 2026-09-14 03:41:16 -05:00
1 parent 1258b2b78d
commit f2cde4ec7c
9 files changed
+491 -12

No files matched your search

+24 -4
View File
@@ -635,13 +635,33 @@ const SLOW_ALBUMS = [
{ id: 4155, title: "Long Winter", release_date: "2023-01-20", record_type: "album", track_count: 10 },
{ id: 4156, title: "Open Water", release_date: "2024-08-30", record_type: "album", track_count: 11 },
].map((a) => ({ ...a, artist: CAT_SLOW.name, cover: svgArt((a.id * 17) % 360, a.title[0]), edition_group: null, is_soundtrack: false }));
/* A prolific artist, so the discography page's cap is WALKED rather than
argued about: 140 records, four a year from 1985, each plainly titled for
the same reason SLOW_ALBUMS' are — the classifier must leave every one of
them on the Albums tab, or the counts the walk asserts stop meaning
anything. Deliberately NOT in RELATED: a fifth card in the rail would move
the artist page's own card census (cat-artist-tab counts 2 images and 2
placeholders there). The walk reaches it with a seed, so no search
answers for it either. */
const CAT_BIG = { id: 416, name: "Ember Parade", picture: null, album_count: 140, fans: 91000 };
const BIG_ALBUMS = Array.from({ length: 140 }, (_, i) => ({
id: 41600 + i,
title: "Field Notes " + (i + 1),
artist: CAT_BIG.name,
cover: svgArt((i * 37) % 360, "F"),
release_date: (1985 + Math.floor(i / 4)) + "-" + String((i % 4) * 3 + 2).padStart(2, "0") + "-15",
record_type: "album",
track_count: 8 + (i % 5),
edition_group: null,
is_soundtrack: false,
}));
/* the heroes chosen this session — pick_image → set_artist_hero */
const heroes = {};
let follows = [];
function mockCatalog(cmd, args) {
if (cmd === "catalog_artist") {
const id = Number(args?.artistId);
const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST;
/* the exhaustive page, kept four seconds on purpose: whatever is on
@@ -650,7 +670,7 @@ function mockCatalog(cmd, args) {
return new Promise((r) => setTimeout(() => r({
artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : a === CAT_BIG ? BIG_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
top_genre: "Electronic", socials: [],
}), slow ? 4000 : 220));
}
@@ -746,9 +766,9 @@ function mockCatalog(cmd, args) {
so `artist.id` must equal the argument or the wrapper throws */
if (cmd === "catalog_artist_overview") {
const id = Number(args?.artistId);
const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist"));
const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : [];
const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : id === CAT_BIG.id ? BIG_ALBUMS.slice(0, 4) : [];
return new Promise((r) => setTimeout(() => r({ artist: a, popular_tracks: [], albums }), 300));
}
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
+25
View File
@@ -62,6 +62,7 @@
if (!h) return null;
if (h.kind !== "more") return h.kind;
const t = top();
if (t.n === "disco") return "discosort";
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
@@ -722,6 +723,30 @@
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
</div>
{/if}
{:else if kind === "discosort"}
{@const c = top().c}
{@const ord = c.order || "new"}
{@const byName = ord === "az" || ord === "za"}
{@const grp = byName ? "none" : (c.group ?? "year")}
<!-- the four orders are written out here rather than imported
from disco.js: this file is edited by every round and a new
import line is a collision waiting to happen. ORDER_LABEL
there carries the same four, and the walk asserts both. -->
<div class="sheet-title">Sort &amp; group</div>
<div class="sheet-sec">Order</div>
<div class="chips" style="padding-bottom:12px">
{#each [["new", "Newest first"], ["old", "Oldest first"], ["az", "A–Z"], ["za", "Z–A"]] as [k, l] (k)}
<button class="chip" class:on={ord === k} class:quiet={ord !== k} aria-pressed={ord === k} onclick={() => patch({ order: k })}>{l}</button>
{/each}
</div>
<div class="sheet-sec">Group</div>
<div class="chips" style="padding-bottom:12px">
{#each [["year", "By year"], ["none", "None"]] as [k, l] (k)}
<button class="chip" class:on={grp === k} class:quiet={grp !== k} aria-pressed={grp === k} disabled={byName && k === "year"} onclick={() => patch({ group: k })}>{l}</button>
{/each}
</div>
{#if byName}<div class="sec-note">By year needs a date order — under A–Z the years would repeat out of sequence.</div>{/if}
<div style="padding: 4px var(--pad) 16px"><button class="btn btn-fill wide" onclick={closeHalf}>Done</button></div>
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
+13
View File
@@ -22,6 +22,7 @@
import Library from "./screens/Library.svelte";
import Album from "./screens/Album.svelte";
import Artist from "./screens/Artist.svelte";
import Discography from "./screens/Discography.svelte";
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
@@ -86,6 +87,16 @@
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
}
/* the whole discography wears the artist's own ground, but no
hero of its own: it is a list, and the bar is over it from the
first pixel rather than after 210 of them */
case "disco": {
const as = albumsBy(e.c.name);
const cat = catalog.artists[artistKey(e.c.name)]?.page;
const p = cat?.banner || cat?.artist?.picture
|| as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null;
return { title: e.c.name, kicker: "Discography", amb: p };
}
case "artist": {
const as = albumsBy(e.c.name);
const cat = catalog.artists[artistKey(e.c.name)]?.page;
@@ -339,6 +350,8 @@
<Album id={entry.c.id} {screenEl} />
{:else if entry.n === "artist"}
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />
{:else if entry.n === "disco"}
<Discography {entry} {screenEl} />
{:else if entry.n === "catalogAlbum"}
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
{:else if entry.n === "playlist"}
+58
View File
@@ -92,3 +92,61 @@ export const SOCIAL_LABEL = {
youtube: "YouTube", instagram: "Instagram", twitter: "X", x: "X", facebook: "Facebook", tiktok: "TikTok",
spotify: "Spotify", apple: "Apple Music", discogs: "Discogs", lastfm: "Last.fm", musicbrainz: "MusicBrainz",
};
/* ── the whole discography, on a page of its own ─────────────────────────
The artist page's tabs are one lane per category; the discography page is
the same filing seen whole. Both read ONE classifier, and it is this one.
The desktop cannot do that: CatalogDiscography.svelte takes the artist
page's already-computed tab list as the same array object, because the
desktop's filing depends on a background description harvest and on
deferred MusicBrainz type hints that a re-run would not read
(CatalogDiscography.svelte:28-46). `discoTabs` here is pure and reads only
what the catalog page carries, so the phone simply calls it twice over the
same cached albums and the two pages can never disagree. */
/** "All" in front of the categories — built by CONCATENATING the per-category
* families, never by re-folding the whole set. A single that shares an
* album's base title (Pond released "Terrestrials" in 2026 as an album AND
* as a single) would fold into it as a phantom edition, and one of the two
* would vanish from its own tab. */
export function allTabs(tabs) {
const ts = tabs || [];
if (!ts.length) return [];
return [{ key: "all", label: "All", items: ts.flatMap((t) => t.items) }, ...ts];
}
/** The four orders the page offers, and what each is called. The sort sheet
* in HalfSheet.svelte writes the same four out inline — it is edited by
* every other round and a new import there is a collision waiting to
* happen — so if a label changes it changes in both places. */
export const ORDER_LABEL = { new: "Newest first", old: "Oldest first", az: "A–Z", za: "Z–A" };
/** The desktop's sort (CatalogDiscography.svelte:71-78): dates on
* `release_date`, names on the title, either direction. */
export function orderFamilies(items, order) {
const list = [...(items || [])];
const byName = order === "az" || order === "za";
list.sort((a, b) => (byName
? (a.title ?? "").localeCompare(b.title ?? "")
: (a.release_date ?? "").localeCompare(b.release_date ?? "")));
return order === "new" || order === "za" ? list.reverse() : list;
}
/** [{ key: "2001", items }] — the year headers, cut out of the list in the
* order it is already in rather than re-sorted, which is why a name order
* must not be grouped: the years would repeat out of sequence. `on` false
* gives the whole list back under one null key, so the caller renders one
* loop either way. */
export function byYear(items, on) {
const list = items || [];
if (!on) return list.length ? [{ key: null, items: list }] : [];
const out = [];
for (const r of list) {
const y = String(r.release_date || "").slice(0, 4) || "—";
const last = out[out.length - 1];
if (last && last.key === y) last.items.push(r);
else out.push({ key: y, items: [r] });
}
return out;
}
+14 -2
View File
@@ -128,7 +128,13 @@
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst();
});
});
const families = $derived(tabs.find((t) => t.key === tab)?.items ?? []);
/* the grid is a SLICE — the whole tab lives on the discography page,
which the head's "All" and the link under the grid both open. Before
this an artist with 140 releases rendered 140 cards here. */
const GRID_CAP = 12;
const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null);
const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP));
const tabCount = $derived(activeTab?.items.length ?? 0);
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
@@ -284,7 +290,10 @@
{/if}
{#if tabs.length}
<div class="sec"><h2>Discography <em>· {releasedCount} on the catalog</em></h2></div>
<div class="sec">
<h2>Discography <em>· {releasedCount} on the catalog</em></h2>
<button class="all" onclick={() => push("disco", { name }, screenEl)} aria-label="All releases">All<Icon name="chevron-right" size={14} /></button>
</div>
{#if tabs.length > 1}
<div class="segtabs disco-tabs" role="tablist" aria-label="Discography">
{#each tabs as t (t.key)}
@@ -306,6 +315,9 @@
/>
{/each}
</div>
{#if tabCount > GRID_CAP}
<button class="more-link dg-all" onclick={() => push("disco", { name, tab }, screenEl)}>Show all {tabCount} {activeTab?.label ?? "releases"}</button>
{/if}
{/if}
{#if timeline}
+196
View File
@@ -0,0 +1,196 @@
<script>
/* The whole discography — the desktop's CatalogDiscography.svelte, which
the artist page reaches with "All" on its Discography head.
The desktop's page is a stack of album-page heroes — the album page's
own hero at `--cover-scale: 0.45`, a 108px cover, with the record's
whole tracklist under it, fetched as the section scrolls near and its
accent sampled from its own cover (CatalogDiscography.svelte:120-175,
1555-1560). On a 411px screen that is one record per screenful and one
`catalog_album` per row over mobile data, for rows that are a tap away
on the record page — so none of those three comes over. What does is
the four things the page has that the artist page's tabs do not: All,
a sort, a grouping, and the editions kept whole.
Filed by the same classifier the artist page uses. The state — tab,
order, grouping — lives on the nav entry, so it survives a push and
the sort sheet's `patch()` reaches it the way Library's does. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Cover from "../Cover.svelte";
import { art, wantArt } from "../art.svelte.js";
import { albumsBy } from "../lib.svelte.js";
import { push, patch, openHalf } from "../nav.svelte.js";
import { catalog, lookupArtist, artistKey, yearOf, looseTitle, picture } from "../catalog.svelte.js";
import { discoTabs, allTabs, orderFamilies, byYear, ORDER_LABEL } from "../disco.js";
import { CAT_ONE } from "../../lib/localReleaseType.js";
import { editionLabel } from "../../lib/editions.js";
let { entry, screenEl = null } = $props();
const name = $derived(entry.c.name);
/* normally warm — the artist page that linked here looked the name up
already — but a page that only works warm is a page that breaks the
day something else links to it. `lookupArtist` is its own guard. */
$effect(() => { const n = name, sd = entry.c.seed ?? null; untrack(() => lookupArtist(n, sd)); });
const cat = $derived(catalog.artists[artistKey(name)] ?? null);
const page = $derived(cat?.page ?? null);
const artistId = $derived(page?.artist?.id ?? cat?.id ?? null);
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
const shownName = $derived(page?.artist?.name || overview?.artist?.name || name);
/* the exhaustive page when it is here, the overview's first round until
then — never the other way round, the artist page's own rule */
const discoAll = $derived(page?.albums ?? overview?.albums ?? []);
/* ── the tabs, the sort, the grouping ────────────────────────────── */
const catTabs = $derived(discoTabs(discoAll));
const tabs = $derived(allTabs(catTabs));
const tab = $derived(tabs.some((t) => t.key === entry.c.tab) ? entry.c.tab : (tabs[0]?.key ?? "all"));
const order = $derived(entry.c.order || "new");
/* Under a name sort the years would repeat out of order, so By year is
not offered there and the grouping falls to none. The sheet says why. */
const byName = $derived(order === "az" || order === "za");
const grouped = $derived(!byName && (entry.c.group ?? "year") === "year");
const active = $derived(tabs.find((t) => t.key === tab) ?? null);
const items = $derived(orderFamilies(active?.items ?? [], order));
/* the desktop's header line: a record with no track count makes the
whole figure a floor, not a number */
const known = $derived(items.reduce((n, r) => n + (r.track_count || 0), 0));
const someUnknown = $derived(items.some((r) => r.track_count == null));
/* which category each family was filed under, read off the per-category
tabs rather than by asking the classifier again once per row */
const kinds = $derived.by(() => {
const m = new Map();
for (const t of catTabs) for (const f of t.items) m.set(f.familyKey ?? f.key ?? f.id, CAT_ONE[t.key] ?? t.label);
return m;
});
/* the cap. A prolific artist is hundreds of rows and the phone renders
every one of them if it is asked to — Browse's rule (Browse.svelte:51):
a slice that extends on demand, reset whenever the list underneath it
changes. It is taken BEFORE the grouping, so the last year on screen
can be a part of itself and the button says how much is left. */
const CAP = 60;
let cap = $state(CAP);
$effect(() => { void tab; void order; void grouped; cap = CAP; });
const groups = $derived(byYear(items.slice(0, cap), grouped));
/* ── what this phone already holds ───────────────────────────────── */
const albums = $derived(albumsBy(name));
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
const subOf = (f, l) => [
yearOf(f.release_date),
kinds.get(familyKeyOf(f)) || "",
f.track_count ? `${f.track_count} tracks` : "",
l ? "in your library" : "",
].filter(Boolean).join(" · ");
const picUrl = $derived(picture(page?.artist?.picture) || picture(cat?.seed?.picture) || null);
const seedPath = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || albums[0]?.art_path || null);
$effect(() => { if (seedPath) wantArt(seedPath); });
const porSrc = $derived(picUrl || (seedPath ? art[seedPath] : "") || "");
/* ── opening things ──────────────────────────────────────────────── */
function openRow(r) {
const l = inLib(r);
if (l) push("album", { id: l.id }, screenEl);
else push("catalogAlbum", { id: r.id, artist: shownName, title: r.title }, screenEl);
}
function openFamily(f) {
const l = familyLib(f);
if (l) push("album", { id: l.id }, screenEl);
else push("catalogAlbum", { id: f.id, artist: shownName, title: f.title }, screenEl);
}
/* a long press is the record sheet the grid's already opens — Play, Save,
Add to playlist, Go to record, Go to artist. No new actions here. */
const moreFor = (f) => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: shownName } });
/* the editions opened in place, under their own record — Browse's folder
and its files, rather than a rail that scrolls sideways inside a list
that scrolls down */
let openEds = $state(new Set());
function toggleEds(key) {
const s = new Set(openEds);
if (s.has(key)) s.delete(key); else s.add(key);
openEds = s;
}
</script>
<div class="dg-head">
<span class="dg-por">
{#if porSrc}<img src={porSrc} alt="" />{:else}<Icon name="users" size={22} />{/if}
</span>
<div class="b">
<div class="t">{shownName}</div>
<div class="m">{items.length} release{items.length === 1 ? "" : "s"} · {someUnknown ? "≥" : ""}{known} tracks</div>
</div>
</div>
{#if tabs.length > 1}
<div class="segtabs dg-tabs" role="tablist" aria-label="Discography">
{#each tabs as t (t.key)}
<button class:on={tab === t.key} onclick={() => patch({ tab: t.key })} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button>
{/each}
</div>
{/if}
{#if items.length}
<div class="sec dg-sec">
<h2>{active?.label ?? "All"} <em>· {items.length} release{items.length === 1 ? "" : "s"}</em></h2>
<button class="all" onclick={() => openHalf("discosort")} aria-label="Sort and group">{ORDER_LABEL[order] ?? ORDER_LABEL.new}<Icon name="chevron-down" size={14} /></button>
</div>
{#each groups as g (g.key ?? "_all")}
{#if g.key}
<div class="dg-group">{g.key} <em>· {g.items.length} release{g.items.length === 1 ? "" : "s"}</em></div>
{/if}
{#each g.items as f (familyKeyOf(f))}
{@const l = familyLib(f)}
{@const key = familyKeyOf(f)}
{@const eds = (f.editions?.length ?? 1) > 1 ? f.editions : null}
<div class="dg-item">
<button class="row dg-row" onclick={() => openFamily(f)} oncontextmenu={(e) => { e.preventDefault(); moreFor(f); }}>
<span class="art">
{#if f.cover}<img src={f.cover} alt="" loading="lazy" />
{:else if l?.art_path}<Cover path={l.art_path} size={20} />
{:else}<Icon name="disc" size={20} />{/if}
</span>
<span class="b">
<span class="t">{f.title}</span>
<span class="m">{subOf(f, l)}</span>
</span>
</button>
{#if eds}
<button class="dg-eline" onclick={() => toggleEds(key)} aria-expanded={openEds.has(key)}>
<Icon name={openEds.has(key) ? "chevron-up" : "chevron-down"} size={13} />{openEds.has(key) ? "Hide editions" : `${eds.length} editions`}
</button>
{#if openEds.has(key)}
<div class="eds dg-eds">
{#each eds as e, i (e.id)}
<button class="ed" onclick={() => openRow(e)}>
<span class="ed-art">{#if e.cover}<img src={e.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={16} />{/if}</span>
<span class="ed-copy">
<span class="n">{i === 0 ? "Original" : editionLabel(e, f)}</span>
<span class="y">{[yearOf(e.release_date), e.track_count ? `${e.track_count} tracks` : ""].filter(Boolean).join(" · ")}</span>
</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
{/each}
{/each}
{#if items.length > cap}
<div style="padding: 10px var(--pad) 4px"><button class="btn btn-ghost wide dg-more" onclick={() => (cap += 120)}>+ {items.length - cap} more releases</button></div>
{/if}
{:else if !cat || cat.status === "loading"}
<div class="sec-note" style="padding-top: 16px">Asking the catalog…</div>
{:else}
<div class="stub"><b>Nothing to list.</b>The catalog has no releases for {shownName}{cat.status === "error" ? " — it did not answer" : ""}.</div>
{/if}
<div class="endpad"></div>
+48
View File
@@ -1613,3 +1613,51 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
}
/* why the rows will not move while the filter has something in it */
.pl-note { padding: 2px var(--pad) 8px; font-size: 12px; color: var(--text-muted); }
/* ── the whole discography ───────────────────────────────────────────────
The artist page's "All". Nearly all of it is the app's own parts — .sec,
.segtabs, .row, .eds/.ed, .more-link, .btn — so what is here is the head,
the year written into the list where the desktop has a sticky rail, the
editions line under a record, and the editions themselves widened out of
the record page's 210px rail into the width this list already has. */
.dg-head { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 6px; }
.dg-head .b { flex: 1; min-width: 0; }
.dg-head .t { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dg-head .m { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
.dg-por {
width: 44px; height: 44px;
border-radius: 50%;
overflow: hidden;
flex: none;
background: var(--surface);
box-shadow: var(--ring);
display: grid;
place-items: center;
color: var(--text-muted);
}
.dg-por img { width: 100%; height: 100%; object-fit: cover; }
.dg-sec { padding-top: 12px; }
/* the year. A phone list is read top to bottom, so the span the desktop's
rail jumps to is simply written into it. */
.dg-group {
padding: 16px var(--pad) 5px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.4px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.dg-group em { font-style: normal; font-weight: 650; letter-spacing: 0; color: var(--text-muted); }
/* both of these line up under the row's TITLE, not under its cover: the
pad, the 46px cover and the row's 12px gap */
.dg-eline {
display: flex;
align-items: center;
gap: 6px;
padding: 0 var(--pad) 9px calc(var(--pad) + 58px);
font-size: 12px;
font-weight: 750;
color: var(--accent-hover);
}
.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); }
.dg-eds .ed { width: 100%; }