birdclaw 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -0
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +7 -6
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +399 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +29 -0
  12. package/src/cli.ts +46 -1
  13. package/src/components/AppNav.tsx +3 -3
  14. package/src/components/BrandMark.tsx +67 -0
  15. package/src/components/ConversationThread.tsx +11 -10
  16. package/src/components/DmWorkspace.tsx +24 -9
  17. package/src/components/FeedState.tsx +147 -0
  18. package/src/components/InboxCard.tsx +14 -2
  19. package/src/components/SavedTimelineView.tsx +64 -56
  20. package/src/components/SyncNowButton.tsx +105 -0
  21. package/src/components/ThemeSlider.tsx +10 -59
  22. package/src/components/TimelineCard.tsx +157 -61
  23. package/src/components/TimelineRouteFrame.tsx +156 -0
  24. package/src/components/TweetMediaGrid.tsx +120 -23
  25. package/src/components/TweetRichText.tsx +13 -2
  26. package/src/components/useTimelineRouteData.ts +137 -0
  27. package/src/lib/api-client.ts +229 -0
  28. package/src/lib/archive-finder.ts +24 -20
  29. package/src/lib/archive-import.ts +18 -3
  30. package/src/lib/conversation-surface.ts +174 -0
  31. package/src/lib/db.ts +2 -0
  32. package/src/lib/queries.ts +93 -28
  33. package/src/lib/ui.ts +11 -3
  34. package/src/lib/web-sync.ts +443 -0
  35. package/src/routeTree.gen.ts +21 -0
  36. package/src/routes/api/sync.tsx +59 -0
  37. package/src/routes/dms.tsx +100 -27
  38. package/src/routes/index.tsx +21 -127
  39. package/src/routes/links.tsx +50 -5
  40. package/src/routes/mentions.tsx +21 -127
  41. package/src/styles.css +74 -11
  42. package/vite.config.ts +8 -0
@@ -1,3 +1,4 @@
1
+ import { CheckCircle2, Circle } from "lucide-react";
1
2
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
3
  import type { DmConversationItem, DmMessageItem } from "#/lib/types";
