Files
SoulStream/src/views/Community.svelte
T

1815 lines
120 KiB
Svelte

<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 Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js";
import {
app,
ui,
slsk,
downloads,
network,
goToView,
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,
refreshLinks,
linkSelf,
unlinkSelf,
checkLink,
linkFor,
loadRemoteProfile,
friendRequest,
friendAccept,
friendDecline,
friendRemove,
friendForget,
friendshipFor,
friendCount,
pendingIn,
sharePreview,
shareImport,
shareDismiss,
sharedPublish,
sharedWithdraw,
publishInbox,
checkInbox,
friendRequestByKey,
} 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));
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 ------------------------------------------------------------
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 inbox is polled while this page is open and the node is up: on
// arrival and every 90 s. Sealed cargo becomes friend requests and share
// cards through the same stores the PM carry feeds.
$effect(() => {
if (!nodeUp || !key || !community.inbox?.published) return;
untrack(() => void checkInbox());
const t = setInterval(() => void checkInbox(), 90_000);
return () => clearInterval(t);
});
let addKey = $state("");
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));
});
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) untrack(() => loadSimilar());
});
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 !convos.length && !community.shares.length && !pendingIn().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">{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 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>
<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. The comparison itself is the next piece of this shelf.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
</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="empty tight">
<span class="spico"><Icon name="sparkle" size={26} /></span>
<div class="big">No Spaces yet</div>
<div class="sub">
A Space is a persistent SoulStream community on Freenet with its own member keys — invitation only, encrypted
when private, and never a Soulseek Room in disguise. The research behind it is done; the Space contract itself
is the next piece of the Freenet track, so nothing here is live yet.
</div>
<span class="chip up" style="margin-top:6px"><Icon name="sparkle" size={10} />beta · not live</span>
</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>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;
/* A designed page width, centred in the pane: at 2560 the sections used
to huddle top-left with the rest of the pane empty (Kyle, 2026-09-02).
Below the cap nothing changes. */
max-width: 1520px;
margin: 0 auto;
padding-top: calc(var(--bar-h) + 18px);
padding-bottom: 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; }
/* ── 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); }
.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>