forked from Keelrleek/SoulStream
community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private
This commit is contained in:
1 parent
4378c4c0cc
commit
8b511b20e7
14 files changed
+2913
-13
No files matched your search
@@ -174,6 +174,14 @@ export const shareDismiss = (id) => invoke("share_dismiss", { id });
|
||||
export const inboxStatus = () => invoke("inbox_status");
|
||||
export const inboxPublish = () => invoke("inbox_publish");
|
||||
export const inboxCheck = () => invoke("inbox_check");
|
||||
export const spacesList = () => invoke("spaces_list");
|
||||
export const spaceCreate = (name, description, isPrivate) => invoke("space_create", { name, description, private: isPrivate });
|
||||
export const spaceOpen = (owner, space) => invoke("space_open", { owner, space });
|
||||
export const spacePost = (owner, space, text) => invoke("space_post", { owner, space, text });
|
||||
export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", { owner, space, personaKey });
|
||||
export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey });
|
||||
export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid });
|
||||
export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space });
|
||||
export const buddyList = () => invoke("buddy_list");
|
||||
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
||||
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
||||
|
||||
@@ -75,6 +75,11 @@ export const community = $state({
|
||||
inbox: null,
|
||||
/** What the last check found ({new_items, quarantined, pruned}). */
|
||||
inboxLast: null,
|
||||
/** spaces_list: the Spaces this device owns or was invited to. */
|
||||
spaces: [],
|
||||
/** The open Space (space_open), or null. */
|
||||
space: null,
|
||||
spaceError: "",
|
||||
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
||||
* people's signed profiles, read once per session when surfaced. */
|
||||
remoteProfiles: {},
|
||||
@@ -319,6 +324,10 @@ export async function initCommunity() {
|
||||
else if (community.persona) community.persona.available = false;
|
||||
});
|
||||
api.onEvent("community:friends", () => void refreshFriends());
|
||||
api.onEvent("community:spaces", () => {
|
||||
void refreshSpaces();
|
||||
if (community.space) void openSpace(community.space.owner, community.space.space_id);
|
||||
});
|
||||
api.onEvent("community:shares", () => {
|
||||
void refreshShares();
|
||||
void refreshPublished();
|
||||
@@ -327,7 +336,7 @@ export async function initCommunity() {
|
||||
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
||||
});
|
||||
}
|
||||
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox()]);
|
||||
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces()]);
|
||||
await refreshPersona();
|
||||
}
|
||||
|
||||
@@ -519,7 +528,57 @@ export const friendshipForKey = (key) => {
|
||||
};
|
||||
|
||||
|
||||
// ---- spaces -------------------------------------------------------------------------
|
||||
export async function refreshSpaces() {
|
||||
try {
|
||||
community.spaces = await api.spacesList();
|
||||
} catch {
|
||||
// stays as it was
|
||||
}
|
||||
}
|
||||
/** Read a Space through the node; the view is what the checkpoint says. */
|
||||
export async function openSpace(owner, space) {
|
||||
community.spaceError = "";
|
||||
community.busy["space:" + space] = true;
|
||||
try {
|
||||
community.space = await api.spaceOpen(owner, space);
|
||||
} catch (e) {
|
||||
community.spaceError = String(e?.message ?? e);
|
||||
} finally {
|
||||
delete community.busy["space:" + space];
|
||||
}
|
||||
}
|
||||
export const createSpace = (name, description, isPrivate) =>
|
||||
run("space:create", async () => {
|
||||
community.space = await api.spaceCreate(name, description, isPrivate);
|
||||
await refreshSpaces();
|
||||
});
|
||||
export const postToSpace = (owner, space, text) =>
|
||||
run("space:post", async () => {
|
||||
await api.spacePost(owner, space, text);
|
||||
await openSpace(owner, space);
|
||||
});
|
||||
export const inviteToSpace = (owner, space, personaKey) =>
|
||||
run("space:invite", async () => {
|
||||
community.space = await api.spaceInvite(owner, space, personaKey);
|
||||
});
|
||||
export const removeFromSpace = (owner, space, personaKey) =>
|
||||
run("space:remove", async () => {
|
||||
community.space = await api.spaceRemoveMember(owner, space, personaKey);
|
||||
});
|
||||
export const pinToSpace = (owner, space, playlistOwner, playlistUuid) =>
|
||||
run("space:pin", async () => {
|
||||
community.space = await api.spaceSetPinned(owner, space, playlistOwner, playlistUuid);
|
||||
});
|
||||
export const leaveSpace = (owner, space) =>
|
||||
run("space:leave", async () => {
|
||||
await api.spaceLeave(owner, space);
|
||||
community.space = null;
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
|
||||
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||
// through this, never through a bare import (which would be a second copy).
|
||||
if (import.meta.env.DEV) globalThis.__community = community;
|
||||
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox };
|
||||
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace };
|
||||
+187
-8
@@ -80,6 +80,14 @@
|
||||
publishInbox,
|
||||
checkInbox,
|
||||
friendRequestByKey,
|
||||
refreshSpaces,
|
||||
openSpace,
|
||||
createSpace,
|
||||
postToSpace,
|
||||
inviteToSpace,
|
||||
removeFromSpace,
|
||||
pinToSpace,
|
||||
leaveSpace,
|
||||
} from "../lib/community.svelte.js";
|
||||
|
||||
onMount(() => {
|
||||
@@ -445,6 +453,53 @@
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
let addKey = $state("");
|
||||
// ---- spaces ------------------------------------------------------------
|
||||
let spaceName = $state("");
|
||||
let spacePrivate = $state(true);
|
||||
let spaceText = $state("");
|
||||
let spaceInviteKey = $state("");
|
||||
let spaceTab = $state("chat");
|
||||
let spaceCreating = $state(false);
|
||||
const sv = $derived(community.space);
|
||||
const spaceOwner = $derived(sv?.my_role === "owner");
|
||||
let spaceEl = $state(null);
|
||||
$effect(() => {
|
||||
const n = sv?.messages.length;
|
||||
if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight;
|
||||
});
|
||||
// The open Space is re-read every 30 s while the section is showing.
|
||||
$effect(() => {
|
||||
if (community.section !== "spaces" || !sv || !nodeUp) return;
|
||||
const { owner, space_id } = sv;
|
||||
const t = setInterval(() => void openSpace(owner, space_id), 30_000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
async function sayInSpace() {
|
||||
const t = spaceText.trim();
|
||||
if (!t || !sv) return;
|
||||
await postToSpace(sv.owner, sv.space_id, t);
|
||||
spaceText = "";
|
||||
}
|
||||
async function makeSpace() {
|
||||
const n = spaceName.trim();
|
||||
if (!n) return;
|
||||
await createSpace(n, "", spacePrivate);
|
||||
spaceName = "";
|
||||
spaceCreating = false;
|
||||
}
|
||||
const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author));
|
||||
const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null);
|
||||
$effect(() => {
|
||||
const s0 = sv;
|
||||
if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) {
|
||||
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
|
||||
}
|
||||
});
|
||||
async function importPinned() {
|
||||
if (!sv?.pinned_uuid) return;
|
||||
const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" });
|
||||
if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" });
|
||||
}
|
||||
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||||
$effect(() => {
|
||||
const lk = drawerLink;
|
||||
@@ -1005,15 +1060,120 @@
|
||||
|
||||
<!-- ── 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 class="split" style="grid-template-columns:260px 1fr">
|
||||
<div class="col">
|
||||
<div class="grp">Your Spaces <span class="n">{community.spaces.length}</span><span class="ln"></span></div>
|
||||
{#each community.spaces as sp0 (sp0.id)}
|
||||
<button class="lrow" class:on={sv?.id === sp0.id} onclick={() => openSpace(sp0.owner, sp0.space_id)}>
|
||||
<span class="spmini">{sp0.name.slice(0, 2).toUpperCase()}</span>
|
||||
<span class="tx"><span class="l1"><span class="nm">{sp0.name}</span></span><span class="l2">{sp0.private ? "private" : "public"} · {sp0.my_role} · epoch {sp0.last_epoch}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if spaceCreating}
|
||||
<form class="panel" style="margin-top:6px" onsubmit={(e) => { e.preventDefault(); void makeSpace(); }}>
|
||||
<div class="field" style="margin-top:0"><label for="sp-name">Name</label><input id="sp-name" bind:value={spaceName} maxlength="64" placeholder="Late Night Ambient" /></div>
|
||||
<label class="swrow" style="margin-top:10px"><button type="button" class="tog" class:on={spacePrivate} role="switch" aria-label="Private" aria-checked={spacePrivate} onclick={() => (spacePrivate = !spacePrivate)}></button><span>{spacePrivate ? "Private — bodies sealed to a key members receive" : "Public — anyone who finds it can read"}</span></label>
|
||||
<div class="btns"><button class="ax-fill sm" type="submit" disabled={!spaceName.trim() || !nodeUp || !key || !!community.busy["space:create"]}>{community.busy["space:create"] ? "Creating…" : "Create"}</button><button class="ax-ghost sm" type="button" onclick={() => (spaceCreating = false)}>Cancel</button></div>
|
||||
</form>
|
||||
{:else}
|
||||
<button class="lrow" style="color:var(--text-secondary)" onclick={() => (spaceCreating = true)} disabled={!nodeUp || !key}>
|
||||
<span class="av" style="border:1px dashed rgba(255,255,255,0.25);display:grid;place-items:center;width:40px;height:40px;border-radius:50%">{@html ""}<Icon name="plus" size={16} /></span>
|
||||
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one.</p></div>
|
||||
</div>
|
||||
<span class="chip up" style="margin-top:6px"><Icon name="sparkle" size={10} />beta · not live</span>
|
||||
{#if sv}
|
||||
<div>
|
||||
<div class="sphead">
|
||||
<div class="spico">{sv.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div>
|
||||
<div class="nm">{sv.name} <span class="src space"><Icon name="sparkle" size={10} />Space</span></div>
|
||||
<div class="sub">
|
||||
<span>{#if sv.private}<Icon name="lock" size={12} /> private · sealed{:else}public{/if}</span><span>·</span>
|
||||
<span>{sv.members.length} {sv.members.length === 1 ? "member" : "members"}</span><span>·</span>
|
||||
<span>epoch {sv.epoch}</span><span>·</span>
|
||||
<span>not a Soulseek Room</span>
|
||||
{#if !sv.reachable}<span>·</span><span class="chip warn">not on this node yet</span>{:else}<span>·</span><span class="chip ok"><Icon name="check" size={10} />read from the network</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="r" style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="ax-ghost sm" onclick={() => openSpace(sv.owner, sv.space_id)} disabled={!!community.busy["space:" + sv.space_id]}><Icon name="refresh" size={12} /></button>
|
||||
{#if !spaceOwner}<button class="ax-ghost sm" onclick={() => leaveSpace(sv.owner, sv.space_id)}>Leave</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sptabs">
|
||||
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
|
||||
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
|
||||
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
|
||||
<span class="subtab" style="opacity:0.55" title="The F7 proposal board is the next piece">Proposals · next</span>
|
||||
</div>
|
||||
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
|
||||
{#if spaceTab === "chat"}
|
||||
{#if sv.pinned_uuid}
|
||||
<div class="panel" style="margin-bottom:12px"><h3><Icon name="pin" size={13} /> Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}<span class="chip acc"><Icon name="disc" size={10} />revision {pinnedPreview.revision_seq}</span>{/if}</h3>
|
||||
<div class="pinned" style="margin-top:8px">
|
||||
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||||
<div>
|
||||
<div class="t">{pinnedPreview?.name ?? "Reading…"}</div>
|
||||
<div class="s">{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}</div>
|
||||
<div class="btns"><button class="ax-fill sm" onclick={importPinned} disabled={!pinnedPreview?.entries?.length || !!community.busy["import:pin:" + sv.pinned_uuid]}><Icon name="download" size={12} /> Import</button>{#if spaceOwner}<button class="ax-ghost sm" onclick={() => pinToSpace(sv.owner, sv.space_id, null, null)}>Unpin</button>{/if}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if spaceOwner && community.published.some((p) => !p.withdrawn)}
|
||||
<div class="noderow" style="margin:0 0 12px"><span class="hint">Pin one of your public playlists:</span>{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}<button class="fchip" onclick={() => pinToSpace(sv.owner, sv.space_id, key, p.uuid)}>{p.name}</button>{/each}</div>
|
||||
{/if}
|
||||
<div class="thread" style="height:auto;min-height:360px">
|
||||
<div class="chat" bind:this={spaceEl} style="max-height:420px">
|
||||
{#if !sv.messages.length}<div class="cm sys"><span class="tm"></span><span class="body">Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.</span></div>{/if}
|
||||
{#each sv.messages as m (m.id)}
|
||||
<div class="cm"><span class="tm">{fmtClock(m.created_at * 1000)}</span><span class="body"><span class="whoin" class:mine={m.is_me} class:c={!m.is_me && !!m.username}>{whoIs(m)}</span>{#if m.text != null}{m.text}{:else}<span class="muted"><Icon name="lock" size={10} /> sealed to a key this device does not hold (epoch {m.epoch})</span>{/if}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayInSpace(); }}>
|
||||
<div class="qbox grow"><input placeholder={sv.can_post ? `Say something in ${sv.name}` : sv.has_key ? "You are not on the roster" : "No key for this epoch yet"} bind:value={spaceText} disabled={!sv.can_post || !nodeUp} /></div>
|
||||
<span class="via">{sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept</span>
|
||||
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
|
||||
</form>
|
||||
</div>
|
||||
{:else if spaceTab === "members"}
|
||||
<div class="panel">
|
||||
<div class="members">
|
||||
{#each sv.members as m (m.key)}
|
||||
<div class="mem">
|
||||
<button class="avbtn" onclick={() => m.username && (community.drawer = m.username)}><Identicon seed={m.key} size={56} /></button>
|
||||
<span class="nm">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
|
||||
<span class="role" class:own={m.role === "owner"}>{m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if}</span>
|
||||
{#if spaceOwner && !m.is_me}<button class="lnk" onclick={() => removeFromSpace(sv.owner, sv.space_id, m.key)} disabled={!!community.busy["space:remove"]}>remove</button>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if spaceOwner}
|
||||
<form class="inl" style="margin-top:14px" onsubmit={(e) => { e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}>
|
||||
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
|
||||
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
|
||||
</form>
|
||||
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
|
||||
{/if}
|
||||
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel">
|
||||
<h3>About</h3>
|
||||
<dl class="kv"><dt>Owner</dt><dd class="mono">{sv.owner}</dd><dt>Space id</dt><dd class="mono">{sv.space_id}</dd><dt>Mode</dt><dd>{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}</dd><dt>Window</dt><dd>the last {sv.message_cap} messages</dd><dt>Epoch</dt><dd>{sv.epoch}</dd></dl>
|
||||
<p class="hint" style="margin-top:10px">What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.</p>
|
||||
{#if sv.description}<p class="desc">{sv.description}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty tight">
|
||||
<span class="spico"><Icon name="sparkle" size={26} /></span>
|
||||
<div class="big">{community.spaces.length ? "Pick a Space" : "No Spaces yet"}</div>
|
||||
<div class="sub">A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── YOU ────────────────────────────────────────────────────────── -->
|
||||
@@ -1694,6 +1854,25 @@
|
||||
.emptyshelf b { color: var(--text-secondary); }
|
||||
.emptyshelf .ax-ghost { margin-left: auto; }
|
||||
|
||||
/* ── spaces ── */
|
||||
.spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; }
|
||||
.sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
|
||||
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; }
|
||||
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; }
|
||||
.sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; }
|
||||
.src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
|
||||
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
|
||||
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
|
||||
.subtab.on { background: var(--surf-hi); color: #fff; }
|
||||
.subtab b { font-size: 10px; }
|
||||
.pinned { display: flex; gap: 14px; align-items: center; }
|
||||
.pinned .t { font-size: 15px; font-weight: 800; }
|
||||
.pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; }
|
||||
.mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
|
||||
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
|
||||
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||||
.mem .role.own { color: #d9a6ff; }
|
||||
/* ── 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. */
|
||||
|
||||
Reference in new issue
Block a user