maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
session,
|
||||
pdsStatus,
|
||||
fetchTimeline,
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
} from "./lib/api/client";
|
||||
import NavRail from "./lib/components/NavRail.svelte";
|
||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||
import PostCard from "./lib/components/PostCard.svelte";
|
||||
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
||||
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
|
||||
let view: View = $state("home");
|
||||
let currentUser: Session | null = $state(null);
|
||||
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
|
||||
|
||||
// Home timeline state.
|
||||
let userPosts: Post[] = $state([]);
|
||||
let timelineCursor: string | null = $state(null);
|
||||
let timelineLoading: boolean = $state(false);
|
||||
let timelineError: string | null = $state(null);
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
let _timelinePollTimer: number | undefined;
|
||||
|
||||
// Profile state.
|
||||
let profile: ProfileResponse | null = $state(null);
|
||||
let profileLoading: boolean = $state(false);
|
||||
let profileError: string | null = $state(null);
|
||||
|
||||
// Search state.
|
||||
let searchQuery: string = $state("");
|
||||
let searchResults: Post[] = $state([]);
|
||||
let searchLoading: boolean = $state(false);
|
||||
let searchError: string | null = $state(null);
|
||||
let _searchDebounce: number | undefined;
|
||||
|
||||
// Thread state — when set, shows the thread root + parent + the focused post.
|
||||
let threadRoot: Post | null = $state(null);
|
||||
let threadParent: Post | null = $state(null);
|
||||
let threadLoading: boolean = $state(false);
|
||||
let threadError: string | null = $state(null);
|
||||
|
||||
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
||||
// window event. We keep the last few so a slow render doesn't
|
||||
// wipe the message before the user reads it.
|
||||
let toasts: { id: number; kind: "error" | "info"; text: string }[] = $state([]);
|
||||
let _toastCounter = 0;
|
||||
function pushToast(kind: "error" | "info", text: string) {
|
||||
const id = ++_toastCounter;
|
||||
toasts = [...toasts, { id, kind, text }];
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function openThread(root_uri: string) {
|
||||
threadLoading = true;
|
||||
threadError = null;
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
try {
|
||||
const r = await fetchPost(root_uri);
|
||||
threadRoot = r.post;
|
||||
threadParent = r.thread.parent;
|
||||
} catch (e) {
|
||||
threadError = String(e);
|
||||
} finally {
|
||||
threadLoading = false;
|
||||
}
|
||||
}
|
||||
function closeThread() {
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
threadError = null;
|
||||
}
|
||||
|
||||
// Tray / notification events dispatched from `main.ts` as DOM
|
||||
// CustomEvents (so we don't depend on `@tauri-apps/api/event` here
|
||||
// — `main.ts` is the only place that wires to the Tauri event bus).
|
||||
function onNavigateToView(e: Event) {
|
||||
const detail = (e as CustomEvent<{ view: View }>).detail;
|
||||
if (detail?.view) view = detail.view;
|
||||
}
|
||||
function onNotification(e: Event) {
|
||||
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
||||
if (detail) {
|
||||
// Surface in the existing toast stack — works for both the
|
||||
// OS-notification click and the in-app events.
|
||||
pushToast("info", `notif: ${detail.title} — ${detail.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// We need to hoist these into the onMount closure (which is below)
|
||||
// so the addEventListener / removeEventListener pair stays paired.
|
||||
// (`_toastHandler` is the function below; the other two are
|
||||
// reassigned inside the async onMount body.)
|
||||
let _navigateHandler: ((e: Event) => void) | null = null;
|
||||
let _notificationHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
// Track cleanup refs WITHOUT awaiting before registering them.
|
||||
// Svelte 5's `onDestroy` throws when called after the parent component
|
||||
// context has been torn down (i.e. after an `await` in `onMount`).
|
||||
// Register everything synchronously inside `onMount`, then do async
|
||||
// work afterward.
|
||||
let _sessionUnsub: (() => void) | null = null;
|
||||
let _destroyRegistered = false;
|
||||
function registerCleanup(teardown: () => void) {
|
||||
if (_destroyRegistered) return;
|
||||
onDestroy(teardown);
|
||||
_destroyRegistered = true;
|
||||
}
|
||||
|
||||
function _toastHandler(ev: Event) {
|
||||
const e = ev as CustomEvent<{ kind: "error" | "info"; text: string }>;
|
||||
if (!e.detail) return;
|
||||
pushToast(e.detail.kind, e.detail.text);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
registerCleanup(() => {
|
||||
if (_sessionUnsub) _sessionUnsub();
|
||||
if (_statusTimer) clearInterval(_statusTimer);
|
||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
||||
window.removeEventListener("maarcadetweet:navigate", _navigateHandler!);
|
||||
window.removeEventListener("maarcadetweet:notification", _notificationHandler!);
|
||||
}
|
||||
});
|
||||
|
||||
// Now safe to await.
|
||||
(async () => {
|
||||
await session.load();
|
||||
_sessionUnsub = session.subscribe((s) => {
|
||||
currentUser = s;
|
||||
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
_navigateHandler = onNavigateToView;
|
||||
_notificationHandler = onNotification;
|
||||
window.addEventListener("maarcadetweet:toast", _toastHandler);
|
||||
window.addEventListener("maarcadetweet:navigate", _navigateHandler);
|
||||
window.addEventListener("maarcadetweet:notification", _notificationHandler);
|
||||
}
|
||||
|
||||
_statusTimer = window.setInterval(async () => {
|
||||
try {
|
||||
status = await pdsStatus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 5000);
|
||||
})();
|
||||
});
|
||||
|
||||
// Re-fetch the home timeline whenever we navigate to "home" or
|
||||
// when the logged-in user changes. We also poll every 5s while
|
||||
// the home view is active so new posts trickle in.
|
||||
$effect(() => {
|
||||
if (view === "home" && currentUser) {
|
||||
void refreshTimeline(true);
|
||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
||||
_timelinePollTimer = window.setInterval(() => {
|
||||
void refreshTimeline(false); // poll = prepend new posts, don't wipe
|
||||
}, 5000);
|
||||
} else if (_timelinePollTimer) {
|
||||
clearInterval(_timelinePollTimer);
|
||||
_timelinePollTimer = undefined;
|
||||
}
|
||||
|
||||
if (view === "profile" && currentUser) {
|
||||
void refreshProfile(currentUser.handle);
|
||||
}
|
||||
|
||||
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
|
||||
scheduleSearch();
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshTimeline(reset: boolean) {
|
||||
if (!currentUser) return;
|
||||
timelineLoading = true;
|
||||
timelineError = null;
|
||||
try {
|
||||
const r = await fetchTimeline(currentUser.did, null, 30);
|
||||
if (reset) {
|
||||
// Full replace on explicit refresh.
|
||||
seenUris = new Set(r.posts.map((p) => p.uri));
|
||||
userPosts = r.posts;
|
||||
timelineCursor = r.cursor;
|
||||
} else {
|
||||
// Poll: prepend posts we haven't seen yet.
|
||||
const fresh: Post[] = [];
|
||||
for (const p of r.posts) {
|
||||
if (!seenUris.has(p.uri)) {
|
||||
fresh.push(p);
|
||||
seenUris.add(p.uri);
|
||||
}
|
||||
}
|
||||
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
||||
}
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
// Keep whatever we had on a transient failure.
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreTimeline() {
|
||||
if (!currentUser || !timelineCursor || timelineLoading) return;
|
||||
timelineLoading = true;
|
||||
try {
|
||||
const r = await fetchTimeline(currentUser.did, timelineCursor, 30);
|
||||
for (const p of r.posts) {
|
||||
if (!seenUris.has(p.uri)) {
|
||||
seenUris.add(p.uri);
|
||||
userPosts = [...userPosts, p];
|
||||
}
|
||||
}
|
||||
timelineCursor = r.cursor;
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfile(handle: string) {
|
||||
profileLoading = true;
|
||||
profileError = null;
|
||||
try {
|
||||
profile = await fetchProfile(handle);
|
||||
} catch (e) {
|
||||
profileError = String(e);
|
||||
profile = null;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
_searchDebounce = window.setTimeout(() => {
|
||||
void runSearch();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
// Empty input -> immediately clear results (no debounce).
|
||||
if (searchQuery.trim().length === 0) {
|
||||
if (_searchDebounce) {
|
||||
clearTimeout(_searchDebounce);
|
||||
_searchDebounce = undefined;
|
||||
}
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
scheduleSearch();
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const needle = searchQuery.trim();
|
||||
if (!needle) {
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
searchLoading = true;
|
||||
searchError = null;
|
||||
try {
|
||||
const r = await fetchSearch(needle, 30);
|
||||
searchResults = r.posts;
|
||||
} catch (e) {
|
||||
searchError = String(e);
|
||||
searchResults = [];
|
||||
} finally {
|
||||
searchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePosted() {
|
||||
// After the user posts, reset to page 1 so they see their own post.
|
||||
await refreshTimeline(true);
|
||||
}
|
||||
|
||||
// Derive a display handle. The session already gives us the user's
|
||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||
// posts that have empty handles it falls back to a synthetic
|
||||
// "@did:plc:abcd…" form, so the fallback here matches that.
|
||||
function displayHandle(h: string | null | undefined): string {
|
||||
if (!h) return "@unknown";
|
||||
if (h.startsWith("@")) return h;
|
||||
return `@${h}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !currentUser}
|
||||
<div class="login-wrap">
|
||||
<LoginScreen
|
||||
onLogin={(s) => {
|
||||
currentUser = s;
|
||||
view = "home";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shell">
|
||||
<NavRail bind:current={view} />
|
||||
<div class="main">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
{#if view === "home"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// home —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||
</div>
|
||||
{#if timelineError}
|
||||
<div class="toast toast--err">err: {timelineError}</div>
|
||||
{/if}
|
||||
{#if timelineLoading && userPosts.length === 0}
|
||||
<Skeleton rows={3} />
|
||||
{:else if userPosts.length === 0}
|
||||
<div class="empty">// timeline is empty. compose your first post →</div>
|
||||
{:else}
|
||||
{#if threadRoot}
|
||||
<div class="thread-modal">
|
||||
<header class="thread-modal__head">
|
||||
<span class="crumb">// thread</span>
|
||||
<button class="btn--ghost" onclick={closeThread}>close</button>
|
||||
</header>
|
||||
{#if threadLoading}
|
||||
<Skeleton rows={2} />
|
||||
{:else if threadError}
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
||||
<div class="thread-parent"><PostCard post={threadParent} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
<button class="btn btn--ghost" onclick={() => void loadMoreTimeline()} disabled={timelineLoading}>
|
||||
{timelineLoading ? "loading…" : "load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if view === "compose"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// compose —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
{:else if view === "profile"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
{#if profileLoading && !profile}
|
||||
<Skeleton rows={4} />
|
||||
{:else if profileError}
|
||||
<div class="toast toast--err">err: {profileError}</div>
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<span class="profile__handle">{displayHandle(profile.handle)}</span>
|
||||
<span class="profile__did" title={profile.did}>{profile.did}</span>
|
||||
</header>
|
||||
<dl class="counts">
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{profile.followers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{profile.following}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts</dt>
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet</div>
|
||||
{:else}
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// search</span>
|
||||
</div>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
{#if searchLoading}
|
||||
<Skeleton rows={2} />
|
||||
{:else if searchQuery.trim().length === 0}
|
||||
<div class="empty">// type to search…</div>
|
||||
{:else if searchResults.length === 0}
|
||||
<div class="empty">// no posts match "{searchQuery}"</div>
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Terminal>
|
||||
</div>
|
||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||
</div>
|
||||
{#if toasts.length > 0}
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
{#each toasts as t (t.id)}
|
||||
<button
|
||||
class="toast-pill"
|
||||
class:toast-pill--err={t.kind === "error"}
|
||||
type="button"
|
||||
onclick={() => (toasts = toasts.filter((x) => x.id !== t.id))}
|
||||
title="dismiss"
|
||||
>
|
||||
{t.kind === "error" ? "err" : "info"}: {t.text}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 24px;
|
||||
grid-template-columns: 88px 1fr;
|
||||
grid-template-areas:
|
||||
"rail main"
|
||||
"rail status";
|
||||
height: 100%;
|
||||
}
|
||||
.shell > :global(nav.rail) { grid-area: rail; }
|
||||
.shell > :global(.statusbar) { grid-area: status; }
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
.head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
align-items: center;
|
||||
padding: 0 var(--s-2) var(--s-3);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.prompt { color: var(--orange); }
|
||||
.title { color: var(--orange); font-weight: 700; }
|
||||
.as { color: var(--text); }
|
||||
.meta { color: var(--text-dim); margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.meta--results { padding: var(--s-2) var(--s-5); margin: 0; }
|
||||
|
||||
.toast {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
margin: 0 var(--s-5) var(--s-3);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.toast--err { border-left-color: var(--red); }
|
||||
|
||||
.empty {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-4) var(--s-5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: block;
|
||||
width: calc(100% - 2 * var(--s-5));
|
||||
margin: 0 var(--s-5) var(--s-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--orange); }
|
||||
.search::placeholder { color: var(--text-dim); }
|
||||
|
||||
.loadmore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--s-4) var(--s-5);
|
||||
}
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.profile {
|
||||
padding: 0 var(--s-3);
|
||||
}
|
||||
.profile__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0 var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
color: var(--orange);
|
||||
}
|
||||
.profile__did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
|
||||
.counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: var(--s-4);
|
||||
bottom: calc(24px + var(--s-3));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
z-index: 100;
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.toast-pill:hover { border-color: var(--orange); }
|
||||
.toast-pill--err {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user