the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it
This commit is contained in:
1 parent
8b511b20e7
commit
bf81792c49
15 files changed
+3207
-10
No files matched your search
@@ -57,6 +57,7 @@
|
||||
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
||||
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
"chevron-up": '<path d="m18 15-6-6-6 6"/>',
|
||||
// Lucide layers-2: its ink fills the viewBox symmetrically, so the
|
||||
// glyph centers correctly inside small pills (the 3-layer variant
|
||||
// rides high when trimmed).
|
||||
|
||||
@@ -182,6 +182,9 @@ export const spaceInvite = (owner, space, personaKey) => invoke("space_invite",
|
||||
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 spaceBoard = (owner, space) => invoke("space_board", { owner, space });
|
||||
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
|
||||
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
|
||||
export const buddyList = () => invoke("buddy_list");
|
||||
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
||||
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
||||
|
||||
@@ -80,6 +80,9 @@ export const community = $state({
|
||||
/** The open Space (space_open), or null. */
|
||||
space: null,
|
||||
spaceError: "",
|
||||
/** space_board: the proposal board beside the open Space's pinned playlist, or null. */
|
||||
board: null,
|
||||
boardError: "",
|
||||
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
||||
* people's signed profiles, read once per session when surfaced. */
|
||||
remoteProfiles: {},
|
||||
@@ -574,11 +577,48 @@ export const leaveSpace = (owner, space) =>
|
||||
run("space:leave", async () => {
|
||||
await api.spaceLeave(owner, space);
|
||||
community.space = null;
|
||||
community.board = null;
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
// ---- the proposal board ---------------------------------------------------------
|
||||
/** Read the board beside the Space's pinned playlist; the playlist's owner also
|
||||
* brings its roster in line with the members here. */
|
||||
export async function openBoard(owner, space) {
|
||||
community.boardError = "";
|
||||
community.busy["board:" + space] = true;
|
||||
try {
|
||||
community.board = await api.spaceBoard(owner, space);
|
||||
} catch (e) {
|
||||
community.board = null;
|
||||
community.boardError = String(e?.message ?? e);
|
||||
} finally {
|
||||
delete community.busy["board:" + space];
|
||||
}
|
||||
}
|
||||
async function boardRun(key, fn) {
|
||||
community.busy[key] = true;
|
||||
community.boardError = "";
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
community.boardError = String(e?.message ?? e);
|
||||
throw e;
|
||||
} finally {
|
||||
delete community.busy[key];
|
||||
}
|
||||
}
|
||||
export const proposeToBoard = (owner, space, base, ops, note) =>
|
||||
boardRun("board:propose", async () => {
|
||||
community.board = await api.spacePropose(owner, space, base, ops, note);
|
||||
});
|
||||
export const decideProposal = (owner, space, proposal, accept, reason) =>
|
||||
boardRun("board:decide", async () => {
|
||||
community.board = await api.spaceDecide(owner, space, proposal, accept, reason);
|
||||
});
|
||||
|
||||
|
||||
// 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, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace };
|
||||
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, openBoard, proposeToBoard, decideProposal };
|
||||
+221
-1
@@ -88,6 +88,9 @@
|
||||
removeFromSpace,
|
||||
pinToSpace,
|
||||
leaveSpace,
|
||||
openBoard,
|
||||
proposeToBoard,
|
||||
decideProposal,
|
||||
} from "../lib/community.svelte.js";
|
||||
|
||||
onMount(() => {
|
||||
@@ -495,6 +498,91 @@
|
||||
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
|
||||
}
|
||||
});
|
||||
// ---- the proposal board (F7). The composer is a working copy of the pinned
|
||||
// playlist that records each act as an op; the Rust side dry-runs the same
|
||||
// ops against the exact base before anything is signed.
|
||||
const bv = $derived(community.board);
|
||||
const boardKey = $derived(sv ? `${sv.id}|${sv.pinned_uuid ?? ""}` : "");
|
||||
const boardBusy = $derived(!!(community.busy["board:propose"] || community.busy["board:decide"] || (sv && community.busy["board:" + sv.space_id])));
|
||||
let boardDraft = $state(null);
|
||||
let boardQuery = $state("");
|
||||
let boardHits = $state([]);
|
||||
let boardReason = $state({});
|
||||
let freshCount = 0;
|
||||
let boardTimer;
|
||||
$effect(() => {
|
||||
const k = boardKey;
|
||||
if (spaceTab !== "proposals" || !k || k.endsWith("|")) return;
|
||||
untrack(() => {
|
||||
boardDraft = null;
|
||||
void openBoard(sv.owner, sv.space_id);
|
||||
});
|
||||
});
|
||||
function applyOp(list, op) {
|
||||
if (op.kind === "add") {
|
||||
list.splice(Math.min(op.position, list.length), 0, { entry_id: op.token, title: op.title, artist: op.artist, album: op.album, duration_secs: op.duration_secs, fresh: true });
|
||||
return true;
|
||||
}
|
||||
const i = list.findIndex((e) => e.entry_id === op.entry_id);
|
||||
if (i < 0) return false;
|
||||
if (op.kind === "remove") {
|
||||
list.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
const [e] = list.splice(i, 1);
|
||||
list.splice(Math.min(op.to_position, list.length), 0, e);
|
||||
return true;
|
||||
}
|
||||
function startDraft() {
|
||||
if (!bv) return;
|
||||
boardDraft = { base: bv.base, list: bv.entries.map((e) => ({ ...e })), ops: [], note: "" };
|
||||
boardQuery = "";
|
||||
boardHits = [];
|
||||
}
|
||||
function draftApply(op) {
|
||||
if (!boardDraft || boardDraft.ops.length >= 32) return;
|
||||
if (applyOp(boardDraft.list, op)) boardDraft.ops.push(op);
|
||||
}
|
||||
function draftUndo() {
|
||||
if (!boardDraft?.ops.length) return;
|
||||
boardDraft.ops.pop();
|
||||
const list = bv.entries.map((e) => ({ ...e }));
|
||||
for (const op of boardDraft.ops) applyOp(list, op);
|
||||
boardDraft.list = list;
|
||||
}
|
||||
function draftAdd(h) {
|
||||
draftApply({ kind: "add", position: boardDraft.list.length, title: h.title, artist: h.artist, album: h.album ?? "", duration_secs: h.duration_secs ?? null, token: `new:${++freshCount}` });
|
||||
boardQuery = "";
|
||||
boardHits = [];
|
||||
}
|
||||
function searchLibrary() {
|
||||
clearTimeout(boardTimer);
|
||||
const q = boardQuery.trim();
|
||||
if (q.length < 2) {
|
||||
boardHits = [];
|
||||
return;
|
||||
}
|
||||
boardTimer = setTimeout(async () => {
|
||||
try {
|
||||
boardHits = await api.libraryTrackSearch(q);
|
||||
} catch {
|
||||
boardHits = [];
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
async function sendDraft() {
|
||||
if (!boardDraft?.ops.length || !sv) return;
|
||||
const ops = boardDraft.ops.map((o) => (o.kind === "add" ? { kind: "add", position: o.position, title: o.title, artist: o.artist, album: o.album, duration_secs: o.duration_secs, token: o.token } : { ...o }));
|
||||
try {
|
||||
await proposeToBoard(sv.owner, sv.space_id, boardDraft.base, ops, boardDraft.note);
|
||||
boardDraft = null;
|
||||
} catch {
|
||||
// boardError shows it
|
||||
}
|
||||
}
|
||||
const decideOn = (p, accept) =>
|
||||
decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {});
|
||||
const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`);
|
||||
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" });
|
||||
@@ -1106,7 +1194,7 @@
|
||||
<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>
|
||||
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
|
||||
</div>
|
||||
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
|
||||
{#if spaceTab === "chat"}
|
||||
@@ -1158,6 +1246,102 @@
|
||||
{/if}
|
||||
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
|
||||
</div>
|
||||
{:else if spaceTab === "proposals"}
|
||||
{#if !sv.pinned_uuid}
|
||||
<div class="panel">
|
||||
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
|
||||
<p class="hint">A board lives beside a pinned playlist: members propose adds, removes and moves against the exact revision they see, and the playlist's owner decides. {#if spaceOwner}Pin one of your public playlists from the Chat tab and the board opens here.{:else}The owner has not pinned a playlist yet.{/if}</p>
|
||||
</div>
|
||||
{:else if !bv && community.boardError}
|
||||
<div class="panel">
|
||||
<h3><Icon name="list-plus" size={13} /> Proposals</h3>
|
||||
<p class="hint warn">{community.boardError}</p>
|
||||
<div class="bbtns"><button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> Try again</button></div>
|
||||
</div>
|
||||
{:else if !bv}
|
||||
<div class="panel"><p class="hint"><span class="bl"></span> Reading the board through your node…</p></div>
|
||||
{:else}
|
||||
<div class="panel">
|
||||
<h3><Icon name="playlist" size={13} /> {bv.name || "Pinned playlist"}{#if bv.withdrawn}<span class="chip warn">withdrawn by its owner</span>{:else}<span class="chip acc"><Icon name="disc" size={10} />revision {bv.revision_seq}</span>{/if}</h3>
|
||||
<p class="hint">Proposals bind to revision {bv.revision_seq} exactly · {bv.i_am_owner ? "you decide" : "the playlist's owner decides"} · every decision is signed · {bv.contributors.length} {bv.contributors.length === 1 ? "contributor" : "contributors"} on epoch {bv.epoch}{#if !bv.reachable} · <span class="chip warn">the owner has not opened the board yet</span>{/if}</p>
|
||||
<div class="bbtns">
|
||||
{#if bv.can_propose && !boardDraft}<button class="ax-fill sm" onclick={startDraft} disabled={!nodeUp || boardBusy}><Icon name="list-plus" size={12} /> Propose changes</button>{/if}
|
||||
{#if !bv.can_propose && !bv.i_am_owner && bv.reachable && !bv.withdrawn}<span class="hint">You are not on this board's roster yet — the owner refreshes it from the members when they open the board.</span>{/if}
|
||||
<button class="ax-ghost sm" onclick={() => openBoard(sv.owner, sv.space_id)} disabled={boardBusy}><Icon name="refresh" size={12} /> {boardBusy ? "Working…" : "Refresh"}</button>
|
||||
</div>
|
||||
{#if community.boardError}<p class="hint warn">{community.boardError}</p>{/if}
|
||||
</div>
|
||||
{#if boardDraft}
|
||||
<div class="panel">
|
||||
<h3><Icon name="list-plus" size={13} /> Your proposal <span class="chip acc">{boardDraft.ops.length} / 32 changes</span></h3>
|
||||
<p class="hint">Move rows, take some out, add from your library. The owner receives titles, artists and albums — never a file, never a path. Nothing here plays or downloads.</p>
|
||||
<ol class="dlist">
|
||||
{#each boardDraft.list as e, i (e.entry_id)}
|
||||
<li class:fresh={e.fresh}>
|
||||
<span class="n">{i + 1}</span>
|
||||
<span class="tx"><span class="t">{e.title}</span><span class="a">{e.artist}{#if e.fresh} · added{/if}</span></span>
|
||||
<span class="b">
|
||||
<button class="ibtn sm" title="Move up" disabled={i === 0} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i - 1 })}><Icon name="chevron-up" size={12} /></button>
|
||||
<button class="ibtn sm" title="Move down" disabled={i === boardDraft.list.length - 1} onclick={() => draftApply({ kind: "move", entry_id: e.entry_id, to_position: i + 1 })}><Icon name="chevron-down" size={12} /></button>
|
||||
<button class="ibtn sm" title="Remove" onclick={() => draftApply({ kind: "remove", entry_id: e.entry_id })}><Icon name="x" size={12} /></button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if !boardDraft.list.length}<li class="none">Empty — add something below.</li>{/if}
|
||||
</ol>
|
||||
<div class="qbox slim"><Icon name="search" size={13} /><input placeholder="Add from your library — title or artist" bind:value={boardQuery} oninput={searchLibrary} /></div>
|
||||
{#if boardHits.length}
|
||||
<div class="hits">{#each boardHits.slice(0, 8) as h (h.path)}<button class="hit" onclick={() => draftAdd(h)}><Icon name="plus" size={11} /><span class="t">{h.title}</span><span class="a">{h.artist}{h.album ? ` · ${h.album}` : ""}</span></button>{/each}</div>
|
||||
{/if}
|
||||
<div class="field"><input placeholder="A note for the owner — optional" bind:value={boardDraft.note} maxlength="280" /></div>
|
||||
<div class="bbtns">
|
||||
<button class="ax-fill sm" onclick={sendDraft} disabled={!boardDraft.ops.length || !nodeUp || boardBusy}><Icon name="send" size={12} /> {community.busy["board:propose"] ? "Signing & offering…" : "Send proposal"}</button>
|
||||
<button class="ax-ghost sm" onclick={draftUndo} disabled={!boardDraft.ops.length}>Undo</button>
|
||||
<button class="ax-ghost sm" onclick={() => (boardDraft = null)}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="panel">
|
||||
<h3>Pending <span class="chip off">{bv.proposals.length}</span></h3>
|
||||
{#if !bv.proposals.length}<p class="hint">Nothing pending. {#if bv.can_propose}Propose an add, a remove or a move against the current revision — up to 32 changes each, 8 pending per person.{/if}</p>{/if}
|
||||
{#each bv.proposals as p (p.id)}
|
||||
<div class="prop" class:stale={p.stale}>
|
||||
<div class="ph">
|
||||
<Identicon seed={p.author} size={30} />
|
||||
<div class="who"><span class="nm">{whoProposed(p)}</span><span class="hint">{fmtWhen(p.created_at * 1000)} · {p.ops.length} {p.ops.length === 1 ? "change" : "changes"}</span></div>
|
||||
{#if p.stale}<span class="chip warn">stale · an older revision</span>{:else}<span class="chip ok"><Icon name="check" size={10} />current revision</span>{/if}
|
||||
</div>
|
||||
{#if p.note}<p class="note">{p.note}</p>{/if}
|
||||
<ul class="ops">
|
||||
{#each p.ops as op, i (i)}
|
||||
<li class={op.kind}>
|
||||
<b>{op.kind === "add" ? "+" : op.kind === "remove" ? "−" : "↕"}</b>
|
||||
<span>
|
||||
{#if op.kind === "add"}add <i>{op.title}</i> — {op.artist} at #{op.position + 1}
|
||||
{:else if op.kind === "remove"}remove {#if op.known}<i>{op.title}</i> — {op.artist}{:else}an entry from an older revision{/if}
|
||||
{:else}move {#if op.known}<i>{op.title}</i>{:else}an entry from an older revision{/if} to #{op.position + 1}{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if bv.i_am_owner}
|
||||
<div class="decide">
|
||||
{#if !p.stale}<button class="ax-fill sm" onclick={() => decideOn(p, true)} disabled={!nodeUp || boardBusy}><Icon name="check" size={12} /> {community.busy["board:decide"] ? "Working…" : "Accept & publish"}</button>{/if}
|
||||
<input placeholder={p.stale ? "Reason — optional; it is marked stale either way" : "Reason — optional"} bind:value={boardReason[p.id]} maxlength="128" />
|
||||
<button class="ax-ghost sm" onclick={() => decideOn(p, false)} disabled={!nodeUp || boardBusy}>{p.stale ? "Reject as stale" : "Reject"}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Decisions <span class="chip off">{bv.decisions.length}</span></h3>
|
||||
{#if !bv.decisions.length}<p class="hint">None yet. Each decision is owner-signed with a sequence number; the last 256 are kept.</p>{/if}
|
||||
{#each bv.decisions as d (d.seq)}
|
||||
<div class="dec"><span class="chip {d.accept ? 'ok' : 'off'}">#{d.seq} · {d.accept ? "accepted" : "rejected"}</span><span class="rs">{d.reason}</span>{#if d.result_hash}<span class="mono hint" title={d.result_hash}>→ revision {fpShort(d.result_hash)}</span>{/if}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="panel">
|
||||
<h3>About</h3>
|
||||
@@ -1861,6 +2045,42 @@
|
||||
.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; }
|
||||
/* the proposal board */
|
||||
.bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||
.dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
|
||||
.dlist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: center; padding: 5px 8px; border-radius: 8px; font-size: 12.5px; }
|
||||
.dlist li:hover { background: var(--surf-hi); }
|
||||
.dlist li.fresh { background: var(--accent-soft); }
|
||||
.dlist li.none { display: block; color: var(--text-muted); font-size: 11.5px; }
|
||||
.dlist .n { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.dlist .tx { display: flex; flex-direction: column; min-width: 0; }
|
||||
.dlist .t { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dlist .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dlist .b { display: flex; gap: 2px; }
|
||||
.hits { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
|
||||
.hit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; text-align: left; font-size: 12.5px; color: var(--text-secondary); min-width: 0; }
|
||||
.hit:hover { background: var(--surf-hi); color: #fff; }
|
||||
.hit .t { font-weight: 700; white-space: nowrap; }
|
||||
.hit .a { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prop { padding: 12px 12px 10px; border: 1px solid var(--hair); border-radius: 10px; margin-top: 10px; }
|
||||
.prop.stale { opacity: 0.8; border-style: dashed; }
|
||||
.prop .ph { display: flex; align-items: center; gap: 10px; }
|
||||
.prop .who { display: flex; flex-direction: column; min-width: 0; }
|
||||
.prop .who .nm { font-weight: 800; font-size: 13px; }
|
||||
.prop .ph .chip { margin-left: auto; }
|
||||
.prop .note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-secondary); font-style: italic; }
|
||||
.ops { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; }
|
||||
.ops li { display: flex; gap: 8px; align-items: baseline; }
|
||||
.ops li b { width: 14px; text-align: center; font-weight: 900; flex: none; }
|
||||
.ops li.add b { color: #5ee89a; }
|
||||
.ops li.remove b { color: var(--err); }
|
||||
.ops li.move b { color: var(--accent-hover); }
|
||||
.ops i { font-style: normal; font-weight: 700; }
|
||||
.decide { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||||
.decide input { flex: 1; min-width: 160px; height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
|
||||
.dec { display: flex; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 12.5px; }
|
||||
.dec:first-of-type { border-top: none; }
|
||||
.dec .rs { flex: 1; }
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user