who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence

This commit is contained in:
KeelrLeek committed 2026-09-08 12:10:49 -05:00
1 parent a98c750908
commit b8e6fb7396
11 files changed
+454 -6

No files matched your search

+2
View File
@@ -28,6 +28,7 @@
import UploadToast from "./components/UploadToast.svelte"; import UploadToast from "./components/UploadToast.svelte";
import ProposalToast from "./components/ProposalToast.svelte"; import ProposalToast from "./components/ProposalToast.svelte";
import FollowNewsToast from "./components/FollowNewsToast.svelte"; import FollowNewsToast from "./components/FollowNewsToast.svelte";
import WhoHasSheet from "./components/WhoHasSheet.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte"; import SetlistScanChip from "./components/SetlistScanChip.svelte";
import { import {
ui, ui,
@@ -188,6 +189,7 @@
<UploadToast /> <UploadToast />
<ProposalToast /> <ProposalToast />
<FollowNewsToast /> <FollowNewsToast />
<WhoHasSheet />
<SetlistScanChip /> <SetlistScanChip />
<PreviewOverlay /> <PreviewOverlay />
<LyricsSyncEditor /> <LyricsSyncEditor />
+196
View File
@@ -0,0 +1,196 @@
<script>
// The answer to "who has this?": the members of your rooms whose holdings
// name this album, grouped by room, each with the line Discover already
// writes for that person and the action the answer allows —
//
// Get it a linked holder: browse their share, find the
// folder the room said they hold, queue the grab
// Wait for them the grab could not reach them: a local wait
// that fires when their presence flips online
// Ask in chat a room-local member with no Soulseek name;
// nothing is ever pushed to them
//
// Reads the cached holdings only; the network was read when the room was.
import Icon from "./Icon.svelte";
import Identicon, { hueOf } from "./Identicon.svelte";
import { slsk } from "../lib/state.svelte.js";
import { community, askHoldings, holdingsGrab, waitFor, forgetWait, waitingOn } from "../lib/community.svelte.js";
let { artist, album, mbid = null, compact = false, onanswer = null } = $props();
let holders = $state(null);
let asking = $state(false);
let grabbed = $state({});
let failed = $state({});
$effect(() => {
const a = artist;
const b = album;
const m = mbid;
if (!a || !b) {
holders = [];
return;
}
let live = true;
asking = true;
askHoldings([{ artist: a, album: b, mbid: m }])
.then((r) => {
if (!live) return;
holders = r?.[0]?.holders ?? [];
onanswer?.(holders);
})
.catch(() => {
if (live) holders = [];
})
.finally(() => {
if (live) asking = false;
});
return () => {
live = false;
};
});
/** Rooms in the order they appear, each with its holders. */
const rooms = $derived.by(() => {
const by = new Map();
for (const h of holders ?? []) {
const k = h.owner + ":" + h.space_id;
if (!by.has(k)) by.set(k, { key: k, name: h.space_name, holders: [] });
by.get(k).holders.push(h);
}
return [...by.values()];
});
const people = $derived.by(() => {
const s = new Set();
for (const h of holders ?? []) s.add((h.username ?? h.member).toLowerCase());
return s.size;
});
const display = (u) => community.contacts[u]?.petname || u;
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
function presenceOf(u) {
const st = slsk.buddyState[u];
if (!st || st.exists === false) return null;
if (st.status === 2) return "online";
if (st.status === 1) return "away";
return "off";
}
const fmtClock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function seenLine(u) {
const ms = community.lastSeen[u];
if (!ms) return null;
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return `last seen by this device today · ${fmtClock(ms)}`;
if (new Date(now - 86400000).toDateString() === d.toDateString()) return `last seen by this device yesterday · ${fmtClock(ms)}`;
return `last seen by this device ${d.toLocaleDateString([], { month: "short", day: "numeric" })}`;
}
/** The second line: presence, then what Discover knows about them. */
function line(h) {
const u = h.username;
if (!u) return "room-local · not linked to a Soulseek account";
const parts = [];
const p = presenceOf(u);
if (p === "online") parts.push("online now");
else if (p === "away") parts.push("away");
else {
const s = seenLine(u);
if (s) parts.push(s);
}
const o = community.overlap.find((x) => x.username.toLowerCase() === u.toLowerCase());
if (o?.albums_in_common_count) parts.push(`shares ${o.albums_in_common_count} ${o.albums_in_common_count === 1 ? "album" : "albums"} you own`);
if (o?.gaps_count) parts.push(`${o.gaps_count} you lack`);
return parts.join(" · ") || "linked to Soulseek";
}
const tierWord = (t) => (t === 2 ? "FLAC" : t === 1 ? "MP3" : null);
async function getIt(h) {
const u = h.username;
delete failed[u];
try {
const m = await holdingsGrab(u, artist, album);
grabbed[u] = m?.files?.length ?? 0;
} catch (e) {
failed[u] = String(e?.message ?? e);
}
}
const busy = (h) => !!community.busy[`grab:${h.username}:${album}`] || !!community.busy[`wait:${h.username}:${album}`];
</script>
<div class="whohas" class:compact>
{#if asking && holders === null}
<div class="quiet"><span class="bl"></span> Asking your rooms…</div>
{:else if !holders?.length}
<div class="quiet">
<Icon name="users" size={16} />
<span>{community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."}</span>
</div>
{:else}
{#each rooms as r (r.key)}
<div class="grp"><span class="src">{r.name}</span><span class="n">{r.holders.length} of the room</span><span class="ln"></span></div>
{#each r.holders as h (h.member)}
{@const u = h.username}
<div class="row">
<span class="av" style:background="hsl({hueOf(u ?? h.member)} 30% 14%)"><Identicon seed={u ?? h.member} size={compact ? 30 : 36} presence={u ? presenceOf(u) : null} /></span>
<span class="tx">
<span class="l1"><span class="nm">{u ? display(u) : fpShort(h.member)}</span>{#if u}<span class="badge">Soulseek</span>{/if}</span>
<span class="l2">{line(h)}</span>
{#if failed[u]}<span class="l2 warn">{failed[u]}</span>{/if}
</span>
<span class="r">
{#if tierWord(h.tier)}<span class="tier" class:lossless={h.tier === 2}>{tierWord(h.tier)}</span>{/if}
{#if !u}
<button class="ax-ghost sm" disabled title="They have no Soulseek name here; ask in the room's chat">Ask in chat</button>
{:else if grabbed[u] != null}
<span class="chip ok"><Icon name="check" size={11} /> {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued</span>
{:else if waitingOn(u, artist, album)}
<span class="chip acc">waiting</span>
<button class="ax-ghost sm" onclick={() => forgetWait(u, artist, album)} disabled={busy(h)}>Forget</button>
{:else if failed[u]}
<button class="ax-ghost sm" onclick={() => waitFor(u, artist, album, h.space_name)} disabled={busy(h)}>Wait for them</button>
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>Try again</button>
{:else}
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>{busy(h) ? "Browsing…" : "Get it"}</button>
{/if}
</span>
</div>
{/each}
{/each}
<div class="foot"><span class="prov"><Icon name="compass" size={11} /> from your rooms' holdings · {people} {people === 1 ? "person" : "people"}</span><span class="note">names compared, never files</span></div>
{/if}
</div>
<style>
.whohas { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quiet { display: flex; align-items: center; gap: 10px; padding: 12px 10px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.bl { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin 0.8s linear infinite; flex: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.grp { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-top: 4px; }
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
.src { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--space, #c77dff) 14%, transparent); color: #d9a6ff; text-transform: none; }
.row { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 5px 8px; border-radius: 8px; }
.compact .row { min-height: 40px; padding: 3px 8px; }
.row:hover { background: var(--surf-hi, var(--bg-highlight)); }
.av { display: grid; place-items: center; border-radius: 50%; flex: none; }
.tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
.l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--surf-hi, var(--bg-highlight)); color: var(--text-secondary); }
.l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.l2.warn { color: #f0c070; white-space: normal; }
.r { display: flex; align-items: center; gap: 8px; flex: none; }
.tier { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); }
.tier.lossless { background: color-mix(in srgb, var(--ok, #3ddc84) 12%, transparent); color: #5ee89a; }
.chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; line-height: 1.5; }
.chip.ok { background: color-mix(in srgb, var(--ok, #3ddc84) 14%, transparent); color: #5ee89a; }
.chip.acc { background: var(--accent-soft, color-mix(in srgb, var(--accent) 15%, transparent)); color: var(--accent-hover); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--hair); }
.prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.note { margin-left: auto; font-size: 11px; color: var(--text-muted); }
</style>
Binary file not shown.
+8
View File
@@ -196,6 +196,14 @@ export const spaceBoard = (owner, space) => invoke("space_board", { owner, space
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note }); export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason }); export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list"); export const overlapList = () => invoke("overlap_list");
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
export const holdingsRepublish = () => invoke("holdings_republish");
export const holdingsWaits = () => invoke("holdings_waits");
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username }); export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username }); export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list"); export const buddyList = () => invoke("buddy_list");
+85 -2
View File
@@ -91,6 +91,11 @@ export const community = $state({
boardError: "", boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */ /** overlap_list: library comparisons computed on this device, best first. */
overlap: [], overlap: [],
/** `owner:space` → holdings_room: who in that room publishes what they
* hold, and whether this device does. Read when a Space opens. */
holdings: {},
/** holdings_waits: albums to grab from someone the moment they come online. */
waits: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other /** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */ * people's signed profiles, read once per session when surfaced. */
remoteProfiles: {}, remoteProfiles: {},
@@ -350,6 +355,18 @@ function wireEvents() {
void refreshFollowing(); void refreshFollowing();
}); });
api.onEvent("community:overlap", () => void refreshOverlap()); api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("community:holdings", () => {
// A read finished, a publish landed, a wait changed: re-read the
// open room's lists from the cache (fresh members cost no network).
if (community.space) void refreshHoldings(community.space.owner, community.space.space_id);
void refreshWaits();
});
// The library changed: every room with the switch on gets the new
// list, coalesced so a scan's burst of changes is one publish.
api.onEvent("soulseek:library-changed", () => {
clearTimeout(republishTimer);
republishTimer = setTimeout(() => void api.holdingsRepublish().catch(() => {}), REPUBLISH_DEBOUNCE_MS);
});
api.onEvent("community:proposal", (p) => { api.onEvent("community:proposal", (p) => {
announceProposal(p.from, p.playlist); announceProposal(p.from, p.playlist);
// If the board is already on screen, show what it is talking about. // If the board is already on screen, show what it is talking about.
@@ -357,6 +374,8 @@ function wireEvents() {
}); });
api.onEvent("soulseek:user-state", (p) => { api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username); if (p.status === 1 || p.status === 2) noteSeen(p.username);
// Someone we are waiting on came online: the grab runs now.
if (p.status === 2) void drainWaits(p.username);
}); });
} }
} }
@@ -364,7 +383,7 @@ function wireEvents() {
/** Idempotent: pulls the state the Community page draws. */ /** Idempotent: pulls the state the Community page draws. */
export async function initCommunity() { export async function initCommunity() {
wireEvents(); wireEvents();
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]); await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap(), refreshWaits()]);
await refreshPersona(); await refreshPersona();
} }
@@ -379,6 +398,9 @@ export async function initCommunity() {
* that comes up later — or an inbox published later — simply starts working * that comes up later — or an inbox published later — simply starts working
* without anything having to restart the timer. */ * without anything having to restart the timer. */
const INBOX_POLL_MS = 90_000; const INBOX_POLL_MS = 90_000;
/** A library scan raises many change events in a row; one publish serves them all. */
const REPUBLISH_DEBOUNCE_MS = 30_000;
let republishTimer = 0;
/** Followed playlists are read far less often: every pointer read is a ring /** Followed playlists are read far less often: every pointer read is a ring
* search that can take seconds, and an owner's next revision is a matter * search that can take seconds, and an owner's next revision is a matter
* of days, not minutes. */ * of days, not minutes. */
@@ -705,6 +727,67 @@ export async function refreshSpaces() {
} }
} }
/** Read a Space through the node; the view is what the checkpoint says. */ /** Read a Space through the node; the view is what the checkpoint says. */
// ---- holdings: who has this, and get it from them ---------------------------------
const holdingsId = (owner, space) => `${owner}:${space}`;
/** The room's lists as the cache has them; the backend reads stale members
* in the background and the event brings the result back here. */
export async function refreshHoldings(owner, space, refresh = false) {
try {
community.holdings[holdingsId(owner, space)] = await api.holdingsRoom(owner, space, refresh);
} catch {
// stays as it was
}
}
export const holdingsFor = (owner, space) => community.holdings[holdingsId(owner, space)] ?? null;
/** The switch: publish this device's list into the room, or withdraw it. */
export const setShareHoldings = (owner, space, on) =>
run("holdings:share", async () => {
community.holdings[holdingsId(owner, space)] = await api.holdingsSetShare(owner, space, on);
if (community.space?.owner === owner && community.space?.space_id === space) await openSpace(owner, space);
});
/** Who holds each (artist, album), across every room — local, instant. */
export const askHoldings = (items) => api.holdingsAsk(items);
/** Get it: browse the holder, find the folder the room said they hold, and
* queue every audio file through the hub's own download — the transfers
* page shows them like any album grab. */
export const holdingsGrab = (username, artist, album) =>
run(`grab:${username}:${album}`, async () => {
const m = await api.holdingsFindFolder(username, artist, album);
if (!m) throw new Error(`${username}'s share does not name ${album} any more`);
for (const f of m.files) await api.downloadFile(username, f.path, f.size);
return m;
});
export async function refreshWaits() {
try {
community.waits = await api.holdingsWaits();
} catch {
// stays as it was
}
}
export const waitFor = (username, artist, album, via = null) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitAdd(username, artist, album, via);
});
export const forgetWait = (username, artist, album) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitRemove(username, artist, album);
});
export const waitingOn = (username, artist, album) =>
community.waits.some((w) => w.username.toLowerCase() === (username ?? "").toLowerCase() && w.artist === artist && w.album === album);
/** A waited-on person came online: run their grabs, each once. */
async function drainWaits(username) {
const mine = community.waits.filter((w) => w.username.toLowerCase() === (username ?? "").toLowerCase());
for (const w of mine) {
try {
await holdingsGrab(w.username, w.artist, w.album);
await forgetWait(w.username, w.artist, w.album);
} catch {
// their share no longer names it, or the browse failed: the wait
// stays, and the row says what happened when it is looked at
}
}
}
export async function openSpace(owner, space) { export async function openSpace(owner, space) {
community.spaceError = ""; community.spaceError = "";
community.busy["space:" + space] = true; community.busy["space:" + space] = true;
@@ -814,4 +897,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy). // through this, never through a bare import (which would be a second copy).
if (import.meta.env.DEV) globalThis.__community = community; if (import.meta.env.DEV) globalThis.__community = community;
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest }; if (import.meta.env.DEV) globalThis.__communityActions = { refreshHoldings, holdingsFor, setShareHoldings, askHoldings, holdingsGrab, refreshWaits, waitFor, forgetWait, waitingOn, friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
Binary file not shown.
+13
View File
@@ -30,6 +30,8 @@
import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { community } from "../lib/community.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … } const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
@@ -887,6 +889,13 @@
</div> </div>
{/if} {/if}
{#if !inLibrary && community.spaces.length}
<section class="whohas-sec">
<div class="whohas-head"><h2>Who has this</h2><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key album.artist + "\u0000" + album.title}<WhoHas artist={album.artist} album={album.title} />{/key}
</section>
{/if}
<div class="tracklist"> <div class="tracklist">
{#if tracks.length} {#if tracks.length}
<div class="tcols" class:has-count={showCount}> <div class="tcols" class:has-count={showCount}>
@@ -1976,4 +1985,8 @@
:global(:root.glass) .stat-note { :global(:root.glass) .stat-note {
border-color: var(--glass-hairline); border-color: var(--glass-hairline);
} }
.whohas-sec { margin: 8px 0 18px; padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
.whohas-head h2 { margin: 0; font-size: 15px; font-weight: 800; }
.whohas-head .p { font-size: 11.5px; color: var(--text-muted); }
</style> </style>
+29
View File
@@ -52,6 +52,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte"; import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -2536,6 +2538,17 @@
}); });
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey)); const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
// Who in your rooms holds a release you lack: asked for the visible tab's
// families at once, filed by pairKey; the count rides the card, and the
// open album shows the people.
let heldBy = $state({});
const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
$effect(() => {
const name = page?.artist?.name;
const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
if (!name || !items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be // FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the // used here — it requires the animated element to be the ONLY child of the
@@ -5399,6 +5412,9 @@
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }} onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
><Icon name="play" size={13} /></button> ><Icon name="play" size={13} /></button>
{/if} {/if}
{#if heldFor(al.title)?.length}
<button class="have" title="In your rooms — who has it?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist: page.artist.name, album: al.title, cover: al.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(heldFor(al.title))} {holderCount(heldFor(al.title)) === 1 ? "has it" : "have it"}</button>
{/if}
</div> </div>
<div class="title" title={al.title}> <div class="title" title={al.title}>
{al.title} {al.title}
@@ -5565,6 +5581,12 @@
{/if} {/if}
</div> </div>
<div class="xp-tracks"> <div class="xp-tracks">
{#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
<div class="xp-whohas">
<div class="xp-whohas-head"><span class="t">Who has this</span><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key openAlbum.id}<WhoHas artist={page.artist.name} album={openAlbum.title} compact />{/key}
</div>
{/if}
{#if openLoading} {#if openLoading}
<!-- Nothing at all until the load <!-- Nothing at all until the load
has outlasted LOADING_GRACE: has outlasted LOADING_GRACE:
@@ -9602,4 +9624,11 @@
:global(:root.glass) .notch { :global(:root.glass) .notch {
background: var(--glass-surface); background: var(--glass-surface);
} }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
.xp-whohas { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.xp-whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
.xp-whohas-head .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; }
.xp-whohas-head .p { font-size: 11px; color: var(--text-muted); }
</style> </style>
+73
View File
@@ -13,6 +13,7 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import Identicon, { hueOf } from "../components/Identicon.svelte"; import Identicon, { hueOf } from "../components/Identicon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { rankPeople, reasonParts } from "../lib/discover.js"; import { rankPeople, reasonParts } from "../lib/discover.js";
import Soulseek from "./Soulseek.svelte"; import Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
@@ -96,6 +97,9 @@
pinToSpace, pinToSpace,
leaveSpace, leaveSpace,
setShareListens, setShareListens,
refreshHoldings,
holdingsFor,
setShareHoldings,
openBoard, openBoard,
proposeToBoard, proposeToBoard,
decideProposal, decideProposal,
@@ -548,6 +552,18 @@
let spaceCreating = $state(false); let spaceCreating = $state(false);
const sv = $derived(community.space); const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner"); const spaceOwner = $derived(sv?.my_role === "owner");
// Holdings: the room's lists are read when a Space opens (stale members
// in the background; the event brings the result back), and the ask box
// answers from the cache.
const hv = $derived(sv ? holdingsFor(sv.owner, sv.space_id) : null);
let askArtist = $state("");
let askAlbum = $state("");
let asked = $state(null);
$effect(() => {
const id = sv?.id;
if (!id) return;
untrack(() => void refreshHoldings(sv.owner, sv.space_id));
});
let spaceEl = $state(null); let spaceEl = $state(null);
$effect(() => { $effect(() => {
const n = sv?.messages.length; const n = sv?.messages.length;
@@ -1362,6 +1378,7 @@
<div class="sptabs"> <div class="sptabs">
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button> <button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button> <button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "holdings"} onclick={() => (spaceTab = "holdings")}>Holdings{#if hv?.sharing}<b>{hv.sharing}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button> <button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button> <button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button> <button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
@@ -1446,6 +1463,50 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if spaceTab === "holdings"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_holdings} aria-pressed={sv.share_holdings} title="Share what I hold in this Space"
onclick={() => setShareHoldings(sv.owner, sv.space_id, !sv.share_holdings)} disabled={!nodeUp || !sv.can_post || !!community.busy["holdings:share"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I hold in this Space</div>
<p class="hint">{#if sv.share_holdings && hv?.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)} albums</b> · published{#if sv.private} sealed to this room's epoch key{/if} · updated when your library changes · <b>synced as of {fmtClock(hv.mine.published_at * 1000)}</b>{:else}Album names as keys, one quality word each, and your Soulseek name so members can get it from you — never files. Per Space, off until you say so.{/if}</p>
</div>
{#if community.busy["holdings:share"]}<span class="chip off">working…</span>{:else if sv.share_holdings}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
<div class="disc"><Icon name="alert" size={14} /><span><b>What the room learns:</b> members can tell whether you hold any album they can name, and roughly how many you hold. They never see files, paths, or formats beyond a lossless/lossy word. Turning this off withdraws the list; copies members already read stay with them.</span></div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="search" size={13} /> Ask this room{#if hv}<span class="chip up">{hv.sharing} of {sv.members.length} sharing</span>{/if}</h3>
<form class="inl askinl" style="margin-top:8px" onsubmit={(e) => { e.preventDefault(); if (askArtist.trim() && askAlbum.trim()) asked = { artist: askArtist.trim(), album: askAlbum.trim() }; }}>
<input placeholder="Artist" bind:value={askArtist} />
<input placeholder="Album" bind:value={askAlbum} />
<button class="ax-ghost sm" type="submit" disabled={!askArtist.trim() || !askAlbum.trim()}>Ask</button>
</form>
{#if asked}{#key asked.artist + "\u0000" + asked.album}<div style="margin-top:8px"><WhoHas artist={asked.artist} album={asked.album} /></div>{/key}{/if}
</div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="users" size={13} /> Who shares here <button class="ax-ghost sm" style="margin-left:auto" onclick={() => refreshHoldings(sv.owner, sv.space_id, true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Refresh</button></h3>
{#if hv}
<div class="hlist">
<div class="hrow">
<Identicon seed={key ?? "you"} size={30} />
<span class="tx"><span class="l1">you</span>
<span class="l2">{#if sv.share_holdings && hv.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)}</b> albums · list {hv.mine.sequence} · published {fmtClock(hv.mine.published_at * 1000)}{:else}not sharing{/if}</span></span>
<span class="chip" class:acc={sv.share_holdings && hv.mine && !hv.mine.withdrawn} class:off={!(sv.share_holdings && hv.mine && !hv.mine.withdrawn)}>{sv.share_holdings && hv.mine && !hv.mine.withdrawn ? "shared" : "none"}</span>
</div>
{#each hv.members.filter((m) => !m.is_me) as m (m.key)}
<div class="hrow">
<Identicon seed={m.username ?? m.key} size={30} />
<span class="tx"><span class="l1">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="l2">{#if m.state === "shared"}<b>{fmtCount(m.count)}</b> albums · list {m.sequence}{#if m.read_at} · read {fmtClock(m.read_at * 1000)}{/if}{:else if m.state === "none"}not sharing{:else if m.state === "withdrawn"}withdrew their list{:else if m.state === "sealed"}sealed to an epoch you have no key for{:else}not read yet{/if}</span></span>
<span class="chip" class:acc={m.state === "shared"} class:off={m.state !== "shared"}>{m.state}</span>
</div>
{/each}
</div>
{:else}
<p class="hint" style="margin-top:8px">Reading the room's lists…</p>
{/if}
<p class="hint" style="margin-top:8px">A member's list is read when the Space opens and again after ten minutes; a member who never published costs a slow search, so they are asked again only hourly.</p>
</div>
{:else if spaceTab === "members"} {:else if spaceTab === "members"}
<div class="panel"> <div class="panel">
<div class="members"> <div class="members">
@@ -2390,6 +2451,18 @@
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; } .mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); } .mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.mem .role.own { color: #d9a6ff; } .mem .role.own { color: #d9a6ff; }
/* holdings: the disclosure and the who-shares list */
.disc { display: flex; gap: 8px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; background: color-mix(in srgb, var(--warn) 7%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 25%, transparent); font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; }
.disc b { color: #f0c070; }
.askinl input { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
.askinl input::placeholder { color: var(--text-muted); }
.hlist { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
.hrow { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 8px; min-height: 40px; }
.hrow:hover { background: var(--surf-hi); }
.hrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.hrow .l1 { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hrow .l2 { font-size: 11.5px; color: var(--text-muted); }
.hrow .l2 b { color: var(--text-secondary); }
/* ── you ── */ /* ── you ── */
/* The You section sized by the PANE (container query), never the window: /* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */ three columns from ~1240px of pane, two down to ~820, then one. */
+25 -4
View File
@@ -19,7 +19,7 @@
// is CatalogArtist's class for class with its animation numbers unchanged. // is CatalogArtist's class for class with its animation numbers unchanged.
// Styles are copied because Svelte scopes them per component — the same // Styles are copied because Svelte scopes them per component — the same
// trade CatalogDiscography made, with provenance comments at each block. // trade CatalogDiscography made, with provenance comments at each block.
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -41,6 +41,7 @@
importResume, importResume,
goToView, goToView,
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import { import {
fileRelease, fileRelease,
CAT_ORDER, CAT_ORDER,
@@ -121,6 +122,17 @@
} catch { return {}; } } catch { return {}; }
})()); })());
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapNames = $state({}); // foldKey(artist) -> the artist's display name
// Who in your rooms holds a gap: asked for every open shelf's rows at
// once, filed by pairKey. A marked card opens the ask sheet.
let heldBy = $state({});
$effect(() => {
const items = Object.keys(gapOpen)
.filter((k) => gapOpen[k] && gapRows[k]?.length && gapNames[k])
.flatMap((k) => gapRows[k].map((r) => ({ artist: gapNames[k], album: r.title })));
if (!items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
let gapOpen = $state({}); // foldKey(artist) -> line expanded let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({}); let gapLoading = $state({});
@@ -182,6 +194,7 @@
...gapRows, ...gapRows,
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))), [key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
}; };
gapNames = { ...gapNames, [key]: artistName };
gapTotals = { ...gapTotals, [key]: { total: released.length } }; gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ } try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ } } catch { /* the line stays collapsed; nothing is invented */ }
@@ -1544,7 +1557,7 @@
</div> </div>
{/snippet} {/snippet}
{#snippet gapCard(r)} {#snippet gapCard(r, artist)}
<!-- A record you do NOT own, at full size and dimmed. It is filed by the <!-- A record you do NOT own, at full size and dimmed. It is filed by the
same classifier as everything else, off the CatalogAlbum payload's own same classifier as everything else, off the CatalogAlbum payload's own
fields (`record_type`/`track_count`/`release_date`, plus the is_live and fields (`record_type`/`track_count`/`release_date`, plus the is_live and
@@ -1558,7 +1571,8 @@
is_live: r.is_live, is_live: r.is_live,
is_soundtrack: r.is_soundtrack, is_soundtrack: r.is_soundtrack,
})} })}
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}> {@const held = heldBy[pairKey(artist, r.title)]}
<div class="release gap" class:held={!!held?.length} role="listitem" title={`${r.title} — not in your library`}>
<div class="cover-wrap"> <div class="cover-wrap">
{#if r.release_date?.slice(0, 4)} {#if r.release_date?.slice(0, 4)}
<span class="ed-badge">{r.release_date.slice(0, 4)}</span> <span class="ed-badge">{r.release_date.slice(0, 4)}</span>
@@ -1568,6 +1582,9 @@
{:else} {:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div> <div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if} {/if}
{#if held?.length}
<button class="have" title="{holderCount(held)} in your rooms {holderCount(held) === 1 ? "has" : "have"} this — who?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist, album: r.title, cover: r.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(held)} {holderCount(held) === 1 ? "has it" : "have it"}</button>
{/if}
</div> </div>
<div class="title" title={r.title}>{r.title}</div> <div class="title" title={r.title}>{r.title}</div>
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div> <div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
@@ -1826,7 +1843,7 @@
<div class="lib-gaps" role="list"> <div class="lib-gaps" role="list">
<div class="grid"> <div class="grid">
{#each missing as r (r.id)} {#each missing as r (r.id)}
{@render gapCard(r)} {@render gapCard(r, name)}
{/each} {/each}
</div> </div>
</div> </div>
@@ -2942,6 +2959,10 @@
.lib-gaps { margin-top: 12px; } .lib-gaps { margin-top: 12px; }
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; } .lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
.lib-gaps .release.gap:hover { opacity: 1; background: none; } .lib-gaps .release.gap:hover { opacity: 1; background: none; }
.lib-gaps .release.gap.held { opacity: 0.72; }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
/* ============================================================ the table /* ============================================================ the table
The power-user surface: every column is something the files carry. The The power-user surface: every column is something the files carry. The
+23
View File
@@ -39,6 +39,7 @@
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import { untrack } from "svelte"; import { untrack } from "svelte";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { community, forgetWait } from "../lib/community.svelte.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -408,6 +409,18 @@
</div> </div>
{#if sub === "downloads"} {#if sub === "downloads"}
{#if community.waits.length}
<div class="waits">
<div class="waits-head">Waiting for <b>{community.waits.length}</b> · grabs that start the moment the person comes online</div>
{#each community.waits as w (w.username + "\u0000" + w.artist + "\u0000" + w.album)}
<div class="wait">
<span class="tx"><span class="t">{w.album}</span><span class="a">{w.artist} · from <b>{w.username}</b>{#if w.via} · via {w.via}{/if}</span></span>
<span class="chip">waiting</span>
<button class="toolbtn" onclick={() => forgetWait(w.username, w.artist, w.album)}>Forget</button>
</div>
{/each}
</div>
{/if}
{#if dlItems.length === 0} {#if dlItems.length === 0}
<div class="empty"> <div class="empty">
<p class="big">No downloads yet</p> <p class="big">No downloads yet</p>
@@ -1622,4 +1635,14 @@
:global(:root.glass) .bub.me { :global(:root.glass) .bub.me {
background: var(--accent-soft); background: var(--accent-soft);
} }
/* holdings: albums waiting on a person's presence */
.waits { margin: 0 0 12px; padding: 8px 10px; border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; }
.waits-head { font-size: 11.5px; color: var(--text-muted); margin-bottom: 4px; }
.waits-head b { color: var(--text-secondary); }
.wait { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
.wait .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.wait .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wait .a { font-size: 11px; color: var(--text-muted); }
.wait .a b { color: var(--text-secondary); }
.wait .chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-secondary); }
</style> </style>