<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import {
    session,
    pdsStatus,
    fetchTimeline,
    fetchProfile,
    fetchSearch,
    fetchPost,
    openExternalUrl,
    showError,
    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" | "settings";

  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;

  // 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) 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") {
      const handle = currentUser.handle;
      void refreshProfile(handle);
    }
    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;
    }
  }

  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);
  }

  async function handleLogout() {
    try {
      await session.logout();
      setView("home");
      profile = null;
      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
  // "@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}`;
  }

  // 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">
      <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">
                <div class="profile__handle">{displayHandle(profile.handle)}</div>
                <div class="profile__did" title={profile.did}>{profile.did}</div>
              </header>

              <div class="profile__actions">
                <button
                  class="btn btn--ghost"
                  type="button"
                  title="Copy DID to clipboard"
                  onclick={() => copyToClipboard(profile!.did)}
                >copy did</button>
                <button
                  class="btn btn--ghost"
                  type="button"
                  title="Copy AT URI to clipboard"
                  onclick={() =>
                    copyToClipboard(`at://${profile!.did}/app.twi.post`)}
                >copy at-uri</button>
                <button
                  class="btn btn--ghost"
                  type="button"
                  title="Open profile in your default browser"
                  onclick={() =>
                    openExternalUrl(
                      `https://bsky.app/profile/${profile!.handle}`,
                    )}
                >open in browser</button>
                <button
                  class="btn btn--ghost"
                  type="button"
                  title="Sign out of this app"
                  onclick={handleLogout}
                >sign out</button>
              </div>

              <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 — compose your first one</div>
              {:else}
                <h3 class="profile__h3">// recent posts</h3>
                {#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} />
                {/each}
              {/if}
            </section>
          {/if}
        {:else if view === "settings"}
          <div class="head">
            <span class="prompt">$</span>
            <span class="title">// settings</span>
          </div>
          <section class="settings">
            <h3 class="settings__h3">// account</h3>
            <dl class="settings__rows">
              <div>
                <dt>handle</dt>
                <dd>@{currentUser?.handle ?? "?"}</dd>
              </div>
              <div>
                <dt>did</dt>
                <dd class="did-cell">{currentUser?.did ?? "?"}</dd>
              </div>
              <div>
                <dt>posts in cache</dt>
                <dd>{userPosts.length}</dd>
              </div>
            </dl>

            <h3 class="settings__h3">// actions</h3>
            <div class="settings__actions">
              <button
                class="btn btn--ghost"
                type="button"
                onclick={() =>
                  currentUser && copyToClipboard(currentUser.did)}
              >copy my did</button>
              <button
                class="btn btn--ghost"
                type="button"
                onclick={() =>
                  openExternalUrl(
                    `https://bsky.app/profile/${currentUser?.handle ?? ""}`,
                  )}
              >open profile in browser</button>
              <button
                class="btn btn--ghost"
                type="button"
                onclick={() => setView("home")}
              >← back to timeline</button>
            </div>

            <h3 class="settings__h3">// about</h3>
            <dl class="settings__rows">
              <div>
                <dt>app</dt>
                <dd>maarcadetweet</dd>
              </div>
              <div>
                <dt>version</dt>
                <dd>0.1.0</dd>
              </div>
              <div>
                <dt>backend</dt>
                <dd>{pdsBase()}</dd>
              </div>
              <div>
                <dt>appview</dt>
                <dd>{appviewBase()}</dd>
              </div>
            </dl>

            <div class="settings__signout">
              <button
                class="btn btn--ghost btn--danger"
                type="button"
                onclick={handleLogout}
              >sign out</button>
            </div>
          </section>
        {: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={() => {
            // 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);
  }

  .profile__actions {
    display: flex;
    gap: var(--s-2);
    margin: var(--s-3) 0;
  }
  .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);
  }

  .btn--danger {
    color: var(--red);
    border-color: var(--red);
  }
  .btn--danger:hover:not(:disabled) {
    background: rgba(255, 59, 48, 0.08);
    color: var(--red);
    border-color: var(--red);
  }

  .profile__h3,
  .settings__h3 {
    font-family: var(--font-mono);
    font-size: var(--fs-50);
    color: var(--text-dim);
    letter-spacing: 0.04em;
    margin: var(--s-4) 0 var(--s-2);
    font-weight: 400;
  }

  .did-cell {
    word-break: break-all;
    font-size: var(--fs-50);
  }

  .settings {
    padding: 0 var(--s-3);
    display: flex;
    flex-direction: column;
    gap: var(--s-2);
  }
  .settings__rows {
    display: flex;
    flex-direction: column;
    gap: var(--s-1);
    padding: var(--s-2) var(--s-4);
    margin: 0 0 var(--s-4);
    font-family: var(--font-mono);
    font-size: var(--fs-50);
  }
  .settings__rows > div {
    display: flex;
    gap: var(--s-3);
  }
  .settings__rows dt {
    color: var(--text-dim);
    letter-spacing: 0.04em;
    min-width: 9rem;
  }
  .settings__rows dd {
    margin: 0;
    color: var(--text);
  }
  .settings__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
    margin: 0 0 var(--s-4);
  }
  .settings__signout {
    margin-top: var(--s-4);
    padding-top: var(--s-4);
    border-top: 1px dashed var(--line);
  }
</style>
