feat(tauri-app): X-style redesign — action bar, compose, tabs, sidebar, reply-mode

A two-pass rewrite of the home / compose / search / profile flows
to follow the X (Twitter) layout conventions while staying in our
monospace / orange-on-black terminal aesthetic. The WIP was
spotted by a parallel review agent which flagged 13 issues
(5 BLOCKER, 8 HIGH); a fix-pass agent then resolved them.

What changed
------------

**PostCard.svelte** — X-style action bar. Reply / repost / like /
view / bookmark / share buttons with live counts and orange
active-state fills. Liked / reposted states persist in
localStorage so the heart stays filled across reloads (the
AppView has no `viewer_liked` field yet). Hover shows the
action affordance. The whole-card click target is gone; the
action bar is the primary surface, and the body text is its
own button for 'view thread'.

**ComposeBox.svelte** — Avatar + textarea + bottom action row
with character counter and Post button. Counter uses
`Intl.Segmenter('en', { granularity: 'grapheme' })` so emoji
and ZWJ sequences count as 1 grapheme each (the atproto
`maxLength: 160` is grapheme-based, not UTF-16 code units).
`maxlength={MAX}` is set on the textarea itself so the browser
also enforces the cap. Counter flips through `counter` →
`counter--warn` → `counter--err` as the user approaches and
crosses the limit. Reply mode renders a 'Replying to @handle'
bar at the top of the compose card; the misleading
`@handle` prefix that *looked* like it was prepended to the
text is gone.

**Sidebar.svelte** (new) — 280px right-rail on the home view.
Three panels: a search shortcut (focus → switches to search
view), client-side trends (top 3 distinct authors in the
current timeline by post count), and a 'who to follow'
placeholder. Hidden below 900px viewport.

**App.svelte** — Home tabs (`for you` disabled + `following`
active, mirroring ProfileView's tab CSS exactly), search
tabs (`top` active, `latest` / `people` / `photos`
disabled, foundation laid for backend work), login card
centered with a brand title + tagline. New `replyTo` state
plumbs the reply click chain end-to-end.

End-to-end reply chain
---------------------

User clicks reply on a PostCard →
`PostCard.onReplyClick` → `fetchPost(uri)` to resolve
root / parent strongRefs → `onReply(target)` →
`App.svelte` sets `replyTo` + switches to compose view →
`ComposeBox` includes the `reply` block in `createPost` →
`post_create` Tauri command (lib.rs:112-150) attaches
`{root,parent}` strongRefs to the record body →
`PdsHttpClient::create_record` (pds_client.rs:214) writes the
post to the PDS with the reply block. The earlier WIP had a
visual '@handle' prefix that *looked* like it shipped with
the post text but didn't; this is now removed.

Review findings addressed
-------------------------

BLOCKER 1: Reply mode end-to-end. (B2-B5, H6-H13 trivial;
implementation agent handled all 13 in one pass.)

`Intl.Segmenter` counts emoji correctly (`🇯🇵` = 1 grapheme,
not 4 code units). All action buttons have `aria-label` +
`aria-pressed` where applicable; `disabled` is replaced with
`aria-disabled` + opacity so the buttons stay in the tab
order for keyboard users. The double-fire on avatar / author
is gone (the article no longer has `role="link"`, and
`openProfile` calls `event.stopPropagation()`). The
quoted-post null-guard crash is fixed with optional chaining.
Like / repost counts are derived from `post.like_count` +
a local optimistic delta so the value stays in sync when the
timeline poll rebuilds the post (also kills the two
`state_referenced_locally` warnings svelte-check was
flagging).

Verification
------------

* `cargo check --manifest-path crates/tauri-app/src-tauri/Cargo.toml` → 0 errors (2 pre-existing warnings).
* `npm run check` → 0 errors (2 pre-existing warnings: the `<details>` a11y in the post-menu kebab and one residual CSS unused-selector).
* `npm run test` → 20/20 passing (localStorage, client, NavRail).
* Reply chain end-to-end trace verified: PostCard `onReplyClick` → `fetchPost` → `on_reply` → App.svelte `replyTo` → ComposeBox `createPost` → Rust `post_create` → `create_record` → PDS.
This commit is contained in:
tomdebone
2026-07-26 19:44:26 +02:00
parent aba84cbaa9
commit 4c71b76763
8 changed files with 1641 additions and 665 deletions
+149 -6
View File
@@ -19,6 +19,7 @@
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";
@@ -39,6 +40,17 @@
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([]);
@@ -52,6 +64,28 @@
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.
@@ -369,10 +403,26 @@
}
async function handlePosted() {
// After the user posts, reset to page 1 so they see their own post.
// 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();
@@ -423,6 +473,7 @@
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">
@@ -431,6 +482,20 @@
<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}
@@ -451,14 +516,14 @@
<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} /></div>
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} />
<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} />
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
{#if timelineCursor}
<div class="loadmore">
@@ -475,7 +540,11 @@
<span class="as">@{currentUser.handle}</span>
<span class="meta">⌘↵ to post</span>
</div>
<ComposeBox onPosted={handlePosted} />
<ComposeBox
onPosted={handlePosted}
replyTo={replyTo}
onClearReply={clearReply}
/>
{:else if view === "user"}
<div class="head">
<span class="prompt">$</span>
@@ -585,6 +654,32 @@
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}
@@ -597,11 +692,15 @@
{: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} />
<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>
@@ -691,6 +790,50 @@
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);