3
4
  import {
@@ -29,7 +30,6 @@ import {
29
30
  dmThreadNameClass,
30
31
  dmThreadSubtitleClass,
31
32
  dmThreadTitleClass,
32
- emptyStateClass,
33
33
  pillAlertClass,
34
34
  pillClass,
35
35
  pillSoftClass,
@@ -37,6 +37,7 @@ import {
37
37
  timestampClass,
38
38
  } from "#/lib/ui";
39
39
  import { AvatarChip } from "./AvatarChip";
40
+ import { BirdclawEmpty } from "./BrandMark";
40
41
 
41
42
  function MessageBubble({ message }: { message: DmMessageItem }) {
42
43
  const outbound = message.direction === "outbound";
@@ -80,14 +81,17 @@ export function DmWorkspace({
80
81
  }) {
81
82
  const participant = selectedConversation?.participant ?? null;
82
83
  const subtitle = selectedConversation
83
- ? `${selectedConversation.needsReply ? "Reply owed" : "Thread clear"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
84
+ ? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
84
85
  : "No conversation selected";
85
86
 
86
87
  return (
87
88
  <section className={dmShellClass}>
88
89
  <aside className={dmListClass}>
89
90
  {conversations.length === 0 ? (
90
- <div className={emptyStateClass}>No conversations.</div>
91
+ <BirdclawEmpty
92
+ detail="Sync DMs to populate this lane."
93
+ label="No conversations"
94
+ />
91
95
  ) : null}
92
96
  {conversations.map((conversation) => {
93
97
  const active = conversation.id === selectedConversation?.id;
@@ -127,8 +131,16 @@ export function DmWorkspace({
127
131
  pillClass,
128
132
  conversation.needsReply ? pillAlertClass : pillSoftClass,
129
133
  )}
134
+ aria-label={
135
+ conversation.needsReply ? "Reply open" : "We replied"
136
+ }
130
137
  >
131
- {conversation.needsReply ? "needs reply" : "clear"}
138
+ {conversation.needsReply ? (
139
+ <Circle className="size-3" strokeWidth={2.2} />
140
+ ) : (
141
+ <CheckCircle2 className="size-3.5" strokeWidth={2} />
142
+ )}
143
+ {conversation.needsReply ? "open" : "replied"}
132
144
  </span>
133
145
  <span className={cx(pillClass, pillSoftClass)}>
134
146
  {conversation.influenceScore} ·{" "}
@@ -192,8 +204,8 @@ export function DmWorkspace({
192
204
  <dt className={contextStatTermClass}>Reply state</dt>
193
205
  <dd className={contextStatValueClass}>
194
206
  {selectedConversation.needsReply
195
- ? "Needs reply"
196
- : "Replied"}
207
+ ? "Reply open"
208
+ : "We replied"}
197
209
  </dd>
198
210
  </div>
199
211
  <div className={contextStatRowClass}>
@@ -221,8 +233,8 @@ export function DmWorkspace({
221
233
  <div className={composerBarClass}>
222
234
  <span className={timestampClass}>
223
235
  {selectedConversation.needsReply
224
- ? "Reply still owed"
225
- : "Thread clear"}
236
+ ? "Reply open"
237
+ : "We replied"}
226
238
  </span>
227
239
  <button
228
240
  className={primaryButtonClass}
@@ -236,7 +248,10 @@ export function DmWorkspace({
236
248
  </div>
237
249
  </>
238
250
  ) : (
239
- <div className={emptyStateClass}>No DM selected.</div>
251
+ <BirdclawEmpty
252
+ detail="Pick a conversation to read the thread."
253
+ label="No DM selected"
254
+ />
240
255
  )}
241
256
  </div>
242
257
  </section>
@@ -0,0 +1,147 @@
1
+ import type { ReactNode } from "react";
2
+ import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
3
+
4
+ function SkeletonBlock({
5
+ className,
6
+ muted = false,
7
+ }: {
8
+ className: string;
9
+ muted?: boolean;
10
+ }) {
11
+ return (
12
+ <span
13
+ aria-hidden="true"
14
+ className={`block animate-pulse rounded-full ${muted ? "bg-[color:color-mix(in_srgb,var(--ink-soft)_16%,transparent)]" : "bg-[color:color-mix(in_srgb,var(--ink-soft)_24%,transparent)]"} ${className}`}
15
+ />
16
+ );
17
+ }
18
+
19
+ export function TweetSkeletonRows({ count = 4 }: { count?: number }) {
20
+ return (
21
+ <div aria-hidden="true" className="divide-y divide-[var(--line)]">
22
+ {Array.from({ length: count }, (_unused, index) => {
23
+ const hasMedia = index % 3 === 0;
24
+ const hasQuote = index % 4 === 2;
25
+ return (
26
+ <article
27
+ className="flex gap-3 px-4 py-3"
28
+ data-perf="tweet-skeleton-row"
29
+ key={`tweet-skeleton-${index}`}
30
+ >
31
+ <SkeletonBlock className="size-10 shrink-0 rounded-full" />
32
+ <div className="min-w-0 flex-1 space-y-3">
33
+ <div className="flex items-center gap-2">
34
+ <SkeletonBlock className="h-3.5 w-32" />
35
+ <SkeletonBlock className="h-3 w-20" muted />
36
+ <SkeletonBlock className="ml-auto h-5 w-16" muted />
37
+ </div>
38
+ <div className="space-y-2">
39
+ <SkeletonBlock className="h-3 w-full" />
40
+ <SkeletonBlock className="h-3 w-11/12" muted />
41
+ {index % 2 === 0 ? (
42
+ <SkeletonBlock className="h-3 w-7/12" muted />
43
+ ) : null}
44
+ </div>
45
+ {hasMedia ? (
46
+ <SkeletonBlock className="h-32 w-full rounded-2xl" muted />
47
+ ) : null}
48
+ {hasQuote ? (
49
+ <div className="space-y-2 rounded-2xl border border-[var(--line)] px-3 py-2">
50
+ <SkeletonBlock className="h-3 w-28" muted />
51
+ <SkeletonBlock className="h-3 w-full" muted />
52
+ </div>
53
+ ) : null}
54
+ <div className="flex max-w-md items-center justify-between">
55
+ {["thread", "reply", "repost", "like"].map((key) => (
56
+ <SkeletonBlock className="h-3 w-11" key={key} muted />
57
+ ))}
58
+ </div>
59
+ </div>
60
+ </article>
61
+ );
62
+ })}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function LinkSkeletonRows({ count = 4 }: { count?: number }) {
68
+ return (
69
+ <div aria-hidden="true" className="divide-y divide-[var(--line)]">
70
+ {Array.from({ length: count }, (_unused, index) => (
71
+ <article
72
+ className="flex gap-3 px-4 py-3"
73
+ data-perf="link-skeleton-row"
74
+ key={`link-skeleton-${index}`}
75
+ >
76
+ <SkeletonBlock className="size-9 shrink-0 rounded-full" muted />
77
+ <div className="min-w-0 flex-1 space-y-3">
78
+ <SkeletonBlock className="h-3.5 w-8/12" />
79
+ <SkeletonBlock className="h-3 w-6/12" muted />
80
+ <div className="flex flex-wrap gap-2">
81
+ <SkeletonBlock className="h-3 w-20" muted />
82
+ <SkeletonBlock className="h-3 w-14" muted />
83
+ <SkeletonBlock className="h-3 w-24" muted />
84
+ </div>
85
+ {index % 2 === 0 ? (
86
+ <SkeletonBlock
87
+ className="h-20 w-72 max-w-full rounded-2xl"
88
+ muted
89
+ />
90
+ ) : null}
91
+ </div>
92
+ </article>
93
+ ))}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ export function FeedLoading({
99
+ children,
100
+ detail,
101
+ label,
102
+ }: {
103
+ children?: ReactNode;
104
+ detail?: string;
105
+ label: string;
106
+ }) {
107
+ return (
108
+ <div className="border-b border-[var(--line)]">
109
+ <BirdclawLoading detail={detail} label={label} />
110
+ {children}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export function FeedError({
116
+ action,
117
+ message,
118
+ title = "Could not load this view",
119
+ }: {
120
+ action?: ReactNode;
121
+ message: string;
122
+ title?: string;
123
+ }) {
124
+ return (
125
+ <div className="border-b border-[var(--line)] px-6 py-10 text-center">
126
+ <div className="mx-auto max-w-sm">
127
+ <div className="text-[14px] font-bold text-[var(--alert)]">{title}</div>
128
+ <p className="mt-2 text-[13px] leading-[1.45] text-[var(--ink-soft)]">
129
+ {message}
130
+ </p>
131
+ {action ? (
132
+ <div className="mt-4 flex justify-center">{action}</div>
133
+ ) : null}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ export function FeedEmpty({
140
+ detail,
141
+ label,
142
+ }: {
143
+ detail?: string;
144
+ label: string;
145
+ }) {
146
+ return <BirdclawEmpty detail={detail} label={label} />;
147
+ }
@@ -1,5 +1,10 @@
1
1
  import { Link } from "@tanstack/react-router";
2
- import { ExternalLink, MessageCircle } from "lucide-react";
2
+ import {
3
+ CheckCircle2,
4
+ Circle,
5
+ ExternalLink,
6
+ MessageCircle,
7
+ } from "lucide-react";
3
8
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
4
9
  import type { InboxItem } from "#/lib/types";
5
10
  import {
@@ -86,7 +91,14 @@ export function InboxCard({
86
91
  <span className={mutedDotClass} />
87
92
  <span>influence {formatCompactNumber(item.influenceScore)}</span>
88
93
  <span className={mutedDotClass} />
89
- <span>{item.needsReply ? "needs reply" : "resolved"}</span>
94
+ <span className="inline-flex items-center gap-1">
95
+ {item.needsReply ? (
96
+ <Circle className="size-3" strokeWidth={2.2} />
97
+ ) : (
98
+ <CheckCircle2 className="size-3.5" strokeWidth={2} />
99
+ )}
100
+ {item.needsReply ? "open" : "replied"}
101
+ </span>
90
102
  </div>
91
103
  <div className="flex items-center gap-2">
92
104
  <button
@@ -1,9 +1,16 @@
1
1
  import { Search } from "lucide-react";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ FeedEmpty,
5
+ FeedError,
6
+ FeedLoading,
7
+ TweetSkeletonRows,
8
+ } from "#/components/FeedState";
9
+ import { SyncNowButton } from "#/components/SyncNowButton";
3
10
  import { TimelineCard } from "#/components/TimelineCard";
4
- import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
11
+ import { useTimelineRouteData } from "#/components/useTimelineRouteData";
12
+ import { ConversationSurfaceScope } from "#/lib/conversation-surface";
5
13
  import {
6
- emptyStateClass,
7
14
  feedClass,
8
15
  pageHeaderClass,
9
16
  pageHeaderRowClass,
@@ -33,30 +40,15 @@ export function SavedTimelineView({
33
40
  loadingLabel,
34
41
  searchPlaceholder,
35
42
  }: SavedTimelineViewProps) {
36
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
37
- const [items, setItems] = useState<TimelineItem[]>([]);
38
43
  const [search, setSearch] = useState("");
39
- const [refreshTick, setRefreshTick] = useState(0);
40
-
41
- useEffect(() => {
42
- fetch("/api/status")
43
- .then((response) => response.json())
44
- .then((data: QueryEnvelope) => setMeta(data));
45
- }, []);
46
-
47
- useEffect(() => {
48
- const url = new URL("/api/query", window.location.origin);
49
- url.searchParams.set("resource", "home");
50
- url.searchParams.set(filter, "true");
51
- url.searchParams.set("refresh", String(refreshTick));
52
- if (search.trim()) {
53
- url.searchParams.set("search", search.trim());
54
- }
55
-
56
- fetch(url)
57
- .then((response) => response.json())
58
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
59
- }, [filter, refreshTick, search]);
44
+ const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
45
+ useTimelineRouteData({
46
+ resource: "home",
47
+ search,
48
+ errorFallback: `${TITLES[filter]} unavailable`,
49
+ likedOnly: filter === "liked",
50
+ bookmarkedOnly: filter === "bookmarked",
51
+ });
60
52
 
61
53
  const subtitle = useMemo(() => {
62
54
  if (!meta) {
@@ -67,23 +59,7 @@ export function SavedTimelineView({
67
59
  return `${String(items.length)} visible · ${meta.transport.statusText}`;
68
60
  }, [items.length, loadingLabel, meta]);
69
61
 
70
- async function replyToTweet(tweetId: string) {
71
- const text = window.prompt("Reply text");
72
- if (!text?.trim()) return;
73
-
74
- await fetch("/api/action", {
75
- method: "POST",
76
- headers: { "content-type": "application/json" },
77
- body: JSON.stringify({
78
- kind: "replyTweet",
79
- accountId: "acct_primary",
80
- tweetId,
81
- text,
82
- }),
83
- });
84
-
85
- setRefreshTick((value) => value + 1);
86
- }
62
+ const syncKind = filter === "liked" ? "likes" : "bookmarks";
87
63
 
88
64
  return (
89
65
  <>
@@ -94,6 +70,12 @@ export function SavedTimelineView({
94
70
  <p className={pageSubtitleClass}>{title}</p>
95
71
  <p className={pageSubtitleClass}>{subtitle}</p>
96
72
  </div>
73
+ <SyncNowButton
74
+ accounts={meta?.accounts}
75
+ kind={syncKind}
76
+ label={filter === "liked" ? "Sync likes" : "Sync bookmarks"}
77
+ onSynced={refreshLocalView}
78
+ />
97
79
  </div>
98
80
  <div className="px-4 pb-3">
99
81
  <label className={searchFieldShellClass}>
@@ -107,19 +89,45 @@ export function SavedTimelineView({
107
89
  </label>
108
90
  </div>
109
91
  </header>
110
- <section className={feedClass}>
111
- {items.length === 0 ? (
112
- <div className={emptyStateClass}>Nothing saved here yet.</div>
113
- ) : null}
114
- {items.map((item) => (
115
- <TimelineCard
116
- key={item.id}
117
- item={item}
118
- onReply={replyToTweet}
119
- showReplyControls={false}
120
- />
121
- ))}
122
- </section>
92
+ <ConversationSurfaceScope>
93
+ <section className={feedClass}>
94
+ {loading ? (
95
+ <FeedLoading
96
+ detail={`Reading local ${TITLES[filter].toLowerCase()}`}
97
+ label={loadingLabel}
98
+ >
99
+ <TweetSkeletonRows />
100
+ </FeedLoading>
101
+ ) : error ? (
102
+ <FeedError
103
+ action={
104
+ <button
105
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
106
+ onClick={retry}
107
+ type="button"
108
+ >
109
+ Retry
110
+ </button>
111
+ }
112
+ message={error}
113
+ title={`Could not load ${TITLES[filter].toLowerCase()}`}
114
+ />
115
+ ) : items.length === 0 ? (
116
+ <FeedEmpty
117
+ detail="Sync this collection or broaden the search."
118
+ label="Nothing saved here yet"
119
+ />
120
+ ) : null}
121
+ {items.map((item) => (
122
+ <TimelineCard
123
+ key={item.id}
124
+ item={item}
125
+ onReply={replyToTweet}
126
+ showReplyControls={false}
127
+ />
128
+ ))}
129
+ </section>
130
+ </ConversationSurfaceScope>
123
131
  </>
124
132
  );
125
133
  }
@@ -0,0 +1,105 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { postSync } from "#/lib/api-client";
4
+ import type { AccountRecord } from "#/lib/types";
5
+ import { cx, selectFieldClass } from "#/lib/ui";
6
+ import type { WebSyncKind, WebSyncResponse } from "#/lib/web-sync";
7
+
8
+ interface SyncNowButtonProps {
9
+ kind: WebSyncKind;
10
+ label: string;
11
+ accounts?: AccountRecord[];
12
+ onSynced: (result: WebSyncResponse) => void;
13
+ }
14
+
15
+ export function SyncNowButton({
16
+ kind,
17
+ label,
18
+ accounts = [],
19
+ onSynced,
20
+ }: SyncNowButtonProps) {
21
+ const [syncing, setSyncing] = useState(false);
22
+ const [message, setMessage] = useState<string | null>(null);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [selectedAccountId, setSelectedAccountId] = useState<
25
+ string | undefined
26
+ >();
27
+ const defaultAccountId = useMemo(
28
+ () => accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id,
29
+ [accounts],
30
+ );
31
+ const accountId = selectedAccountId ?? defaultAccountId;
32
+
33
+ useEffect(() => {
34
+ if (!accounts.length) {
35
+ setSelectedAccountId(undefined);
36
+ return;
37
+ }
38
+ if (!accountId || !accounts.some((account) => account.id === accountId)) {
39
+ setSelectedAccountId(defaultAccountId);
40
+ }
41
+ }, [accountId, accounts, defaultAccountId]);
42
+
43
+ async function syncNow() {
44
+ setSyncing(true);
45
+ setError(null);
46
+ setMessage(null);
47
+ try {
48
+ const data = await postSync(kind, accountId);
49
+ if (!data.ok) throw new Error(data.summary);
50
+ setMessage(data.summary);
51
+ onSynced(data);
52
+ } catch (syncError) {
53
+ setError(syncError instanceof Error ? syncError.message : "Sync failed");
54
+ } finally {
55
+ setSyncing(false);
56
+ }
57
+ }
58
+
59
+ return (
60
+ <div className="flex shrink-0 items-center gap-2">
61
+ {accounts.length > 1 ? (
62
+ <select
63
+ aria-label="Sync account"
64
+ className={cx(selectFieldClass, "h-9 w-[132px]")}
65
+ disabled={syncing}
66
+ onChange={(event) => setSelectedAccountId(event.target.value)}
67
+ value={accountId ?? ""}
68
+ >
69
+ {accounts.map((account) => (
70
+ <option key={account.id} value={account.id}>
71
+ {account.handle}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ ) : null}
76
+ <button
77
+ type="button"
78
+ className={cx(
79
+ "inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:cursor-wait disabled:opacity-65",
80
+ syncing && "text-[var(--ink-soft)]",
81
+ )}
82
+ aria-label={syncing ? `${label}: syncing` : label}
83
+ disabled={syncing}
84
+ onClick={syncNow}
85
+ >
86
+ <RefreshCw
87
+ className={cx("size-4", syncing && "animate-spin")}
88
+ strokeWidth={2}
89
+ />
90
+ <span className="hidden sm:inline">
91
+ {syncing ? "Syncing..." : label}
92
+ </span>
93
+ </button>
94
+ <span
95
+ className={cx(
96
+ "hidden max-w-[190px] truncate text-[12px] sm:inline",
97
+ error ? "text-[var(--alert)]" : "text-[var(--ink-soft)]",
98
+ )}
99
+ role="status"
100
+ >
101
+ {error ?? message ?? ""}
102
+ </span>
103
+ </div>
104
+ );
105
+ }
@@ -1,6 +1,5 @@
1
1
  import { Monitor, Moon, Sun } from "lucide-react";
2
- import type { CSSProperties, MouseEvent } from "react";
3
- import { useMemo } from "react";
2
+ import type { MouseEvent } from "react";
4
3
  import { type ThemeValue, useTheme } from "#/lib/theme";
5
4
  import {
6
5
  startThemeTransition,
@@ -18,67 +17,18 @@ const THEME_OPTIONS = [
18
17
  label: string;
19
18
  }>;
20
19
 
21
- const ACTIVE_ITEM_WIDTH_PX = 30;
22
- const GAP_PX = 6;
23
- const CONTAINER_PADDING_PX = 6;
24
- const INDICATOR_SIZE_PX = 30;
25
- const INDICATOR_OVERHANG_PX = (INDICATOR_SIZE_PX - ACTIVE_ITEM_WIDTH_PX) / 2;
26
- const INDICATOR_BASE_OFFSET_PX = CONTAINER_PADDING_PX - INDICATOR_OVERHANG_PX;
27
-
28
- function toPx(value: number) {
29
- return `${String(value)}px`;
30
- }
31
-
32
20
  export function ThemeSlider() {
33
- const { isReady, theme, resolvedTheme, setTheme } = useTheme();
34
-
35
- const activeIndex = useMemo(() => {
36
- const index = THEME_OPTIONS.findIndex((option) => option.key === theme);
37
- return index === -1 ? 0 : index;
38
- }, [theme]);
39
-
40
- const indicatorOffset = activeIndex * (ACTIVE_ITEM_WIDTH_PX + GAP_PX);
41
- const indicatorStyle = useMemo<CSSProperties>(
42
- () => ({
43
- left: toPx(INDICATOR_BASE_OFFSET_PX),
44
- transform: `translate(${toPx(indicatorOffset)}, -50%)`,
45
- }),
46
- [indicatorOffset],
47
- );
48
- const sliderStyle = useMemo<CSSProperties>(
49
- () => ({
50
- gridTemplateColumns: `repeat(${String(THEME_OPTIONS.length)}, ${toPx(ACTIVE_ITEM_WIDTH_PX)})`,
51
- columnGap: toPx(GAP_PX),
52
- padding: `0 ${toPx(CONTAINER_PADDING_PX)}`,
53
- width: toPx(
54
- THEME_OPTIONS.length * ACTIVE_ITEM_WIDTH_PX +
55
- (THEME_OPTIONS.length - 1) * GAP_PX +
56
- CONTAINER_PADDING_PX * 2,
57
- ),
58
- }),
59
- [],
60
- );
21
+ const { isReady, theme, setTheme } = useTheme();
61
22
 
62
23
  return (
63
24
  <fieldset
64
- className="theme-slider-shell m-0 border-0 p-0"
25
+ className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
65
26
  aria-label="Theme selector"
66
27
  >
67
- <div
68
- className="theme-slider relative grid h-[42px] place-items-center overflow-hidden rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)] shadow-[inset_0_1px_0_color-mix(in_srgb,white_40%,transparent)] transition-[background,border-color] duration-180"
69
- style={sliderStyle}
70
- >
71
- <div
72
- className={cx(
73
- "theme-slider-indicator pointer-events-none absolute top-1/2 z-0 size-[30px] rounded-full border border-[color:color-mix(in_srgb,var(--line-strong)_85%,transparent)] bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.6),transparent_58%),var(--panel-strong)] shadow-[0_10px_24px_var(--shadow),inset_0_1px_0_rgba(255,255,255,0.35)] transition-[transform,background,border-color,box-shadow] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]",
74
- resolvedTheme === "dark" &&
75
- "theme-slider-indicator-dark shadow-[0_10px_24px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)]",
76
- )}
77
- style={indicatorStyle}
78
- />
28
+ <div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
79
29
  {THEME_OPTIONS.map((option, index) => {
80
30
  const Icon = option.icon;
81
- const isActive = index === activeIndex;
31
+ const isActive = option.key === theme || (index === 0 && !theme);
82
32
 
83
33
  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
84
34
  if (isActive) return;
@@ -102,8 +52,9 @@ export function ThemeSlider() {
102
52
  key={option.key}
103
53
  type="button"
104
54
  className={cx(
105
- "theme-slider-button relative z-[1] inline-flex size-[30px] items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[color,transform] duration-160 hover:-translate-y-px hover:text-[var(--ink)] disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
106
- isActive && "theme-slider-button-active text-[var(--ink)]",
55
+ "theme-slider-button inline-flex size-9 items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[background,color,transform] duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
56
+ isActive &&
57
+ "theme-slider-button-active bg-[var(--bg-active)] text-[var(--ink)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_72%,transparent)]",
107
58
  )}
108
59
  onClick={handleClick}
109
60
  aria-label={option.label}
@@ -112,8 +63,8 @@ export function ThemeSlider() {
112
63
  disabled={!isReady}
113
64
  >
114
65
  <Icon
115
- className="theme-slider-icon size-[15px]"
116
- strokeWidth={1.8}
66
+ className="theme-slider-icon size-[17px]"
67
+ strokeWidth={isActive ? 2.1 : 1.8}
117
68
  />
118
69
  </button>
119
70
  );