Files
SoulStream/src/views/Community.svelte
T

2595 lines
184 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// The Community area — the people around your music, on two networks
// that are never confused for each other. Soulseek is the "here right
// now" network: rooms, PMs, presence. SoulStream (over Freenet) is the
// "kept" network: a signed identity, a published profile, later friends
// and Spaces. Every card names which one it came from; presence dots are
// Soulseek's alone.
//
// Shipped from the approved mockup (research/community-mockups,
// 2026-09-02: pills, grooves identicon, compact density, persona
// ambience). Only what has a real backend is live here; what does not
// says so in its own words rather than showing placeholder data.
import { onMount, untrack } from "svelte";
import Icon from "../components/Icon.svelte";
import Identicon, { hueOf } from "../components/Identicon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { rankPeople, reasonParts } from "../lib/discover.js";
import Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js";
import {
app,
ui,
slsk,
downloads,
network,
goToView,
searchNetworkFor,
openChat,
sendDm,
joinRoom,
leaveRoom,
sayRoom,
addBuddy,
removeBuddy,
loadBuddies,
} from "../lib/state.svelte.js";
import {
community,
setSection,
adapterLabel,
adapterConnected,
statusKind,
startNode,
stopNode,
initCommunity,
setFreenetEnabled,
setLoadRemoteImages,
detectNode,
createPersona,
exportPersona,
importPersona,
rotatePersona,
removePersona,
loadProfile,
publishProfile,
pickAndSetAvatar,
clearAvatar,
metaOf,
setContactMeta,
toggleRoomFav,
personaKey,
loadUserInfo,
loadSimilar,
refreshInterests,
setInterest,
refreshLinks,
linkSelf,
unlinkSelf,
checkLink,
linkFor,
loadRemoteProfile,
friendRequest,
friendAccept,
friendDecline,
friendRemove,
friendForget,
friendshipFor,
friendCount,
pendingIn,
sharePreview,
shareImport,
shareDismiss,
followNews,
followUpdate,
unfollow,
sharedPublish,
sharedWithdraw,
publishInbox,
checkInbox,
friendRequestByKey,
refreshSpaces,
openSpace,
createSpace,
postToSpace,
inviteToSpace,
removeFromSpace,
pinToSpace,
leaveSpace,
setShareListens,
refreshHoldings,
holdingsFor,
setShareHoldings,
openBoard,
proposeToBoard,
decideProposal,
overlapFor,
computeOverlap,
forgetOverlap,
} from "../lib/community.svelte.js";
onMount(() => {
void initCommunity();
if (app.connection === "online") void loadBuddies();
});
// ---- the fused hub -------------------------------------------------------
// Search, Transfers and Browse are the Soulseek hub rendered embedded;
// its old Chat / Users / Rooms tabs map onto this page's own sections.
const HUB_SECTIONS = new Set(["search", "transfers", "browse"]);
const hubOpen = $derived(HUB_SECTIONS.has(community.section));
function fromHub(tab) {
const map = { chat: "inbox", users: "people", rooms: "rooms" };
const s = map[tab] ?? tab;
if (s !== community.section) setSection(s);
}
// The hub's routable ids (the download ring, "get on soulseek", Home's
// CTA) arrive as ui.view; a repeat jump bumps ui.hubJump.
$effect(() => {
const v = ui.view;
void ui.hubJump;
const s = v === "search" ? "search" : v === "downloads" ? "transfers" : null;
if (s) untrack(() => setSection(s));
});
const TRANSFER_DONE = new Set(["complete", "failed", "cancelled"]);
const transfersActive = $derived(
Object.values(downloads.items).filter((d) => !TRANSFER_DONE.has(d.state)).length
+ Object.values(network.uploads).filter((u) => u.state === "transferring").length,
);
// ---- small helpers -------------------------------------------------------
const key = $derived(personaKey());
const profile = $derived(community.profile);
const avatarUrl = $derived(
profile?.avatar_png_base64 ? `data:image/png;base64,${profile.avatar_png_base64}` : null,
);
const hue = $derived(key ? hueOf(key) : null);
const freenetOn = $derived(!!community.freenet?.enabled);
const nodeUp = $derived(adapterConnected(community.freenet?.status));
// A boolean, deliberately: reading community.inbox?.published straight from
// an effect makes that effect depend on the whole record, and a check
// REPLACES the record — which re-ran the effect, which checked again. A
// derived only notifies when its value actually flips.
const inboxLive = $derived(!!community.inbox?.published);
const online = $derived(app.connection === "online");
const me = $derived(app.username ?? "");
const ownLink = $derived(community.links?.own ?? null);
/** The stored link is only "current" for the signed-in account and the
* persona this device holds now — a different login or key is unlinked. */
const ownLinkCurrent = $derived(
!!ownLink && !!key && !!me && ownLink.username_normalized === me.trim().toLowerCase() && ownLink.persona_key === key,
);
const display = (u) => community.contacts[u]?.petname || u;
const isContact = (u) => slsk.buddies.includes(u);
/** Soulseek's word for a user's state, from what this device last heard. */
function presenceOf(u) {
const st = slsk.buddyState[u];
if (!st) return null;
if (st.exists === false) return null;
if (st.status === 2) return "online";
if (st.status === 1) return "away";
return "off";
}
const presenceFromStatus = (s) => (s === 2 ? "online" : s === 1 ? "away" : "off");
const presenceWord = { online: "online", away: "away", off: "offline" };
const fmtClock = (ms) =>
new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function fmtWhen(ms) {
if (!ms) return "";
const d = new Date(ms);
const now = new Date();
const day = 86400000;
if (d.toDateString() === now.toDateString()) return fmtClock(ms);
if (now - d < 2 * day && new Date(now - day).toDateString() === d.toDateString()) return "Yesterday";
if (now - d < 6 * day) return d.toLocaleDateString([], { weekday: "short" });
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function fmtSeen(ms) {
if (!ms) return null;
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return `today · ${fmtClock(ms)}`;
return `${fmtWhen(ms)} · ${fmtClock(ms)}`;
}
const dayLabel = (ms) => {
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
if (new Date(now - 86400000).toDateString() === d.toDateString()) return "Yesterday";
return d.toLocaleDateString([], { month: "long", day: "numeric" });
};
const byteLen = (s) => new TextEncoder().encode(s ?? "").length;
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
const fpGroups = (k) => (k ? k.match(/.{1,4}/g) : []);
const fmtCount = (n) => (n ?? 0).toLocaleString();
async function copyKey() {
if (!key) return;
try {
await navigator.clipboard.writeText(key);
copied = true;
setTimeout(() => (copied = false), 1400);
} catch {
// clipboard refused — the key is still on screen to select
}
}
let copied = $state(false);
// Hand-offs into the Soulseek hub: browse lives there.
function browseUser(u) {
slsk.browseRequest = u;
setSection("browse");
}
function messageUser(u) {
openChat(u);
setSection("inbox");
community.drawer = null;
}
// ---- inbox ---------------------------------------------------------------
const convos = $derived(
Object.entries(slsk.chats)
.map(([u, c]) => ({
u,
last: c.messages[c.messages.length - 1] ?? null,
unread: c.unread,
mine: c.messages.some((m) => m.me),
}))
.sort((a, b) => (b.last?.ts ?? 0) - (a.last?.ts ?? 0)),
);
/** People who wrote first and are not contacts — held apart, not hidden. */
const requests = $derived(convos.filter((c) => !isContact(c.u) && !c.mine));
const threads = $derived(convos.filter((c) => isContact(c.u) || c.mine));
const unreadTotal = $derived(convos.reduce((n, c) => n + c.unread, 0));
let requestsOpen = $state(false);
const activeChat = $derived(slsk.activeChat ? slsk.chats[slsk.activeChat] : null);
$effect(() => {
// Landing on Inbox with nothing selected opens the freshest thread.
if (community.section === "inbox" && !slsk.activeChat && convos.length) {
untrack(() => openChat(convos[0].u));
}
});
let dmText = $state("");
let dmState = $state({ sending: false, failed: "" });
async function sendCurrent() {
const to = slsk.activeChat;
const text = dmText.trim();
if (!to || !text) return;
dmState = { sending: true, failed: "" };
try {
await sendDm(to, text);
dmText = "";
dmState = { sending: false, failed: "" };
} catch (e) {
dmState = { sending: false, failed: text };
}
}
let msgsEl = $state(null);
$effect(() => {
const n = activeChat?.messages.length;
if (n && msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
});
function groupedMessages(list) {
const out = [];
let lastDay = "";
for (const m of list) {
const d = dayLabel(m.ts);
if (d !== lastDay) {
out.push({ divider: d });
lastDay = d;
}
out.push(m);
}
return out;
}
// ---- rooms ---------------------------------------------------------------
let roomQuery = $state("");
let roomText = $state("");
const joinedNames = $derived(Object.keys(slsk.joined));
const activeRoom = $derived(slsk.activeRoom ? slsk.joined[slsk.activeRoom] : null);
const directory = $derived.by(() => {
const q = roomQuery.trim().toLowerCase();
return [...slsk.rooms]
.filter((r) => !q || r.name.toLowerCase().includes(q))
.sort((a, b) => (b.users ?? 0) - (a.users ?? 0))
.slice(0, 80);
});
const favRooms = $derived(community.roomFavs.filter((n) => !slsk.joined[n]));
/** When this device first saw each room this session — "history since". */
const roomSince = $state({});
$effect(() => {
for (const n of joinedNames) roomSince[n] ??= Date.now();
});
const roster = $derived.by(() => {
const users = activeRoom?.users ?? [];
const here = users.filter((u) => u.username !== me);
return {
contacts: here.filter((u) => isContact(u.username)),
others: here.filter((u) => !isContact(u.username)),
};
});
let roomEl = $state(null);
$effect(() => {
const n = activeRoom?.messages.length;
if (n && roomEl) roomEl.scrollTop = roomEl.scrollHeight;
});
async function sayCurrent() {
const r = slsk.activeRoom;
const t = roomText.trim();
if (!r || !t) return;
await sayRoom(r, t);
roomText = "";
}
const roomCount = (name) => slsk.rooms.find((r) => r.name === name)?.users;
// ---- people --------------------------------------------------------------
let peopleQuery = $state("");
let peopleGroup = $state("all");
let addName = $state("");
let adding = $state(false);
const groups = $derived(
[...new Set(Object.values(community.contacts).map((c) => c.group).filter(Boolean))].sort(),
);
const people = $derived.by(() => {
const q = peopleQuery.trim().toLowerCase();
return slsk.buddies
.map((u) => ({ u, meta: metaOf(u), st: slsk.buddyState[u] ?? {}, presence: presenceOf(u) }))
.filter((p) => !q || p.u.toLowerCase().includes(q) || (p.meta.petname ?? "").toLowerCase().includes(q))
.filter((p) =>
peopleGroup === "all"
? true
: peopleGroup === "trusted"
? p.meta.trusted
: peopleGroup === "friends"
? friendshipFor(p.u)?.state === "friends"
: peopleGroup === "favorite"
? p.meta.favorite
: p.meta.group === peopleGroup,
)
.sort((a, b) => display(a.u).localeCompare(display(b.u)));
});
const peopleOnline = $derived(people.filter((p) => p.presence === "online"));
const peopleAway = $derived(people.filter((p) => p.presence === "away"));
const peopleOff = $derived(people.filter((p) => p.presence !== "online" && p.presence !== "away"));
async function addContact(u) {
const name = (u ?? addName).trim();
if (!name) return;
adding = true;
try {
await addBuddy(name);
addName = "";
} finally {
adding = false;
}
}
// ---- discover ------------------------------------------------------------
// The ranked shelf: the four silos below scored as one list, each card
// naming the shelves it drew from. Contacts and friends are left off —
// they have shelves of their own; this one is for people not yet sorted.
const ranked = $derived.by(() =>
rankPeople({
me,
skip: (u) => isContact(u) || friendshipFor(u)?.state === "friends",
rooms: slsk.joined,
activeRoom: slsk.activeRoom,
downloads: Object.values(downloads.items),
uploads: Object.values(network.uploads),
similar: community.similar?.users ?? [],
overlap: community.overlap,
space: community.space ? { name: community.space.name, members: community.space.members } : null,
}),
);
const roomPeople = $derived.by(() => {
if (!activeRoom) return [];
return activeRoom.users
.filter((u) => u.username !== me && !isContact(u.username))
.slice(0, 24);
});
const recentPeers = $derived.by(() => {
const tally = {};
for (const d of Object.values(downloads.items)) {
if (!d.username || isContact(d.username)) continue;
(tally[d.username] ??= { u: d.username, from: 0, to: 0 }).from += 1;
}
for (const u of Object.values(network.uploads)) {
if (!u.username || isContact(u.username)) continue;
(tally[u.username] ??= { u: u.username, from: 0, to: 0 }).to += 1;
}
return Object.values(tally)
.sort((a, b) => b.from + b.to - (a.from + a.to))
.slice(0, 24);
});
// ---- you -----------------------------------------------------------------
let nameDraft = $state("");
let bioDraft = $state("");
let seededSeq = $state(null);
$effect(() => {
// Seed the form from the published profile once per revision, so a
// publish (or a first load) fills it and typing is never clobbered.
const p = community.profile;
const seq = p?.sequence ?? 0;
if (seq === seededSeq) return;
untrack(() => {
nameDraft = p?.display_name ?? "";
bioDraft = p?.bio ?? "";
seededSeq = seq;
});
});
const dirty = $derived(
nameDraft !== (profile?.display_name ?? "") || bioDraft !== (profile?.bio ?? ""),
);
const nameBytes = $derived(byteLen(nameDraft));
const bioBytes = $derived(byteLen(bioDraft));
const canPublish = $derived(
nodeUp && !!key && dirty && nameBytes <= 64 && bioBytes <= 280 && !community.busy.publish,
);
let passExport = $state("");
let passImport = $state("");
let importForce = $state(false);
let exportDone = $state(null);
let confirmRotate = $state(false);
let confirmRemove = $state(false);
let disclosuresOpen = $state(false);
async function doExport() {
const r = await exportPersona(passExport);
if (r) {
exportDone = r;
passExport = "";
}
}
async function doImport() {
await importPersona(passImport, importForce);
passImport = "";
importForce = false;
}
const status = $derived(adapterLabel(community.freenet?.status));
// ---- the person drawer ---------------------------------------------------
const drawerUser = $derived(community.drawer);
const drawerMeta = $derived(drawerUser ? metaOf(drawerUser) : null);
const drawerState = $derived(drawerUser ? (slsk.buddyState[drawerUser] ?? null) : null);
let petDraft = $state("");
let groupDraft = $state("");
let noteDraft = $state("");
$effect(() => {
const m = drawerMeta;
untrack(() => {
petDraft = m?.petname ?? "";
groupDraft = m?.group ?? "";
noteDraft = m?.note ?? "";
});
});
// The native card is fetched only for a surfaced profile (the design's
// rule), once per session per person, and only while signed in.
$effect(() => {
const u = drawerUser;
if (u && online) untrack(() => loadUserInfo(u));
});
const drawerInfo = $derived(drawerUser ? community.userInfo[drawerUser] : null);
const drawerInterests = $derived(drawerUser ? community.interests[drawerUser] : null);
const drawerPhoto = $derived(
drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null,
);
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
async function importShare(card) {
const id = await shareImport(card);
if (id) goToView("playlist", { playlistId: id, label: card.name });
}
// The app polls the inbox for the whole session (startInboxWatch, in
// community.svelte.js) — arriving here only asks for a fresh read now, so
// the page opens on current state instead of waiting out the interval.
$effect(() => {
if (!nodeUp || !key || !inboxLive) return;
untrack(() => void checkInbox());
});
let addKey = $state("");
// ---- spaces ------------------------------------------------------------
let spaceName = $state("");
let spacePrivate = $state(true);
let spaceText = $state("");
let spaceInviteKey = $state("");
let spaceTab = $state("chat");
/* A listen is a point in time, not a state: nothing on the wire says
"still playing". So the room reads one as live for a while and then
lets it go quiet, rather than claiming someone is listening because
their app never got to say they stopped. */
const LISTEN_LIVE_MINS = 15;
// Newest first, which is the opposite of the chat's order: a trail is
// read from the top, a conversation from the bottom.
const listensNewestFirst = $derived([...(sv?.listens ?? [])].sort((a, b) => b.created_at - a.created_at));
const nowPlaying = $derived.by(() => {
const seen = new Map();
const cutoff = Date.now() / 1000 - LISTEN_LIVE_MINS * 60;
for (const l of listensNewestFirst) {
if (!seen.has(l.author)) seen.set(l.author, { ...l, live: l.created_at >= cutoff });
}
// Whoever is playing leads; the rest are the room's most recent, and
// only enough of them to stay a band rather than a second list.
return [...seen.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.created_at - a.created_at).slice(0, 8);
});
// Day separators and run-grouping are decided once, here, so the markup
// stays a list and the rules stay readable.
const trail = $derived.by(() => {
let lastDay = "";
let lastAuthor = "";
return listensNewestFirst.map((l) => {
const d = fmtWhen(l.created_at * 1000);
const dayLabel = new Date(l.created_at * 1000).toDateString();
const day = dayLabel === lastDay ? null : (lastDay = dayLabel) && (isToday(l.created_at) ? "Today" : d);
const run = !day && l.author === lastAuthor;
lastAuthor = l.author;
return { ...l, day, run };
});
});
const isToday = (secs) => new Date(secs * 1000).toDateString() === new Date().toDateString();
function ago(secs) {
const m = Math.max(0, Math.round(Date.now() / 1000 - secs) / 60);
if (m < 60) return `${Math.max(1, Math.round(m))}m ago`;
const h = m / 60;
return h < 24 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`;
}
/* The cover has not been resolved for a track someone else played, and
fetching one per row would be a lot of network for a list. A hue keyed
off the album gives the trail a stable colour per record instead. */
function artWash(seed) {
let h = 0x811c9dc5;
for (const c of String(seed)) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
const hue = h % 360;
return `linear-gradient(140deg,hsl(${hue} 42% 26%),hsl(${(hue + 40) % 360} 38% 16%))`;
}
// The one action a listen affords: go and find it. Artist and title are
// the pair every other dead end in the app searches on, and
// `searchNetworkFor` is that road — it seeds the query and switches the
// pill, so this needs no path of its own.
const findListen = (l) => void searchNetworkFor(`${l.artist} ${l.title}`.trim());
let spaceCreating = $state(false);
const sv = $derived(community.space);
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);
$effect(() => {
const n = sv?.messages.length;
if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight;
});
// The open Space is re-read every 30 s while the section is showing.
$effect(() => {
if (community.section !== "spaces" || !sv || !nodeUp) return;
const { owner, space_id } = sv;
const t = setInterval(() => void openSpace(owner, space_id), 30_000);
return () => clearInterval(t);
});
async function sayInSpace() {
const t = spaceText.trim();
if (!t || !sv) return;
await postToSpace(sv.owner, sv.space_id, t);
spaceText = "";
}
async function makeSpace() {
const n = spaceName.trim();
if (!n) return;
await createSpace(n, "", spacePrivate);
spaceName = "";
spaceCreating = false;
}
const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author));
const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null);
$effect(() => {
const s0 = sv;
if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) {
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
}
});
// ---- the proposal board (F7). The composer is a working copy of the pinned
// playlist that records each act as an op; the Rust side dry-runs the same
// ops against the exact base before anything is signed.
const bv = $derived(community.board);
const boardKey = $derived(sv ? `${sv.id}|${sv.pinned_uuid ?? ""}` : "");
const boardBusy = $derived(!!(community.busy["board:propose"] || community.busy["board:decide"] || (sv && community.busy["board:" + sv.space_id])));
let boardDraft = $state(null);
let boardQuery = $state("");
let boardHits = $state([]);
let boardReason = $state({});
let freshCount = 0;
let boardTimer;
$effect(() => {
const k = boardKey;
if (spaceTab !== "proposals" || !k || k.endsWith("|")) return;
untrack(() => {
boardDraft = null;
void openBoard(sv.owner, sv.space_id);
});
});
function applyOp(list, op) {
if (op.kind === "add") {
list.splice(Math.min(op.position, list.length), 0, { entry_id: op.token, title: op.title, artist: op.artist, album: op.album, duration_secs: op.duration_secs, fresh: true });
return true;
}
const i = list.findIndex((e) => e.entry_id === op.entry_id);
if (i < 0) return false;
if (op.kind === "remove") {
list.splice(i, 1);
return true;
}
const [e] = list.splice(i, 1);
list.splice(Math.min(op.to_position, list.length), 0, e);
return true;
}
function startDraft() {
if (!bv) return;
boardDraft = { base: bv.base, list: bv.entries.map((e) => ({ ...e })), ops: [], note: "" };
boardQuery = "";
boardHits = [];
}
function draftApply(op) {
if (!boardDraft || boardDraft.ops.length >= 32) return;
if (applyOp(boardDraft.list, op)) boardDraft.ops.push(op);
}
function draftUndo() {
if (!boardDraft?.ops.length) return;
boardDraft.ops.pop();
const list = bv.entries.map((e) => ({ ...e }));
for (const op of boardDraft.ops) applyOp(list, op);
boardDraft.list = list;
}
function draftAdd(h) {
draftApply({ kind: "add", position: boardDraft.list.length, title: h.title, artist: h.artist, album: h.album ?? "", duration_secs: h.duration_secs ?? null, token: `new:${++freshCount}` });
boardQuery = "";
boardHits = [];
}
function searchLibrary() {
clearTimeout(boardTimer);
const q = boardQuery.trim();
if (q.length < 2) {
boardHits = [];
return;
}
boardTimer = setTimeout(async () => {
try {
boardHits = await api.libraryTrackSearch(q);
} catch {
boardHits = [];
}
}, 220);
}
async function sendDraft() {
if (!boardDraft?.ops.length || !sv) return;
const ops = boardDraft.ops.map((o) => (o.kind === "add" ? { kind: "add", position: o.position, title: o.title, artist: o.artist, album: o.album, duration_secs: o.duration_secs, token: o.token } : { ...o }));
try {
await proposeToBoard(sv.owner, sv.space_id, boardDraft.base, ops, boardDraft.note);
boardDraft = null;
} catch {
// boardError shows it
}
}
const decideOn = (p, accept) =>
decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {});
const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`);
const drawerOverlap = $derived(drawerUser ? overlapFor(drawerUser) : null);
async function importPinned() {
if (!sv?.pinned_uuid) return;
const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" });
if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" });
}
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
$effect(() => {
const lk = drawerLink;
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
});
$effect(() => {
for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key));
});
let likeDraft = $state("");
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) {
untrack(() => loadSimilar());
untrack(() => refreshInterests());
}
});
function saveMeta(patch = {}) {
if (!drawerUser) return;
void setContactMeta({
...metaOf(drawerUser),
petname: petDraft,
group: groupDraft,
note: noteDraft,
...patch,
});
}
function closeDrawer() {
community.drawer = null;
}
// The drawer must escape the content pane: the pane is a containing
// block (its band clip and scroll timeline), so a fixed child stays
// inside it and under the Now Playing panel — and the glass bar's fog
// clone would carry a second copy. Body-level, it is the one overlay.
function portal(node) {
document.body.appendChild(node);
return { destroy: () => node.remove() };
}
function onKey(e) {
if (e.key === "Escape" && community.drawer) closeDrawer();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="community" style:--hue={hue ?? 200}>
{#if hue !== null}
<div class="amb" aria-hidden="true"></div>
{/if}
<!-- ── the identity strip ─────────────────────────────────────────── -->
<div class="who">
{#if key}
<Identicon seed={key} size={48} photo={avatarUrl} ring presence={online ? "online" : "off"} />
{:else}
<span class="noid"><Icon name="users" size={22} /></span>
{/if}
<div class="whotx">
<div class="name">
{profile?.display_name || (key ? "You" : "You, on Soulseek")}
{#if key}<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>{/if}
</div>
<div class="id">
{#if key}
<button class="fp" title="Copy your full SoulStream ID" onclick={copyKey}>
<span class="k">SoulStream ID</span>
<span class="mono">{fpGroups(key).slice(0, 4).join("·")}·…·{key.slice(-4)}</span>
<span class="cp"><Icon name={copied ? "check" : "copy"} size={12} /></span>
</button>
<span class="muted">·</span>
{/if}
<span class="sl">
<Icon name="globe" size={12} />
{#if me}<b>{me}</b> on Soulseek{:else}not signed in to Soulseek{/if}
<span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span>
{#if ownLinkCurrent}<span class="chip acc" title="Verified through a Soulseek message on {new Date(ownLink.verified_at * 1000).toLocaleString()}"><Icon name="link" size={10} />linked</span>{/if}
</span>
</div>
</div>
<div class="right">
<div class="st">
{#if profile}
<span class="chip ok"><Icon name="check" size={10} />{profile.from_ledger ? "profile published" : `profile synced as of ${fmtClock(community.profileAt)}`} · rev {profile.sequence}</span>
{:else if key}
<span class="chip off">nothing published yet</span>
{:else if freenetOn}
<span class="chip {status.tone}">{status.text}</span>
{:else}
<span class="chip off">SoulStream identity off</span>
{/if}
<span class="hint">{friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
</div>
<button class="ax-ghost" onclick={() => setSection("you")}>
<Icon name={key ? "pencil" : "sparkle"} size={13} />
{key ? "Edit profile" : "Set up identity"}
</button>
</div>
</div>
<!-- ── the pills ──────────────────────────────────────────────────── -->
<div class="qtabs">
<button class="qtab" class:on={community.section === "search"} onclick={() => setSection("search")}>Search</button>
<button class="qtab" class:on={community.section === "transfers"} onclick={() => setSection("transfers")}>
Transfers{#if transfersActive}<span class="n">{transfersActive}</span>{/if}
</button>
<button class="qtab" class:on={community.section === "browse"} onclick={() => setSection("browse")}>Browse</button>
<span class="pillsep" aria-hidden="true"></span>
<button class="qtab" class:on={community.section === "inbox"} onclick={() => setSection("inbox")}>
Inbox{#if unreadTotal}<span class="n">{unreadTotal}</span>{/if}
</button>
<button class="qtab" class:on={community.section === "rooms"} onclick={() => setSection("rooms")}>
Rooms{#if joinedNames.length}<span class="n">{joinedNames.length}</span>{/if}
</button>
<button class="qtab" class:on={community.section === "people"} onclick={() => setSection("people")}>
People{#if slsk.buddies.length}<span class="n">{slsk.buddies.length}</span>{/if}
</button>
<button class="qtab" class:on={community.section === "discover"} onclick={() => setSection("discover")}>Discover</button>
<button class="qtab" class:on={community.section === "spaces"} onclick={() => setSection("spaces")}>
Spaces<span class="chip up">beta</span>
</button>
<span class="pillsep" aria-hidden="true"></span>
<button class="qtab you" class:on={community.section === "you"} onclick={() => setSection("you")}>
{#if key}<Identicon seed={key} size={22} photo={avatarUrl} />{:else}<Icon name="users" size={14} />{/if}
You
</button>
</div>
<!-- ── SEARCH · TRANSFERS · BROWSE — the hub, embedded ─────────────── -->
{#if hubOpen}
<Soulseek embedded section={community.section} onsection={fromHub} />
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
{:else if community.section === "inbox"}
{#if pendingIn().length}
<div class="freqs">
{#each pendingIn() as f (f.id)}
<div class="card inv">
<div class="top">
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
<div>
<div class="t">{f.username ? display(f.username) : community.remoteProfiles[f.persona_key]?.display_name || `SoulStream ID ${fpShort(f.persona_key)}`} wants to be friends</div>
<div class="s">{f.username ? `${f.username} · ` : ""}SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>{f.carry === "inbox" ? "sealed · via your encrypted inbox · signed by their key" : "signed request · their ID proven over Soulseek"}</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
</div>
</div>
<div class="bot">
<button class="ax-fill sm" onclick={() => friendAccept(f.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + f.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(f.id)} disabled={!!community.busy["friend:" + f.id]}>Decline</button>
<span class="hint">friends are two signed answers — yours goes back to them over Soulseek</span>
</div>
</div>
{/each}
</div>
{/if}
{#if community.shares.length}
<div class="freqs">
{#each community.shares as c (c.id)}
{@const pv = community.sharePreviews[c.id]}
<div class="card">
<div class="top">
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
<div>
<div class="t">{c.name}</div>
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {#if c.from_username}<button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button>{:else}SoulStream ID {fpShort(c.from_key)}{/if} {fmtWhen(c.received_at * 1000)}</div>
<div class="prov">
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
<span>signed by {fpShort(c.owner_key)}</span>
{#if c.carry === "inbox"}<span>·</span><span><Icon name="lock" size={9} /> sealed · via your encrypted inbox</span>{/if}
{#if pv && !pv.pending && !pv.error && !pv.withdrawn}<span>·</span><span class="chip ok"><Icon name="check" size={9} />verified · revision {pv.revision_seq}</span>{/if}
{#if pv?.withdrawn}<span>·</span><span class="chip warn">withdrawn by the owner</span>{/if}
{#if pv?.error}<span>·</span><span class="chip err">{pv.error}</span>{/if}
</div>
</div>
</div>
<div class="bot">
<button class="ax-fill sm" onclick={() => importShare(c)} disabled={!nodeUp || !!community.busy["import:" + c.id] || pv?.withdrawn}><Icon name="download" size={12} /> {community.busy["import:" + c.id] ? "Importing…" : "Import"}</button>
<button class="ax-ghost sm" onclick={() => sharePreview(c)} disabled={!nodeUp || pv?.pending}>{pv?.pending ? "Fetching…" : "Verify"}</button>
<button class="lnk" onclick={() => shareDismiss(c.id)}>dismiss</button>
<span class="hint">tracks resolve against your library first; the rest become Soulseek streams</span>
</div>
</div>
{/each}
</div>
{/if}
{#if followNews().length}
<div class="freqs">
{#each followNews() as f (f.owner_key + ":" + f.playlist_uuid)}
{@const busy = !!community.busy["follow:" + f.playlist_uuid]}
<div class="card">
<div class="top">
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
<div>
<div class="t">{f.name}</div>
<div class="s">
{#if f.added.length}<b>{f.added.length} new {f.added.length === 1 ? "track" : "tracks"}</b> since you imported it{#if f.removed}{" · "}{f.removed} removed{/if}{:else}the owner removed {f.removed} {f.removed === 1 ? "track" : "tracks"} — your copy keeps {f.removed === 1 ? "it" : "them"}{/if}
{" · "}revision {f.seen_seq} · checked {fmtWhen(f.checked_at * 1000)}
</div>
<div class="prov">
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
<span>signed by {fpShort(f.owner_key)}</span>
<span>·</span>
<span>your copy: <button class="lnkname" onclick={() => goToView("playlist", { playlistId: f.playlist_id, label: f.name })}>{f.name}</button></span>
</div>
{#if f.added.length}
<div class="s">{f.added.slice(0, 3).map((e) => `${e.artist} — ${e.title}`).join(" · ")}{#if f.added.length > 3} · and {f.added.length - 3} more{/if}</div>
{/if}
</div>
</div>
<div class="bot">
<button class="ax-fill sm" onclick={() => followUpdate(f)} disabled={busy}><Icon name="download" size={12} /> {busy ? "Adding…" : f.added.length ? `Add ${f.added.length} ${f.added.length === 1 ? "track" : "tracks"}` : "OK"}</button>
<button class="lnk" onclick={() => unfollow(f)} disabled={busy}>stop following</button>
<span class="hint">new tracks resolve against your library first; the rest become Soulseek streams — nothing is removed from your copy</span>
</div>
</div>
{/each}
</div>
{/if}
{#if !convos.length && !community.shares.length && !pendingIn().length && !followNews().length}
<div class="empty">
<Icon name="inbox" size={30} />
<div class="big">No conversations yet</div>
<div class="sub">
Message someone from People, a room roster, or a search result. Conversations are kept on this
device only — Soulseek stores nothing for you.
</div>
</div>
{:else}
<div class="split">
<div class="col">
{#if requests.length}
<button class="lrow req" onclick={() => (requestsOpen = !requestsOpen)}>
<span class="stack">
{#each requests.slice(0, 3) as r (r.u)}<Identicon seed={r.u} size={26} />{/each}
</span>
<span class="tx">
<span class="l1"><span class="nm">Message requests</span><span class="chip off">{requests.length}</span></span>
<span class="l2">from people not in your contacts</span>
</span>
<span class="r"><Icon name={requestsOpen ? "chevron-down" : "chevron-right"} size={14} /></span>
</button>
{#if requestsOpen}
{#each requests as c (c.u)}
<button class="lrow" class:on={slsk.activeChat === c.u} onclick={() => openChat(c.u)}>
<Identicon seed={c.u} size={36} presence={presenceOf(c.u)} />
<span class="tx">
<span class="l1"><span class="nm">{c.u}</span><span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></span>
<span class="l2">{c.last?.text ?? ""}</span>
</span>
<span class="r"><span>{fmtWhen(c.last?.ts)}</span>{#if c.unread}<span class="unread">{c.unread}</span>{/if}</span>
</button>
{/each}
{/if}
{/if}
<div class="list">
{#each threads as c (c.u)}
<button class="lrow" class:on={slsk.activeChat === c.u} onclick={() => openChat(c.u)}>
<Identicon seed={c.u} size={36} presence={presenceOf(c.u)} />
<span class="tx">
<span class="l1"><span class="nm">{display(c.u)}</span><span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></span>
<span class="l2">{c.last ? (c.last.me ? "you: " : "") + c.last.text : ""}</span>
</span>
<span class="r"><span>{fmtWhen(c.last?.ts)}</span>{#if c.unread}<span class="unread">{c.unread}</span>{/if}</span>
</button>
{/each}
</div>
<div class="hint pad">Retained on this device only. Nothing here is a receipt — Soulseek has no delivery or read reports.</div>
</div>
{#if slsk.activeChat && activeChat}
{@const u = slsk.activeChat}
<div class="thread">
<div class="thead-bar">
<Identicon seed={u} size={44} presence={presenceOf(u)} />
<div class="tt">
<div class="nm">
{display(u)}
{#if metaOf(u).favorite}<span class="mark fav"><Icon name="star" size={11} /></span>{/if}
{#if metaOf(u).trusted}<span class="mark tr"><Icon name="shield" size={11} /></span>{/if}
</div>
<div class="sub">
{#if community.contacts[u]?.petname}<span>{u}</span><span>·</span>{/if}
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
<span>{presenceOf(u) ? presenceWord[presenceOf(u)] : isContact(u) ? "status unknown" : "not a contact — no status"}</span>
</div>
</div>
<div class="r">
<button class="ax-ghost sm" onclick={() => (community.drawer = u)}><Icon name="eye" size={13} /> Profile</button>
<button class="ax-ghost sm" onclick={() => browseUser(u)}><Icon name="folder" size={13} /> Browse shares</button>
{#if !isContact(u)}
<button class="ax-ghost sm" onclick={() => addContact(u)}><Icon name="plus" size={13} /> Add to contacts</button>
{/if}
</div>
</div>
<div class="msgs" bind:this={msgsEl}>
{#each groupedMessages(activeChat.messages) as m, i (m.divider ? "d" + m.divider + i : i)}
{#if m.divider}
<div class="mdate">{m.divider}</div>
{:else}
<div class="m" class:me={m.me}>
{#if !m.me}<Identicon seed={u} size={24} />{/if}
<div>
<div class="b">{m.text}</div>
<div class="mst">{fmtClock(m.ts)}{#if m.me} · submitted{/if}</div>
</div>
</div>
{/if}
{/each}
{#if dmState.failed}
<div class="m me">
<div>
<div class="b">{dmState.failed}</div>
<div class="mst fail"><Icon name="alert" size={10} /> failed · <button class="lnk" onclick={() => { dmText = dmState.failed; dmState = { sending: false, failed: "" }; }}>edit and retry</button></div>
</div>
</div>
{/if}
</div>
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sendCurrent(); }}>
<div class="qbox grow">
<input placeholder={online ? `Message ${display(u)} on Soulseek` : "Sign in to Soulseek to send"} bind:value={dmText} disabled={!online || dmState.sending} />
</div>
<span class="via">{dmState.sending ? "queued locally" : "Soulseek PM · plain text"}</span>
<button class="send" type="submit" disabled={!online || !dmText.trim() || dmState.sending} title="Send"><Icon name="send" size={15} /></button>
</form>
</div>
{/if}
</div>
{/if}
<!-- ── ROOMS ──────────────────────────────────────────────────────── -->
{:else if community.section === "rooms"}
<div class="split narrow">
<div class="col">
{#if joinedNames.length}
<div class="grp">Joined <span class="ln"></span></div>
{#each joinedNames as n (n)}
<button class="lrow slim" class:on={slsk.activeRoom === n} onclick={() => (slsk.activeRoom = n)}>
<span class="roomic"><Icon name="hash" size={14} /></span>
<span class="tx">
<span class="l1"><span class="nm">{n}</span>{#if community.roomFavs.includes(n)}<span class="mark fav"><Icon name="star" size={10} /></span>{/if}</span>
<span class="l2">{fmtCount(slsk.joined[n].users.length)} here · joined</span>
</span>
</button>
{/each}
{/if}
{#if favRooms.length}
<div class="grp">Favorites <span class="ln"></span></div>
{#each favRooms as n (n)}
<button class="lrow slim" onclick={() => joinRoom(n)} disabled={!online}>
<span class="roomic"><Icon name="hash" size={14} /></span>
<span class="tx"><span class="l1"><span class="nm">{n}</span></span><span class="l2">{roomCount(n) != null ? `${fmtCount(roomCount(n))} here` : "click to join"}</span></span>
</button>
{/each}
{/if}
<div class="grp">
Directory <span class="n muted">· {fmtCount(slsk.rooms.length)} rooms</span><span class="ln"></span>
<button class="ibtn sm" title="Refresh the list" onclick={() => api.slskRoomList()} disabled={!online}><Icon name="refresh" size={12} /></button>
</div>
<div class="qbox slim"><Icon name="search" size={14} /><input placeholder="Find a room" bind:value={roomQuery} /></div>
<div class="list">
{#each directory as r (r.name)}
<div class="drow" class:joined={!!slsk.joined[r.name]}>
<button class="star" class:on={community.roomFavs.includes(r.name)} title="Favorite" onclick={() => toggleRoomFav(r.name)}><Icon name="star" size={12} /></button>
<button class="dname" onclick={() => (slsk.joined[r.name] ? (slsk.activeRoom = r.name) : joinRoom(r.name))} disabled={!online && !slsk.joined[r.name]}>{r.name}</button>
<span class="cnt">{fmtCount(r.users)}</span>
</div>
{/each}
{#if !slsk.rooms.length}
<div class="hint pad">{online ? "The room list arrives after login — refresh if it stays empty." : "Sign in to Soulseek to see the room directory."}</div>
{/if}
</div>
<div class="hint pad">Counts are the server's. A room streams only after you join it; the global firehose stays off.</div>
</div>
{#if slsk.activeRoom && activeRoom}
{@const rn = slsk.activeRoom}
<div class="thread">
<div class="roomhead">
<span class="roomic lg"><Icon name="hash" size={22} /></span>
<div class="tt">
<div class="nm">#{rn}</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek Room</span>
<span>{fmtCount(activeRoom.users.length)} here</span>
<span>·</span>
<span>history on this device since {fmtClock(roomSince[rn] ?? Date.now())}</span>
</div>
</div>
<div class="r">
<button class="ibtn" class:fav={community.roomFavs.includes(rn)} title="Favorite" onclick={() => toggleRoomFav(rn)}><Icon name="star" size={15} /></button>
<button class="ax-ghost sm" onclick={() => leaveRoom(rn)}>Leave</button>
</div>
</div>
<div class="roomwrap">
<div class="roomchat">
<div class="chat" bind:this={roomEl}>
<div class="cm sys"><span class="tm">{fmtClock(roomSince[rn] ?? Date.now())}</span><span class="body">You joined #{rn}. History on this device starts here.</span></div>
{#each activeRoom.messages as m, i (i)}
<div class="cm">
<span class="tm">{fmtClock(m.ts)}</span>
<span class="body">
<button class="whoin" class:c={isContact(m.username)} class:mine={m.username === me} onclick={() => (community.drawer = m.username)}>{display(m.username)}</button>{m.message}
</span>
</div>
{/each}
</div>
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayCurrent(); }}>
<div class="qbox grow"><input placeholder="Say something in #{rn}" bind:value={roomText} disabled={!online} /></div>
<span class="via">public · everyone in the room</span>
<button class="send" type="submit" disabled={!online || !roomText.trim()} title="Send"><Icon name="send" size={15} /></button>
</form>
</div>
<div class="roster">
{#if roster.contacts.length}
<div class="grp">Contacts here <span class="n">{roster.contacts.length}</span></div>
{#each roster.contacts as u (u.username)}
<button class="rrow" onclick={() => (community.drawer = u.username)}><Identicon seed={u.username} size={20} presence={presenceFromStatus(u.status)} />{display(u.username)}</button>
{/each}
{/if}
<div class="grp">Everyone <span class="n">{roster.others.length}</span></div>
{#each roster.others.slice(0, 80) as u (u.username)}
<button class="rrow" onclick={() => (community.drawer = u.username)}><Identicon seed={u.username} size={20} presence={presenceFromStatus(u.status)} />{u.username}</button>
{/each}
{#if roster.others.length > 80}<div class="hint pad">+{roster.others.length - 80} more</div>{/if}
</div>
</div>
</div>
{:else}
<div class="empty tight">
<Icon name="hash" size={30} />
<div class="big">No room open</div>
<div class="sub">Pick one from the directory. Joined rooms keep their roster and messages here for the session.</div>
</div>
{/if}
</div>
<!-- ── PEOPLE ─────────────────────────────────────────────────────── -->
{:else if community.section === "people"}
<div class="ptool">
<div class="qbox slim"><Icon name="search" size={14} /><input placeholder="Find a contact" bind:value={peopleQuery} /></div>
<button class="fchip" class:on={peopleGroup === "all"} onclick={() => (peopleGroup = "all")}>All</button>
{#each groups as g (g)}
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
{/each}
<button class="fchip" class:on={peopleGroup === "friends"} onclick={() => (peopleGroup = "friends")}>Friends</button>
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
<span class="sp"></span>
<form class="addrow" onsubmit={(e) => { e.preventDefault(); const k = addKey.trim(); if (k) { void friendRequestByKey(k); addKey = ""; } }}>
<div class="qbox slim"><Icon name="link" size={13} /><input placeholder="Friend by SoulStream ID" bind:value={addKey} disabled={!nodeUp || !key} title="Sends a sealed friend request through their encrypted inbox — they must have published one" /></div>
<button class="ax-ghost sm" type="submit" disabled={!nodeUp || !key || !addKey.trim() || !!community.busy["friend:" + addKey.trim()]}>Ask</button>
</form>
<form class="addrow" onsubmit={(e) => { e.preventDefault(); void addContact(); }}>
<div class="qbox slim"><Icon name="plus" size={13} /><input placeholder="Add by Soulseek username" bind:value={addName} disabled={!online} /></div>
<button class="ax-ghost sm" type="submit" disabled={!online || !addName.trim() || adding}>Add</button>
</form>
</div>
{#if !slsk.buddies.length}
<div class="empty tight">
<Icon name="users" size={30} />
<div class="big">No contacts yet</div>
<div class="sub">Add someone by username, or from a room roster and a conversation. Contacts are watched for status while you are signed in.</div>
</div>
{:else}
<div class="list">
{#snippet personRow(p, seenCol)}
<div class="prow">
<button class="avbtn" onclick={() => (community.drawer = p.u)}><Identicon seed={p.u} size={36} presence={p.presence} /></button>
<div class="ptx">
<div class="nm">
<button class="lnkname" onclick={() => (community.drawer = p.u)}>{display(p.u)}</button>
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
{#if friendshipFor(p.u)?.state === "friends"}<span class="mark fr" title="Friend — a signed, reciprocal SoulStream relationship"><Icon name="link" size={11} /></span>{/if}
</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
{#if linkFor(p.u)}<span class="src ss" title="Verified SoulStream ID {fpShort(linkFor(p.u).persona_key)}"><Icon name="disc" size={10} />SoulStream</span>{/if}
{#if p.meta.group}<span>·</span><span>{p.meta.group}</span>{/if}
{#if p.st.file_count != null}<span>·</span><span>{fmtCount(p.st.file_count)} files</span>{/if}
{#if p.meta.note}<span>·</span><span class="note">{p.meta.note}</span>{/if}
</div>
</div>
<div class="seen">
{#if p.presence === "online"}<b>online</b>Soulseek, now
{:else if p.presence === "away"}<b>away</b>Soulseek, now
{:else if p.st.exists === false}<b>unknown user</b>Soulseek has no such name
{:else if fmtSeen(community.lastSeen[p.u])}<b>{fmtSeen(community.lastSeen[p.u])}</b>last seen by this device
{:else}<b>offline</b>not seen by this device yet{/if}
</div>
<span class="acts">
<button class="ibtn" title="Message" onclick={() => messageUser(p.u)}><Icon name="message" size={14} /></button>
<button class="ibtn" title="View profile" onclick={() => (community.drawer = p.u)}><Icon name="eye" size={14} /></button>
<button class="ibtn" title="Browse shared library" onclick={() => browseUser(p.u)}><Icon name="folder" size={14} /></button>
<button class="ibtn danger" title="Remove from contacts" onclick={() => removeBuddy(p.u)}><Icon name="x" size={14} /></button>
</span>
</div>
{/snippet}
{#if peopleOnline.length}
<div class="grp">Online <span class="n">{peopleOnline.length}</span><span class="ln"></span></div>
{#each peopleOnline as p (p.u)}{@render personRow(p, false)}{/each}
{/if}
{#if peopleAway.length}
<div class="grp">Away <span class="n">{peopleAway.length}</span><span class="ln"></span></div>
{#each peopleAway as p (p.u)}{@render personRow(p, false)}{/each}
{/if}
{#if peopleOff.length}
<div class="grp">Offline or unknown <span class="n">{peopleOff.length}</span><span class="ln"></span></div>
{#each peopleOff as p (p.u)}{@render personRow(p, true)}{/each}
{/if}
</div>
<div class="hint wide">
Contacts are yours and stay on this device. "Trusted" is a permission you granted, not a verified identity. Petnames,
groups and notes live in the profile drawer — click a name.
</div>
{/if}
<!-- ── DISCOVER ───────────────────────────────────────────────────── -->
{:else if community.section === "discover"}
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Closest to you</h2>
<span class="prov"><Icon name="sparkle" size={12} /> the shelves below, scored together · every reason named</span>
</div>
{#if ranked.length}
<div class="track">
{#each ranked as p (p.username)}
<button class="pcard" onclick={() => (community.drawer = p.username)}>
<span class="face" style:background="hsl({hueOf(p.username)} 30% 14%)"><Identicon seed={p.username} size={84} presence={presenceOf(p.username)} /></span>
<span class="nm">{display(p.username)}</span>
<span class="why">{#each p.reasons as r, i}{#if i}{" · "}{/if}{#each reasonParts(r) as seg}{#if seg.b}<b>{seg.t}</b>{:else}{seg.t}{/if}{/each}{/each}</span>
</button>
{/each}
</div>
{:else}
<div class="emptyshelf"><Icon name="sparkle" size={22} /><div><b>Nothing to rank yet.</b> This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.</div></div>
{/if}
</div>
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2>
<span class="prov"><Icon name="hash" size={12} /> from the room roster{#if activeRoom} · {fmtCount(activeRoom.users.length)} here{/if}</span>
</div>
{#if roomPeople.length}
<div class="track">
{#each roomPeople as u (u.username)}
<button class="pcard" onclick={() => (community.drawer = u.username)}>
<span class="face" style:background="hsl({hueOf(u.username)} 30% 14%)"><Identicon seed={u.username} size={84} presence={presenceFromStatus(u.status)} /></span>
<span class="nm">{u.username}</span>
<span class="why">in <b>#{slsk.activeRoom}</b> now · not a contact</span>
</button>
{/each}
</div>
{:else}
<div class="emptyshelf"><Icon name="hash" size={22} /><div>{activeRoom ? "Everyone in this room is already a contact." : "Join a room and the people in it show up here, with the room as their provenance."}</div>{#if !activeRoom}<button class="ax-ghost sm" onclick={() => setSection("rooms")}>Open Rooms</button>{/if}</div>
{/if}
</div>
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Recent peers</h2>
<span class="prov"><Icon name="download" size={12} /> from your transfers this session</span>
</div>
{#if recentPeers.length}
<div class="track">
{#each recentPeers as p (p.u)}
<button class="pcard" onclick={() => (community.drawer = p.u)}>
<span class="face" style:background="hsl({hueOf(p.u)} 30% 14%)"><Identicon seed={p.u} size={84} /></span>
<span class="nm">{p.u}</span>
<span class="why">
{#if p.from}sent you <b>{p.from}</b> {p.from === 1 ? "file" : "files"}{/if}{#if p.from && p.to} · {/if}{#if p.to}you sent them <b>{p.to}</b>{/if}
</span>
</button>
{/each}
</div>
{:else}
<div class="emptyshelf"><Icon name="download" size={22} /><div>Nobody yet. People who send you files, or take files from you, appear here for the session.</div></div>
{/if}
</div>
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Similar Soulseek interests</h2>
<span class="prov"><Icon name="heart" size={12} /> the server's match on declared likes · not follows</span>
</div>
{#if online}
<div class="likes">
{#each community.interests?.likes ?? [] as thing (thing)}
<span class="chip acc">{thing}<button class="x" title="Stop declaring {thing}" onclick={() => void setInterest(thing, true, false)} disabled={!!community.busy.interest}>×</button></span>
{/each}
<form class="inl" onsubmit={(e) => { e.preventDefault(); const t = likeDraft.trim(); if (t) { void setInterest(t, true, true); likeDraft = ""; } }}>
<input placeholder="Declare a like — an artist, a label, a genre" maxlength="64" bind:value={likeDraft} />
<button class="ax-ghost sm" type="submit" disabled={!likeDraft.trim() || !!community.busy.interest}>{community.busy.interest ? "Telling the server…" : "Declare"}</button>
</form>
</div>
{/if}
{#if community.similar?.users?.length}
<div class="track">
{#each community.similar.users.slice(0, 24) as su (su.username)}
<button class="pcard" onclick={() => (community.drawer = su.username)}>
<span class="face" style:background="hsl({hueOf(su.username)} 30% 14%)"><Identicon seed={su.username} size={84} presence={presenceOf(su.username)} /></span>
<span class="nm">{su.username}</span>
<span class="why">shares <b>{su.rating}</b> of your declared likes{#if isContact(su.username)} · a contact{/if}</span>
</button>
{/each}
</div>
{:else if community.busy.similar}
<div class="emptyshelf"><span class="bl"></span><div>Asking the server…</div></div>
{:else}
<div class="emptyshelf"><Icon name="heart" size={22} /><div>{community.similar?.error ? "The server did not answer." : online ? "The server matches people on the likes you declare on Soulseek — SoulStream has none set for you yet, so this shelf is empty." : "Sign in to Soulseek to ask the server."}</div>{#if online}<button class="ax-ghost sm" onclick={() => loadSimilar(true)} disabled={!!community.busy.similar}>Ask again</button>{/if}</div>
{/if}
</div>
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Library overlap</h2>
<span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span>
</div>
{#if community.overlap.length}
<div class="track">
{#each community.overlap.slice(0, 24) as o (o.username)}
<button class="pcard" onclick={() => (community.drawer = o.username)}>
<span class="face" style:background="hsl({hueOf(o.username)} 30% 14%)"><Identicon seed={o.username} size={84} presence={presenceOf(o.username)} /></span>
<span class="nm">{display(o.username)}</span>
<span class="why">
{#if o.albums_in_common_count}shares <b>{o.albums_in_common_count}</b> {o.albums_in_common_count === 1 ? "album" : "albums"} you own {#if o.gaps_count}· <b>{o.gaps_count}</b> you lack{/if}
{:else if o.artists_in_common_count}<b>{o.artists_in_common_count}</b> {o.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{o.gaps_count}</b> albums you lack
{:else if o.their_albums}nothing in common · {fmtCount(o.their_albums)} albums browsed
{:else}no album folders found{/if}
</span>
</button>
{/each}
</div>
<p class="hint pad">Names compared, not files: a folder counts as yours when your library has that album under that artist. Browse someone again to refresh their line.</p>
{:else}
<div class="emptyshelf">
<Icon name="folder" size={22} />
<div><b>Nothing computed yet.</b> Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
{/if}
</div>
<div class="hint wide">
There is no global directory here and no "people you may know". Every shelf says where its people came from. Freenet
discovery indexes are opt-in, curator-run, and currently <b>off</b>.
</div>
<!-- ── SPACES ─────────────────────────────────────────────────────── -->
{:else if community.section === "spaces"}
<div class="split" style="grid-template-columns:260px 1fr">
<div class="col">
<div class="grp">Your Spaces <span class="n">{community.spaces.length}</span><span class="ln"></span></div>
{#each community.spaces as sp0 (sp0.id)}
<button class="lrow" class:on={sv?.id === sp0.id} onclick={() => openSpace(sp0.owner, sp0.space_id)}>
<span class="spmini">{sp0.name.slice(0, 2).toUpperCase()}</span>
<span class="tx"><span class="l1"><span class="nm">{sp0.name}</span></span><span class="l2">{sp0.private ? "private" : "public"} · {sp0.my_role} · epoch {sp0.last_epoch}</span></span>
</button>
{/each}
{#if spaceCreating}
<form class="panel" style="margin-top:6px" onsubmit={(e) => { e.preventDefault(); void makeSpace(); }}>
<div class="field" style="margin-top:0"><label for="sp-name">Name</label><input id="sp-name" bind:value={spaceName} maxlength="64" placeholder="Late Night Ambient" /></div>
<label class="swrow" style="margin-top:10px"><button type="button" class="tog" class:on={spacePrivate} role="switch" aria-label="Private" aria-checked={spacePrivate} onclick={() => (spacePrivate = !spacePrivate)}></button><span>{spacePrivate ? "Private — bodies sealed to a key members receive" : "Public — anyone who finds it can read"}</span></label>
<div class="btns"><button class="ax-fill sm" type="submit" disabled={!spaceName.trim() || !nodeUp || !key || !!community.busy["space:create"]}>{community.busy["space:create"] ? "Creating…" : "Create"}</button><button class="ax-ghost sm" type="button" onclick={() => (spaceCreating = false)}>Cancel</button></div>
</form>
{:else}
<button class="lrow" style="color:var(--text-secondary)" onclick={() => (spaceCreating = true)} disabled={!nodeUp || !key}>
<span class="av" style="border:1px dashed rgba(255,255,255,0.25);display:grid;place-items:center;width:40px;height:40px;border-radius:50%">{@html ""}<Icon name="plus" size={16} /></span>
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
</button>
{/if}
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one. A member joins under a key minted for this Space alone, so its roster says nothing about who they are anywhere else; only the owner can put a name to one.</p></div>
</div>
{#if sv}
<div>
<div class="sphead">
<div class="spico">{sv.name.slice(0, 2).toUpperCase()}</div>
<div>
<div class="nm">{sv.name} <span class="src space"><Icon name="sparkle" size={10} />Space</span></div>
<div class="sub">
<span>{#if sv.private}<Icon name="lock" size={12} /> private · sealed{:else}public{/if}</span><span>·</span>
<span>{sv.members.length} {sv.members.length === 1 ? "member" : "members"}</span><span>·</span>
<span>epoch {sv.epoch}</span><span>·</span>
<span>not a Soulseek Room</span>
{#if !sv.reachable}<span>·</span><span class="chip warn">not on this node yet</span>{:else}<span>·</span><span class="chip ok"><Icon name="check" size={10} />read from the network</span>{/if}
</div>
</div>
<div class="r" style="margin-left:auto;display:flex;gap:6px">
<button class="ax-ghost sm" onclick={() => openSpace(sv.owner, sv.space_id)} disabled={!!community.busy["space:" + sv.space_id]}><Icon name="refresh" size={12} /></button>
{#if !spaceOwner}<button class="ax-ghost sm" onclick={() => leaveSpace(sv.owner, sv.space_id)}>Leave</button>{/if}
</div>
</div>
<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 === "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 === "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>
</div>
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
{#if spaceTab === "chat"}
{#if sv.pinned_uuid}
<div class="panel" style="margin-bottom:12px"><h3><Icon name="pin" size={13} /> Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}<span class="chip acc"><Icon name="disc" size={10} />revision {pinnedPreview.revision_seq}</span>{/if}</h3>
<div class="pinned" style="margin-top:8px">
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
<div>
<div class="t">{pinnedPreview?.name ?? "Reading…"}</div>
<div class="s">{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}</div>
<div class="btns"><button class="ax-fill sm" onclick={importPinned} disabled={!pinnedPreview?.entries?.length || !!community.busy["import:pin:" + sv.pinned_uuid]}><Icon name="download" size={12} /> Import</button>{#if spaceOwner}<button class="ax-ghost sm" onclick={() => pinToSpace(sv.owner, sv.space_id, null, null)}>Unpin</button>{/if}</div>
</div>
</div>
</div>
{:else if spaceOwner && community.published.some((p) => !p.withdrawn)}
<div class="noderow" style="margin:0 0 12px"><span class="hint">Pin one of your public playlists:</span>{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}<button class="fchip" onclick={() => pinToSpace(sv.owner, sv.space_id, key, p.uuid)}>{p.name}</button>{/each}</div>
{/if}
<div class="thread" style="height:auto;min-height:360px">
<div class="chat" bind:this={spaceEl} style="max-height:420px">
{#if !sv.messages.length}<div class="cm sys"><span class="tm"></span><span class="body">Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.</span></div>{/if}
{#each sv.messages as m (m.id)}
<div class="cm"><span class="tm">{fmtClock(m.created_at * 1000)}</span><span class="body"><span class="whoin" class:mine={m.is_me} class:c={!m.is_me && !!m.username}>{whoIs(m)}</span>{#if m.text != null}{m.text}{:else}<span class="muted"><Icon name="lock" size={10} /> sealed to a key this device does not hold (epoch {m.epoch})</span>{/if}</span></div>
{/each}
</div>
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayInSpace(); }}>
<div class="qbox grow"><input placeholder={sv.can_post ? `Say something in ${sv.name}` : sv.has_key ? "You are not on the roster" : "No key for this epoch yet"} bind:value={spaceText} disabled={!sv.can_post || !nodeUp} /></div>
<span class="via">{sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept</span>
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
</form>
</div>
{:else if spaceTab === "listening"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_listens} aria-pressed={sv.share_listens} title="Share what I play here"
onclick={() => setShareListens(sv.owner, sv.space_id, !sv.share_listens)} disabled={!nodeUp || !!community.busy["space:listens"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I play in this Space</div>
<p class="hint">Per Space, off until you say so. A track is posted once it has actually played, never on a skip{#if sv.private}, and sealed to this room's epoch key — the same key its messages use{/if}. Leaving takes it with you.</p>
</div>
{#if sv.share_listens}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
{#if nowPlaying.length}
<div class="panel" style="margin-bottom:12px">
<h3><Icon name="play" size={13} /> Right now{#if nowPlaying.some((n) => n.live)}<span class="chip ok">{nowPlaying.filter((n) => n.live).length} playing</span>{/if}</h3>
<div class="nowband">
{#each nowPlaying as n (n.author)}
<div class="nowcard" class:quiet={!n.live}>
<Identicon seed={n.author} size={40} presence={n.live ? "online" : null} />
<div class="body">
<div class="who">{n.is_me ? "you" : n.username ? display(n.username) : fpShort(n.author)}
{#if n.live}<span class="bars"><i></i><i></i><i></i></span>{:else}<span class="muted">{ago(n.created_at)}</span>{/if}</div>
<div class="t">{n.title}</div>
<div class="a">{n.artist}</div>
</div>
</div>
{/each}
</div>
<p class="hint" style="margin-top:10px">A card goes quiet after {LISTEN_LIVE_MINS} minutes with nothing new — nobody is reported as listening because the app forgot to say they stopped.</p>
</div>
{/if}
<div class="panel">
<h3><Icon name="disc" size={13} /> The trail<span class="chip off">last {sv.message_cap} kept, with the chat</span></h3>
{#if !sv.listens.length}
<p class="hint" style="padding:18px 0 8px">Nothing played here yet. Turn the switch above on and the next track you finish appears — for everyone in the room, and nobody outside it.</p>
{:else}
<div class="lf">
{#each trail as l (l.id)}
{#if l.day}<div class="daysep"><span>{l.day}</span><span class="rule"></span></div>{/if}
<div class="lr" class:self={l.is_me}>
<span class="tm">{fmtClock(l.created_at * 1000)}</span>
<span class="art" style="background:{artWash(l.album || l.artist || l.title)}">{(l.album || l.artist || l.title).slice(0, 2).toUpperCase()}</span>
<span class="meta"><span class="t">{l.title}</span><span class="a">{l.artist}</span></span>
<span class="who" class:mine={l.is_me}>
{#if l.run}<span class="runpad"></span>{:else}<Identicon seed={l.author} size={22} />{/if}
{#if !l.run}{l.is_me ? "you" : l.username ? display(l.username) : fpShort(l.author)}{/if}
<span class="acts"><button class="ico" title="Find it on Soulseek" onclick={() => findListen(l)}><Icon name="search" size={12} /></button></span>
</span>
</div>
{/each}
</div>
{/if}
</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"}
<div class="panel">
<div class="members">
{#each sv.members as m (m.key)}
<div class="mem">
<button class="avbtn" onclick={() => m.username && (community.drawer = m.username)}><Identicon seed={m.key} size={56} /></button>
<span class="nm">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="role" class:own={m.role === "owner"}>{m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if}</span>
{#if spaceOwner && !m.is_me}<button class="lnk" onclick={() => removeFromSpace(sv.owner, sv.space_id, m.key)} disabled={!!community.busy["space:remove"]}>remove</button>{/if}
</div>
{/each}
</div>
{#if spaceOwner}
<form class="inl" style="margin-top:14px" onsubmit={(e) => { e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}>
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
</form>
<p class="hint" style="margin-top:8px">They appear in Members once they answer with the key they will wear here — an invitation on its own puts nobody on the roster.</p>
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
{/if}
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
</div>
{:else if spaceTab === "proposals"}
{#if !sv.pinned_uuid}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
<p class="hint">A board lives beside a pinned playlist: members propose adds, removes and moves against the exact revision they see, and the playlist's owner decides. {#if spaceOwner}Pin one of your public playlists from the Chat tab and the board opens here.{:else}The owner has not pinned a playlist yet.{/if}</p>
</div>
{:else if !bv && community.boardError}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
<p class="hint warn">{community.boardError}</p>
<div class="bbtns"><button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> Try again</button></div>
</div>
{:else if !bv}
<div class="panel"><p class="hint"><span class="bl"></span> Reading the board through your node…</p></div>
{:else}
<div class="panel">
<h3><Icon name="playlist" size={13} /> {bv.name || "Pinned playlist"}{#if bv.withdrawn}<span class="chip warn">withdrawn by its owner</span>{:else}<span class="chip acc"><Icon name="disc" size={10} />revision {bv.revision_seq}</span>{/if}</h3>
<p class="hint">Proposals bind to revision {bv.revision_seq} exactly · {bv.i_am_owner ? "you decide" : "the playlist's owner decides"} · every decision is signed · {bv.contributors.length} {bv.contributors.length === 1 ? "contributor" : "contributors"} on epoch {bv.epoch}{#if !bv.reachable} · <span class="chip warn">the owner has not opened the board yet</span>{/if}</p>
<div class="bbtns">
{#if bv.can_propose && !boardDraft}<button class="ax-fill sm" onclick={startDraft} disabled={!nodeUp || boardBusy}><Icon name="list-plus" size={12} /> Propose changes</button>{/if}
{#if !bv.can_propose && !bv.i_am_owner && bv.reachable && !bv.withdrawn}<span class="hint">You are not on this board's roster yet — the owner refreshes it from the members when they open the board.</span>{/if}
<button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> {boardBusy ? "Working…" : "Refresh"}</button>
</div>
{#if community.boardError}<p class="hint warn">{community.boardError}</p>{/if}
</div>
{#if boardDraft}
<div class="panel">
<h3><Icon name="list-plus" size={13} /> Your proposal <span class="chip acc">{boardDraft.ops.length} / 32 changes</span></h3>
<p class="hint">Move rows, take some out, add from your library. The owner receives titles, artists and albums — never a file, never a path. Nothing here plays or downloads.</p>
<ol class="dlist">
{#each boardDraft.list as e, i (e.entry_id)}
<li class:fresh={e.fresh}>
<span class="n">{i + 1}</span>
<span class="tx"><span class="t">{e.title}</span><span class="a">{e.artist}{#if e.fresh} · added{/if}</span></span>
<span class="b">
<button class="ibtn sm" title="Move up" disabled={i === 0} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i - 1 })}><Icon name="chevron-up" size={12} /></button>
<button class="ibtn sm" title="Move down" disabled={i === boardDraft.list.length - 1} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i + 1 })}><Icon name="chevron-down" size={12} /></button>
<button class="ibtn sm" title="Remove" onclick={() => draftApply({ kind: "remove", entry_id: e.entry_id })}><Icon name="x" size={12} /></button>
</span>
</li>
{/each}
{#if !boardDraft.list.length}<li class="none">Empty — add something below.</li>{/if}
</ol>
<div class="qbox slim"><Icon name="search" size={13} /><input placeholder="Add from your library — title or artist" bind:value={boardQuery} oninput={searchLibrary} /></div>
{#if boardHits.length}
<div class="hits">{#each boardHits.slice(0, 8) as h (h.path)}<button class="hit" onclick={() => draftAdd(h)}><Icon name="plus" size={11} /><span class="t">{h.title}</span><span class="a">{h.artist}{h.album ? ` · ${h.album}` : ""}</span></button>{/each}</div>
{/if}
<div class="field"><input placeholder="A note for the owner — optional" bind:value={boardDraft.note} maxlength="280" /></div>
<div class="bbtns">
<button class="ax-fill sm" onclick={sendDraft} disabled={!boardDraft.ops.length || !nodeUp || boardBusy}><Icon name="send" size={12} /> {community.busy["board:propose"] ? "Signing & offering…" : "Send proposal"}</button>
<button class="ax-ghost sm" onclick={draftUndo} disabled={!boardDraft.ops.length}>Undo</button>
<button class="ax-ghost sm" onclick={() => (boardDraft = null)}>Discard</button>
</div>
</div>
{/if}
<div class="panel">
<h3>Pending <span class="chip off">{bv.proposals.length}</span></h3>
{#if !bv.proposals.length}<p class="hint">Nothing pending. {#if bv.can_propose}Propose an add, a remove or a move against the current revision — up to 32 changes each, 8 pending per person.{/if}</p>{/if}
{#each bv.proposals as p (p.id)}
<div class="prop" class:stale={p.stale}>
<div class="ph">
<Identicon seed={p.author} size={30} />
<div class="who"><span class="nm">{whoProposed(p)}</span><span class="hint">{fmtWhen(p.created_at * 1000)} · {p.ops.length} {p.ops.length === 1 ? "change" : "changes"}</span></div>
{#if p.stale}<span class="chip warn">stale · an older revision</span>{:else}<span class="chip ok"><Icon name="check" size={10} />current revision</span>{/if}
</div>
{#if p.note}<p class="note">{p.note}</p>{/if}
<ul class="ops">
{#each p.ops as op, i (i)}
<li class={op.kind}>
<b>{op.kind === "add" ? "+" : op.kind === "remove" ? "−" : "↕"}</b>
<span>
{#if op.kind === "add"}add <i>{op.title}</i> — {op.artist} at #{op.position + 1}
{:else if op.kind === "remove"}remove {#if op.known}<i>{op.title}</i> — {op.artist}{:else}an entry from an older revision{/if}
{:else}move {#if op.known}<i>{op.title}</i>{:else}an entry from an older revision{/if} to #{op.position + 1}{/if}
</span>
</li>
{/each}
</ul>
{#if bv.i_am_owner}
<div class="decide">
{#if !p.stale}<button class="ax-fill sm" onclick={() => decideOn(p, true)} disabled={!nodeUp || boardBusy}><Icon name="check" size={12} /> {community.busy["board:decide"] ? "Working…" : "Accept & publish"}</button>{/if}
<input placeholder={p.stale ? "Reason — optional; it is marked stale either way" : "Reason — optional"} bind:value={boardReason[p.id]} maxlength="128" />
<button class="ax-ghost sm" onclick={() => decideOn(p, false)} disabled={!nodeUp || boardBusy}>{p.stale ? "Reject as stale" : "Reject"}</button>
</div>
{/if}
</div>
{/each}
</div>
<div class="panel">
<h3>Decisions <span class="chip off">{bv.decisions.length}</span></h3>
{#if !bv.decisions.length}<p class="hint">None yet. Each decision is owner-signed with a sequence number; the last 256 are kept.</p>{/if}
{#each bv.decisions as d (d.seq)}
<div class="dec"><span class="chip {d.accept ? 'ok' : 'off'}">#{d.seq} · {d.accept ? "accepted" : "rejected"}</span><span class="rs">{d.reason}</span>{#if d.result_hash}<span class="mono hint" title={d.result_hash}>→ revision {fpShort(d.result_hash)}</span>{/if}</div>
{/each}
</div>
{/if}
{:else}
<div class="panel">
<h3>About</h3>
<dl class="kv"><dt>Owner</dt><dd class="mono">{sv.owner}</dd><dt>Space id</dt><dd class="mono">{sv.space_id}</dd><dt>Mode</dt><dd>{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}</dd><dt>Window</dt><dd>the last {sv.message_cap} messages</dd><dt>Epoch</dt><dd>{sv.epoch}</dd></dl>
<p class="hint" style="margin-top:10px">What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.</p>
{#if sv.description}<p class="desc">{sv.description}</p>{/if}
</div>
{/if}
</div>
{:else}
<div class="empty tight">
<span class="spico"><Icon name="sparkle" size={26} /></span>
<div class="big">{community.spaces.length ? "Pick a Space" : "No Spaces yet"}</div>
<div class="sub">A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.</div>
</div>
{/if}
</div>
<!-- ── YOU ────────────────────────────────────────────────────────── -->
{:else if community.section === "you"}
<!-- Three panel columns on a wide pane, two on a normal one, one when narrow
(container queries below): the section USES the width instead of leaving
it empty (Kyle, full screen, 2026-09-02). -->
<div class="yougrid" class:setup={!key}>
<div class="yc a">
{#if key}
<div class="panel">
<h3>Your public profile <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></h3>
<p class="hint">
Anyone who has your SoulStream ID can fetch this. It is signed by your persona key and replicated —
publishing is not something that can be fully undone.
</p>
<div class="avset">
<Identicon seed={key} size={88} photo={avatarUrl} />
<div class="opts">
<button class="opt" class:on={!avatarUrl} onclick={() => avatarUrl && clearAvatar()} disabled={!!community.busy.avatar || !nodeUp}>
<span class="rad"></span><span>Identicon<span class="hint">cut from your key · nothing uploaded</span></span>
</button>
<button class="opt" class:on={!!avatarUrl} onclick={() => pickAndSetAvatar()} disabled={!!community.busy.avatar || !nodeUp}>
<span class="rad"></span><span>A photo<span class="hint">square crop · 256 px · every trace of metadata removed · under 96 KB</span></span>
</button>
{#if avatarUrl}<button class="ax-ghost sm" onclick={() => pickAndSetAvatar()} disabled={!!community.busy.avatar || !nodeUp}><Icon name="image" size={12} /> Replace photo</button>{/if}
</div>
</div>
{#if community.busy.avatar}
<p class="hint"><span class="bl"></span> Preparing your photo and publishing it — a few seconds normally; the first publication can take longer.</p>
{:else if profile?.avatar_note && profile.avatar_note !== "none published"}<p class="hint warn">{profile.avatar_note}</p>{/if}
<div class="field">
<label for="cm-name">Display name <span class:over={nameBytes > 64}>{nameBytes} / 64</span></label>
<input id="cm-name" bind:value={nameDraft} maxlength="64" placeholder="How you want to be shown" />
</div>
<div class="field">
<label for="cm-bio">Bio <span class:over={bioBytes > 280}>{bioBytes} / 280</span></label>
<textarea id="cm-bio" rows="3" bind:value={bioDraft} maxlength="280" placeholder="A line or two — optional"></textarea>
<p class="hint">Display names are not unique. People you meet may give you a private petname; you cannot see it.</p>
</div>
<div class="pub">
<button class="ax-fill" disabled={!canPublish} onclick={() => publishProfile(nameDraft, bioDraft)}>
<Icon name="export" size={13} /> {community.busy.publish ? "Publishing…" : profile ? "Publish changes" : "Publish profile"}
</button>
{#if community.busy.publish}
<span class="chip acc">pending publication</span>
{:else if community.busy.profile}
<span class="chip acc"><span class="bl"></span>reading from the network — up to a minute</span>
{:else if profile}
<span class="chip ok"><Icon name="check" size={10} />{profile.from_ledger ? "published from this device" : `synced as of ${fmtClock(community.profileAt)}`} · revision {profile.sequence}</span>
{:else}
<span class="chip off">nothing published yet</span>
{/if}
{#if community.profileError}<span class="chip err">{community.profileError}</span>{/if}
<button class="ibtn sm" title="Re-read from the network (slow when nothing is published)" onclick={() => loadProfile(true)} disabled={!nodeUp || !!community.busy.profile}><Icon name="refresh" size={12} /></button>
</div>
</div>
{:else}
<div class="panel">
<h3>Your SoulStream identity <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></h3>
{#if !freenetOn}
<p class="hint">
A SoulStream identity is a key pair held by your local Freenet node. Turn the Freenet layer on
(right) and the node will hold it; nothing is published until you say so.
</p>
{:else if !nodeUp}
<p class="hint">Waiting for the node — <span class="chip {status.tone}">{status.text}</span>. Identity needs a connected node.</p>
{:else}
<p class="hint">
Create a fresh key here, or bring one back from a recovery file. Creating one publishes nothing;
your profile is a separate button, later.
</p>
<div class="btns">
<button class="ax-fill" onclick={() => createPersona()} disabled={!!community.busy.persona}><Icon name="sparkle" size={13} /> {community.busy.persona ? "Creating…" : "Create identity"}</button>
</div>
<div class="field">
<label for="cm-imp">Or import a recovery file</label>
<div class="inl">
<input id="cm-imp" type="password" placeholder="Its passphrase" bind:value={passImport} />
<button class="ax-ghost sm" onclick={() => doImport()} disabled={passImport.length < 1 || !!community.busy.import}>{community.busy.import ? "Importing…" : "Choose file & import"}</button>
</div>
</div>
{/if}
{#if community.error}<p class="hint warn">{community.error}</p>{/if}
</div>
{/if}
</div>
{#if key}
<div class="yc b">
<div class="panel">
<h3><Icon name="playlist" size={13} /> Your public playlists <span class="chip off">{community.published.filter((p) => !p.withdrawn).length}</span></h3>
{#if !community.published.length}
<p class="hint">Nothing published yet. Open a playlist and choose <b>Share on SoulStream</b>: a signed manifest goes to the network under your ID — titles, artists, albums, durations, your notes; never a file, never a path.</p>
{:else}
{#each community.published as p (p.playlist_id)}
<div class="pubrow">
<Icon name="playlist" size={14} />
<div class="ptx"><div class="nm">{p.name}</div><div class="hint">{p.entry_count} tracks · revision {p.revision_seq} · {p.withdrawn ? "withdrawn" : "published"} {fmtWhen(p.published_at * 1000)}</div></div>
{#if p.withdrawn}
<button class="ax-ghost sm" onclick={() => sharedPublish(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Publish again</button>
{:else}
<button class="ax-ghost sm" onclick={() => sharedWithdraw(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Withdraw</button>
{/if}
<button class="ax-ghost sm" onclick={() => goToView("playlist", { playlistId: p.playlist_id, label: p.name })}>Open</button>
</div>
{/each}
<p class="hint">Withdrawing stops your pointer; copies others already fetched stay with them.</p>
{/if}
</div>
<div class="panel">
<h3><Icon name="key" size={13} /> Your SoulStream ID</h3>
<p class="hint">
The fingerprint of your persona key. It is the stable you — names change, this does not. Share it as
text; nothing else identifies you across networks.
</p>
<div class="fpblock">
<div class="kbox mono">{#each fpGroups(key) as g, i (i)}{#if i}<i>·</i>{/if}<span>{g}</span>{/each}</div>
<button class="ax-ghost sm" onclick={copyKey}><Icon name={copied ? "check" : "copy"} size={12} /> {copied ? "Copied" : "Copy"}</button>
</div>
<dl class="kv">
<dt>Key version</dt><dd>{community.persona?.persona?.key_version ?? 1}{#if (community.persona?.persona?.key_version ?? 1) > 1} · rotated{/if}</dd>
<dt>Signs</dt><dd>profile today; playlists, friend links, Soulseek link and Space links as those land</dd>
<dt>Delegate</dt><dd class="mono">{fpShort(community.persona?.delegate_key)}</dd>
</dl>
</div>
<div class="panel">
<h3><Icon name="shield" size={13} /> Recovery</h3>
<p class="hint">
Your identity lives in the node's key custody on this machine. Export a recovery file — encrypted
with a passphrase you choose — and keep it somewhere that is not this disk.
</p>
<div class="field">
<label for="cm-exp">Export</label>
<div class="inl">
<input id="cm-exp" type="password" placeholder="A passphrase for the file (8+ characters)" bind:value={passExport} />
<button class="ax-fill sm" onclick={() => doExport()} disabled={passExport.length < 8 || !!community.busy.export || !nodeUp}><Icon name="download" size={12} /> {community.busy.export ? "Exporting…" : "Export"}</button>
</div>
{#if exportDone}<p class="hint ok">Written: {exportDone.path} ({exportDone.bytes} bytes). Test it on another machine before you need it.</p>{/if}
</div>
<div class="field">
<label for="cm-imp2">Import <span>replaces the identity on this node</span></label>
<div class="inl">
<input id="cm-imp2" type="password" placeholder="The file's passphrase" bind:value={passImport} />
<button class="ax-ghost sm" onclick={() => doImport()} disabled={passImport.length < 1 || !!community.busy.import || !nodeUp}>{community.busy.import ? "Importing…" : "Choose file & import"}</button>
</div>
<label class="chk"><input type="checkbox" bind:checked={importForce} /> replace the identity that is already here</label>
</div>
<div class="danger">
{#if confirmRotate}
<span class="hint warn">Rotating signs a transition with the old key; friends re-verify on their next fetch.</span>
<button class="ax-fill sm" onclick={() => { confirmRotate = false; void rotatePersona(); }} disabled={!!community.busy.rotate}>Confirm rotate</button>
<button class="ax-ghost sm" onclick={() => (confirmRotate = false)}>Cancel</button>
{:else if confirmRemove}
<span class="hint warn">This deletes the key from this node. Without a recovery file, this identity ends.</span>
<button class="ax-fill sm danger" onclick={() => { confirmRemove = false; void removePersona(); }} disabled={!!community.busy.remove}>Confirm remove</button>
<button class="ax-ghost sm" onclick={() => (confirmRemove = false)}>Cancel</button>
{:else}
<button class="ax-ghost sm" onclick={() => (confirmRotate = true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Rotate key…</button>
<button class="ax-ghost sm dangerb" onclick={() => (confirmRemove = true)} disabled={!nodeUp}>Remove identity…</button>
{/if}
</div>
{#if community.error}<p class="hint warn">{community.error}</p>{/if}
</div>
</div>
{/if}
<div class="yc c">
<div class="panel">
<h3>Networks</h3>
<div class="netcard">
<div class="nico slsk"><Icon name="globe" size={18} /></div>
<div class="ntx">
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
<div class="s">
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
<br />
{#if !key}
Create a SoulStream identity to link this account to it.
{:else if !me}
Sign in to Soulseek to link this account to your identity.
{:else if ownLinkCurrent}
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
{:else}
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
{/if}
</div>
{#if key && me}
<div class="noderow">
{#if ownLinkCurrent}
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
{:else}
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
{/if}
</div>
{/if}
</div>
</div>
<div class="netcard">
<div class="nico fn"><Icon name="disc" size={18} /></div>
<div class="ntx">
<div class="t">Freenet <span class="chip acc">experimental</span></div>
<div class="s">
{#if freenetOn}
Local node on 127.0.0.1:{community.freenet?.ws_port ?? 7509} · <span class="chip {status.tone}">{status.text}</span>
<button class="lnk" onclick={() => detectNode()} disabled={!!community.busy.detect}>{community.busy.detect ? "checking…" : "check again"}</button>
{#if statusKind(community.freenet?.status) === "node-absent"}
<div class="noderow">
{#if community.nodeInfo?.binary}
<button class="ax-fill sm" onclick={() => startNode()} disabled={!!community.busy.node}>
<Icon name="disc" size={12} /> {community.busy.node ? "Starting…" : "Start node"}
</button>
<span class="hint">runs the installed peer{#if community.nodeInfo.version} ({community.nodeInfo.version}){/if} in the background · telemetry pinned off · data in the app folder</span>
{:else}
<span class="hint warn">No Freenet peer found on this machine — install it (cargo install freenet) and check again.</span>
{/if}
</div>
{:else if community.nodeInfo?.started_by_app}
<div class="noderow">
<button class="ax-ghost sm" onclick={() => stopNode()} disabled={!!community.busy.node}>{community.busy.node ? "Stopping…" : "Stop node"}</button>
<span class="hint">started by SoulStream · keeps running when the app closes; this button stops it</span>
</div>
{/if}
{:else}
Off. Carries your identity and profile; later playlists, friend links and Spaces. Turning it on publishes nothing. Turning it off is instant and touches nothing else in the app.
{/if}
</div>
<details class="disc" bind:open={disclosuresOpen}>
<summary>Before you rely on it — seven things that are true</summary>
<ul>
<li>A desktop background peer must run; it uses network, disk, memory and CPU.</li>
<li>The network is alpha. Things you publish are replicated and cannot be guaranteed erased.</li>
<li>Freenet is not an anonymity service.</li>
<li>The node's own diagnostics are a node setting SoulStream pins off; verify after upgrades.</li>
<li>Nothing is published when you enable this. Publishing is always a button.</li>
<li>Nothing here ever says "online" — a successful fetch is not presence.</li>
<li>Phones and TVs cannot run the same peer today.</li>
</ul>
</details>
{#if freenetOn}
<label class="swrow">
<button class="tog" class:on={community.freenet?.load_remote_images ?? true} role="switch" aria-label="Load remote profile images" aria-checked={community.freenet?.load_remote_images ?? true} onclick={() => setLoadRemoteImages(!(community.freenet?.load_remote_images ?? true))} disabled={!!community.busy.images}></button>
<span>Load remote profile images</span>
<span class="hint r">verified, resized, cached by hash</span>
</label>
{#if key}
<div class="noderow">
{#if community.inbox?.published}
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
<span class="hint">{community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one</span>
{:else}
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
{/if}
</div>
{/if}
{/if}
</div>
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>
</div>
</div>
<div class="panel dim">
<h3><Icon name="devices" size={13} /> Devices <span class="chip off">later</span></h3>
<p class="hint">A paired device gets its own key and a signed grant — it never receives your root key or Soulseek password. Comes after the profile work settles.</p>
</div>
</div>
</div>
{/if}
</div>
<!-- ── the person drawer: two sources, two sections, one footnote ─────── -->
{#if drawerUser}
<div class="overlay" use:portal>
<div class="scrim" role="presentation" onclick={closeDrawer}></div>
<aside class="drawer" aria-label="Profile of {drawerUser}">
<div class="dhead">
<Identicon seed={drawerUser} size={56} photo={drawerPhoto} presence={presenceOf(drawerUser)} />
<div class="dtx">
<div class="nm">
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
{#if drawerFriend?.state === "friends"}<span class="mark fr" title="Friend"><Icon name="link" size={12} /></span>{/if}
</div>
<div class="sub">
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
<span>{presenceOf(drawerUser) ? presenceWord[presenceOf(drawerUser)] : isContact(drawerUser) ? "status unknown" : "not a contact"}</span>
</div>
</div>
<button class="ibtn x" onclick={closeDrawer} title="Close"><Icon name="x" size={16} /></button>
</div>
<div class="dacts">
<button class="ax-fill sm" onclick={() => messageUser(drawerUser)}><Icon name="message" size={12} /> Message</button>
<button class="ax-ghost sm" onclick={() => browseUser(drawerUser)}><Icon name="folder" size={12} /> Browse shares</button>
{#if isContact(drawerUser)}
<button class="ax-ghost sm" onclick={() => removeBuddy(drawerUser)}>Remove contact</button>
{:else}
<button class="ax-ghost sm" onclick={() => addContact(drawerUser)} disabled={!online}><Icon name="plus" size={12} /> Add to contacts</button>
{/if}
</div>
<div class="marks">
<button class="markbtn" class:on={drawerMeta?.favorite} onclick={() => saveMeta({ favorite: !drawerMeta?.favorite })}><Icon name="star" size={13} /> Favorite</button>
<button class="markbtn tr" class:on={drawerMeta?.trusted} onclick={() => saveMeta({ trusted: !drawerMeta?.trusted })}><Icon name="shield" size={13} /> Trusted</button>
<input class="grp-in" placeholder="Group" bind:value={groupDraft} onchange={() => saveMeta()} list="cm-groups" />
<datalist id="cm-groups">{#each groups as g (g)}<option value={g}></option>{/each}</datalist>
</div>
<textarea class="note-in" rows="2" placeholder="A private note — only you see it" bind:value={noteDraft} onchange={() => saveMeta()}></textarea>
<div class="dsec">
<h4>Library overlap <span class="src slsk"><Icon name="library" size={10} />this device</span></h4>
{#if drawerOverlap}
<p class="hint">
{#if drawerOverlap.their_albums}<b>{drawerOverlap.albums_in_common_count}</b> of their {fmtCount(drawerOverlap.their_albums)} albums are in your library · <b>{drawerOverlap.artists_in_common_count}</b> {drawerOverlap.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{drawerOverlap.gaps_count}</b> you lack{:else}No album folders among {fmtCount(drawerOverlap.audio_files)} audio files.{/if}
· compared {fmtWhen(drawerOverlap.computed_at * 1000)}
</p>
{#if drawerOverlap.albums_in_common.length}
<div class="ovl"><span class="lbl">In common</span>{#each drawerOverlap.albums_in_common.slice(0, 8) as a, i (i)}<span class="ov"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.albums_in_common_count > 8}<span class="ov more">+{drawerOverlap.albums_in_common_count - 8} more</span>{/if}</div>
{/if}
{#if drawerOverlap.gaps.length}
<div class="ovl"><span class="lbl">You lack</span>{#each drawerOverlap.gaps.slice(0, 8) as a, i (i)}<span class="ov gap"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.gaps_count > 8}<span class="ov more">+{drawerOverlap.gaps_count - 8} more</span>{/if}</div>
{/if}
<div class="bbtns">
<button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="refresh" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing…" : "Compare again"}</button>
<button class="lnk" onclick={() => forgetOverlap(drawerUser)} disabled={!!community.busy["overlap:" + drawerUser]}>forget</button>
</div>
{:else}
<p class="hint">Not compared yet. Their share folders against your albums, on this device — nothing is uploaded to do it.</p>
<div class="bbtns"><button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="library" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing their shares…" : "Compare with my library"}</button></div>
{/if}
</div>
<div class="dsec">
<h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4>
<dl class="kv">
<dt>Username</dt>
<dd><b>{drawerUser}</b>{#if presenceOf(drawerUser)} · {presenceWord[presenceOf(drawerUser)]}{/if}{#if presenceOf(drawerUser) === "off" && fmtSeen(community.lastSeen[drawerUser])} · last seen by this device {fmtSeen(community.lastSeen[drawerUser])}{/if}</dd>
{#if drawerState?.exists === false}
<dt>Status</dt><dd>Soulseek has no user by this name.</dd>
{:else if drawerState}
<dt>Shares</dt><dd>{fmtCount(drawerState.file_count)} files in {fmtCount(drawerState.dir_count)} folders</dd>
{#if drawerState.avg_speed}<dt>Upload speed</dt><dd>{Math.round(drawerState.avg_speed / 1024)} KB/s average</dd>{/if}
{:else}
<dt>Shares</dt><dd class="muted">not watched — add to contacts and Soulseek reports status and share counts</dd>
{/if}
{#if drawerInfo && !drawerInfo.pending && !drawerInfo.error}
<dt>Uploads</dt><dd>{drawerInfo.has_free_slot ? "a slot is free" : "no free slot"} · queue {fmtCount(drawerInfo.queue_size)} · {fmtCount(drawerInfo.total_uploads)} served</dd>
{/if}
</dl>
{#if !online}
<p class="hint">Sign in to Soulseek to ask this person's client for their card.</p>
{:else if !drawerInfo || drawerInfo.pending}
<p class="hint"><span class="bl"></span> Asking their client for the card — direct, peer to peer.</p>
{:else if drawerInfo.error}
<p class="hint">Their client did not answer ({drawerInfo.error.replace(/^.*?: /, "")}) — offline, firewalled, or not sharing a card.</p>
{:else}
<p class="desc">{drawerInfo.description.trim() || "No description."}</p>
{#if drawerInfo.picture_note}<p class="hint warn">{drawerInfo.picture_note}</p>{/if}
{/if}
<div class="hint" style="margin-top:10px">Soulseek interests <span class="muted">— raw likes and hates, not follows</span></div>
{#if drawerInterests && !drawerInterests.pending && !drawerInterests.error}
{#if drawerInterests.likes.length || drawerInterests.hates.length}
<div class="tagrow">
{#each drawerInterests.likes as l (l)}<span class="tag">{l}</span>{/each}
{#each drawerInterests.hates as h (h)}<span class="tag hate">{h}</span>{/each}
</div>
{:else}
<p class="hint">None declared.</p>
{/if}
{:else if drawerInterests?.error}
<p class="hint">The server did not answer.</p>
{:else if online}
<p class="hint"><span class="bl"></span> asking the server</p>
{/if}
</div>
<div class="dsec">
{#if drawerLink}
<h4>Signed SoulStream profile <span class="chip ok"><Icon name="check" size={10} />verified {fmtWhen(drawerLink.verified_at * 1000)}</span></h4>
<div class="rp">
<Identicon seed={drawerLink.persona_key} size={44} photo={drawerRemote?.avatar_png_base64 ? `data:image/png;base64,${drawerRemote.avatar_png_base64}` : null} />
<div class="rptx">
{#if !drawerRemote || drawerRemote.pending}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint"><span class="bl"></span> reading their profile from the network</p>
{:else if drawerRemote.error}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint warn">Could not read their profile: {drawerRemote.error}</p>
{:else if drawerRemote.none}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint">They proved their ID but have not published a profile yet.</p>
{:else}
<div class="nm">{drawerRemote.display_name || "(no name)"} <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></div>
<div class="mono muted" style="font-size:11px">{fpShort(drawerLink.persona_key)} · revision {drawerRemote.sequence}</div>
{#if drawerRemote.bio}<p class="desc">{drawerRemote.bio}</p>{/if}
{#if drawerRemote.avatar_note && drawerRemote.avatar_note !== "none published"}<p class="hint">{drawerRemote.avatar_note}</p>{/if}
{/if}
</div>
</div>
<p class="hint" style="margin-top:8px">Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
{#if !isMe(drawerUser)}
<div class="friendrow">
{#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"}
<button class="ax-fill sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp || !key || !!community.busy["friend:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["friend:" + drawerUser] ? "Sending…" : "Add as friend"}</button>
<span class="hint">{drawerFriend?.state === "revoked" ? "the earlier friendship was ended — a new request starts over" : "a signed request goes to them over Soulseek; friends are two reciprocal signed answers"}</span>
{:else if drawerFriend.state === "friends"}
<span class="chip ok"><Icon name="link" size={10} />friends since {fmtWhen(drawerFriend.since * 1000)}</span>
<button class="ax-ghost sm" onclick={() => friendRemove(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Unfriend</button>
{:else if drawerFriend.state === "pending-in"}
<span class="chip acc">they asked to be friends</span>
<button class="ax-fill sm" onclick={() => friendAccept(drawerFriend.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + drawerFriend.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Decline</button>
{:else if drawerFriend.state === "pending-out"}
<span class="chip acc"><span class="bl"></span>request sent · waiting for their answer</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>withdraw</button>
{:else if drawerFriend.state === "accepted-waiting"}
<span class="chip acc"><span class="bl"></span>you accepted · waiting for their signed answer</span>
{:else if drawerFriend.state === "declined"}
<span class="chip off">{drawerFriend.direction === "out" ? "they declined" : "you declined"}</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>forget</button>
{:else if drawerFriend.state === "revoked"}
<span class="chip warn">friendship ended</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{:else if drawerFriend.state === "reacceptance-required"}
<span class="chip warn">their key changed — must re-accept</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{/if}
</div>
{/if}
{:else}
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
<p class="hint">Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.</p>
<div class="noderow">
<button class="ax-ghost sm" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["link:" + drawerUser] ? "Asking… up to 30 s" : "Ask for their SoulStream ID"}</button>
</div>
{#if community.linkErrors[drawerUser]}<p class="hint" style="margin-top:6px">{community.linkErrors[drawerUser]}</p>{/if}
{/if}
</div>
<div class="dfoot">
A SoulStream badge means a compatible, signed profile was presented and verified against its key. It is not proof of who
this person is in real life — that is what your petname, your note and "trusted" are for.
</div>
</aside>
</div>
{/if}
<style>
/* A "start BELOW the bar" page, the hub's rule exactly. Compact density
throughout (the approved knob): 46px rows, 12px gaps and pads. */
.community {
position: relative;
/* The whole pane, edge to edge (Kyle, 2026-09-08: "not using the full
window"). The 1520px cap from 2026-09-02 answered a page that
huddled top-left at 2560; now every section stretches instead —
the shelves, the split grids and the You grid all size to the pane,
and only a chat line keeps a readable cap (.split .thread). */
max-width: none;
margin: 0;
padding: calc(var(--bar-h) + 18px) 24px 40px;
--row: 46px;
--gap: 12px;
--pad: 12px;
--ok: #3ddc84;
--warn: #e8a33d;
--err: #f15e6c;
--space: #c77dff;
--surf: var(--bg-elevated);
--surf-hi: var(--bg-highlight);
--hair: rgba(255, 255, 255, 0.07);
}
/* Dark glass: surfaces on the frost add white, never black (the ladder
in global.css). */
:global(:root.glass) .community {
--surf: rgba(255, 255, 255, 0.03);
--surf-hi: rgba(255, 255, 255, 0.08);
--hair: rgba(255, 255, 255, 0.1);
}
.community > :not(.amb) {
position: relative;
z-index: 1;
}
/* The persona ambience: the page top washed in the identicon's own hue,
the artist page's wash idea with the key as the palette. */
.amb {
position: absolute;
left: -28px;
right: -28px;
top: 0;
height: 380px;
z-index: 0;
pointer-events: none;
background:
radial-gradient(900px 340px at 18% 0%, hsl(var(--hue) 60% 40% / 0.28), transparent 70%),
radial-gradient(600px 300px at 70% 0%, hsl(calc(var(--hue) + 150) 50% 40% / 0.16), transparent 70%);
-webkit-mask-image: linear-gradient(180deg, #000 30%, transparent);
mask-image: linear-gradient(180deg, #000 30%, transparent);
}
/* ── shared vocabulary (hub + artist page) ── */
.ax-head { display: flex; align-items: center; justify-content: space-between; margin: 26px 0 8px; }
.ax-title { margin: 0; font-size: 20px; font-weight: 800; }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 15px; border-radius: 99px; font-size: 12px; 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.sm { height: 28px; padding: 0 11px; font-size: 11px; }
.ax-ghost:disabled { opacity: 0.4; cursor: default; }
.ax-ghost.dangerb { border-color: color-mix(in srgb, var(--err) 45%, transparent); color: var(--err); }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 17px; border-radius: 99px; font-size: 12px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
.ax-fill.sm { height: 28px; padding: 0 12px; font-size: 11px; }
.ax-fill:disabled { opacity: 0.4; cursor: default; }
.ax-fill.danger { background: var(--err); }
.qtabs { display: flex; gap: 4px; margin: 12px 0 16px; flex-wrap: wrap; align-items: center; }
.qtab { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 15px; border-radius: 99px; font-size: 12.5px; font-weight: 800; color: var(--text-secondary); background: var(--surf); }
.qtab:hover { color: var(--text-primary); background: var(--surf-hi); }
.qtab.on { background: var(--accent); color: #000; }
.qtab .n { font-size: 10.5px; font-weight: 900; padding: 1px 7px; border-radius: 99px; background: rgba(255, 255, 255, 0.14); }
.qtab.on .n { background: rgba(0, 0, 0, 0.18); }
.qtab.you { padding-left: 6px; gap: 9px; }
.pillsep { width: 1px; height: 18px; background: rgba(255, 255, 255, 0.14); margin: 0 6px; flex: none; }
.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; vertical-align: middle; }
.chip.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
.chip.warn { background: color-mix(in srgb, var(--warn) 13%, transparent); color: var(--warn); }
.chip.acc { background: var(--accent-soft); color: var(--accent-hover); }
.chip.up { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
.chip.off { background: var(--surf-hi); color: var(--text-muted); }
.chip.err { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
.qbox { display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 15px; border-radius: 18px; background: var(--surf-hi); color: var(--text-muted); }
.qbox input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: 13px; min-width: 0; }
.qbox input::placeholder { color: var(--text-muted); }
.qbox.slim { height: 32px; }
.qbox.grow { flex: 1; }
.fchip { padding: 5px 11px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--surf); color: var(--text-secondary); }
.fchip:hover { color: var(--text-primary); }
.fchip.on { background: rgba(255, 255, 255, 0.14); color: #fff; }
.panel { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; padding: var(--pad) 14px; }
.panel + .panel { margin-top: 12px; }
.panel.dim { opacity: 0.7; }
.panel h3 { margin: 0 0 4px; font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.panel h3 .chip, .panel h3 .src { margin-left: auto; }
.ibtn { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: #fff; }
.ibtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
.ibtn:disabled { opacity: 0.4; cursor: default; }
.ibtn.sm { width: 24px; height: 24px; }
.ibtn.fav { color: var(--warn); }
.muted { color: var(--text-muted); }
.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 11.5px; letter-spacing: 0.02em; }
.hint { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
.hint.pad { padding: 8px 10px 0; }
.hint.wide { margin-top: 14px; max-width: 90ch; }
.hint.warn { color: var(--warn); }
.hint.ok { color: #5ee89a; }
.hint b { color: var(--text-secondary); }
.lnk { color: var(--text-secondary); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; }
.lnk:hover:not(:disabled) { color: #fff; }
.lnk:disabled { opacity: 0.5; }
/* ── source badges: the two networks, always named ── */
.src { display: inline-flex; align-items: center; gap: 5px; height: 18px; padding: 0 7px 0 5px; border-radius: 99px; font-size: 10px; font-weight: 900; letter-spacing: 0.04em; white-space: nowrap; vertical-align: middle; }
.src.slsk { background: rgba(255, 255, 255, 0.09); color: var(--text-secondary); }
.src.ss { background: var(--accent-soft); color: var(--accent-hover); }
.mark { display: inline-flex; align-items: center; color: var(--text-muted); }
.mark.fav { color: var(--warn); }
.mark.tr { color: #5ee89a; }
.stack { display: flex; }
.stack > :global(.av) { margin-left: -8px; box-shadow: 0 0 0 2px var(--bg-base); border-radius: 50%; }
.stack > :global(.av:first-child) { margin-left: 0; }
/* ── the identity strip ── */
.who { display: flex; align-items: center; gap: 14px; padding: 2px 0; }
.noid { width: 48px; height: 48px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-muted); flex: none; }
.whotx { min-width: 0; }
.who .name { font-size: 22px; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; display: flex; align-items: center; gap: 10px; }
.who .id { display: flex; align-items: center; gap: 9px; margin-top: 5px; font-size: 12px; color: var(--text-secondary); flex-wrap: wrap; }
.who .fp { white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; padding: 2px 6px 2px 8px; border-radius: 6px; background: var(--surf-hi); color: var(--text-primary); }
.who .fp:hover { background: rgba(255, 255, 255, 0.14); }
.who .fp .k { color: var(--text-muted); font-weight: 700; font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; }
.who .fp .cp { color: var(--text-muted); display: grid; }
.who .sl { display: inline-flex; align-items: center; gap: 5px; }
.who .sl b { color: var(--text-primary); }
.who .right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex: none; }
.who .st { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; margin-right: 6px; }
/* ── lists ── */
.split { display: grid; grid-template-columns: 300px 1fr; gap: var(--gap); align-items: start; }
.split.narrow { grid-template-columns: 270px 1fr; }
.split > * { min-width: 0; }
.col { display: flex; flex-direction: column; gap: 6px; }
.list { display: flex; flex-direction: column; gap: 2px; }
.lrow { display: flex; align-items: center; gap: 10px; min-height: var(--row); padding: 5px 10px; border-radius: 8px; text-align: left; width: 100%; }
.lrow.slim { min-height: 40px; }
.lrow:hover:not(:disabled) { background: var(--surf-hi); }
.lrow.on { background: var(--surf-hi); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); }
.lrow:disabled { opacity: 0.5; cursor: default; }
.lrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.lrow .l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
.lrow .l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lrow .l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lrow .r { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.unread { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--accent); color: #000; font-size: 10px; font-weight: 900; display: grid; place-items: center; }
.grp { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
.roomic { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: var(--surf-hi); color: var(--text-secondary); flex: none; }
.roomic.lg { width: 44px; height: 44px; border-radius: 12px; }
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding-top: 12vh; text-align: center; color: var(--text-secondary); }
.empty.tight { padding-top: 7vh; }
.big { font-size: 20px; font-weight: 700; color: var(--text-primary); }
.sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; }
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; }
/* friend requests — the invitation card, parsed and signed, never pasted text */
.freqs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.card { border-radius: 12px; background: var(--surf); border: 1px solid var(--hair); overflow: hidden; max-width: 560px; }
.card .top { display: flex; gap: 12px; padding: 12px; align-items: center; }
.card .t { font-size: 14px; font-weight: 800; }
.card .s { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; }
.card .prov { font-size: 10.5px; color: var(--text-muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.card .bot { display: flex; gap: 8px; padding: 0 12px 12px; align-items: center; flex-wrap: wrap; }
.card .bot .hint { margin-left: auto; font-size: 10.5px; }
/* ── thread / room ── */
.thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; }
.thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); }
.tt { min-width: 0; }
.thead-bar .nm, .roomhead .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 7px; }
.thead-bar .sub, .roomhead .sub { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 7px; margin-top: 2px; flex-wrap: wrap; max-width: none; }
.thead-bar .r, .roomhead .r { margin-left: auto; display: flex; gap: 4px; align-items: center; flex: none; }
.msgs { flex: 1; overflow-y: auto; padding: 12px 4px 8px; display: flex; flex-direction: column; gap: 8px; }
.mdate { align-self: center; font-size: 10.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 6px 0; }
.m { display: flex; gap: 8px; max-width: 78%; align-items: flex-end; }
.m .b { padding: 7px 11px; border-radius: 14px; background: var(--surf-hi); font-size: 13px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
.m.me { align-self: flex-end; flex-direction: row-reverse; }
.m.me .b { background: color-mix(in srgb, var(--accent) 26%, transparent); border-bottom-right-radius: 4px; }
.m:not(.me) .b { border-bottom-left-radius: 4px; }
.mst { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; margin-top: 3px; font-variant-numeric: tabular-nums; }
.m.me .mst { justify-content: flex-end; }
.mst.fail { color: var(--err); }
.compose { display: flex; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--hair); }
.compose .qbox { height: 38px; border-radius: 19px; }
.compose .via { font-size: 10.5px; color: var(--text-muted); white-space: nowrap; }
.send { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #000; display: grid; place-items: center; flex: none; }
.send:hover:not(:disabled) { background: var(--accent-hover); }
.send:disabled { opacity: 0.4; cursor: default; }
.roomwrap { display: grid; grid-template-columns: 1fr 170px; gap: 16px; flex: 1; min-height: 0; }
.roomchat { display: flex; flex-direction: column; min-height: 0; }
.chat { flex: 1; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; padding: 10px 2px; font-size: 13px; }
.cm { display: grid; grid-template-columns: 42px 1fr; gap: 0 8px; padding: 3px 6px; border-radius: 6px; align-items: baseline; }
.cm:hover { background: var(--surf); }
.cm .tm { font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.cm .body { line-height: 1.5; overflow-wrap: anywhere; }
.whoin { font-weight: 800; margin-right: 7px; color: var(--text-secondary); }
.whoin:hover { color: #fff; }
.whoin.c { color: var(--accent-hover); }
.whoin.mine { color: #fff; }
.cm.sys { color: var(--text-muted); font-size: 11.5px; }
.roster { border-left: 1px solid var(--hair); padding-left: 12px; overflow-y: auto; min-height: 0; }
.roster .grp { padding: 0 2px; height: 24px; }
.rrow { display: flex; align-items: center; gap: 8px; height: 28px; font-size: 12px; font-weight: 700; color: var(--text-secondary); padding: 0 4px; border-radius: 6px; width: 100%; text-align: left; white-space: nowrap; overflow: hidden; }
.rrow:hover { background: var(--surf-hi); color: #fff; }
.drow { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 6px 0 4px; border-radius: 8px; font-size: 12.5px; font-weight: 700; }
.drow:hover { background: var(--surf-hi); }
.drow.joined .dname { color: var(--accent-hover); }
.drow .dname { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 4px; }
.drow .dname:disabled { opacity: 0.5; cursor: default; }
.drow .cnt { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.drow .star { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; color: var(--text-muted); }
.drow .star.on { color: var(--warn); }
/* ── people ── */
.ptool { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.ptool .qbox { width: 260px; }
.ptool .sp { flex: 1; }
.addrow { display: flex; gap: 6px; align-items: center; }
.addrow .qbox { width: 230px; }
.prow { display: grid; grid-template-columns: 36px 1fr auto auto; gap: 0 12px; align-items: center; min-height: var(--row); padding: 5px 10px; border-radius: 8px; }
.prow:hover { background: var(--surf-hi); }
.avbtn { display: grid; }
.ptx { min-width: 0; }
.prow .nm { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.lnkname { font: inherit; font-weight: 800; }
.lnkname:hover { text-decoration: underline; text-underline-offset: 2px; }
.prow .nm .real { font-weight: 600; color: var(--text-muted); font-size: 12px; }
.prow .sub { font-size: 11.5px; color: var(--text-muted); margin-top: 2px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; max-width: none; line-height: 1.4; }
.prow .sub .note { font-style: italic; color: var(--text-secondary); }
.prow .seen { font-size: 10.5px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.prow .seen b { display: block; color: var(--text-secondary); font-weight: 700; }
.acts { display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s; justify-self: end; }
.prow:hover .acts { opacity: 1; }
.acts .ibtn { width: 26px; height: 26px; color: var(--text-secondary); }
.acts .ibtn:hover { color: #fff; }
.acts .ibtn.danger:hover { color: var(--err); }
/* ── discover ── */
.shelf .prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.track { display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 8px; scrollbar-width: none; }
.track::-webkit-scrollbar { display: none; }
.pcard { flex: 0 0 150px; padding: 9px; border-radius: 8px; text-align: left; }
.pcard:hover { background: var(--surf-hi); }
.pcard .face { width: 132px; height: 132px; border-radius: 6px; display: grid; place-items: center; }
.pcard .nm { display: block; margin-top: 9px; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcard .why { display: block; font-size: 11.5px; color: var(--text-muted); line-height: 1.35; margin-top: 2px; }
.pcard .why b { color: var(--text-secondary); font-weight: 700; }
.emptyshelf { border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; padding: 16px 18px; display: flex; align-items: center; gap: 14px; color: var(--text-muted); font-size: 12.5px; line-height: 1.5; }
.emptyshelf b { color: var(--text-secondary); }
.emptyshelf .ax-ghost { margin-left: auto; }
/* ── spaces ── */
.spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; }
.sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; }
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; }
.sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; }
.src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
/* library overlap in the drawer */
.ovl { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; align-items: center; }
.ovl .lbl { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 4px; }
.ovl .ov { font-size: 11px; padding: 3px 8px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovl .ov b { color: var(--text-primary); font-weight: 700; }
.ovl .ov.gap { background: color-mix(in srgb, var(--warn) 10%, transparent); }
.ovl .ov.more { background: none; color: var(--text-muted); }
/* the proposal board */
.bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
.dlist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; padding: 5px 8px; border-radius: 8px; font-size: 12.5px; }
.dlist li:hover { background: var(--surf-hi); }
.dlist li.fresh { background: var(--accent-soft); }
.dlist li.none { display: block; color: var(--text-muted); font-size: 11.5px; }
.dlist .n { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.dlist .tx { display: flex; flex-direction: column; min-width: 0; }
.dlist .t { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dlist .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dlist .b { display: flex; gap: 2px; }
.hits { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
.hit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; text-align: left; font-size: 12.5px; color: var(--text-secondary); min-width: 0; }
.hit:hover { background: var(--surf-hi); color: #fff; }
.hit .t { font-weight: 700; white-space: nowrap; }
.hit .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prop { padding: 12px 12px 10px; border: 1px solid var(--hair); border-radius: 10px; margin-top: 10px; }
.prop.stale { opacity: 0.8; border-style: dashed; }
.prop .ph { display: flex; align-items: center; gap: 10px; }
.prop .who { display: flex; flex-direction: column; min-width: 0; }
.prop .who .nm { font-weight: 800; font-size: 13px; }
.prop .ph .chip { margin-left: auto; }
.prop .note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-secondary); font-style: italic; }
.ops { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; }
.ops li { display: flex; gap: 8px; align-items: baseline; }
.ops li b { width: 14px; text-align: center; font-weight: 900; flex: none; }
.ops li.add b { color: #5ee89a; }
.ops li.remove b { color: var(--err); }
.ops li.move b { color: var(--accent-hover); }
.ops i { font-style: normal; font-weight: 700; }
.decide { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
.decide input { flex: 1; min-width: 160px; height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
.dec { display: flex; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 12.5px; }
.dec:first-of-type { border-top: none; }
.dec .rs { flex: 1; }
/* ── the room's listening trail ── */
.sharebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.sharebar .grow { flex: 1; min-width: 240px; }
.sharebar .ttl { font-size: 12.5px; font-weight: 800; }
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
.toggle.on { background: var(--accent); }
.toggle.on i { left: 20px; background: #000; }
.toggle:disabled { opacity: 0.45; cursor: default; }
.nowband { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; margin-top: 10px; }
.nowcard { display: flex; gap: 11px; align-items: center; padding: 10px 12px; border-radius: 10px; background: var(--surf-hi); border: 1px solid var(--hair); min-width: 0; }
.nowcard .body { min-width: 0; flex: 1; }
.nowcard .who { font-size: 11px; font-weight: 800; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.nowcard .t { font-size: 13px; font-weight: 800; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nowcard .a { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nowcard.quiet { opacity: 0.55; }
.bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 9px; }
.bars i { width: 2px; background: var(--ok); border-radius: 1px; animation: eq 900ms ease-in-out infinite alternate; }
.bars i:nth-child(2) { animation-delay: 160ms; }
.bars i:nth-child(3) { animation-delay: 320ms; }
@keyframes eq { from { height: 2px; } to { height: 9px; } }
@media (prefers-reduced-motion: reduce) { .bars i { animation: none; height: 6px; } }
.lf { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
/* Wider than the chat's 42px time column: these rows are centred rather
than baseline-aligned, so a locale clock that wraps ("11:49 / AM")
shows as two lines instead of pushing the row. */
.lr { display: grid; grid-template-columns: 58px auto 1fr auto; gap: 0 10px; align-items: center; padding: 0 8px; height: 44px; border-radius: 8px; }
.lr:hover { background: var(--surf-hi); }
.lr.self { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.lr .tm { font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.lr .art { width: 32px; height: 32px; border-radius: 5px; flex: none; display: grid; place-items: center; font-size: 9px; font-weight: 900; color: rgba(255, 255, 255, 0.5); overflow: hidden; }
.lr .meta { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
.lr .t { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lr .a { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lr .who { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; color: var(--text-secondary); }
.lr .who.mine { color: #fff; }
/* A run keeps the name's column without repeating it, so the rows stay
aligned and a person still reads as one block. */
.lr .runpad { width: 22px; flex: none; }
.lr .acts { display: flex; gap: 5px; opacity: 0; }
.lr:hover .acts, .lr:focus-within .acts { opacity: 1; }
.lr .ico { width: 26px; height: 26px; border-radius: 99px; display: grid; place-items: center; background: rgba(255, 255, 255, 0.1); }
.lr .ico:hover { background: rgba(255, 255, 255, 0.2); }
.daysep { display: flex; align-items: center; gap: 10px; padding: 12px 8px 6px; }
.daysep span { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.daysep .rule { flex: 1; height: 1px; background: var(--hair); }
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
.subtab.on { background: var(--surf-hi); color: #fff; }
.subtab b { font-size: 10px; }
.pinned { display: flex; gap: 14px; align-items: center; }
.pinned .t { font-size: 15px; font-weight: 800; }
.pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; }
.mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.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.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 ── */
/* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */
.community { container-type: inline-size; }
.yougrid { display: grid; gap: 18px; align-items: start; grid-template-columns: minmax(380px, 1.15fr) minmax(340px, 1fr) minmax(340px, 1fr); }
.yougrid.setup { grid-template-columns: minmax(380px, 1fr) minmax(360px, 1fr); }
.yc { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.yc .panel + .panel { margin-top: 0; }
@container (max-width: 1240px) {
.yougrid { grid-template-columns: minmax(360px, 1fr) minmax(340px, 1fr); }
.yc.a { grid-column: 1; }
.yc.b { grid-column: 1; }
.yc.c { grid-column: 2; grid-row: 1 / span 2; }
}
@container (max-width: 820px) {
.yougrid, .yougrid.setup { grid-template-columns: 1fr; }
.yc.c { grid-column: 1; grid-row: auto; }
}
/* A chat wider than a comfortable line reads badly; the pane may be 1900px. */
.split .thread { max-width: 1200px; }
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.bl { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hover); animation: cm-blink 1.4s infinite; margin-right: 2px; vertical-align: middle; }
@keyframes cm-blink { 50% { opacity: 0.25; } }
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.field label { font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; justify-content: space-between; }
.field label span { letter-spacing: 0; text-transform: none; font-weight: 700; }
.field label span.over { color: var(--err); }
.field input, .field textarea { background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font-size: 13px; outline: none; resize: none; font-family: inherit; }
.field input:focus, .field textarea:focus { border-color: rgba(255, 255, 255, 0.28); }
.likes { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 2px 0 12px; }
.likes .inl { flex: 1; min-width: 240px; }
.likes .chip .x {
all: unset;
cursor: pointer;
margin-left: 6px;
opacity: 0.65;
line-height: 1;
}
.likes .chip .x:hover { opacity: 1; }
.inl { display: flex; gap: 8px; align-items: center; }
.inl input { flex: 1; min-width: 0; }
.chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
.btns { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.avset { display: flex; gap: 16px; align-items: center; margin-top: 14px; }
.avset .opts { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
.opt { display: flex; align-items: center; gap: 9px; padding: 5px 10px; border-radius: 8px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary); text-align: left; }
.opt:hover:not(:disabled) { background: var(--surf-hi); color: #fff; }
.opt:disabled { opacity: 0.5; cursor: default; }
.opt.on { color: #fff; }
.opt .hint { display: block; font-weight: 600; }
.opt .rad { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, 0.35); display: grid; place-items: center; flex: none; }
.opt.on .rad::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.pub { display: flex; align-items: center; gap: 10px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
.fpblock { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.kbox { flex: 1; background: var(--surf-hi); border-radius: 8px; padding: 8px 12px; color: #fff; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; user-select: all; }
.kbox i { font-style: normal; color: var(--text-muted); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 11.5px; margin: 8px 0 0; }
.kv dt { color: var(--text-muted); font-weight: 700; }
.kv dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
.kv dd b { color: var(--text-primary); }
.netcard { display: flex; gap: 12px; align-items: flex-start; margin-top: 12px; }
.nico { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: none; }
.nico.slsk { background: rgba(255, 255, 255, 0.08); color: #fff; }
.nico.fn { background: var(--accent-soft); color: var(--accent-hover); }
.ntx { min-width: 0; flex: 1; }
.netcard .t { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.netcard .s { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
.netcard .s b { color: var(--text-secondary); font-weight: 700; }
.tog { display: inline-block; width: 36px; height: 20px; border-radius: 10px; background: rgba(255, 255, 255, 0.16); position: relative; flex: none; transition: background 0.15s; }
.tog::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s; }
.tog.on { background: var(--accent); }
.tog.on::after { left: 18px; }
.tog:disabled { opacity: 0.5; cursor: default; }
.tog.big { margin-top: 6px; }
.swrow { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
.swrow .hint.r { margin-left: auto; }
.disc { margin-top: 10px; }
.disc summary { font-size: 11.5px; font-weight: 800; color: var(--text-secondary); cursor: pointer; }
.disc ul { margin: 8px 0 0; padding-left: 18px; font-size: 11.5px; color: var(--text-muted); line-height: 1.6; }
.danger { display: flex; gap: 8px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
/* ── the person drawer (portaled to body — see portal()) ── */
.overlay { --hair: rgba(255, 255, 255, 0.07); --surf: var(--bg-elevated); --surf-hi: var(--bg-highlight); --ok: #3ddc84; --warn: #e8a33d; --err: #f15e6c; font-family: var(--app-font); font-size: 13px; color: var(--text-primary); }
.scrim { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45); }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 440px; z-index: 61; background: var(--bg-elevated); border-left: 1px solid rgba(255, 255, 255, 0.08); box-shadow: -24px 0 60px rgba(0, 0, 0, 0.5); overflow-y: auto; padding: 18px 20px 30px; animation: drawer-in 0.24s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes drawer-in { from { transform: translateX(100%); } to { transform: none; } }
.dhead { display: flex; gap: 14px; align-items: center; }
.dtx { min-width: 0; flex: 1; }
.dhead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.pet { background: none; border: 0; border-bottom: 1px dashed rgba(255, 255, 255, 0.3); color: #fff; font: inherit; font-size: 20px; font-weight: 800; width: 180px; outline: none; padding: 0 0 2px; }
.pet::placeholder { color: #fff; opacity: 0.85; }
.dhead .sub { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; max-width: none; }
.dhead .x { align-self: flex-start; }
.dacts { display: flex; gap: 6px; margin: 14px 0 10px; flex-wrap: wrap; }
.marks { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.markbtn { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--surf); color: var(--text-secondary); }
.markbtn.on { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
.markbtn.tr.on { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
.grp-in { background: var(--surf-hi); border: 1px solid transparent; border-radius: 99px; color: #fff; padding: 5px 12px; font: inherit; font-size: 11.5px; width: 130px; outline: none; }
.grp-in:focus { border-color: rgba(255, 255, 255, 0.28); }
.note-in { width: 100%; margin-top: 8px; background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; font-size: 12.5px; outline: none; resize: none; }
.note-in:focus { border-color: rgba(255, 255, 255, 0.28); }
.tagrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.tag { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); }
.tag.hate { text-decoration: line-through; opacity: 0.7; }
.desc { margin: 8px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; }
.rp { display: flex; gap: 12px; align-items: flex-start; }
.rptx { min-width: 0; flex: 1; }
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.mark.fr { color: var(--accent-hover); }
.pubrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--hair); }
.pubrow .ptx { flex: 1; min-width: 0; }
.pubrow .nm { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mosaic-ph { width: 56px; height: 56px; border-radius: 8px; flex: none; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-hover); }
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
.dsec .kv { margin-top: 0; }
.dsec p.hint { margin: 8px 0 0; }
.dfoot { margin-top: 20px; padding: 12px 14px; border-radius: 10px; border: 1px dashed rgba(255, 255, 255, 0.16); font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
@media (max-width: 1180px) {
.split, .split.narrow { grid-template-columns: 1fr; }
.roomwrap { grid-template-columns: 1fr; }
.roster { display: none; }
}
</style>