Files
maarcadetweet/crates/tauri-app/src/App.svelte
T
tomdebone baeb87214b chore(app): drop unused .btn--danger CSS
The legacy `.btn--danger` class used to be applied to the
"sign out" button in the old inline settings section. The X-style
settings refactor replaced that with
`.settings__group--danger .settings__action`, which has its
own selector tree. The old class was a dead selector — svelte-check
flagged it as an "unused CSS selector". Drop it.
2026-07-26 21:34:14 +02:00

1079 lines
36 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
session,
pdsStatus,
fetchTimeline,
fetchSearch,
fetchPost,
openExternalUrl,
showError,
type Session,
type Post,
} 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 ProfileView from "./lib/components/ProfileView.svelte";
import LoginScreen from "./lib/components/LoginScreen.svelte";
import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The
// "profile" view remains the current-user view (the NavRail icon
// goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state("");
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;
// Home tab strip — "for you" is a placeholder (no real algo yet),
// "following" is the live behavior. Mirrors the X-style "For you /
// Following" tabs.
type HomeTab = "for-you" | "following";
let homeTab: HomeTab = $state("following");
// Search tab strip — only "top" is wired (matches the current
// search endpoint). The rest are visually present but disabled.
type SearchTab = "top" | "latest" | "people" | "photos";
let searchTab: SearchTab = $state("top");
// 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);
// Reply state — when the user clicks the reply button on a
// PostCard, the parent fires `on_reply` with strongRefs. We
// stash them here and switch to the compose view; the ComposeBox
// reads `replyTo` to render the "Replying to @handle" bar and
// attach the reply block on submit.
type ReplyTarget = {
handle: string;
root: { uri: string; cid: string };
parent: { uri: string; cid: string };
};
let replyTo: ReplyTarget | null = $state(null);
/// Called by PostCard's reply button. Stores the strongRefs and
/// routes the user to the compose view.
function onReply(target: ReplyTarget) {
replyTo = target;
view = "compose";
}
function clearReply() {
replyTo = 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;
}
}
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<ProfileView>` on mount.
function openUserProfile(handle: string) {
selectedHandle = handle;
view = "user";
threadRoot = null;
threadParent = null;
}
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) setView(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. The toast pill is
// clickable — clicking it navigates to the URL the notification
// was about (e.g. an at:// post URI). For OS-level
// notifications, the user has to first click the OS notification
// (which focuses the app) and then click the toast in the app
// to actually navigate; this is the limitation of the
// tauri-plugin-notification v2.x click callbacks.
pushToast(
"info",
`notif: ${detail.title}${detail.body}` + (detail.url ? " (click to open)" : ""),
);
// Save the URL on a per-toast basis via a side-channel so the
// toast pill can navigate when clicked. We attach it to the
// notification event for simplicity (re-look-up via last).
lastNotificationUrl = detail.url ?? null;
}
}
let lastNotificationUrl: string | null = $state(null);
function openLastNotification() {
const url = lastNotificationUrl;
if (!url) return;
if (url.startsWith("at://")) {
// at://<did>/app.twi.post/<rkey> or at://<did>/app.bsky.feed.post/<rkey>
const parts = url.replace(/^at:\/\//, "").split("/");
const rkey = parts[parts.length - 1];
const did = parts[0];
if (rkey && did) {
// Use the existing thread-context machinery to open the post.
void openThread(`${did}/app.twi.post/${rkey}`);
lastNotificationUrl = null;
}
} else {
// unknown scheme — just open home
setView("home");
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
// Surface a tiny inline confirmation by reusing the toast stack.
// We dispatch the toast event (not pushToast directly) so the
// notification toast gets the close-on-click behavior.
const ev = new CustomEvent("maarcadetweet:notification", {
detail: {
title: "clipboard",
body: `copied: ${text.length > 40 ? text.slice(0, 37) + "…" : text}`,
url: null,
},
});
window.dispatchEvent(ev);
} catch (e) {
pushToast("error", `> clipboard failed: ${String(e)}`);
}
}
// 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 (_pollTimer != null) clearInterval(_pollTimer);
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 };
// Drive the 5s poll off the session lifecycle instead of a
// reactive effect — the effect form kept tripping Svelte 5's
// depth guard.
if (s) startPoll();
else stopPoll();
});
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);
})();
});
// Imperative view-switching. We dispatch from a single function
// (called by NavRail on_select, the LoginScreen onLogin path, and
// the tray-event bridge) so every view transition runs the same
// side effects in one place. Previously this was four separate
// `$effect` blocks that read `view` / `currentUser` and called
// `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte
// 5's depth tracker kept aborting with `effect_update_depth_exceeded`
// because the sync portions of those refresh functions (`timelineLoading
// = true`, `profileLoading = true`) wrote $state that the effect's
// proxy-tracking had flagged as a self-write. Driving everything
// imperatively from a setter sidesteps the reactive cycle.
function setView(next: View) {
const prev = view;
view = next;
if (!currentUser) return;
// Entering home from elsewhere — pull a fresh timeline and
// (re)start the poll timer. Leaving home clears it.
if (next === "home" && prev !== "home") {
void refreshTimeline(true);
}
if (next === "profile") {
// ProfileView fetches its own data on mount; nothing to
// preload here.
}
if (next === "search" && searchQuery.trim().length > 0) {
scheduleSearch();
}
}
let _pollTimer: number | undefined;
function startPoll() {
if (_pollTimer != null) return;
_pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false);
}, 5000);
}
function stopPoll() {
if (_pollTimer == null) return;
window.clearInterval(_pollTimer);
_pollTimer = undefined;
}
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;
}
}
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, and clear any active reply target so the next compose
// doesn't re-attach the reply block.
replyTo = null;
await refreshTimeline(true);
}
/// Wired into the right-rail Sidebar. Fills the search query and
/// switches to the search view. If the query is empty we just
/// switch to the search view (the input there will keep focus).
function onSidebarSearch(query: string) {
searchQuery = query;
view = "search";
if (query.trim().length > 0) {
// Run the search immediately so the Sidebar click feels
// responsive (no debounce delay).
scheduleSearch();
}
}
async function handleLogout() {
try {
await session.logout();
setView("home");
searchResults = [];
threadRoot = null;
threadParent = null;
userPosts = [];
} catch (e) {
showError(`logout failed: ${String(e)}`);
}
}
// 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
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is
// talking to. Kept as plain helpers so they can be swapped for a
// `pds_describe`/`appview_describe` Tauri command later.
function pdsBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
return (import.meta as any).env.VITE_PDS_URL as string;
}
return "http://127.0.0.1:2583";
}
function appviewBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
return (import.meta as any).env.VITE_APPVIEW_URL as string;
}
return "http://127.0.0.1:2584";
}
</script>
{#if !currentUser}
<div class="login-wrap">
<LoginScreen
onLogin={(s) => {
currentUser = s;
setView("home");
}}
/>
</div>
{:else}
<div class="shell">
<NavRail
{view}
on_select={(v) => setView(v)}
/>
<div class="main">
<div class="main-inner">
<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>
<nav class="tabs" aria-label="Timeline">
<button
class="tab"
type="button"
disabled
title="for you — algo coming soon"
>for you</button>
<button
class="tab"
class:tab--active={homeTab === "following"}
type="button"
onclick={() => (homeTab = "following")}
>following</button>
</nav>
{#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} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/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}
replyTo={replyTo}
onClearReply={clearReply}
/>
{:else if view === "user"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{selectedHandle}</span>
</div>
<ProfileView
handle={selectedHandle}
on_thread_click={openThread}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
{#if currentUser}
<div class="head">
<span class="prompt">$</span>
<span class="title">// profile —</span>
<span class="as">@{currentUser.handle}</span>
</div>
<ProfileView
handle={currentUser.handle}
on_thread_click={openThread}
current_user_did={currentUser.did}
/>
{/if}
{:else if view === "settings"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// settings</span>
<span class="meta">@{currentUser?.handle ?? "?"}</span>
</div>
<section class="settings">
<!-- Account — X-style rows: label left, value right, full-width clickable -->
<div class="settings__group">
<h3 class="settings__h3">// account</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">handle</span>
<span class="settings__value">@{currentUser?.handle ?? "?"}</span>
</div>
<div class="settings__row">
<span class="settings__label">did</span>
<code class="settings__value settings__value--mono">{currentUser?.did ?? "?"}</code>
</div>
<div class="settings__row">
<span class="settings__label">posts cached</span>
<span class="settings__value">{userPosts.length}</span>
</div>
</div>
<div class="settings__actions">
<button
class="settings__action"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>
<span>copy did</span>
<span class="settings__action-hint">atproto</span>
</button>
<button
class="settings__action"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>
<span>open profile in browser</span>
<span class="settings__action-hint">↗ bsky.app</span>
</button>
<button
class="settings__action"
type="button"
onclick={() => setView("home")}
>
<span>← back to timeline</span>
</button>
</div>
</div>
<!-- Backend / connection info — same row pattern -->
<div class="settings__group">
<h3 class="settings__h3">// backend</h3>
<div class="settings__list">
<div class="settings__row">
<span class="settings__label">app</span>
<span class="settings__value">maarcadetweet</span>
</div>
<div class="settings__row">
<span class="settings__label">version</span>
<span class="settings__value">0.1.0</span>
</div>
<div class="settings__row">
<span class="settings__label">pds</span>
<code class="settings__value settings__value--mono">{pdsBase()}</code>
</div>
<div class="settings__row">
<span class="settings__label">appview</span>
<code class="settings__value settings__value--mono">{appviewBase()}</code>
</div>
</div>
</div>
<!-- Sign-out — separate danger zone at the bottom, like X's "Log out" row -->
<div class="settings__group settings__group--danger">
<div class="settings__list">
<button
class="settings__action settings__action--danger"
type="button"
onclick={handleLogout}
>
<span>sign out</span>
<span class="settings__action-hint">→</span>
</button>
</div>
</div>
</section>
{:else if view === "search"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// search —</span>
<input
class="search"
type="text"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="grep posts…"
/>
</div>
<nav class="tabs" aria-label="Search sections">
<button
class="tab"
class:tab--active={searchTab === "top"}
type="button"
onclick={() => (searchTab = "top")}
>top</button>
<button
class="tab"
type="button"
disabled
title="latest — coming soon"
>latest</button>
<button
class="tab"
type="button"
disabled
title="people — coming soon"
>people</button>
<button
class="tab"
type="button"
disabled
title="photos — coming soon"
>photos</button>
</nav>
{#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} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
{/if}
{/if}
</Terminal>
{#if view === "home"}
<Sidebar posts={userPosts} onSearch={onSidebarSearch} />
{/if}
</div>
</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={() => {
// If this toast was a notification with a URL, navigate
// to it before dismissing. Otherwise just dismiss.
if (lastNotificationUrl) {
openLastNotification();
}
toasts = toasts.filter((x) => x.id !== t.id);
}}
oncontextmenu={(e) => {
// Right-click dismisses without navigating — useful when
// the user clicks a notification toast by mistake.
e.preventDefault();
lastNotificationUrl = null;
toasts = toasts.filter((x) => x.id !== t.id);
}}
title={lastNotificationUrl ? "click to open · right-click to dismiss" : "dismiss"}
>
{t.kind === "error" ? "err" : "info"}: {t.text}
</button>
{/each}
</div>
{/if}
{/if}
<style>
/* The whole page is a flex column filling the viewport. The
shell is grid-laid-out with NavRail | main + statusbar. The
main area is overflow:auto so long timelines scroll
inside the main area, while the statusbar stays pinned at the
bottom. With this layout the body is `flex: 1` child of the
global html/body flex container, so the shell fills the
viewport regardless of how much content is in it. Without
this, the shell collapses to its content height (because
`flex: 1` requires a flex parent) and the main area has no
scroll target — clicks still work but you cannot scroll. */
/* :global() escapes Svelte's CSS scoping so the rules below
target the actual <html> and <body> elements, not just
elements with the Svelte component class. Without this, the
flex layout was silently a no-op and the shell collapsed to
its content height, leaving the viewport blank. */
:global(html), :global(body) {
display: flex;
flex-direction: column;
height: 100%;
}
:global(#app) {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.login-wrap {
flex: 1 1 auto;
display: grid;
place-items: center;
min-height: 0;
}
.shell {
flex: 1 1 auto;
display: grid;
grid-template-rows: 1fr 24px;
grid-template-columns: 88px 1fr;
grid-template-areas:
"rail main"
"rail status";
min-height: 0;
}
/* NavRail and StatusBar self-assign their own grid-area
(`grid-area: rail` / `grid-area: status`) in their component
styles, so the parent doesn't need any :global() child
selectors. The `.main` slot is just the next sibling; we set
its grid-area explicitly below. */
.main {
grid-area: main;
overflow: auto;
padding: var(--s-3);
}
.main-inner {
display: flex;
gap: var(--s-3);
align-items: flex-start;
min-width: 0;
}
.main-inner > :global(.terminal) {
flex: 1 1 auto;
min-width: 0;
}
/* Tab strip — mirrors the ProfileView's `.tab` pattern so the
home + search tabs read as siblings of the profile tabs. */
.tabs {
display: flex;
border-bottom: 1px solid var(--line);
margin: 0 0 var(--s-3);
}
.tab {
flex: 1;
background: none;
border: 0;
padding: var(--s-3);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-100);
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.tab:hover:not(:disabled) {
color: var(--text);
}
.tab:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab--active {
color: var(--orange);
border-bottom-color: var(--orange);
font-weight: 700;
}
.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; }
.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);
}
/* (the legacy .btn--danger class used to be applied to the
"sign out" button — that's now styled via
`.settings__group--danger .settings__action` which is its own
selector tree in the settings section below) */
/* X-style settings page: sectioned cards with label-left /
value-right rows, then a list of clickable action rows, then
a danger zone at the bottom. Stays monospace + terminal-
commented, but the structure is the same as X's. */
.settings {
padding: 0 var(--s-3) var(--s-6);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.settings__group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.settings__h3 {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
margin: 0;
font-weight: 700;
}
.settings__list {
display: flex;
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
/* Each row is a label-left / value-right flex line, separated
by a hairline (X uses a single border on each row except the
last). */
.settings__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--line);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.settings__list .settings__row:last-child {
border-bottom: 0;
}
.settings__label {
color: var(--text-dim);
letter-spacing: var(--tracking-label);
flex: 0 0 auto;
}
.settings__value {
color: var(--text);
text-align: right;
word-break: break-all;
min-width: 0;
}
.settings__value--mono {
font-size: var(--fs-50);
}
/* Actions live in their own list — same border-radius but each
item is a full-width clickable button. The hint on the right
(e.g. "atproto", "↗ bsky.app") is a dim secondary label, the
same way X shows the destination on follow / open-in-app
rows. */
.settings__actions {
display: flex;
flex-direction: column;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.settings__action {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--fs-100);
text-align: left;
cursor: pointer;
transition: background-color var(--dur) var(--ease),
color var(--dur) var(--ease);
}
.settings__actions .settings__action:last-child {
border-bottom: 0;
}
.settings__action:hover {
background: var(--orange-8);
color: var(--orange);
}
.settings__action-hint {
color: var(--text-dim);
font-size: var(--fs-50);
}
.settings__action:hover .settings__action-hint {
color: var(--orange);
}
.settings__group--danger .settings__action {
color: var(--red);
}
.settings__group--danger .settings__action:hover {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
}
.settings__group--danger {
margin-top: var(--s-3);
}
</style>